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

Rule pack structure and validation contract

A rule pack is a directory under packs/: rule documents plus the evidence that they behave. test/engine/catalog/packs.test.ts validates each one against the validation contract, and the release workflow publishes each on its own, except a private one, which is never published and is installed from a checkout with rerail packs add packs/<id>.

Core ships no packs and has no rules of its own. Set-up and rerail packs add install the published packs from npm into $RERAIL_HOME/packs, and core reads them from there. To start a pack, see language packs.

How core lists packs

Core lists published packs by name only, in src/engine/catalog/pack-list.generated.ts (rule packs). Like every By Rerail rule, a pack's rules run only in repositories that add them or the pack.

A pack adds no file format: it combines the two schemas the repository already has. Code rules compile against the engine's capabilities.json, native/src/code_rule_dsl/capabilities.json.

Principles

Four principles decide what a pack holds and how it is checked.

  1. A pack is data. It holds no code and never imports the engine. Core runs its rules; the repository's tests validate it.
  2. The directory is the contract. The files a pack has decide what it must prove.
  3. Every rule is proven, by examples it must catch and let pass, or, for a port, by the tool's own tests.
  4. Packs version on their own. Each is its own npm package, released separately from core.
More on each principle

Layout

A pack's files, and which of them ship to npm.

text
packs/<id>/
├── package.json        npm package @neralabs/rerail-pack-<id>: licence, files, core range
├── pack.json           the pack: rerail.rule-pack/v1
├── README.md           what the pack covers, for people: the rules it ships, never a candidate's details
├── LICENSE, NOTICE     the pack's licence and notice
├── rules/
│   └── <rule id>.json  rule documents, each named for its id: code rules (rerail.policy/v1) or conversation rules
├── licenses/
│   └── <project>/      upstream LICENSE (verbatim) and UPSTREAM.md (per repository: source, path patterns taken, not copied)
│
│   only for packs that port rules from another tool:
├── conformance.json    the conformance pack schema of conformance/languages/pack.ts
├── divergences.json    declared disagreements with the tools
├── upstream/
│   └── <tool>/         gates.yaml, the generator, test-data.lock, samples.lock and SAMPLES.md (optional), and
│                       UPSTREAM.md when Rerail wrote files for the tool to score; the tool's copied tests
│                       (fixtures/, rename variants under fixtures/renamed/) and expected findings (*.expected.json)
│                       are pack test data, not in git (see Pack data); publish/ while a pull request publishes data
└── candidates/         ports still being evaluated: scored and reported, never gated, never shipped; README.md
                        says what each is waiting on and holds their rule details

<id> is lowercase letters, digits and dashes. It is the directory name, the id in pack.json and the npm name @neralabs/rerail-pack-<id>. That scope is Rerail's. A pack published elsewhere is named rerail-pack-<id> or @<scope>/rerail-pack-<id>, and core loads it if it passes the catalog's checks (third parties' packs).

package.json

json
{
  "name": "@neralabs/rerail-pack-typescript",
  "version": "0.1.0",
  "license": "Apache-2.0 AND MIT",
  "repository": { "type": "git", "url": "git+https://github.com/neralabs/rerail.git", "directory": "packs/typescript" },
  "files": ["pack.json", "rules", "licenses", "LICENSE", "NOTICE", "README.md"],
  "peerDependencies": { "@neralabs/rerail": ">=0.1.0-0 <0.2.0" },
  "peerDependenciesMeta": { "@neralabs/rerail": { "optional": true } },
  "publishConfig": { "access": "public" }
}
Why these fields

pack.json

pack.json is the pack, in the rerail.rule-pack/v1 format (rulePackFileSchema in src/engine/catalog/rule-packs.ts). The TypeScript pack, with one of its rules:

json
{
  "format": "rerail.rule-pack/v1",
  "id": "typescript",
  "name": "TypeScript",
  "category": "language",
  "covers": "typescript-eslint and @vitest/eslint-plugin rules for safe, idiomatic TypeScript and Vitest tests.",
  "reason": "matches TypeScript",
  "match": {
    "any": [
      { "dependencies": { "ecosystem": "npm", "names": ["typescript"] } },
      { "files": ["**/*.ts", "**/*.mts", "**/*.cts"], "exclude": ["**/*.d.ts"], "label": "*.ts files" },
      { "files": ["**/*.tsx"], "label": "*.tsx files" }
    ]
  },
  "rules": [
    { "id": "typescript-eslint-ban-tslint-comment", "document": "rules/typescript-eslint-ban-tslint-comment.json", "title": "typescript-eslint ban-tslint-comment: Disallow `// tslint:<rule-flag>` comments", "group": "typescript-eslint", "on": false }
  ]
}
Field details

Conversation rules. A pack may also ship a conversation rule: the rule object of rule-schema.ts (id, mode, match, sample, action, optional repeat) as its document, with no format. It steers on the coding agent's session rather than on code. Its examples take no path: text is the command the coding agent runs for a rule that samples tool calls (tool:* is tried as tool:Bash, the command as the call's input.command), and the sampled text otherwise. Its schema has no description, so check 8 leaves it out; say what it does in the pack's README.

