Enforcement

Last updated: 2026-10-01How the standard stops being text — vocabularies as data, an ESLint plugin, a checker for any language, a blocking pre-commit hook, levels and the badge.

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 list

The 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.

bash
bun grain --all --level 1   # check L1 only
bun grain --all --badge     # markdown badge, if clean

L3 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

bash
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.md

Debt: 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 8

The 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

js
// 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.

RuleWhat it catches
grain/verb-lexiconA verb outside the lexicon, or a banned one
grain/find-get-contractfind* without null in the type, get* with it
grain/boolean-prefixA boolean without is/has/can/should/was/will/must
grain/unit-suffixA number without a unit
grain/no-vague-nameEmpty words, abbreviations and self-praise in names
grain/comment-formUntagged comments, TODO, dividers, emoji
grain/no-emoji-logEmoji in logs
grain/catch-must-actA catch that logs and continues
grain/no-redundant-tempA variable that lives one line before return (auto-fixable)
grain/no-default-exportDefault exports
grain/fail-codeA failure code not shaped domain.subject.reason
grain/file-roleThe role in the filename, and kebab-case
grain/no-dump-fileDumping-ground files and directories (utils, helpers, common)
grain/dir-depthA directory hierarchy deeper than the limit
grain/boundary-effectTime, randomness or process.env inside the domain
grain/inward-importAn outward import: the domain learned about I/O or about its own boundary
grain/explicit-parallelIndependent awaits run in sequence instead of Promise.all
grain/collection-contractlist* returning something other than a collection, count* other than a number, or null
grain/promise-unhandledA .then/.finally chain with no .catch and no rejection handler
grain/switch-exhaustiveA switch over variants without default (warning, new files only)
grain/sql-unsafeA value glued into the text of unsafe() by a template or +
grain/raw-htmldangerouslySetInnerHTML 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:

RuleState of the check
Dead codePartial. 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 demandNo. 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 funnelIndirectly, through nesting, length and argument count
find* / get* without a declared return typeNo. Without an annotation the contract is invisible to the machine
list* / count* with a type aliasNo. TrackList cannot be expanded without type checking — collection-contract judges only the explicit form
An unhandled promise without .thenNo. promise-unhandled sees chains; a bare async call without await needs type checking

Pre-commit

bash
git config core.hooksPath .githooks   # once per clone

The 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:

synx
adopt src

The 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:

  1. Rename files by role and group them by domain — a mechanical change, visible in git mv.
  2. Run grain <path>, fix names, comments and contracts.
  3. Rebuild the debt — grain --all --baseline; that module's lines should disappear from it.
  4. 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:

ts
// grain:allow unit-suffix — field of an external Stripe contract, cannot be renamed

The 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.

Enforcement | AS Docs