Rule format
Rules and settings live in .rulecast-config.yaml at the project root. Keys are snake_case. rulecast validate checks the file.
Relative links in this file resolve against this file’s own URL.
exclude: ^vendor/repos: - repo: https://github.com/syv-ai/rulecast rev: v0.2.0 rules: - id: python/no-httpexception-in-services files: ^app/services/ context: ["@AGENTS.md#errors"] - repo: local rules: - id: api/no-client-in-components name: Components never call the API client files: ^frontend/src/components/ types: [tsx] exclude: \.test\.tsx$ detect: regex: pattern: 'from "@/client"' message: "{{file}}:{{line}} imports the API client. Use the feature's query hook." context: - "@docs/api-access.md#frontend-data-flow"Top-level keys
Section titled “Top-level keys”| Key | Default | Meaning |
|---|---|---|
repos |
required | Repo entries (below) |
minimum_rulecast_version |
none | X.Y.Z: older rulecast versions reject the config |
files |
"" |
Regex every rule’s files must also match |
exclude |
^$ |
Regex no rule’s files may match |
default_stages |
none | Stages for rules without stages |
context.mode |
inject |
Default reference mode: inject or read |
context.max_bytes |
32768 |
A larger reference is delivered as read |
max_matches_per_rule |
10 |
Findings shown per rule |
max_file_bytes |
1048576 |
A larger file is skipped by the in-process detectors (regex, path, ast-grep) on edit and guard, where an agent is waiting. verify always runs |
timeouts.edit_deadline_ms |
350 |
Detection deadline after an edit |
timeouts.verify_ms |
60000 |
Detection timeout when an agent stops and in rulecast run |
stop_gate.max_blocks |
1 |
Stops blocked per agent per user prompt |
mode |
none | attended or unattended: whether a person answers the agent. RULECAST_MODE overrides it, and it overrides CI detection (below) |
unattended.max_blocks |
10 |
Stops blocked per agent per prompt in unattended mode, instead of stop_gate.max_blocks |
unattended.escalate |
[] |
Ids of warning rules that are error in unattended mode |
refuse_gate.max_refusals |
1 |
Writes a refuse_write rule may refuse per file per session |
llm.provider, llm.base_url, llm.api_key_env, llm.max_files_per_verify |
claude-code, null, ANTHROPIC_API_KEY, 10 |
Settings for the llm detector. provider is one of claude-code, opencode, anthropic, openai-compatible. There is no llm.model: every llm rule names its own |
Repo entries
Section titled “Repo entries”repo: a git URL, orlocalfor rules written in this file.rev: required for a URL, not allowed forlocal. A tag or a full commit SHA;rulecast autoupdatemoves it to the latest tag.rules: for a URL, entries that select rules from the repo’s.rulecast-rules.yamlbyidand may override any other key. Forlocal, complete rules.
Rule keys
Section titled “Rule keys”| Key | Required | Meaning |
|---|---|---|
id |
yes | [a-z0-9-]+(/[a-z0-9-]+)*, unique within its repo |
alias |
no | A second name, unique across the config, so one repo rule can be selected twice with different overrides. Findings use it |
name |
for local and published rules |
Short title |
description |
no | Longer text, shown by rulecast init |
files |
no, default "" |
Regex searched in the repo-relative path (forward slashes, not anchored: add ^ and $ yourself) |
exclude |
no, default ^$ |
Regex: matching files are skipped |
types |
no, default [file] |
The file has every one of these type tags |
types_or |
no | The file has at least one of these |
exclude_types |
no | The file has none of these |
stages |
no | Subset of touch, edit, verify (below) |
severity |
no, default error |
error blocks the agent’s stop and fails rulecast run; warning is delivered and never blocks |
escalate |
no, default false |
true: error in unattended mode, whatever severity says. The same as listing the id in unattended.escalate. A catalog rule’s escalate: true is turned off with escalate: false on its entry |
scope |
no, default instance |
Whether the convention belongs to the matched token or to the node around it, or, with removed, whether a file lost matches (below) |
refuse_write |
no, default false |
Refuse an edit-tool write when this rule fires on what the agent is writing; catch a shell write right after it (below). unattended: only in unattended mode |
enabled |
no, default true |
false switches the rule off without deleting it; works as an override on a catalog rule (below) |
detect |
unless stages: [touch] |
One detector, { <kind>: <config> }: see detectors.md |
fix |
no; inside detect.regex or detect.ast-grep |
The replacement rulecast fix writes over each match (below). No other detector takes one |
message |
with detect |
Template with {{file}}, {{line}}, {{column}}, {{text}}, {{rule}} and the detector’s captures; a removed rule also has {{removed}} and {{baseline}} (below) |
context |
for touch rules | References delivered with the rule (below) |
examples |
no | Code the rule must flag and must not, run by rulecast test (below) |
minimum_rulecast_version |
no | X.Y.Z, for rules published in rule repos. Read before the rest of the rule, so an older rulecast says to upgrade rather than rejecting a key it does not know |
A rule applies to a file when the top-level files and exclude, the rule’s files and exclude, and its type keys all match.
Instance and container rules
Section titled “Instance and container rules”rulecast only reports what the agent’s own edit caused; everything else is summarised as pre-existing
and never blocks. scope says how it decides.
instance, the default, calls a finding new when the agent’s edit touched the lines the detector
matched. That is right when the convention is about a token: a crud. call in a route is a violation
at the line it sits on, and an edit that adds one is an edit that touched it.
container calls a finding new when the node the match spans was not already violating the rule
before the agent started. Use it when the convention is a property of the enclosing function or
component:
- id: python/slim-routes files: '(^|/)routes/.*\.py$' scope: container detect: ast-grep: language: python rule: { kind: function_definition, has: { kind: if_statement, stopBy: end } } message: "{{file}}:{{line}} has branching in a route. Move the decision into a service."Without scope: container this rule would fire on every edit anywhere inside a long route, because
the match is the whole function and so overlaps every change in it. Measured over 1,377
agent-written sites, that is precision 0.62; the container comparison is 0.76.
Two things to know before reaching for it:
- A second violation added to an already-violating container is pre-existing. The rule is about
the container, and the container was already broken. If you want every added occurrence reported,
the convention is really about the token — use
instance. - It needs a baseline to compare against. Where rulecast has none for a file — the file was
changed outside the agent’s tools, or is over
max_file_bytes— acontainerrule falls back toinstance. Anllmrule always does: measuring its baseline would mean a second model call every time the agent opens a file.
Removed rules
Section titled “Removed rules”scope: removed turns the question round. A match is something the code should keep, such as
an assertion in a test, and the finding is a file that has fewer matches than it had at its
baseline:
- id: tests/keep-assertions files: '(^|/)tests/.*\.py$' scope: removed detect: ast-grep: language: python rule: { kind: assert_statement } message: "{{file}}:{{line}} removes assertions ({{removed}} of {{baseline}} removed). Put them back, or, if you combined them on purpose, keep that and say so in your report."There is one finding per file, always new. It sits on the first line the change touched (the
file’s last line, for a deletion at its end). The message says how many went with {{removed}}
and how many the file had at its baseline with {{baseline}}, so the example above reads
2 of 5 removed. Its {{text}} is always removed: deleting one more match is the same finding,
not a new one, so an unattended run that keeps deleting is blocked at most twice for it, like any
other finding (see Unattended mode). A match by itself is never a finding, and adding, moving or rewriting matches is
nothing as long as the count does not drop. The count is compared when the agent stops and in
rulecast run, not after each edit: an edit may cut an assertion that the next edit pastes
somewhere else. Matches on a
line with a rulecast-ignore still count, and the finding is not on a matched line, so an ignore
comment does not silence it; use enabled: false or exclude.
What it needs:
- A baseline. In an agent session that is the file as the agent first read it, or the session’s
start commit. A file the agent changed before it read it, with its shell, keeps the start commit
as its baseline: the read comes after the change.
rulecast run --from-refcounts against the merge base, and a staged run againstHEAD.rulecast run --filesand--all-fileshave no baseline, and a removed rule reports nothing there. - A detector that reads the content it is handed and reports each match in it:
regexorast-grep. Notpath, whose match is the whole file, and not a linter, command orllm. editamong its stages, which is the default for both. A session’s baseline is taken when the agent opens a file, by the edit-stage rules. It cannot be combined withrefuse_writeorfix: the matches are what the rule keeps.- A file that still exists. A deleted file has nothing to count and is not reported. Nor is a
renamed one (
git mv): it is a deleted file and a new one, and the new path has no baseline.
It counts, so it cannot tell which match went: deleting one assertion and adding an unrelated one
leaves the count where it was. Nor can it tell a deletion from a consolidation: three asserts
folded into one parametrized test, or several expects into one toEqual, lower the count and are
reported. Say so in the rule’s message, so the agent keeps a deliberate consolidation and mentions
it in its report rather than undoing it. A file over max_file_bytes is not counted. Only projects with a
removed or container rule pay for the baseline measurement.
examples on a removed rule show what it counts: a bad example has at least one match, which
deleting would report, and a good one has none. rulecast test --against does not count a
removed rule: its matches are what a file keeps, not violations.
Turning a rule or a finding off
Section titled “Turning a rule or a finding off”-
A whole rule:
enabled: false. Under a URL repo, an entry- id: <rule>withenabled: falseswitches a catalog rule off and keeps the entry, so re-enabling it is one line. -
One finding: a comment on the matched line or the line above it, in the file’s own comment syntax:
# rulecast-ignore: python/no-httpexception-in-services legacy endpoint, removed in #412raise HTTPException(404)The id is the one the finding shows. The reason is required: an ignore without one suppresses nothing and is reported. Ignores are counted under
rulecast run --all-files --summaryand listed by--format json, and one added during an agent session is reported to the developer when the agent stops. An agent adds one only when the developer agrees the finding is wrong.
Refusing a write
Section titled “Refusing a write”severity decides how loud a finding is after the fact: error blocks the agent’s stop, warning
never blocks. refuse_write is a different axis — it refuses the edit itself, before the file
changes, and the agent is shown the rule’s message and the section it cites instead of a result.
- id: codegen/no-edit-client files: '^src/client/' detect: path: {} refuse_write: true message: "{{file}} is generated from openapi.yaml. Edit the schema and run `pnpm codegen`."Use it where the right answer is “not this file, not this way” — generated code, a vendored directory, a secret that must not be committed — rather than for anything the agent could fix after writing it. Three things it does not do, all deliberate:
- It only fires on what the agent is writing. The same violation already in the file, on a line the edit does not touch, is reported after the write as usual. A rule that fires on an absence (“every service needs a docstring”) can never refuse, because the evidence is not in the new text.
- It gives up rather than guess. If the edit cannot be applied exactly as the tool describes it
—
old_stringmissing, or appearing twice withoutreplace_all— the write goes ahead. - It refuses once per file per session (
refuse_gate.max_refusals), so a rule cannot trap an agent that has no way to satisfy it.
A write made with the shell is caught right after it, not refused. Nothing can know what a
command such as sed -i will write before it runs. So rulecast compares the working tree before and
after the command: a refuse_write finding in a file the command changed is told to the agent at
once, with how to revert it (git checkout -- <file>, or “undo only your change” when the file had
uncommitted work before the session). Stop is blocked while the finding is new, whatever the rule’s
severity, and the developer is told which file the agent changed.
refuse_write needs a detector that can judge content it is handed: regex, path or ast-grep.
linter, command and llm hand a path to another program, which would read the file as it still
is, so rulecast validate rejects them.
refuse_write: unattended refuses only in unattended mode, and is an ordinary
rule otherwise. Use it for a write a developer could make on purpose, but a loop with nobody
watching should not. Its requirements are the same as for true.
Unattended mode
Section titled “Unattended mode”The stop gate assumes someone is in the loop. When the agent stops with new error findings, the
gate blocks the stop and the agent gets a turn to fix them. That happens once per user prompt
(stop_gate.max_blocks). After that the findings go to the developer as a message. A goal loop
(Claude Code’s /goal), claude -p, a scheduled task or a CI job sends one prompt and has nobody
reading messages, so it gets one block per run. After that enforcement quietly ends.
In unattended mode:
- The stop gate blocks every stop with new error findings, up to
unattended.max_blocks(10) per prompt. - A finding that has blocked two stops no longer blocks. It is reported instead, so a false
positive, with nobody to overrule it, cannot use up the budget. A finding is the same finding when
its rule, its file and the text it matched are the same, wherever the line has moved. When the
text tells nothing apart (a redacted secret, a hook’s whole-file finding), the tool’s rule id,
message and line are compared too. A
refuse_writefinding in a protected file the agent changed with its shell is the exception: it keeps blocking until the change is reverted. - When nothing is left that may block, the agent is held once more, to report. This happens when the budget is spent, or when every remaining finding has blocked twice. The agent is given the findings and told to record each one as unresolved in its final report, or in the run’s decisions file if it keeps one, and then finish. This happens once per prompt. A later stop is let through with the findings in a message, as in attended mode.
- A block tells the agent that nobody is there to ask. If it believes a finding is wrong, it leaves the code, records the finding and its reason in the decisions file or final report, and carries on. In attended mode it would say so to the developer.
- A
rulecast-ignoredoes not silence a finding on a line the agent changed during the run, whoever wrote the comment. Ignores on code the run did not touch keep working. The finding is judged like any other: it blocks, and is reported if it keeps blocking. That includes an edit to code under a comment that was already there: the comment was agreed to for the code as it was. The developer is still told about an ignore on changed code. Arefuse_writerule works the same way: an ignore does not stop the refusal of a line the write changes, while a write that only restates a line, or an edit that carries it as context, does not change it. - Rules can be stricter.
unattended.escalatemakes namedwarningrules errors, as doesescalate: trueon the rule itself, andrefuse_write: unattendedrules refuse writes.
The mode is decided in this order:
RULECAST_MODE:unattended, orattended(alsointeractive). It overrides the config, so an orchestrator can set it per run.mode:in.rulecast-config.yaml.CIorGITHUB_ACTIONSset (and notfalse, in any case, or0): unattended.- Otherwise attended.
An unknown RULECAST_MODE value is ignored: rulecast doctor warns about it, and a hook writes it
to its debug log. A missing terminal does not count, because a hook never has one. rulecast doctor prints the mode
it would run in from your shell, and where that came from. The mode applies to agent hooks only.
rulecast run in a git hook or a CI job always runs the rules as written: there is no agent there
to answer. The exception is rulecast run --session <id>, which shows a session’s findings: it runs
in the mode that session’s hooks recorded, so it lists what the stop blocks on. For a session
recorded before modes were, it resolves the mode from its own environment as a hook would.
With /goal. /goal is a Stop hook too. While a goal is active, its evaluator and rulecast
both run on every stop. A rulecast block is answered by a fixing turn even when the evaluator says
the goal is met, and the evaluator judges only its own condition. /goal sends no new prompt
between turns, so unattended.max_blocks is in effect a budget for the whole run. rulecast does
not read stop_hook_active: it is true after any hook’s block, /goal’s included, so rulecast
counts its own blocks in its session store instead. Two recorded runs are in
docs/evidence/2026-10-16-unattended-goal-runs.md in the rulecast repository.
mode: unattendedunattended: max_blocks: 6 escalate: [python/no-print]Examples
Section titled “Examples”examples holds good code the rule must leave alone, bad code it must flag, and baseline
edits whose findings it must sort into new and pre-existing. rulecast test runs them;
rulecast test <id> --against <paths> fires the rule over real files and says how many violations
it would produce and in how many of the files it matches. Without an id,
rulecast test --against <paths> prints that count for every rule, one line each. Add
--max-rate 0.5 to exit 1 when a rule flags more than that share of the files it matches; under 25
matching files it is not applied, because a share of a handful of files says nothing. --format json
prints the results as a document (output.md).
examples: good: - path: backend/app/services/users.py code: | log.info("created user %s", user.id) bad: - path: backend/app/services/users.py code: | user = create(name) print("created user", user.id) expect: { lines: [2] } fixed: | # optional, for a rule with fix (below) user = create(name) log.info("created user", user.id) baseline: - path: backend/app/services/users.py before: | def create_user(name): user = create(name) print("created user", user.id) return user after: | def create_user(name): user = create(name) user.save() print("created user", user.id) print("saved") return user expect: { new: [5], preexisting: [4] }-
pathis required and must match the rule’s ownfiles,excludeand type keys — those, andast-grep’s choice of parser, are all decided by the path, so an example without the right one tests a different rule. A path the rule would never be given is arulecast validateerror. -
A
badexample passes on at least one finding, unless itsexpectsays more:count: exactly this many matches.lines: the line each match starts on (line 1 for a finding about the whole file, such as apathrule’s). Compared as a set: every listed line has a match starting on it, and no match starts on a line not listed. Two matches on one line need the line once.
Pin
lineswhen it matters that the rule fires on the right line, not merely somewhere: an over-broad pattern passes a barebadexample by matching the wrong line. A line past the end ofcode(emptycodecounts as one line), or acountbelow the number oflines, is arulecast validateerror.goodexamples take noexpect: theirs is “nothing”. -
A
baselineexample is an edit.beforeis the file when the agent first opens it,afterthe file once it has edited it.rulecast testsorts the rule’s findings inafterinto new and pre-existing the way a session would, by the rule’s ownscope, and the example passes when the start lines of its new findings are exactlyexpect.newand those of its pre-existing ones exactlyexpect.preexisting, as sets. A status left out must have no findings, soexpect: {}means “no finding at all”. It catches a rule that reports code the edit did not touch as new: a regex that spans lines, anast-greprule that matches a whole function, acontainerrule. To catch a match that is too wide, put the edit’s unrelated line inside the block that holds the old violation, next to it but not on it, as above. -
Some rules have no baseline example to give. A
pathrule’s file is the break or it is not. Anllmrule only reports lines the edit changed, so an untouched old violation is never reported, as in a session; its baseline is never measured either, so anllmcontainerrule classifies asinstance. Ahookrule’s finding about the whole file (line 1) is new after any edit to the file. Baseline examples count towards neither precision nor recall. -
A
pathrule has nogoodexample to give: every file it matches is a violation by definition. -
An
llmrule’s examples are only run when you name the rule —rulecast teston its own skips them, because running one costs a model call.
Write the examples from real code in the repository rather than inventing them, and treat a failing example as a wrong rule before a wrong example.
A regex or ast-grep rule can carry a fix: the text that replaces each match. Only
rulecast fix applies it. Agent hooks never do: rulecast never rewrites an agent’s file behind its
back, so under a hook a fixable finding is delivered like any other.
- id: python/logger-not-print files: '\.py$' detect: regex: pattern: 'print\((?<args>[^)]*)\)' fix: "log.info({{args}})" message: "{{file}}:{{line}} uses print. Use the logger." examples: bad: - path: app/x.py code: | print("hi") fixed: | log.info("hi")
- id: frontend/logger-not-console files: '\.tsx?$' detect: ast-grep: language: tsx rule: { pattern: "console.log($$$ARGS)" } fix: "logger.debug($$$ARGS)" message: "{{file}}:{{line}} uses console.log. Use the logger." examples: bad: - path: src/x.ts code: "console.log(a, b)\n"regex: a template in the message’s{{name}}syntax, with the pattern’s named groups and{{text}}(the whole match). Values go in exactly as matched, line breaks included.{{file}},{{line}},{{column}}and{{rule}}are refused: they would be written into the code. A literal{{x}}in the replacement (Vue, Jinja) is read as a variable; an unknown one is arulecast validateerror.ast-grep: ast-grep’s own syntax.$NAMEis the text its node matched;$$$NAMESis the source from the first matched node to the last, so separators and comments between them are kept. A fix over several lines has lines 2 and on indented like the line the match starts on. A fix may only name metavariables the rule captures. ast-grep’sFixConfigobject (expandStart,expandEnd),transformandrewritersare not supported.- A fix needs a
badexample. A rule withfixand nobadexample keeps running, but its fix is dropped andrulecast validatewarns.rulecast testapplies the fix to everybadexample: the rule must not fire on the result, and when the example givesfixed, the result must equal it exactly (a failure prints the diff).rulecast fixruns these checks again before it applies a fix, and skips a rule that fails them. - What it does:
rulecast fix [RULE_ID] (--files F... | --all-files | --from-ref A)fixes every finding of a fixable rule in those files, pre-existing ones too, and then checks them asrulecast runwould;--dry-runprints the diff and writes nothing. A fix after which the rule still matches the text it wrote is undone and reported as unresolved, so a second run changes nothing. The exception is a file still changing after 10 passes, such asaa→aon a long run ofas: what the passes did is written, a warning says it is not finished, and the next run goes on from there. Findings with arulecast-ignoreare left alone. An ignored match over several lines protects every line of it, so a finding of the same rule nested inside it is not fixed either. Fixes stay within one match in one file: no fix spans files, andllmand tool rules have none. - What it will not write: a symlink (it is reported, not followed), a file with a symlinked
directory in its path, a file whose real path is outside the project, a file that is not UTF-8, and
a file that changed after it was read, checked again just before it is replaced. A file whose text
comes back to an earlier one while fixes are still pending after 10 passes (two rules undoing each
other) is left as it was, and so is a file where a fix would move a
rulecast-ignorecomment away from the finding it is for; their findings are reported as unresolved. A file that cannot be written is reported, and the others are still written.--dry-runleaves out the same files. A file is replaced through a rename, so its mode is kept, but another hard link to it keeps the old content.
Messages
Section titled “Messages”A rule that fires three or more times has its message printed once, with the variables shown as {name}, and one line per site under it carrying that site’s values. So write the message as one sentence that holds for every site — what to do instead, not just what is wrong — and let the capture groups carry what differs. A message that is nearly all variables, such as "{{file}}:{{line}} {{text}}", has nothing to print once and is repeated per finding as before.
File types
Section titled “File types”file (every file), text (every extension below), python (.py, .pyi), pyi, ts (.ts, .mts, .cts), tsx, javascript (.js, .mjs, .cjs), jsx, markdown (.md), mdx, yaml (.yaml, .yml), json, toml, css, scss, html, shell (.sh), sql, go, rust (.rs), plain-text (.txt). ts does not include tsx: write types_or: [ts, tsx]. An unknown tag is a rulecast validate error.
Stages
Section titled “Stages”touch: the first time an agent reads or edits a matching file in its context, the rule’scontextis delivered, without a message.edit: the detector runs on each file the agent edits.verify: the detector runs when the agent stops, on the files it changed, and inrulecast run. A stagedrulecast run(no file flags, the pre-commit check) skipsllmrules unless given--llm.
Defaults, in order: the rule’s stages, then default_stages, then [touch] for rules without detect or the detector’s own defaults. Those are edit and verify, except verify only for llm, eslint, hook, and command with scope: project: each is too slow to be felt on every edit, and stages: [edit, verify] asks for it anyway. A rule without detect needs stages: [touch] and context. A rule with detect needs edit or verify among its stages.
Overrides
Section titled “Overrides”An entry under a URL repo is merged over the published rule key by key. The merge is shallow: an overridden detect or context replaces the whole value. @ paths in an overriding context resolve against your project; the published rule’s own paths resolve against the rule repo.
Context references
Section titled “Context references”context: - "@AGENTS.md" # the whole file - "@AGENTS.md#frontend-data-flow" # one section - path: "@docs/state.md" mode: read # tell the agent to read it instead- Path:
@plus a path relative to the project root, or to the rule repo’s root for rules published there. Any text file. A path may not leave its root. - Anchor:
.mdand.mdxonly. The GitHub heading slug: lowercase, punctuation dropped, spaces turned into-; repeated headings get-1,-2. The section runs from its heading to the next heading of the same or a higher level, subsections included. - Mode:
injectdelivers the content;readtells the agent to read the file. The default iscontext.mode.
rulecast dedupes references: content the agent already has in its context is not delivered again.