Internal testing: conversation rules in a pack, as every conversation rule; Rerail 0.1.0 releases code rules only. See Internal testing.

A ported rule starts at the level upstream's recommended configuration gives it: upstream warn becomes "mode": "log" in pack.json.

Upstream levels and modes

The document stays a faithful port with no level.

test/engine/catalog/rule-packs.test.ts holds what match and examples promise: every rule starts in the mode its pack or document gives it, and every example is caught or passes as it says.

Rule ids

An id must not change once its pack is published, because people turn rules on and off by id.

Where the id must match

The id is the same string in pack.json, in the document's id, in its file name and in its port's id in conformance.json. The file is rules/<id>.json, or candidates/rules/<id>.json while a candidate, so the id survives shipping. Check 1 enforces this; the pack tests reject an id two documents in any packs share.

conformance.json, divergences.json, upstream/

Packs that port rules keep their proof beside the rules, in the schemas of conformance/languages/pack.ts.

Divergences, locks and oracles

conformance.json

It records the language families, the engine capabilities, the pinned tools and, per port, the upstream tool and rule.

Full contents and per-port options

It records:

A port whose document is under candidates/ is scored on the fixtures, whose expected findings must cover it, and reported; its scores never fail a check. A port may also set:

Expected findings

upstream/<tool>/fixtures.expected.json is what the tool reported on its own tests. The generator beside it writes it, run through npm run fixtures:derive (docs/upstream.md).

Generators and the source block

Its source block names origin (upstream repository @ commit), the rules the run reported, the files (units: a path, or a list of paths for a multi-file unit), and units. A units entry, keyed by the unit's path (or first path), gives its upstream origin: rules, the upstream rules whose tests it was written from. A unit without an entry (Rerail's probes, or of unknown origin) is scored but is no rule's own evidence for stays-silent.

ECMA language options

An ECMA unit's entry may carry languageOptions, where its test case set a language beyond ESLint's flat-config defaults for its extension (unitLanguage in scripts/pack-data/eslint-expected.mjs):

A rename variant carries its original's. The conformance runner merges them into each AST clause's languageOptions, so the port judges the file as the linter did. It merges ecmaVersion, sourceType, parserOptions.ecmaFeatures' globalReturn and impliedStrict, and typescript-eslint's jsxPragma, jsxFragmentName and lib, which a code rule spells at the top of languageOptions. A query that had none keeps reading its listed global names as unbound (declared: false). A unit without them gets the port's own languageOptions (ESLint's defaults unless set).

The javascript pack's two tools and eslint-plugin-vitest record them. The other ECMA generators lint every case under one configuration of ESLint's defaults (vue's, next's, react's, typescript-eslint's) or convey the source type through the unit's package.json (eslint-plugin-n's), so their units carry none.

Rename variants

For the ECMA tool-packs, the generator also lints a renamed copy of every upstream unit, under fixtures/renamed/<the original's path>. A port that matches the tests' names rather than the construct then fails.

How renaming works

The conformance check scores variants apart from the originals (holds-under-rename). A family without variants takes its overfit evidence from its samples alone.

