For AI agents: this page as Markdown · llms.txt · JSON Schema

Language packs

Each language family's engine code sits behind one adapter (native/src/code_rule_dsl/language/adapter.rs). The host's schema checks shapes only; the engine alone refuses what a rule's language lacks. The registry, the not-supported-yet behaviour and the engine adapter seam are implemented.

What a language pack is

A language is three parts, each owned by one place.

Part Where Holds
Registry entry languages/<id>.json Id, family, dialects (file suffixes, parser, grammar, tape tag), supported capabilities, suppression comments, embedded languages, status
Engine adapter native/src/code_rule_dsl/language/adapter.rs and its concern files, one adapter per family How the engine reads it (scope, values, call arguments, call graph, format strings, suppressions), and the DSL features it refuses
Rule pack packs/<id>/ (pack structure) Code rules written in it, with evidence that they behave

The layering rule

Language-specific code lives only in a language's adapter and registry entry; nothing else branches on a language.

Core, the host and packs ask the registry or the adapter. Both sides are generated from languages/:

Only the engine decides whether a rule uses something its language lacks.

How the engine refuses a rule

"Something its language lacks" is a key, option, format style, suppression comment or another family's grammar. The host's schema checks a rule's shape only. Every write, and validateCodeRule from @neralabs/rerail/rules, compiles the rule in the engine. The engine refuses it as invalid, or as language-not-supported when the registry plans the language or capability.

The engine adapter

Each language family has one adapter type, and every engine call site asks it instead of matching on a language.

Adding a language lists the steps.

