Enforcement
A standard held together by its author's memory dies within a month. GRAIN is enforced by machine at three layers, and all three read the same vocabulary file.
grain.synx vocabularies as data: verbs, units, roles, limits, levels, severity
grain.local.synx the project's local layer: rules that know its infrastructure
├── @as/style ESLint plugin: 22 AST rules for TypeScript
├── grain checker: runs those AST rules itself, plus filenames,
│ comments, keys, Rust, SQL — any language
├── baseline.synx debt taken as given: the gate only fails on what is new; the ratchet shrinks it
└── pre-commit blocks the commit, scoped to the adoption listThe checker does not rely on the project having ESLint configured: it loads the rules itself. ESLint in the project is for editor highlighting; enforcement lives in the hook and in CI regardless of it.
Levels and the badge
A project declares its level with the level key in its own grain.synx. The checker verifies exactly the rules of that level — and refuses to issue a badge if the project claimed more than it holds.
bun grain --all --level 1 # check L1 only
bun grain --all --badge # markdown badge, if cleanL3 differs from L2 not in rules but in guarantee: enforcement in CI, a blocking pre-commit hook, an adoption list covering all the code, and not a single bypass in history. The first three are what grain --audit checks. The fourth no tool can prove — a skipped hook leaves a trace only in shell history — which is why the check in CI runs on its own and does not depend on the hook.
Commands
bun grain # staged files — the same thing the hook runs
bun grain --all # the whole repository, scoped to the adoption list
bun grain --all --force # the whole repository, ignoring the adoption list
bun grain src/billing # a specific path, no gate
bun grain --json # machine-readable output for CI
bun grain --all --baseline # record the current debt
bun grain --audit # is the declared level actually earned
bun grain --all --stat # debt map by module
bun grain --explain <rule> # what a rule checks
bun grain --fix # mechanical fixes: autofixes, verb renames
bun grain --all --status # per-module summary into STATUS.mdDebt: the baseline
A standard cannot be switched on in a live project "starting tomorrow": code written before it breaks it in thousands of places, and a gate that fails on everything is removed by day two. So the debt is recorded as numbers — baseline.synx, one line of <file> <rule> <how many>:
src/billing/invoice.ts comment-form 8The gate allows exactly what is recorded and fails on anything beyond it: new code holds the standard from day one, old code is repaid as someone gets to it. Line numbers are deliberately not stored — they shift on any edit above and would turn the debt into a source of false positives. Once part of it is repaid, rebuild the debt: grain --all --baseline.
Severity and the ratchet
Every rule has a severity. An error fails the gate and is recorded as debt. A rule listed in rules_warn is a warning: it shows in the report but does not stop the commit and never enters the debt. That is how a new rule joins a project without stopping work: first a warning, then — once it is clear how often it fires and whether it misfires — recorded debt and promotion to an error.
rules_new_only rules are asked only of new files. They are for rules whose debt in old code is not reasonably payable: nobody rewrites markup with a thousand literal colours for a linter, but a new screen is written on tokens from the start.
The pre-commit hook works as a ratchet: when a checked file has fewer violations than its recorded debt, the debt line shrinks in the same commit. Repaid debt never comes back — the next edit hits the new, smaller number.
--fix
What is mechanical is fixed by the machine: ESLint autofixes (no-redundant-temp, no-else-return, no-lonely-if) and banned verbs with an unambiguous replacement — calculate/compute → derive, generate/build/prepare → make, determine → resolve. The rename covers every reference in the project through the TypeScript LanguageService, like "Rename" in an editor; on a name collision the file is left alone. fetch → read or load is the author's decision, not a mechanical one, and --fix does not make it.
Right after the edit
The check can arrive at the moment a file is written rather than at commit time: an editor or assistant hook calls grain <file> --json and shows only what the edit added on top of the debt. The earlier a violation is visible, the cheaper it is to fix: at commit time the work is already "done" and has to be redone.
The local layer
The public standard knows nothing about a particular project's infrastructure. Rules that do (database schemas and roles, migrations, the event bus, design tokens) go into grain.local.synx next to grain.synx: its keys are appended to the standard's, so a local rule is enabled with the same level_2, and its severity is set with the same rules_warn and rules_new_only. A good local rule is the trace of a real production failure, not a matter of taste.
Is the level earned
grain --audit checks the promise rather than the code: L3 requires a blocking pre-commit hook, a workflow that runs the check, and an adoption list covering all the code. Declaring a level the project does not hold is no longer possible — the audit fails and names the reason.
ESLint
// eslint.config.js
import { grainConfig } from '@as/style'
export default [grainConfig]For a codebase migrating gradually there is grainMigrationConfig: GRAIN's own rules stay errors, while the built-in shape limits drop to warnings.
| Rule | What it catches |
|---|---|
grain/verb-lexicon | A verb outside the lexicon, or a banned one |
grain/find-get-contract | find* without null in the type, get* with it |
grain/boolean-prefix | A boolean without is/has/can/should/was/will/must |
grain/unit-suffix | A number without a unit |
grain/no-vague-name | Empty words, abbreviations and self-praise in names |
grain/comment-form | Untagged comments, TODO, dividers, emoji |
grain/no-emoji-log | Emoji in logs |
grain/catch-must-act | A catch that logs and continues |
grain/no-redundant-temp | A variable that lives one line before return (auto-fixable) |
grain/no-default-export | Default exports |
grain/fail-code | A failure code not shaped domain.subject.reason |
grain/file-role | The role in the filename, and kebab-case |
grain/no-dump-file | Dumping-ground files and directories (utils, helpers, common) |
grain/dir-depth | A directory hierarchy deeper than the limit |
grain/boundary-effect | Time, randomness or process.env inside the domain |
grain/inward-import | An outward import: the domain learned about I/O or about its own boundary |
grain/explicit-parallel | Independent awaits run in sequence instead of Promise.all |
grain/collection-contract | list* returning something other than a collection, count* other than a number, or null |
grain/promise-unhandled | A .then/.finally chain with no .catch and no rejection handler |
grain/switch-exhaustive | A switch over variants without default (warning, new files only) |
grain/sql-unsafe | A value glued into the text of unsafe() by a template or + |
grain/raw-html | dangerouslySetInnerHTML without a sanitizer (warning) |
On top of that come the built-in ESLint rules that hold the shape — max-depth, max-params, max-lines-per-function, max-lines, no-else-return, no-nested-ternary, no-lonely-if — with their limits taken from grain.synx rather than written by hand.
The grain checker
It starts with no installed dependencies (it carries its own SYNX reader), which is what makes it usable as a pre-commit hook in a fresh clone: without them it runs the text checks, and the AST rules join once ESLint is installed. It covers what ESLint cannot see: .rs, .sql, filenames and directories, file length, comments in any language, and vendor keys (secret-literal, signatures in the secret_patterns key).
What a machine cannot check
A standard that lies about its own capabilities falls apart on the first false positive. So, honestly:
| Rule | State of the check |
|---|---|
| Dead code | Partial. no-dead-export finds an export nothing else in this repository mentions, and only in --all mode. A name called from another service or dynamically is indistinguishable from a dead one |
| Public surface on demand | No. It needs knowledge of every consumer of the package — this is a review matter |
| A comment answers "why", not "what" | Partial. The machine sees a missing tag, but cannot tell a meaningful "why" from a meaningless one |
The guard → acquire → derive → effect funnel | Indirectly, through nesting, length and argument count |
find* / get* without a declared return type | No. Without an annotation the contract is invisible to the machine |
list* / count* with a type alias | No. TrackList cannot be expanded without type checking — collection-contract judges only the explicit form |
An unhandled promise without .then | No. promise-unhandled sees chains; a bare async call without await needs type checking |
Pre-commit
git config core.hooksPath .githooks # once per cloneThe hook checks only staged files, and only paths on the adoption list. The emergency bypass is GRAIN_SKIP=1 git commit … — it stays in shell history, not in the code.
Where the standard does not apply
The paths_ignored key lists paths GRAIN does not reach: dependencies, build output and generated code. The main case is database migrations: an applied migration cannot be rewritten — it is frozen history — and its index names and housekeeping comments are written by a generator, not an author. Without this key every new migration would fail the commit.
The adoption list
grain.synx has an adopt key listing the paths where GRAIN is mandatory. Once the debt is recorded, it covers all the code:
adopt srcThe rule is simple: a new module is created to GRAIN, and no debt is recorded against it — so it holds the whole standard from its first commit. Existing code repays its debt when someone gets to it — a whole module at a time, never file by file: a half-migrated directory is worse than an unmigrated one, because it stops being predictable.
The order for repaying a module's debt:
- Rename files by role and group them by domain — a mechanical change, visible in
git mv. - Run
grain <path>, fix names, comments and contracts. - Rebuild the debt —
grain --all --baseline; that module's lines should disappear from it. - Commit the fixes and the updated debt as one change: the diff shows exactly how much was repaid.
The escape hatch
A rule may be broken deliberately, but not silently:
// grain:allow unit-suffix — field of an external Stripe contract, cannot be renamedThe comment sits on the line of the finding or the line above and silences exactly the named rule at that spot. The reason is mandatory — without it the checker complains about the escape itself, and the escape counts as a violation. GRAIN_SKIP bypasses only the local hook: CI does not read it and checks the code in full regardless. Before 1.3 the escape was only checked for form and did not silence anything.
Changing the standard
grain.synx and the standard's documents change in one commit. Adding to a vocabulary is a minor version; removing a verb, a role or a unit is a major one, because it breaks existing code.