gates.yaml

upstream/<tool>/gates.yaml is a gated tool-pack's upstream manifest: pins, linted file types, and each upstream rule's deprecation and recommended configuration (conformance/README.md). Its watch: block says what the nightly upstream report (docs/upstream.md) compares between the pinned commit and upstream's latest release:

yaml
watch:
  rule-prefix: "unicorn/"          # optional: removed from upstream.rule to give {rule}
  extra-rules: [NoArgumentsValidator]  # optional: rules the generator reads that conformance.json does not port
  tests: ["test/{rule}.js"]        # the tests the generator reads; or { file: fixtures.json [, with: [".stderr"]] } or { named-by: fixtures.json }
  code: ["rules/{rule}.js"]        # each ported rule's code; or { index: clippy-declared-lints | ruff-codes }
  shared: ["rules/utils/**"]       # code every ported rule depends on; no {rule}
  new-tests: ["tests/{rule}/**"]   # optional: where upstream adds tests for a ported rule that the generator does not read
  rules:                           # optional: a rule's own tests or code, in place of the {rule} patterns
    DebugDirectiveValidator: { tests: ["tests/Validation/DebugDirectiveTest.php", "src/Compiler/**"] }
Paths, special forms and coverage

The conformance check reads the manifest. Paths are relative to upstream's repository root; * matches within a path segment and ** across segments. {rule} is each ported rule: conformance.json's upstream.rule for this tool without rule-prefix, plus extra-rules.

expandWatch in conformance/languages/watch.ts resolves the block. A new-tests glob may overlap the generator's tests; a file counts as a new test only when tests does not cover it.

test/scripts/upstream/watch-coverage.test.ts checks that tests covers every upstream file the tool-pack's pulled provenance.json records as read. Which files a named-by test names is upstream's text, which it does not read, so it counts any file under that test's top directory (tests/) as covered.

Pack data

A tool-pack's test data and real-code samples live in S3 archives, never in git or npm.

Fetch the archives your checkout's locks name, and pull again after a checkout or merge that changes a lock:

sh
npm run fixtures:pull

docs/upstream.md covers how the data is made, published and judged, and who does what (Publishing pack data).

What test data holds

Test data is the files under packs/<id>/upstream/<tool>/ matched by PACK_FIXTURE_PATTERNS (scripts/s3/pack-fixtures.mjs), which .gitignore also covers: the tool's test cases (fixtures/, tests/, case lists such as cases.json and *.cases.json, fixtures.upstream-check.json, lizard-*.tsv), its expected findings (*.expected.json), authored/, and the archive's provenance.json and LICENSES.md (below). It is of two kinds:

Samples archive layout

A tool-pack without a samples.lock has no samples. In the archive (scripts/samples/format.ts), each sample is <sample-id>/<source path> beside <source path>.expected.json: the source repository, commit, path, sha256, SPDX licence, and per rule the labelled lines. Context files go under the same <sample-id>/; LICENSES.md is at the root.

The tracked SAMPLES.md beside samples.lock lists the repositories with licences, commits and file counts, which licence texts LICENSES.md holds, and how the set was trimmed, if it was.

Archive format, S3 locations and locks

An archive is the canonical tar of its files, zstd-compressed (scripts/s3/test-data-archive.mjs): one regular-file entry per file, sorted by path's UTF-8 bytes, path relative to the tool directory, mode 0644, owner 0, time 0, no directory entries. Archives live in us-east-2:

Writes are create-only and the bucket is versioned, so changed data is a new archive and a new lock. test-data.lock and samples.lock share one format, read by parseLock: { "format": 1, "sha256", "files", "bytes" }. These are the tar's sha256 (not the compressed file's, so a different zstd build changes nothing), file count and bytes. Every tool directory with a tracked file outside publish/ has a test-data.lock; samples.lock is optional.

How pulling works

npm run fixtures:pull fetches from S3, with the AWS CLI and read access to the bucket, only archives it lacks, keeping them in .rerail/pack-data/archives/.

Every consumer (checks 4 and 6, the conformance runner, the generators, the Rust tests) says to pull when data is absent.

