Detectors
| Detector | Catches | Runs on |
|---|---|---|
regex |
A pattern in the file’s text | every edit |
path |
The file’s path alone — “never edit src/client/” |
every edit |
ast-grep |
Structure, not text: a call shape, a swallowed exception | every edit |
command |
Whatever a tool or your own script reports: SARIF, JSON, checkstyle, code-quality JSON or file:line: lines. Type checkers, dead-code finders and scanners by config |
every edit (whole-project tools: verify) |
hook |
A hook your .pre-commit-config.yaml or lefthook.yml already declares |
verify |
linter |
ruff, oxlint, eslint or gitleaks findings, narrowed to the rules you care about | edit (eslint: verify) |
llm |
Conventions nothing mechanical expresses, judged by a model | verify only |
llm rules are opt-in, name their own model, and by default run only when the agent stops, not on every edit, and so do hook rules and whole-project tools. A rule can ask for the edit hook with stages: [edit, verify]. Everything else is fast enough to run on every file the agent writes. See agents/reference/detectors.md.
A rule’s detect names one detector and its config. The message template of every match can use {{file}}, {{line}}, {{column}}, {{text}} (the matched text) and {{rule}}, plus the detector’s captures. Any other variable is a rulecast validate error.
A detector needs things the project may not have: a linter binary, a parser, an API key. rulecast doctor reports what each configured rule needs and whether it is there, so run it after drafting a rule that names a tool.
detect: regex: pattern: 'raise HTTPException\((?<args>[^)]*)\)' flags: mpattern: a JavaScript regular expression, searched in the whole file. Quote it with single quotes in YAML so backslashes stay as written.flags: any ofd,i,m,s,u,v,y; default none.gis always added. Usemfor^and$at each line,sfor.across lines.\smatches a line break. Withm,^\s*print\(matches from an empty line above theprint(and reports that empty line. Indentation is[ \t]*:^[ \t]*print\(.rulecast testprints what a failing example matched, line breaks included.- Captures: the pattern’s named groups.
(?<args>…)gives{{args}}, an empty string when the group did not take part in the match. fix: optional, the replacementrulecast fixwrites over each match, in the{{name}}syntax with the named groups and{{text}}. See rule-format.md.- Position: the line and column where the match starts. A match that spans lines counts as new when any of its lines changed.
- Default stages:
edit,verify.
detect: path: {}- Every file the rule selects is a match at line 1, so the rule’s
filesis the whole check. Use it for files that must not be edited at all, such as generated code. - Captures: none.
{{text}}is the file path. - Default stages:
edit,verify.
ast-grep
Section titled “ast-grep”detect: ast-grep: language: python rule: pattern: "raise HTTPException($$$ARGS)" inside: { kind: function_definition }language: one ofcss,html,javascript,python,tsx,typescript. Usetsxfor both.tsxand.jsx; it also parses plain TypeScript.rule: an ast-grep rule object —pattern,kind,regex,inside,has,all,any,not, and the rest. It is compiled when you runrulecast validate, so a rule ast-grep rejects is caught before any agent sees it.constraintsandutils: optional, exactly as in ast-grep’s own YAML.fix: optional, the replacementrulecast fixwrites over each match, in ast-grep’s syntax ($NAME,$$$NAMES). Only the string form: noFixConfigobject,transformorrewriters. See rule-format.md.- Captures: every metavariable in the config.
$NAMEgives{{NAME}}(one node, empty when it did not match);$$$NAMESgives{{NAMES}}with the matched nodes joined by,. A metavariable starting with an underscore ($_TMP) matches without capturing. - Position and
{{text}}: the matched node’s start, and its source. - Default stages:
edit,verify.
Matching something that is not a whole statement. A bare pattern has to parse on its own, which a JSX attribute or a lone argument does not. Give the pattern a context to parse in, and a selector for the node you actually want:
rule: pattern: context: '<div style={{ $$$PROPS }}/>' selector: jsx_attributeThat matches a style attribute on any element — the <div> is only there so the snippet parses.
command
Section titled “command”Any tool that works as a pre-commit hook works as a command rule: rulecast runs it, reads what it prints, and hands each finding to the agent with the rule’s message and the doc section it cites. tool-recipes.md has a tested rule for ty, mypy, pyright, vulture, tsc, knip, jscpd, staticcheck, golangci-lint, deadcode, semgrep, hadolint, tflint, shellcheck, osv-scanner and checkov.
detect: command: run: [ty, check, --output-format, gitlab, "{{files}}"] output: gitlab rules: [invalid-assignment] # optional: only these rule ids exit_codes: { error: [2, 101] } # optional: these codes mean the tool failedrun: the command and its arguments, run from the project root. The argument that is exactly{{files}}is replaced by the rule’s files; if there is no such argument, the files are appended. A rule that selects no files does not run the command at all.output: how to read what the tool prints. Defaultjson.json: an array of{ file, line, endLine?, column?, endColumn?, ruleId?, message?, severity?, text?, ...captures }.sarif: SARIF 2.1.0,runs[].results[]. A result with no region is on line 1; one that passed, did not apply or is suppressed is skipped.checkstyle: checkstyle XML. The rule id is the last dotted part ofsource(ShellCheck.SC2164→SC2164).gitlab: GitLab code-quality JSON, the only JSON ty prints.check_nameis the rule id.rdjson: reviewdog’s diagnostic format, or rdjsonl, one diagnostic per line.{ lines: gcc }:file:line:col: [severity[rule]:] message [(rule)]— mypy--show-column-numbers, ty--output-format concise, shellcheck-f gcc, staticcheck, golangci-lint, deadcode, tflint-f compact. A trailing(U1000)or[SC2115]is the rule id when it has no spaces and has a letter.{ lines: gcc-nocol }: the same without a column — vulture, mypy without columns.{ lines: msbuild }:file(line,col): severity CODE: message— tsc, dotnet.{ lines: { pattern: '…' } }: a JavaScript regular expression matched against each line, with named groupsfile,lineandmessage(required) andcol,endLine,endCol,rule,severity(optional). Any other named group is a capture. A pattern that does not compile or lacks a required group is arulecast validateerror.- For every line format, a line that does not match is ignored: tools interleave source excerpts and summaries with their findings.
filemay be absolute or repo-relative.
stream:stdout(default),stderr, orbothfor a tool that reports on stderr.rules: keep only findings with these rule ids. Rules with the samerun,output,stream,scopeandexit_codesshare one run and split its findings, so three semgrep rules scan once.exit_codes:{ error: [...] }names the exit codes that mean the tool failed rather than found something — ty exits 1 on findings and 2 on a bad config, vulture 3 on findings, semgrep 0 either way. A listed code disables the rule with the tool’s first lines of stderr. Every other code is read as findings-or-clean: the output is the answer.scope:files(default) gives the tool the rule’s files.projectruns it without files ({{files}}resolves to nothing) and keeps only the findings in the rule’s files. Use it for a type checker, a dead-code tool or a duplication finder, whose answer about a file depends on the rest of the project.captures: extra names your message uses, each a string field the tool emits on every finding (JSON fields, SARIFproperties, a pattern’s named groups). A missing one disables the rule, so the mistake is visible rather than silently blank. A capture may not shadowfile,line,column,textorrule.- Every finding carries
{{message}},{{ruleId}}and{{severity}}(the tool’s own word:error,major,note; empty when it gave none).{{text}}is the source at the finding’s range, unless a JSON result gives its owntext. - Output that cannot be parsed disables the rule.
- In a staged run (
rulecast runwith no file flags) or with--to-ref, afilesrule is handed a copy of each file under a scratch directory (.rulecast-staged-XXXX/<path>), because the content it must judge is the index or a commit, not the file on disk. Findings are mapped back to the real path. A script that decides by path should match the end of the path, not its start. Aprojectrule needs the real project, so it runs on the working tree when its files there match the index (or the commit), and is skipped with a warning naming the files when they don’t. rulecast never stashes to make them match. - Default stages:
edit,verify;verifyonly withscope: project, since a whole-project run is usually too slow for an edit. Writestages: [edit, verify]if your tool is fast enough (ty, hadolint, shellcheck and tflint take 15–50 ms per file).
detect: hook: pre-commit: mypy # a hook id in .pre-commit-config.yaml # lefthook: pre-commit/typecheck # or a lefthook job: <hook>/<job> output: { lines: gcc } # defaultRuns a hook the project already declares, so the agent sees the same thing a commit would show it, with the rule’s message and doc section.
pre-commit: a hook id (or alias) in.pre-commit-config.yaml. rulecast runsprek run <id> --files <files>when prek is installed, elsepre-commit run …, from the project’snode_modules/.binorPATH. That mode never stashes. A hook whosestagesleave outpre-commitis run at its first stage.rulecast validatenames an id the config does not declare.lefthook:<hook>/<job>in lefthook’s merged config (lefthook dump), including jobs inside groups and lefthook 1’scommands:. rulecast runs the job’sruncommand itself, from the job’sroot, with the rule’s files in place of{staged_files},{files},{all_files}and{push_files}. It does not calllefthook run, which can install git hooks. The job’sglobis not applied: the rule’sfilesdecide.output,rules,exit_codes,captures: as forcommand. The runner’s own framing (pre-commit’s status line and- hook idblock, prek’s indent) is removed before the output is read.- A hook that changes a file — a formatter, a fixer — has the file put back as it was, and each changed file is a finding:
{{message}}is “would change this file”. rulecast never rewrites an agent’s file behind its back. Only the rule’s files are compared. - A hook that fails and prints nothing the
outputformat reads is one finding on the rule’s first file, carrying the hook’s first lines of output, so the agent still hears that it failed and why. - A runner that never ran the hook (an unknown id, an environment it could not install) disables the rule with what it said.
rulecast doctorreports the runner it would use and every hook id or job it cannot find.- husky is not supported: its hooks are shell scripts with nothing structured to read. Use
commandwith the script’s command. - Like a
projectcommand rule, a hook rule runs on the working tree in a staged run when its files match the index, and is skipped with a warning when they don’t. - Default stages:
verifyonly. Hooks are written for a commit, not a keystroke. Writestages: [edit, verify]if yours is fast.
linter
Section titled “linter”detect: linter: { tool: ruff, rules: [T201] }tool:ruff,oxlint,eslintorgitleaks. rulecast looks for it in the project’snode_modules/.bin, then — forruffin a project with apyproject.toml— asuv run ruff, then onPATH. A tool that is not installed disables its own rules and nothing else.rules: the linter’s own rule ids to report. rulecast turns them on for its run (ruff--extend-select, oxlint-D, eslint--rule), so they fire even where the project’s config leaves them off; the rule’s settings, such as a maximum, still come from that config. Inline suppressions (# noqa,// eslint-disable-next-line,// oxlint-disable-next-line) still hold, and so do ruff’sper-file-ignoresand oxlint’soverrides. eslint’s--rulewins over its config’s per-file settings too. A plugin rule needs its plugin loaded by the project’s eslint config, and eslint needs a config file to run at all.gitleaksfinds hard-coded secrets: credential formats it knows (aws-access-token,github-pat, …) and high-entropy strings next to key-like names (generic-api-key).rulesare its rule ids; withoutrules, every rule in its config runs. The project’s.gitleaks.toml,.gitleaksignoreandgitleaks:allowcomments apply.{{text}}is always[redacted], so a finding never repeats the secret into the agent’s context, a log or SARIF. It is a Go binary, not an npm package: install it from your package manager (brew install gitleaks) or its releases.- Leave
rulesout to take every finding the tool reports under the project’s own config. Such rules get a run of their own, so the rules other rules turn on never reach them. - One process per tool per event, over every file its rules select together, with the findings handed back to the rules that asked for them. Ten
ruffrules cost oneruffrun. - Captures:
{{ruleId}}and{{message}}.{{text}}is the source the linter pointed at. - In a staged run or with
--to-ref,ruffandeslintare handed each file’s staged content on stdin under its real path, one process per file, so path-keyed configuration (per-file-ignores, eslintfiles:globs) applies as usual.oxlinthas no stdin mode and gets a scratch copy, which such configuration does not match. - Default stages:
edit,verify— excepteslint, which isverifyonly, because it is slow enough to be felt on every edit. Writestages: [edit, verify]if you want it anyway.
detect: llm: model: haiku question: > Does this route do more than parse input, call a service, and return the result? Report each offending line. grounding: true-
model: required, no default. Either a rulecast alias —haiku,sonnet,opus,fable— which every provider maps to its own name, or the exact model name your provider uses (claude-haiku-4-5-20251001,gpt-5-mini,qwen3-coder:30b), which is passed through untouched. There is no project-wide default on purpose: the cost of a rule is chosen by whoever writes it. -
question: what the model is asked about the file. Ask for specific lines — “Report each offending line” — or you get an essay instead of findings. -
grounding: defaults totrue. Sends the rule’scontextreferences with the question, whatever their delivery mode, so the model judges against your conventions rather than its own taste. Set it tofalsewhen the question stands alone. -
One call per file and model, covering every llm rule that selected that file. Three llm rules on one file all using
haikucost one call; a fourth usingsonnetcosts a second. -
Files with no changed lines are not sent at all. When there is a change set the changed lines are marked and the model is told to report only those; with no change set it judges the whole file.
-
Answers are cached on the file’s content, the change set, the provider, the model and the rules’ questions and grounding. Re-running a verify with nothing changed makes no calls.
-
At most
llm.max_files_per_verifyfiles per verify (default 10), most recently edited first. The rest are named in a warning. -
Captures:
{{reason}}, the model’s explanation.{{text}}is the line from the file, not the model’s quote of it. -
Default stages:
verifyonly. A model call takes seconds; it has no business in an edit hook. Writestages: [edit, verify]if you want it anyway. -
Providers are configured once for the project, in
llm.provider:Provider Needs llm.base_urlclaude-code(default)claudeonPATH, signed in. No API keyignored opencodeopencodeonPATHignored anthropicllm.api_key_env(defaultANTHROPIC_API_KEY)the origin, e.g. https://api.anthropic.com; rulecast appends/v1/messagesopenai-compatiblellm.api_key_envincludes the version prefix, e.g. http://localhost:11434/v1for Ollama; rulecast appends/chat/completionsEach
base_urlfollows its own ecosystem’s SDK convention, so a URL copied from your provider’s docs works as written. Azure OpenAI is not supported in 0.1: it needs a per-deployment path with anapi-versionquery string and anapi-keyheader, which nobase_urlcan express.
An llm rule sends the file’s contents and the rule’s grounding to the configured provider, and costs money for every file that is not already cached. So rulecast run with no file flags, the pre-commit check, skips llm rules unless given --llm, and says so in one line. --all-files, --from-ref and the agent’s stop run them; --no-llm skips them anywhere. It is the right answer only when no pattern can express the convention. Reach for path, regex and ast-grep first: they are free, instant and exact. If you cannot write the check mechanically but the convention still matters, a stages: [touch] rule that delivers the section is usually better than asking a model — it puts the convention in front of the agent before the code is written, instead of judging it afterwards.