Rule Anatomy
A rule lives in <rulesDir>/<RuleName>/ (the default <rulesDir> is .codegen/rules, override with -r). The directory contains:
Directory.codegen/rules/RuleName/
- config.yaml (required) Engine configuration
- transform.luau (required) Handler (transformation) script
- preamble.luau (required) File header emitter
- grouping.luau (optional) Output path router
- .env (optional) Per-rule dotenv
The directory name is the rule name — it’s how the rule is referenced on the CLI (-a RuleName) and as the C++ attribute ([[codegen::RuleName]]). Companion files are named by their role, not the rule, so a directory always looks the same.
A workspace-shared directory at <rulesDir>/../shared/ (e.g. .codegen/shared/) is loaded automatically: every .luau file there is concatenated in alphabetical filename order and prepended to each rule’s transform at execution time, and a shared/.env file becomes the baseline dotenv. By default every shared script is included; rules can narrow the set with shared.include / shared.exclude globs in their config (see Config Schema Reference). This is the Team-tier shared-libraries feature; today the directory is loaded unconditionally and the licence gate ships in a future release.
config.yaml
Section titled “config.yaml”Controls engine behaviour for this rule. Minimum viable config:
version: 1
output: language: cppFull schema: see Config Schema Reference.
transform.luau — handler
Section titled “transform.luau — handler”Receives one matched entity as JSON, returns JSON describing the output.
Contract:
- Input: a JSON-encoded entity payload (the codex AST node with heavy fields stripped, plus synthetic
_namespaces,_registryId, and_outputPath, plus aparamskey copied from the rule config). Decode withjson.decode(input). _outputPathis the post-grouping output path (thedefaultPathif no grouping script exists, or the value returned by grouping).- Output:
json.encode({ source = "...", inline = { {source = "..."}, ... }, includes = { "<vector>", "\"my/header.h\"" } }). Theinlineandincludeskeys are optional. - A Lua
error()skips this entity, emitsE006, and continues. An internal LuaU error is fatal (exit 2). - The script is a pure function: no I/O, no globals carried between invocations (each call gets a fresh execution).
preamble.luau — preamble
Section titled “preamble.luau — preamble”Required. Runs once per unique (rule, outputFile) pair; the result is later deduplicated by content across rules contributing to the same output file.
Contract:
- Input: a JSON-encoded context object:
{ language: "cpp", outputFile: "path/to/output.cpp", ruleName: "<RuleName>", params: <rule.params>, entities: [{registryId, qualifiedName, structName, namespaces, inputFile, includes}, ...] }. entitiescontains every entity the handler produced output for that’s bound to this output file, with handler-emittedincludesexposed for conditional preamble logic.- Output: a raw string (not JSON), prepended to the output file once per
(rule, outputFile)pair (deduplicated by content across rules contributing to the same file). Return""if you don’t need a preamble — but the file must still exist. - A Lua
error()here aborts the entire run withE007(exit 1).
grouping.luau — grouping
Section titled “grouping.luau — grouping”Optional. Runs once per rule, before handlers fire, with the list of candidate entities. Used to override each entity’s default output path (the 1:1 routing derived from the input header).
Contract:
- Input:
json.decode(input)returns{ params = <rule.params>, entities = [...] }. Each entity has only the summary fields:registryId(integer)qualifiedName(string, e.g."app::Color")structName(string, unqualified)namespaces(array of strings)inputFile(string, the source header path)defaultPath(string, the 1:1 output path derived from the input header)
- Output:
json.encode({ ["<registryId>"] = "path/to/output.cpp", ... }). Keys areregistryIds stringified; values are output paths. - Paths are resolved against the project root (the process working directory). A path that escapes the project root is rejected with
E005and the entity is skipped. An entity missing from the returned map emitsE004and is skipped. The run continues in both cases.
.env — per-rule dotenv
Section titled “.env — per-rule dotenv”Optional. Standard dotenv format (KEY=VALUE, # comments, optional surrounding quotes on the value). Loaded automatically when present; entries override the shared <rulesDir>/../shared/.env baseline. Surfaced to scripts via the env module when env capability is active.
Triggering a rule
Section titled “Triggering a rule”Two trigger mechanisms can fire a rule:
Attribute annotation at declaration sites. Honoured for any node kind in node_kinds (default: Struct only):
struct [[codegen::RuleName]] MyStruct { /* ... */ };Anchor comment in the source. Useful for entities the C++ grammar doesn’t allow attributes on (enums, free functions in some contexts) and as the injection site for inline patches:
// [[codegen::generated::RuleName::namespace::EntityName]]enum class MyEnum { A, B, C };The third segment of the anchor is the qualified name of the target entity (namespace path included). Anchors are scanned per-file and matched against the AST to find the corresponding entity. See Inline Injection for the on-disk anchor lifecycle.
- Up to five files in a rule directory:
config.yaml,transform.luau, andpreamble.luauare required;grouping.luauand.envare optional. - Handler is a pure function: JSON in, JSON out (
source, optionalinline, optionalincludes). Receives_outputPath(post-grouping route). - Preamble runs once per (rule, outputFile) pair; its return is a raw string, deduplicated by content per output file. Receives the entities bound to that file.
- Grouping runs once per rule, before handlers, and rewrites output paths via a stringified-
registryIdmap. - Shared scripts in
<rulesDir>/../shared/and a shared.envare loaded automatically.