Family Languages Capabilities Suppressions
python python all five noqa, nosec
ecma typescript, javascript scope, value, arguments; calls and formatArgs Refused eslint, oxlint, biome
php php all five phpstan, psalm, phpcs
blade blade all five none
rust rust all five clippy
vue vue scope, value, arguments; calls and formatArgs Refused eslint
sql sql scope, value, arguments; calls and formatArgs Refused sqlfluff
What each concern trait holds
Part Holds
Adapter Diagnostic label, line terminators, name normalisation (Python's NFKC, SQL's PostgreSQL case folding), module paths from package markers (Python), inline suppression comments (an embedding family's read every document of its files)
ScopeRules (adapter/scope.rs) Scope tables: building, resolving, bindings, usage, PHP's flow, SQL's query blocks; refused binding and resolution features
ValueRules (adapter/value.rs) Value decoding, kinds, structures, names, keys and references for comparisons, type names; refused compare modes
ArgumentRules (adapter/arguments.rs) Call kinds, a call's arguments; refused signature markers and modes
CallRules (adapter/calls.rs) Call and function kinds, per-file call summaries, call-graph links and labels
FormatArgRules (adapter/format_args.rs) The format styles a family reads
SyntaxRules (adapter/syntax.rs) Literal grammars a family hosts (parse), languageOptions and when keys
How dispatch, refusals and embedding work

Vue

adapter/vue.rs reads a .vue file as two documents (embed): the component's tree-sitter-vue tree, and one program built from its <script> blocks and template expressions. A clause reads the program with ast.embedded: script-block; a template rule reaches it with embedded.

How the program is built and read

The program comes from vue::view, via script-block (embed/script_block.rs): TypeScript for lang="ts", TSX for lang="tsx", else JavaScript. Another language (coffee) embeds none. The program has its own grammar and text, with segments mapping that text to the file's bytes.

Concern Vue
Documents The component's tree; the program, in the dialect ScriptBlock built it in. Each has its own syntax errors: template errors leave a clause over a clean program complete
Scope, value, arguments Ecma's, over the program. Template references resolve in the scopes Vue's compiler gives them; tokens the view writes (an interpolation's ( and );) are not spelled (embed::rewritten)
Calls, format arguments Refused, as for ECMAScript: vue-eslint-parser hands scripts to ESLint, which has neither
Suppressions eslint: ESLint directives in <script> comments (suppress::vue::script_directives, the program's comments at the file bytes they copy), or eslint-plugin-vue's template comment directives (vue/comment-directive, VueDirectives)
Patterns Vue's over the component's tree; the dialect's over the program
languageOptions, when Ecma's keys

The Rust adapter

adapter/rust.rs reads Rust from tree-sitter-rust's syntax alone (ast-grep:Rust, tape tag 9), without types or macro expansion. The Clippy pack (packs/rust/) ships the syntax ports that pass the conformance check; the rest stay candidates.

What each capability reads, and its oracle

The adapter does not read names inside macro token trees and macro_rules! bodies. Each concern is proven against a recording of the Rust 1.99.0 toolchain under fixtures/rust/ (generate.py beside each; run by hand, never in CI).

Capability Module Reads Oracle
scope scope/rust.rs, scope/usage/rust.rs rustc's lexical name resolution. Items are visible in their whole block or module (extern blocks too); let and pattern bindings from where they are made; generic and for<'a> lifetimes in their item or type; no local of an enclosing fn. Types, values, macros, lifetimes and labels are separate namespaces. A macro invoked in item position may define items, so a name nothing else binds there is not unresolved. resolves.import lists :: paths, a read's head through use and extern crate, self::/super:: normalised to crate:: rust-analyzer's SCIP occurrences, rustc's unused-name diagnostics (fixtures/rust/scope)
value value/rust.rs Literals and constant expressions as rustc evaluates them (any-width integers typed by suffix, floats, char, strings, byte and C strings), within the decode's depth cap. follow reads a const item of the file rustc's values for every literal and expression case (fixtures/rust/literal, fixtures/rust/value)
arguments matcher/arguments/rust.rs Positional arguments rustc's argument binding per case (fixtures/rust/matcher/arguments)
calls calls/rust.rs, Graph::link_rust A crate's functions by path, Self::f/Type::f through its impl blocks, self.m() in an inherent impl. Open: method calls on other values, trait dispatch, closures, function pointers, macros whose expansion may call a crate function rust-analyzer's resolution of every call in a test crate (fixtures/rust/calls)
formatArgs literal/rust_format.rs, matcher/format_args/rust.rs rustFormat templates and style rust: the format_args! family as rustc binds it rustc's diagnostics per format macro case (fixtures/rust/matcher/format_args)

Rust refuses languageOptions, resolves.imported, and other families' grammars and format styles.

Clippy suppression

Suppression is clippy (suppress/clippy.rs), checked against clippy-driver over UI fixtures and the ripgrep and tokio corpora (fixtures/rust/suppress).

The SQL adapter

adapter/sql.rs reads SQL as SQLFluff 4.4.0 does, with one generic dialect for PostgreSQL, SQLite, MySQL and ANSI files. It refuses calls and formatArgs.

The dialect, and what each concern reads

That dialect is GrammarDialect::Sql, the vendored tree-sitter-sql with Rerail's patches (native/grammars/tree-sitter-sql/PATCHES.md). Where dialects disagree it reads PostgreSQL's; the pack declares the rest as divergences.

Concern Reads Oracle
Adapter Names as PostgreSQL looks them up: naked identifiers fold to lower case, quoted ones are exact (value::sql::identifier). SQLFluff's -- noqa (suppress/sqlfluff.rs) fixtures/sql/suppress/sqlfluff.expected.json: SQLFluff's reports with noqa ignored and honoured
ScopeRules Query blocks (ScopeKind::Query, scope query) from scope/sql.rs, below. resolves.unresolved is RF01's report; binds: { used: false } is AL05's SQLFluff's expected RF01 and AL05 findings (packs/sql/upstream/sqlfluff/fixtures.expected.json, pack test data: npm run fixtures:pull), with the declared ANSI-versus-PostgreSQL mismatches
ValueRules Literals and names (value/sql.rs) only where PostgreSQL and SQLite agree. type and the key, member, reference and structural compare modes are refused fixtures/sql/scope/postgres.expected.json (PostgreSQL 18.6 via libpg_query), fixtures/sql/value/sqlite.expected.json (SQLite 3.50.4)
ArgumentRules An invocation's arguments as PostgreSQL 18.6's parser binds them (matcher/arguments_sql.rs): by position, then by name. Signature markers, syntactic and estree are refused fixtures/sql/matcher/arguments/postgres.expected.json: 50 calls, 49 matched by position, 1 asserted unreadable (substring(s FOR n), whose start PostgreSQL supplies)
SyntaxRules No literal grammar (parse), no languageOptions; when reads the document's options

A query block binds:

A qualified column's table part resolves to a relation or target of its block or an enclosing one; a CTE only through a relation over it. The pack's ports read syntax, value, compare, scope and suppression. None reads arguments; that capability's own oracle proves it.

Why calls and formatArgs are refused

calls and formatArgs are refused in languages/sql.json: SQL gives the engine nothing to read for them. A rule reading either is invalid for sql, never language-not-supported. The module doc of adapter/sql.rs has the evidence:

The registry entry

Each language is one file, languages/<id>.json, so changing one language touches no other's file.

json
{
  "format": "rerail.language/v1",
  "id": "blade",
  "status": "supported",
  "family": "blade",
  "dialects": [
    { "label": "blade", "variant": "Blade", "suffixes": [".blade.php"], "parser": "forte", "grammar": "native:forte", "tapeTag": 8 }
  ],
  "capabilities": { "scope": "supported", "value": "supported", "arguments": "supported", "calls": "supported", "formatArgs": "supported" },
  "suppressions": [],
  "embeds": [{ "language": "php", "via": "compiled-view" }]
}

id equals the file name and is what a code rule's language names. capabilities gives each of scope, value, arguments, calls and formatArgs as supported, refused or not-supported-yet.

Field reference
Field Required Meaning
format yes rerail.language/v1
id yes What a code rule's language names. Equals the file name
status yes supported or planned (see the lifecycle)
family yes The semantic family that reads it. Languages sharing one (typescript and javascript share ecma) share its suppressions
dialects yes The grammars its files are parsed with (below)
runsOn no Dialect labels its rules run on, its own first; default its own. A typescript rule also runs on JavaScript files
capabilities yes Per capability (scope, value, arguments, calls, formatArgs): supported, refused (no such thing or no plan; a rule using it is invalid) or not-supported-yet
suppressions yes Linter suppression comments its files honour (noqa, nosec, eslint, oxlint, biome, phpstan, psalm, phpcs, clippy, sqlfluff)
embeds no Embedded languages and how they are reached (via): Blade's PHP through the compiled view, Vue's script blocks. A rule runs on the program with ast.embedded: "<via>" and reaches it from the file's tree with embedded (DSL reference)

Each dialect has:

Embeds, versioning and validation

Embeds. A supported entry may embed only supported languages. An Embedder builds at most one program per file and via: a document derived from the file's (tape::DerivedImage), whose own tape the store keeps as a row beside the file's (file_documents).

Versioning. A new optional field or value is a rerail.language/v1 change. Renaming or removing a field, or changing a value's meaning, needs rerail.language/v2.

Validation. The build and test/engine/code-rule/language-registry.test.ts fail when:

Status lifecycle

A language moves from planned, through capabilities one at a time, to supported.

  1. Planned. Dialects have suffixes but no grammar; every capability is not-supported-yet. Rules may name the language, so its packs can land first, but the engine reads none of it.
  2. Capabilities, one at a time. A capability becomes supported only when its oracle suite passes: its fixtures/ oracles and the conformance scores of code rules that read it.
  3. Supported. The entry flips once its dialects have a grammar, parser and tape tag and its adapter exists. The build fails until each does.
What happens to a rule the engine cannot read yet

A rule of a planned language, or one reading a not-supported-yet capability, is refused as language-not-supported and kept inactive:

The test-only language

native/test-languages/exemplar.json is a planned language only tests see, so supporting a real language never touches a test.

How tests use it

A test that needs "a planned language" names it. It has family exemplar, one dialect exemplar (suffix .exemplar), every capability not-supported-yet, no suppressions and no tape tag. It is in no package and never in the product:

Test-only embedding

The test-only HTML grammar embeds TypeScript, TSX and JavaScript through Via::TestScript (language::test_embeds, native/src/code_rule_dsl/embed/test_script.rs). Its program is an HTML file's scripts, then its on* handlers. Its tests (embed/tests.rs) cover the embedder loop, derived documents and their dialects stored apart from the file's tape, and scope and usage over a program mapped to the file out of order. The generated Via and with_embedder! carry it only under #[cfg(test)].

Adding a language

Register the language as planned, add its grammar and adapter, then flip capabilities as each is proven.

  1. Register it. Add languages/<id>.json with status: "planned": family, dialect labels and suffixes, every capability not-supported-yet, suppressions and embeds. Give each dialect the tapeTag it reserves (Rust 9, SQL 10 and Vue 12 are taken; 11 is free for a second SQL dialect). Run npm run generate:languages and commit the regenerated src/engine/code-rule/code-rule-language-registry.generated.ts. Rules and packs can land now.
  2. Add its grammar. Vendor or pick one (add a vendored one to GRAMMARS in native/build.rs). Flip the entry to supported with each dialect's variant, parser and grammar. The build fails until adapter::<Family> exists.
  3. Write its adapter in its own file, registered by one mod and one pub(crate) use line in native/src/code_rule_dsl/language/adapter.rs. A new family's type implements Adapter and each concern trait, or Refused for a concern the language will not have.
  4. Add missing suppression kinds to the engine, the host schema, the entry and the adapter (below).
  5. Add an embedding (embeds with a new via): native/src/code_rule_dsl/embed/<via>.rs implementing Embedder, plus one mod and one pub(crate) use line in embed.rs. Nothing else in the engine changes.
  6. Add a kit test: one #[test] function in native/src/code_rule_dsl/language/adapter/kit.rs calling kit::<Family>(&Expect { .. }) with the family's expectations.
  7. Flip capabilities to supported only when implemented and proven by their oracle suite; mark ones the language will never have refused. Flip status last.
Steps 2 and 3: generated code and concern traits

For step 2, the build generates GrammarDialect::<Variant>, Family::<Family>, Family::of, honours and the with_adapter! arm; no enum is edited by hand. For step 3, a new family implements:

A new dialect in an existing family needs no per-dialect table: ECMAScript's scope reads each dialect's grammar (scope::ecma::script).

Step 4: where a suppression kind goes

Each missing suppression kind needs:

Step 5: what an embedding provides

build returns one embed::Program or none: its dialect, its tree over its own text, the SegmentMap mapping that text to the host's bytes (copied or written over them, in any order), and the host syntax errors it reads through. The engine handles the rest:

The embedding family's adapter (step 3) names the embedded family's concerns, as Blade's does PHP's.

Start a pack

A pack is a directory packs/<id>/ that passes the validation contract.

Models to copy:

A pack for a supported language

A Laravel pack with one code rule proven by examples. The skeleton passes as written.

sh
mkdir -p packs/laravel/rules
cp LICENSE packs/laravel/LICENSE

packs/laravel/package.json:

json
{
  "name": "@neralabs/rerail-pack-laravel",
  "version": "0.1.0",
  "license": "Apache-2.0",
  "files": ["pack.json", "rules", "LICENSE", "README.md"],
  "peerDependencies": { "@neralabs/rerail": ">=0.1.0-0 <0.2.0" },
  "peerDependenciesMeta": { "@neralabs/rerail": { "optional": true } },
  "publishConfig": { "access": "public" }
}

packs/laravel/pack.json:

json
{
  "format": "rerail.rule-pack/v1",
  "id": "laravel",
  "name": "Laravel",
  "category": "framework",
  "covers": "Laravel conventions: no debugging helpers left in application code.",
  "reason": "matches Laravel",
  "match": { "any": [{ "files": ["artisan"], "label": "an artisan file" }] },
  "rules": [
    {
      "id": "laravel-no-dd",
      "document": "rules/laravel-no-dd.json",
      "title": "No dd() or dump() calls",
      "group": "Laravel",
      "examples": {
        "caught": { "path": "app/Http/Controllers/UserController.php", "text": "<?php\nfunction show($user) {\n    dd($user);\n}\n" },
        "passes": { "path": "app/Http/Controllers/UserController.php", "text": "<?php\nfunction show($user) {\n    return view('user', ['user' => $user]);\n}\n" }
      }
    }
  ]
}

packs/laravel/rules/laravel-no-dd.json is a code rule (rerail.policy/v1, see the code-rule DSL reference). Its id matches pack.json and its file name. Rule ids are lowercase letters, digits and dashes, in one style for every pack (rule ids).

json
{
  "format": "rerail.policy/v1",
  "id": "laravel-no-dd",
  "revision": 1,
  "mode": "steer",
  "description": "A call to Laravel's dd() or dump() debugging helper.",
  "scope": { "include": ["app/**/*.php", "routes/**/*.php"], "exclude": [] },
  "clauses": [
    {
      "id": "no-dd",
      "forbid": { "ast": { "language": "php", "rule": { "kind": "function_call_expression", "has": { "field": "function", "regex": "^(dd|dump)$" } } } },
      "violation": { "anchor": "witness.span", "code": "LARAVEL-NO-DD", "message": "Remove dd()/dump() before committing" }
    }
  ]
}

Add a README.md saying what the pack covers. Then record the new workspace and run the contract:

sh
npm install --package-lock-only --ignore-scripts   # packs/* are npm workspaces: package-lock.json lists each
npx vitest run test/engine/catalog/packs.test.ts -t "pack laravel"

Add a changeset (patch, "Add the Laravel pack: …"), unless the pack starts private ("private": true in its package.json, no publishConfig): a private pack is never published and takes no changeset until it goes public (package.json). Every later change to a published pack adds one too; versioning says which bump (patch until 1.0.0):

sh
npx changeset   # pick @neralabs/rerail-pack-laravel, choose the bump, write the summary; then rename it added-laravel-pack.md

Every rule needs proof: examples (one caught, one that passes, each path inside the rule's scope) or a port entry (below). Each check and rule is one test. A failure names the rule and file, for example laravel-no-dd: the caught example (app/Http/Controllers/UserController.php) produces no LARAVEL-NO-DD violation.

A pack for a planned language

Use the same files, with rules whose language is planned or that read a not-supported-yet capability.

The pack lands: its checks pass, and each such rule's test is skipped as not supported yet with the engine's reason, for example /clauses/0/forbid/ast/language: `exemplar` is not supported yet. Write examples now: checks 3 and 4 run them once core reads the language. Any other compile error fails check 1 today.

fixtures/packs/exemplar/ is a complete example for the test-only planned language.

A ported pack

A pack that reproduces another tool's rules adds validation material, which files never publishes. Each ported tool is a tool-pack, upstream/<tool>/, with its own pack data (pack data):

  1. conformance.json (schema): the family, declared capabilities, pinned tools, and one entry per ported rule.
  2. upstream/<tool>/: a generator kept beside the data, a recipe under scripts/pack-data/recipes/, and gates.yaml, the upstream manifest (conformance/README.md).
  3. licenses/<project>/, one per upstream repository: its licence verbatim, and UPSTREAM.md (UPSTREAM.md).
  4. divergences.json: each line where Rerail and the tool disagree, with its kind and checked evidence (divergences). Start with []; add only disagreements you have checked.
  5. Optional samples: upstream/<tool>/samples.lock and SAMPLES.md, real-code samples made with the sample builders and published like test data (docs/upstream.md).
What each file holds

conformance.json, per ported rule: its upstream tool and rule, id (the rule's own), policy (its document), how findings compare (line or line+column) and the capabilities it exercises.

upstream/<tool>/: each recipe is one module per pack, listed in scripts/pack-data/recipes/index.ts.

licenses/<project>/UPSTREAM.md has:

Add the tool's licence to package.json's license (Apache-2.0 AND MIT for an MIT tool), and annotate the rule documents in REUSE.toml with that expression.

Score one rule, listing every false positive and false negative, then run the contract and the gate:

sh
npm run conformance:languages -- --pack <id> --rule <port id> --fixtures
npx vitest run test/engine/catalog/packs.test.ts -t "pack <id>"
npm run conformance:languages -- --check --pack <id>