Skip to content

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.

Controls engine behaviour for this rule. Minimum viable config:

version: 1
output:
language: cpp

Full schema: see Config Schema Reference.

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 a params key copied from the rule config). Decode with json.decode(input).
  • _outputPath is the post-grouping output path (the defaultPath if no grouping script exists, or the value returned by grouping).
  • Output: json.encode({ source = "...", inline = { {source = "..."}, ... }, includes = { "<vector>", "\"my/header.h\"" } }). The inline and includes keys are optional.
  • A Lua error() skips this entity, emits E006, 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).

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}, ...] }.
  • entities contains every entity the handler produced output for that’s bound to this output file, with handler-emitted includes exposed 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 with E007 (exit 1).

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 are registryIds 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 E005 and the entity is skipped. An entity missing from the returned map emits E004 and is skipped. The run continues in both cases.

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.

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.

Key Takeaways
  • Up to five files in a rule directory: config.yaml, transform.luau, and preamble.luau are required; grouping.luau and .env are optional.
  • Handler is a pure function: JSON in, JSON out (source, optional inline, optional includes). 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-registryId map.
  • Shared scripts in <rulesDir>/../shared/ and a shared .env are loaded automatically.