DRAFT-RULES.md
Use this when asked to draft rulecast rules for a project from a document, usually AGENTS.md or CLAUDE.md. Every convention in the doc that code can visibly break becomes a rule that catches the break and points back at the doc. The developer decides which rules stay.
Load these when you need them. Relative links resolve against this file’s own URL.
- reference/rule-format.md: config and rule keys.
- reference/detectors.md: the detectors this rulecast version has, their config and template variables.
Commands below say rulecast. If it is not on the PATH, use npx @syv-ai/rulecast.
The steps ask the developer to decide several things, and the developer is there to decide them. Ask, and wait for the answer. If you were told to work without the developer, stop and say why: rulecast enforces conventions the developer chose, and nobody else can choose them. The gates in steps 5 and 6 do not change that. Passing them makes a rule ready to show the developer, not to adopt. The developer decides.
1. Read
Section titled “1. Read”- Read the doc you were pointed at in full. If it says its conventions live elsewhere (
AGENTS.mdsaying “seedocs/conventions.md”), read that doc too and draft from it: the rules cite the sections that hold the conventions, not the pointer. - Read
.rulecast-config.yaml. If it is missing, stop and ask the developer to runnpx @syv-ai/rulecast init. Then runrulecast list. It prints every configured rule, catalog rules included: itsfilesand how many project files they match, what its detector looks for (detects …), and the doc sections it cites.rulecast list <rule-id>prints one rule with the files it matches, its message and the text of those sections. You need both for the catalog rules in step 2. - For each convention, read a few of the files it talks about, so your file and code patterns match the code as it is.
2. Sort the conventions
Section titled “2. Sort the conventions”- Checkable: a break is visible in one file’s path or text. “Never edit
src/client/”, “services never raiseHTTPException”, “noprint(in the backend”. - Guidance: true and useful, but not visible as a pattern. “Keep services small”, “test RBAC both ways”.
- Not about code: commands, deployment, process. Skip these.
Two kinds need splitting:
- Partly checkable: one part is visible as a pattern and the rest is not. “Keep services small and free of framework types”: a
fastapiimport in a service is visible, “small” is not. Draft a pattern rule for the visible part and a touch rule for the rest, both citing the same section. - Something must be present: “every module gets its logger with
logging.getLogger(__name__)”. A detector reports what is in a file, so it cannot see a missing line without firing on every file that lacks it,__init__.pyincluded. Check the wrong form instead, if there is one (a logger with another name), and let a touch rule deliver the rest. Scope that touch rule to the files the convention is about, and exclude files that are empty by design, such as__init__.py.
Touch rules go one per area, not one per section: a single rule whose files is the area (^app/services/) and whose context lists every section that applies there. Two touch rules on the same files deliver twice as many reminders for the same reading.
Catalog rules (the rules rulecast list shows from a URL repo) were written for projects in general. For each one, answer two questions, separately. A rule can need a change for both.
-
Which section should it cite? If the project’s doc has a section that states the same convention, point the rule at it with a
contextoverride and change nothing else. The developer’s own words beat the package’s, even when they agree. A section about the same area is not enough: a section on how to raise errors does not state a rule against swallowing them. A rule you propose to switch off (question 2) keeps its context: pointing it at a section that contradicts it would make it cite the opposite of what it checks.- repo: https://github.com/syv-ai/rulecastrev: v0.3.0 # unchanged: keep the entry as init wrote itrules:- id: python/no-httpexception-in-servicescontext:- "@AGENTS.md#errors" -
Does what it checks fit this project? Compare its
filesmatch count and itsdetectsline with the doc, and read the section it cites withrulecast list <rule-id>. Judge what the rule does, not its name:-
It fits: nothing more to do. A convention it already enforces needs no local rule.
-
The doc covers files the rule misses: the doc says “outside
app/api/” and the rule’sfilesisservices/, or the doc’s route module isapp/api/routes.pyand the rule’sfilesisroutes/. Draft a local rule of the same kind for the files it misses (a detector rule for a detector rule, a touch rule for a touch rule):- Its
filesis what the doc covers. A doc that says “never” without naming a place covers every file of that language:\.py$. - Its
excludeis the catalog rule’sfiles, copied asrulecast listprints it, so no site is reported twice. If your rule already has anexclude, join the two with|. - Its
descriptionnames the catalog rule it extends. - Check the files
rulecast list <rule-id>names: a count of 2 may be one real module and an__init__.py.
A local rule, not a
filesoverride on the catalog rule: an override freezes the catalog rule’s scope, so a later tag that fixes itsfileswould never reach this project. Afterrulecast autoupdate, compare the catalog rule’sfileswith your rule’sexcludeagain. - Its
-
It contradicts the doc, or matches no file at all: it assumes a layer, a library or a layout the project doesn’t have, or its cited section says the opposite of the doc. Leave it unchanged, tell the developer why, and propose
enabled: falseon its entry. Add it only if they agree. -
You can’t tell: treat it as a contradiction and propose. Proposing costs nothing, because the developer decides; staying silent hides the question.
-
For checkable conventions, use path when the file itself is the break. Otherwise use the first of these that can see the break:
linter: a rule of a linter the project runs, or could run (ruff, oxlint, eslint). The linter already knows the language.commandorhook: a tool the project already runs that checks the convention — a type checker, a dead-code finder, a hook in.pre-commit-config.yamlorlefthook.yml. The tool already knows the code. reference/tool-recipes.md has a tested block for each common one.ast-grep: a pattern over the code’s syntax, which never matches inside a comment or a string.regex: only for a pattern that is text rather than code, such as a comment marker, the content of a string or a file header.
Write the narrowest pattern that catches the break: a noisy rule gets ignored.
Do not decide up front that a convention needs an llm rule. Which tier a convention belongs in is a property of how it is worded, not of the convention: the same rule, written one way, scored P 0.99 / R 0.90 as a pattern, and written more strictly by the same project’s owner, P 0.70. So the tier cannot be settled by reading the sentence. Attempt a pattern, measure it against examples, and fall back to llm only when the pattern fails — step 3 below is that loop.
3. Draft one rule at a time
Section titled “3. Draft one rule at a time”For each checkable convention, one at a time.
When the doc can be read two ways, flag only what both readings call wrong, and name the rest in the summary so the developer can tighten it. “Every module gets its logger with log = logging.getLogger(__name__); no other logger names”: logging.getLogger("users") is wrong whichever way you read it, so the rule flags it; logger = logging.getLogger(__name__) is wrong only if “logger names” means the variable, so the rule leaves it and the summary asks.
-
Show the developer the rule as YAML and the doc section it enforces.
-
Add it to the
rulesof therepo: localentry in.rulecast-config.yaml. Add that entry at the end ofreposif there is none:- repo: localrules:- id: backend/no-printname: Backend code logs instead of printingfiles: ^backend/.*\.py$exclude: ^backend/tests/detect:regex:pattern: '^[ \t]*print\('flags: mmessage: "{{file}}:{{line}} prints. Use the logger instead."context:- "@AGENTS.md#logging"id: lowercasearea/name, unique in the config.files: a regex searched in the repo-relative path. Anchor it with^so it matches the directory you mean.message: what is wrong at{{file}}:{{line}}and what to do instead, in one or two sentences.context: the doc section the rule enforces, as@<file>#<heading-slug>. The slug is the heading in lowercase, punctuation dropped, spaces turned into-.
-
Give it
examples: twogood, twobadand onebaseline, no two the same. Eachgoodandbadexample is apaththe rule matches and thecodeat it.- Quote real code first. Search the files the rule matches for an actual violation to use as
badand compliant code forgood; a violation the repository already has is the bestbadexample there is. - Where it has none, which is usual in a small or clean repository, write your own: the smallest code that shows the break for
bad, and forgooda near miss, code that looks like the break and is not. Invented examples are fine. Say which ones are invented in the summary. - A
pathrule judges the file name, not its code: give itbadexamples only, at paths the rule must refuse. - Every
badexample getsexpect: { lines: [...] }: the line each match starts on, counted from 1 (line 1 for a finding about the whole file). Without it, a pattern that fires on the wrong line still passes. - The
baselineexample is an edit:beforehas one violation inside a function or other block, andafteradds an unrelated line in that same block, next to the violation but not on it, and a second violation. It expects the old violation, on the line it moved to, aspreexisting, and the added one asnew. It catches a rule that reports code the edit did not touch as the edit’s: a pattern that spans lines, anast-grepmatch on a whole function, ascope: containerrule. The unrelated line goes inside the block because a match that is too wide covers it there and comes outnew; above the block it would be missed. - Three kinds of rule have no baseline example to give, so skip it for them. A
pathrule: the file is either the break or it is not. Anllmrule: it only reports lines the edit changed, so the old violation is never reported at all. Ahookrule whose findings are about the whole file (line 1, as a formatter’s are): any edit to the file makes it new.
examples:good:- path: backend/app/services/users.pycode: |log.info("created user %s", user.id)bad:- path: backend/app/services/users.pycode: |user = create(name)print("created user", user.id)expect: { lines: [2] }baseline:- path: backend/app/services/users.pybefore: |def create_user(name):user = create(name)print("created user", user.id)return userafter: |def create_user(name):user = create(name)user.save()print("created user", user.id)print("saved")return userexpect: { new: [5], preexisting: [4] } - Quote real code first. Search the files the rule matches for an actual violation to use as
-
Run
rulecast validateand fix every diagnostic for your rule. Anexpectline past the end of its example is one of them. -
Run
rulecast test <id> --format json. The rule passes this gate when itsstatusispassed. Otherwise each failing outcome’sfailuressays why (reference/output.md has the fields).- A
badexample with no finding means the pattern is too narrow. Agoodexample that fires, or abadone that fires on a line it does not expect, means it is too broad. - A
baselineexample whose old violation comes outnewmeans the rule reports code the edit did not touch: a pattern that spans more lines than the violation, or a rule that needsscope: container. - A
regexpattern also matches inside comments and strings. If the only hits it finds in the code are prose — a comment that namesprocess.env— that is a slip: useast-grep, which matches code, not text. - A slip is not a revision: a typo, a missing escape, or
\scrossing a line break (rulecast testshows what a failing example matched, and says when it crossed a line). Fix slips as often as you find them. - Revise what the pattern means — what it accepts and refuses — once. If it still fails, move the rule to an
llmdetector and say in itsdescriptionthat a pattern was tried. Do not keep tightening a regex past one attempt: that is how a rule ends up fitting its examples and nothing else.
- A
-
Run
rulecast test <id> --against . --max-rate 0.5 --format json(without the id it measures every rule, for checking a whole config at once). The rule’s ownfilesandexcludedecide which files count, so.is right for every rule.- Exit 1,
against.exceeded: true: the rule flags more than half the files it matches. Do not propose it as written. Tell the developer it may describe the code as it is rather than a convention, and propose a narrowerfiles, or a touch rule instead. against.smallSample: true: under 25 matching files, the rate is not applied, because a share of a handful of files says nothing. Report the counts and judge the rule by its doc.- Either way, report both numbers to the developer —
violations, andviolatingFilesofmatchingFiles— before asking them to keep it. Passing the gate is necessary, not sufficient: neither the numbers nor the gate decide it. The developer does.
- Exit 1,
-
Ask the developer to keep, edit or drop the rule. After an edit, repeat steps 4 to 6. Remove dropped rules from the config.
For each guidance convention, add a touch rule. It delivers the section the first time an agent reads or edits a matching file, and has no detector or message:
- id: backend/services name: Service layer conventions files: ^backend/app/services/ stages: [touch] context: - "@AGENTS.md#services"Validate it the same way. A touch rule has no detector, so it takes no examples and rulecast test has nothing to run for it: skip steps 3, 5 and 6.
4. Finish
Section titled “4. Finish”Run rulecast validate and rulecast test --format json a last time; validate must be clean and the document must say passed: true. Bare rulecast test does not run llm rules, because each example costs money, and gives them status: not-run: run rulecast test <id> --format json for each of those. Then summarise:
- the rules kept: id, one line each, and the
--againstcount (touch rules have no count); - where the doc could be read two ways, and the reading you chose;
- the rules dropped;
- the catalog rules you pointed at the project’s doc, extended with a local rule, or propose to switch off, and why;
- the examples you invented;
- the conventions you skipped, with the reason.
When revising rules a project already has, read rulecast stats --format json first (its shape is in reference/output.md): a rule listed as noisy needs a clearer message or a tighter pattern, and one in neverFired may not be worth keeping.
- Never change rules or overrides under other
reposentries, except to add acontextoverride to a catalog rule, orenabled: falsewhen the developer agrees (step 2). Everything else goes in therepo: localentry. - Never add a
rulecast-ignorecomment orenabled: falseunless the developer agrees the finding or the rule is wrong. - Never change code to make a rule pass. Report findings; the developer decides.
- Never take a decision these steps give to the developer, or a default in their place.
- Never commit.