In git under upstream/<tool>/: gates.yaml, the generators, UPSTREAM.md, test-data.lock, samples.lock, SAMPLES.md, and publish/ while a pull request publishes data (docs/upstream.md). Structure metric #3 fails while pack test data is tracked in the pulled layout, a tool directory lacks its test-data.lock, a lock does not parse, or a publish/ is tracked.

Rule text

A code-rule document has text for two readers: people, and the coding agent.

text
Rerail found 2 problems in your last edit:
- src/grade.js:1:8 — <message> [complexity-cyclomatic-javascript]
  Suggested fix: delete 14:3–14:12
- src/api.py:42:5 — <message> [bandit-b113-request-without-timeout]
Fix these, or tell the user in one line why you won't and run `rerail feedback <rule-id> '<that line>'`.

The block already gives the location, rule id, lead and latitude ("fix or explain"), so the message only advises what to do about this code. Check 8 enforces the machine-checkable description criteria; review covers the rest and all message criteria. just rerail-guidance-review shows every rule's description, messages and block for each case.

Description

One plain-English imperative sentence of at most 200 characters, saying what to do or avoid, with no code and no origin.

Example: Avoid dd() and dump() calls; they leak debug output to users.

All twelve criteria
  1. Readable at a glance by a developer, in plain English.
  2. States the intent: what the rule wants the developer to do or avoid.
  3. No code or source material: no snippets, regexes, AST node kinds, globs, JSON, config keys, token lists or backticks. Naming the API or construct the rule is about is fine when the intent needs it ("dd() and dump() calls", "mutable default arguments", "useEffect").
  4. No origin or source: no upstream tool or rule names, no versions, no "port of", no conformance or divergence notes.
  5. Clear and concise.
  6. Accurate: it matches what the clauses and scope catch. It doesn't overpromise (if tests are excluded and that matters, don't claim "everywhere") or underdescribe.
  7. One voice for the whole repository: one imperative sentence starting with a verb (Avoid, Use, Prefer, Keep, Remove, Add, Declare, Close, Handle, Don't…) and ending with a period, optionally followed by ; <short reason>.
  8. At most 200 characters, a hard cap.
  9. A reason after the semicolon only when it isn't obvious and still fits.
  10. Siblings are told apart: when similar rules exist for several languages or file types, name the language or file type (complexity-cyclomatic-python, -typescript, -javascript).
  11. Meaningful thresholds stay ("above 6", "more than 5 parameters"); implementation trivia goes.
  12. It doesn't just repeat the rule's title or its message.

Detail left out goes under "Rule details" in the pack's README.

Rule details and candidates

Rule details hold exact counting rules, edge cases, deviations from upstream and scope detail. The README is published, so it covers only rules pack.json lists. A candidate's details go under "Rule details" in the unpublished candidates/README.md, and move to the README when the rule ships (check 5).

Message

The message tells the agent what to change and, when the change could be wrong, when not to.

Instead of Write
Bandit B113 (request-without-timeout) Pass a finite timeout to this HTTP call; without one a slow server can hang the program.
zip() without an explicit strict= parameter Pass `strict=` explicitly: `strict=True` when lengths must match, `strict=False` when truncation is intended.
Comparison to `None` should be `cond is None`, one clause for == and != One clause each: Use `is None` instead of `== None` for this comparison. and Use `is not None` instead of `!= None` for this comparison.

These criteria are for reviewers and never block a merge: no check reads a message's text.

All seven criteria
  1. It names the change: a replacement or what to remove. A fact or a ban alone isn't advice.
  2. If following it would be wrong for some code it matches, it says when to leave the code alone or what the change trades off.
  3. It states a risk only as far as the rule can confirm it, without softening ("consider") or shouting (NEVER, MUST).
  4. It is right for every match it doesn't exempt, agrees with the clause's fix, and names captures ($NAME) where that shows which code it means.
  5. It leaves out the rule id, location, "Rerail" and "fix it or explain", and names no tool, rule code, tool version or matcher term outside quoted code.
  6. Each text it can show is at most 200 characters (placeholders as written), in any number of sentences.
  7. Matches that need different advice get different messages (x == None and x != None).

The validation contract

Eight checks every pack must pass; ported rules must also pass the conformance gate to ship.

sh
npx vitest run test/engine/catalog/packs.test.ts                         # every pack: checks 1 to 8, each rule's status
npx vitest run test/engine/catalog/packs.test.ts -t "pack python"        # one pack

To see every false positive and false negative of one ported rule: node --import tsx conformance/languages/cli.ts --pack <id> --rule <rule> --fixtures.

# Check Fails when
1 Rules A rule document or candidate is unlisted, missing, misnamed or doesn't compile (a conversation rule: doesn't parse, or has a pattern the engine refuses); an id breaks the id rule
2 Proof A rule has neither examples nor a port entry in conformance.json
3 Examples An example isn't caught, or doesn't pass, as it says (a conversation rule: doesn't fire, or fires)
4 Ports A shipped port disagrees with its expected findings outside divergences.json; suppression data is missing or stale
5 Package package.json (public or "private": true), its license or a README's rule details are wrong
6 Licences Provenance records are malformed or disagree with the pulled test data
7 Manifest pack.json has no valid match; core's generated pack list lacks a published pack, names a private one, or is stale
8 Text A code rule's description fails a machine-checkable description criterion
Where the checks run

validatePack(directory, { listed?, fixtures? }) in conformance/pack-validation.ts runs checks 1 to 8 over one pack. It returns each check's failures and each rule's status (ok, failed or not-supported-yet).

test/engine/catalog/packs.test.ts calls it for every directory under packs/, with one test per check and per rule, plus the validator's own tests over the fixture packs in fixtures/packs/. It runs with this repository's core in npm test, and in release-packs.yml on a pull request from this repository touching packs/ and before each publish (release).

The contract is a floor. A ported rule must also pass the conformance check's gate: on upstream's cases and their rename variants (no unexplained false positive or miss), on real code, for size, and for silence where it should be silent. The gate blocks CI for shipped rules and only reports candidates.

How check 4 is scored in CI and at publish

Check 4 scores ported rules on the tools' fixtures. Rename variants and real-code samples are scored by the conformance check (npm run test:conformance), not here.

Exact conditions: checks 1 to 3
  1. Rules. pack.json must parse (rulePackFileSchema) and its id must be the directory name.
    • Every file in rules/ is listed in pack.json, and every listed document exists. Each rule's id is lowercase letters, digits and dashes; its document is rules/<id>.json and carries the same id.
    • Each document is a valid code rule (rerail.policy/v1, the host's schema) and compiles in the engine with core's native/src/code_rule_dsl/capabilities.json, as the catalog compiles it; or a valid conversation rule (a JSON object with a match and no format, parseConversationRule) whose every pattern the engine accepts (engineRegexIssues).
    • Candidates need not compile. Every rule document under candidates/ (each JSON file in a rules/ directory there, and any file there whose format names a policy, such as the older tempo.policy/v1) must be rerail.policy/v1 or a conversation rule, sit in a rules/ directory and be named <id>.json (candidates/rules/<id>.json, candidates/probes/rules/<id>.json).
    • Every port in the pack's conformance.json, and in each conformance.json under candidates/ (probes), has its document's id. Check 4 loads only the root conformance.json, so check 1 also fails a conformance.json under candidates/ that does not parse (packSchema) or has a port whose document does not exist.
  2. Proof. A port entry is a conformance.json rule whose policy is the rule's document.
  3. Examples. A caught example must produce a violation of its rule and a passes example none, for pack.json's examples and a candidate's in conformance.json. Each is evaluated alone through the engine, as a one-file repository at its path (evaluateExample in conformance/pack-validation.ts). The path must be in the rule's scope, and evaluation must produce no engine failure. A violation counts when its code is one of the rule's clauses' codes. A candidate's pair is held to the same against its document under candidates/. A conversation rule's examples are matched alone through the conversation engine (ConversationRuleRegistry, repeat ignored), built as Conversation rules says: the caught one must fire the rule, the passes one must not.
Exact conditions: check 4, ports and suppression comments
  1. Ports (with conformance.json). The fixture tier of the conformance runner (conformance/languages/runner.ts, the language-conformance test's), over the production workspace path.

    • An unexplained false positive fails on every source. An unexplained false negative fails on the fixtures only, as do engine failures, expected-findings errors and stale divergences (matching nothing) or exemptions.
    • A declared capability needs a ported rule or an exemption.
    • Candidates (ports pack.json does not list) are scored too; their false positives, false negatives and engine failures are reported and fail nothing.

    Suppression comments. Every port, candidates included, must honour its upstream tool's suppression comments as the tool does: each violation of its document carries the suppress key below, naming the port's upstream.rule. This lives in the document because conformance.json never ships. Ports of other tools (Bladestan, tlint, blade-parser, lizard) need none.

    upstream.tool The comment it honours violation.suppress
    eslint, eslint-plugin-*, typescript-eslint // eslint-disable-line <rule> and the other eslint-disable forms eslint.rules
    ruff # noqa: <code> noqa.codes
    bandit # nosec <id> nosec.codes
    phpstan @phpstan-ignore <identifier> phpstan.identifiers
    psalm @psalm-suppress <issue> psalm.issues
    phpcs phpcs:ignore <sniff> phpcs.sniffs: the sniff or one of its message codes, <sniff>.<code>

    A port whose document does not model the comment is listed in suppressionExemptions as { "rule": <port id>, "reason": … }: its expected findings ignored the comments (Bandit's --ignore-nosec), or it has a clause the engine cannot suppress (suppress is refused on a regex query). An exemption is stale when the port carries the suppression on every violation, or its tool honours no comment.

    Oxlint. A port of a rule Oxlint 1.88.0 also implements carries oxlint.rules too, with Oxlint's rule name without its plugin (rules-of-hooks, no-console), so oxlint-disable comments silence it as they silence Oxlint's. Oxlint also reads eslint-disable comments, so once any violation of a document carries suppress.oxlint, each violation carrying suppress.eslint must carry it, and none may carry it alone.

    Biome. A port of a rule Biome 2.5.15 has a rule the same as (its sources mark the ESLint rule .same(), not .inspired(): useHookAtTopLevel for react-hooks/rules-of-hooks) carries biome.rules too, with Biome's full name (lint/correctness/useHookAtTopLevel). Then biome-ignore comments naming it, its group or lint silence the port as they silence Biome's rule. Each violation carrying suppress.eslint must carry the Biome rules the same as its ESLint rules, and none may carry suppress.biome without suppress.eslint. scripts/generate/packs/biome-suppression.mjs reads the mapping, conformance/biome-same-sources.generated.ts, from Biome's source and writes suppress.biome into the pack documents.

Exact conditions: checks 5 to 8
  1. Package. The npm name is @neralabs/rerail-pack-<id>, publishConfig.access is public unless private is true, private is never false, there are no dependencies, and repository is { "type": "git", "url": <the root package.json's repository url>, "directory": "packs/<id>" }.

    • files publishes pack.json, rules, LICENSE and, when present, licenses. No entry may cover conformance.json, divergences.json, upstream or candidates.
    • Some way to satisfy license names exactly Apache-2.0 plus, per licenses/<project>/UPSTREAM.md, one way to satisfy the SPDX expression its License: line records. An OR there lets the package elect either side (MIT OR Apache-2.0 with Apache-2.0 AND MIT); an AND must be named whole.
    • Under ## Rule details in README.md, every ### heading names at least one rule in backticks, and every backticked name is a rule pack.json lists. Under ## Rule details in candidates/README.md, none is. A heading inside a fenced code block doesn't count.
  2. Licences. The pack's provenance records (UPSTREAM.md) parse and agree with the pulled test data.

    • Each licenses/<project>/UPSTREAM.md has a well-formed License: expression that some way to satisfy uses only licences on the repository allowlist (scripts/check/licence-allowlist.json), a complete Source row, and only well-formed Not copied lines. The pack has a LICENSE.
    • Every pulled file in a subdirectory of upstream/<tool>/ (the tool's copied tests: fixtures/, tests/) gets a licence by the rules in Licence of each file, which the publish also uses. Files directly in upstream/ or upstream/<tool>/ (expected findings, generators) are Rerail's.
    • Each declared glob matches at least one pulled file. A checkout without the test data fails once per tool, saying to run npm run fixtures:pull.
    • The tool's own UPSTREAM.md lists only existing files under its own upstream/<tool>/.

    reuse lint and node scripts/check/check-licenses.mjs are repository-wide CI gates, not part of check 6. Structure metric 7 (npm run check:structure) checks the rule documents' REUSE licence.

  3. Manifest. match must be present and pass the schema: without it set-up never recommends the pack. For a directory under packs/, src/engine/catalog/pack-list.generated.ts equals what npm run generate:packs writes and lists the pack's id, or, for a private pack, doesn't. Fixture packs elsewhere are unpublished, so no list names them; listed: true checks the list for them too.

  4. Text. Every code-rule description, shipped and candidate (not probes), meets the machine-checkable description criteria (textFailures): present, one sentence ending in a period, at most 200 characters, no backticks, no upstream tool named outside quoted text ('…' or `…`). Messages are not checked; the message criteria are for review. Check 1 covers a message's $ placeholders: a message naming a capture its clause doesn't bind does not compile. A conversation rule has no description and is left out.

Languages not supported yet. A pack for a language core's language registry lists as planned can land before the engine supports it.

How unsupported languages are tested

A pack may hold code rules for a planned language, or rules reading a capability their language has not-supported-yet. Such a rule fails to compile with code language-not-supported. The tests report it as not supported yet (its rule test is skipped with the engine's reason) and don't fail: check 1 passes it, and checks 3 and 4 skip it until core reads the language. Any other compile error still fails check 1.

Versioning and release

Packs version with changesets and release from main through a reviewed Release pull request.

Packs are pre-release until 1.0.0: below it, every change is a patch. From 1.0.0:

Change Version bump from 1.0.0
A false positive or false negative fixed; wording, examples or expected findings changed patch
A rule added; the core range widened minor
A rule removed or renamed; a default mode made stricter; the core range narrowed major

Versions are managed with changesets. A pull request that changes what a pack publishes adds a changeset naming the pack's package, its bump and a one-line summary for users, then renames it so it starts with its change type (fixed-vue-no-unused-vars-filters.md, .changeset/README.md):

sh
npx changeset

Nobody edits a pack's version or CHANGELOG.md by hand: at release, npx changeset version applies pending changesets and bumps each pack, and the Release pull request writes its CHANGELOG.md.

What needs a changeset, and how it is enforced

Release notes. The summary goes out as written on the pack's GitHub Release: what changed for the person upgrading, in at most 60 words. Which rules a pack ships and how each starts is not the summary's to say: the Release pull request writes that from pack.json (step 1 below). A summary says what a rule now does differently, or why a rule no longer ships; a change with nothing else to say, a rule shipped or a README reworded, leaves it empty, and the changeset still bumps the pack.

Release note rules

The summary says what changed plainly, in a sentence or two, without commit hashes, ticket ids, pull request numbers or repository-internal maintenance. The Changesets job fails a changeset that breaks this (release notes), one whose file name gives no change type, and one under Added, Removed or Fixed that doesn't open with the id of the rule it is about (changesetRuleRefusal in scripts/release/packs.mjs). The Release pull request writes each summary as one entry, as written, under its type, and an empty summary as no entry; changesets itself writes no changelog ("changelog": false in .changeset/config.json). Rule counts are left to the Release pull request because changesets pile up between releases and each counts against the main it was written on.

Release

.github/workflows/release-pr.yml versions the packs, with core; .github/workflows/release-packs.yml releases them. Core's v* release (release.yml) is separate.

  1. Version. Each push to main with anything releasable opens or updates the Release pull request. Merging it is the release.
  2. Publish. On each push to main, every published pack (not private) whose package.json version has no git tag @neralabs/rerail-pack-<id>@<version> passes the publish gate and is published.
  3. Put back. Run the workflow from main (Actions → Release packs → Run workflow) with the pack's id and an older released version, to point latest back at it.
Step 1: the Release pull request
Step 2: the publish gate and dist-tags

The publish gate is:

Set-up and rerail packs add <id> fetch under latest (on the default update channel), so the version is used from then on. next never trails latest: it moves to the version too unless already at a newer one, so the next channel gets the release. A pack held at an older latest (step 3) is published under next instead and stays held.

Packs publish one by one, and a failed run can be rerun. A tagged version is skipped. One the registry has without its tag (a run that failed after publishing) is checked, has next moved up and is tagged, not republished. The Published dist-tags job lists each published pack's dist-tags in the run summary, and fails if latest (next for a held pack) is not the version just published or next is older than latest. A pull request touching packs runs the gate and packing as a dry run.

Step 3: holds and promotion

The first job lists in the run summary what will move. After a reviewer approves the release environment, the promote job points latest at that version and tags it held there.

GitHub Releases. Every released pack version gets one on its tag @neralabs/rerail-pack-<id>@<version>. The notes are that version's CHANGELOG.md section, so they are written once, by the Release pull request and in the changeset summaries.

How GitHub Releases are made

The scripts are in scripts/release/. To check a pack's tarball locally: node --import tsx scripts/release/packs-pack.ts --out /tmp/rule-packs <id>.

Publishing a new pack

A pack's first version is published by hand; the workflow publishes later ones. Steps: publishing-a-new-pack.md. To publish a private pack, remove "private": true in a pull request that adds its changeset and publishConfig, then follow those steps.

Why the first publish is manual

npm configures a trusted publisher only for an existing package, and a new configuration expires unless a publish uses it within 2 days. Until the first publish, each release run reports the pack as needing it and fails after publishing the others.

Licensing

Rerail's own material in a pack is Apache-2.0; material copied or adapted from upstream keeps its licence.

Rule documents, test data and REUSE

UPSTREAM.md

A pack keeps two kinds of provenance record in git.

Format of licenses/<project>/UPSTREAM.md

It names patterns, never single files; the per-file record is in each archive (below).

Format of upstream/<tool>/UPSTREAM.md

A table with one row per file, backticked pack path under upstream/<tool>/ first (| `upstream/ruff/fixtures/rerail/noqa/B002.py` | written by Rerail |).

provenance.json and LICENSES.md

Each test data archive holds upstream/<tool>/provenance.json (every file with its source, upstream path and how it was made) and upstream/<tool>/LICENSES.md (each source's licence and licence text).

How they are written and read

Both are derive output. The generator writes both last, through scripts/pack-data/provenance.mjs, which refuses to write either when the files disagree with the pack's UPSTREAM.md records (docs/upstream.md). They are for audit: no licence check reads them. The publish workflow's prep reads only the key set of provenance.json's files, to refuse an archive whose files it does not describe.

A snapshot key appears only in archives bootstrapped by the one-off snapshot taken when the records moved from per-file rows to patterns, until the tool-pack is derived again. A samples archive's LICENSES.md lists each source repository, its licence and the files taken from it (docs/upstream.md).

Licence of each file

Pack check 6 and the publish workflow's prep decide each test data file's licence by the same rules: Not copied first, then Rerail's own files, then the longest matching Taken from it glob.

The exact rules

They share parsing and pattern choice (readUpstreamSources, decidingPattern and inToolSubdirectory in scripts/s3/pack-fixtures.mjs):

  1. A file under a Not copied glob is refused, even one the tool's UPSTREAM.md lists or one under authored/.
  2. Otherwise a listed file, or one under authored/, is Rerail's Apache-2.0.
  3. Any other file takes the licence of the longest Taken from it glob covering it. Equal-length longest globs that disagree on licence or copy kind are refused.
  4. A file in a subdirectory of upstream/<tool>/ that none of these takes is refused. One directly in upstream/<tool>/ (expected findings, cases.json, provenance.json) is Rerail's.

The publish adds one rule: a file from an Apache-2.0-only source is refused unless that glob's kind is verbatim, since a modified Apache-2.0 file must carry its change notices.

A licence passes when some way to satisfy its SPDX expression uses only allowlisted licences (AND binds tighter than OR); a malformed expression is refused. A sample's licence is its repository's recorded one (docs/upstream.md).