Writing a rule
A file, an entry, four strings and three fixtures — and the fixture that matters is the one that must not fire.
A rule is a pure function that declares what it needs. The runner gives it exactly that and nothing else, which is what lets the same catalogue run in the browser for the inputs already in memory and on the server for the inputs that need a connection.
Adding one means four things, and there is no way to do three of them.
1. The rule
interface Rule {
/** `<subject>.<problem>`, lower-kebab. Stable, and a public contract. */
id: string;
severity: "blocker" | "warning" | "style";
/** What it needs to decide. The runner only calls rules whose
* inputs it can supply. */
inputs: RuleInput[];
/** Returns nothing when it cannot tell. Never a hedge. */
evaluate(context: GripeContext): Finding[];
}
The inputs are statement, schema, object, access, execution and
plan. A rule declaring ["statement", "schema"] is never called
without both, so it never has to check.
A finding carries facts, not sentences:
{ ruleId: "column.nullable-inequality",
severity: "warning",
at: documentLocation(statement, start, end),
facts: { column: "status" } }
{ rows: 41203882 }, never "41M rows". This is what makes attitude a
presentation layer rather than four parallel analyses — see
gripe levels.
2. The entry
One line in packages/gripes/src/catalogue.ts. That file is the
catalogue, and the rules page on this site is rendered from
it — so a rule that is in the code is on the website, and a rule that is
not is not.
3. Four strings
One per attitude level, in messages.ts, with no fallback. A test
enforces the mechanical half:
- all four present, none missing and none inherited;
- length caps, so a gripe fits where gripes go;
- the barred-term list at every level;
noticeis profanity-free;- every
{placeholder}resolves against the facts the rule emits; - the id matches
<subject>.<problem>.
What no test can check is whether the sentence is any good. These are
copy and they are reviewed like copy. The calibration to write against is
join.no-condition, which is quoted verbatim from the brand
specification and is deliberately not paraphrased or improved.
A style finding never swears. Spending the currency on a redundant
index is the exact failure the brand spec warns about.
4. Three fixtures
One that fires. One that does not. And the one that earns its place:
a case that looks like the finding and is not.
That third class is not a formality. It is where every false positive in this catalogue would have come from, and each existing rule carries the one that caught it:
delete.no-where—create trigger t after delete on x, which reads as an unqualified delete unless you read the statement's main verb through a leadingWITH. Without it the rule fires on every trigger in the database.view.select-star—select qty * price, where the*sits at exactly the same depth as a real projection star. Depth alone is not enough; the rule has to look at what surrounds it.index.duplicate— a unique index whose columns prefix a wider one. It enforces something the wider index does not, so dropping it changes behaviour rather than saving writes. Never flag it.routine.volatile-but-readonly— aplpgsqlbody, which can write through dynamic SQL that no amount of reading will reveal. The rule declines to judge it.subquery.not-in—NOT EXISTS, which is the fix, and a written-out list, where a null is visible to whoever reads it. Neither fires.
The id is a contract
A rule id appears in the gripe footer, in dismissal rows, and in whatever anyone greps their logs for. Renaming one silently un-dismisses it for every user.
So ids are stable, and a rule that changes meaning gets a new id rather than new behaviour under the old one. This is the same discipline the engine applies to the wording, for the same reason: the parts users build habits on do not move.
Where it runs
The split is by input, not by preference:
- The browser runs rules whose inputs are already in memory —
statement, the client'sSchemaInput, andobjectin the object view, where the describe result is the whole input. - The server runs rules needing a connection.
executionandplanrules wait on the server-side runner, which is being written.
The client's SchemaInput is honest about the cache behind it:
isNullable is real, and rowsFor and indexLeadsWith always answer
null because the completion catalogue carries neither. They are
declared rather than omitted so a rule needing them compiles and stays
quiet.
Related
- Why it complains — what a rule has to earn.
- The rules — the current catalogue.
- Roadmap — rules written down and not built.
- The gripes engine spec — the full design, including the parts that are not built.
This page as Markdown, for an agent or a diff.