The loop
1
Scaffold
Scaffold a pack plus fixtures
--category selects the directory and defaults to models. Valid values:
models, embeddings, frameworks, vectordb, infra, params, prompts,
datasets. The pack name must match [a-z0-9-]+.2
Edit the rule and its fixtures
Write the pattern, then annotate fixture lines with the cases it must and must not
match.
3
Test
Run the pack against its fixtures
4
Lint
Validate the full contract plus fixture coverage
File layout
- Packs live under
rules/<category>/. - One pack file per provider.
rules/models/openai.yaml, neverrules/models/all-providers.yaml— monoliths are merge-conflict hotspots and defeat CODEOWNERS routing. - The
pack:field must equal the filename stem:openai.yamlimpliespack: openai. - Fixtures live in
rules/<category>/testdata/<pack>/.
Top-level fields
string
required
Must match
[a-z0-9-]+ and equal the filename stem.integer
required
Must be ≥ 1. Informational only — cache invalidation is driven by the content hash
of the effective compiled ruleset, never by this number. Bump it as a human-readable
change marker.
list
required
At least one rule.
Rule fields
id
Format <pack>/<slug>, e.g. openai/model-literal, matching [a-z0-9-]+/[a-z0-9/-]+.
The prefix before / must equal the pack name.
kind
One of the ten rule-expressible kinds:
local-model-file, rag-pipeline, and application are not rule-expressible by
design and are rejected by the compiler.
languages
Subset of the supported set; omitted means the rule runs on all of them.
keywords — mandatory
Literal substrings, matched against the file’s code and string regions by a single
Aho–Corasick trie built over all packs’ keywords at startup. A rule’s regex executes
only if at least one of its keywords hits.
Both the keyword and the scanned buffer are folded before the trie runs: ASCII letters are
lowercased, and runs of whitespace collapse to a single space. The gate is therefore
case- and whitespace-insensitive. That only widens the candidate set — the rule’s regex
still runs over the original, position-preserving bytes and enforces the exact semantics.
This is the load-bearing performance invariant, so it is enforced rather than
recommended:
Practical consequences:
- Casing does not have to match:
"ChatOpenAI"gates a file containingchatopenai. Fold the rest of the distinction into thepattern, which is case-sensitive unless you write(?i). - Prefer selective literals: at least 4 characters, provider-distinctive. Short or common keywords defeat the prefilter.
- Comments are never scanned. A keyword that appears only in a comment cannot activate the rule.
pattern
A Go RE2 regex — no backtracking, no
lookaround, linear-time by construction. Compiled once at startup; a non-compiling
pattern fails lint.
Named groups (?P<name>…) are the data channel. Each named group’s match is recorded on
the occurrence and is referenceable as ${name} in any template.
Two cross-referencing rules are enforced in both directions:
- Every named group must be referenced — by a claim template, by a relation’s
from_field, or by beingmodel, which the assembler consumes semantically. An unreferenced group is a lint error: it is either dead weight or a typo. - Every
${var}must name an existing named group.
regions
Which classified regions the pattern may match: any subset of code and string
(default: both). There is deliberately no comment value — comment regions are never
scanned, not even by the keyword prefilter.
claim
The templated component claim. The assembler — never the rule — normalizes names, mints
identity, dedups, merges, and computes final confidence.
kind and provider come from the rule fields. A purl is derived by the assembler;
hashes do not apply to pattern matches.
relations
Edges are first-class rule output — no Go needed to claim a relationship.
type is one of: uses, depends-on, served-by, queries, embeds-with,
prompted-by, trained-on, derived-from, configures, contains.
target must set exactly one of three hint forms:
The
name and from_field forms require a kind. from_field must reference a named
group or a capture_params name.
Resolution happens in the assembler after all components exist. A hint that matches no
component becomes a warning in the scan stats — never a phantom node, never a guessed
edge. This is the same refusal that keeps CycloneDX
dependencies[] empty rather than
fabricated.capture_params
Same-call-site generation-parameter capture — the highest-precision layer of the
AI-config binding story.
Captured bindings land on the occurrence. The assembler promotes them into
provenance-carrying bound params on a model’s facet only when the same occurrence also
carries a
model binding. Two call sites with different temperatures stay two separate
bound params — nothing is averaged or guessed.
confidence
Float in (0, 0.99] — the confidence of one sighting by this rule alone.
A complete pack
This is a full, working pack — it passesairom rules lint and airom rules test as
written. Rule one claims the model from a literal; rule two claims the SDK, links it to
the model with a uses edge, and captures generation params at the call site.
rules/models/acme.yaml
keywords: ["acme-"] gates the regex; the regex’s
(?P<model>…) group feeds claim: { name: "${model}" }; and from_field: model in rule
two reaches the same field name so the assembler can resolve the edge once both
components exist.
Fixtures
Every rule ships at least one positive and at least one negative case — lint-enforced. Annotations use the host language’s comment syntax (# or //):
rules/models/testdata/acme/usage.py
Run it
rules/models/acme.yaml uses
rules/models/testdata/acme/. Failures print with file, line, rule ID, and reason, and
the command exits non-zero.
For packs in the repository, go test ./rules/... runs the same check over every embedded
pack: the whole set must load and compile, and every pack’s fixtures must pass.
The lint contract
airom rules lint enforces items 2–10:
Structure and identity
Structure and identity
packmatches the filename stem; one provider per file. Checked for the embedded packs only — at startup and bygo test ./rules/.... An overlay you pass to--rulesorairom rules lintmay be named anything; the naming rule is a repository convention there, not a machine check.- Every rule
idis well-formed and globally unique across all packs and layers.
Pattern and prefilter
Pattern and prefilter
- Every
patterncompiles as RE2. keywordsis non-empty for every rule.- Every named group is referenced; every
${var}is backed by a named group.
Vocabulary and bounds
Vocabulary and bounds
regionsis a subset of code/string;languagesis within the supported set;kindis rule-expressible.relations[].targethas exactly one hint form;from_fieldreferences an existing field source.capture_params.within_linesis in [1, 64];namesis non-empty.confidenceis in (0, 0.99].
Coverage
Coverage
- At least one positive and one negative fixture annotation per rule, and every annotation holds.
When a rule isn’t enough
A rule cannot read a second file, parse a structured format, loop, compute a hash, mint an ID, set a purl, assert confidence 1.0, or emitlocal-model-file, rag-pipeline, or
application. If your detection needs any of those, write a Go detector:
Scaffold a Go detector
internal/detectors/<name>/ with an implementation stub and a contract test.
Rules Overview
Layering, merge semantics, and how the engine executes a rule.
CLI Reference
Every command and global flag.