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
├── @as/style ESLint plugin: 17 AST rules for TypeScript
├── grain checker: filenames, comments, Rust, SQL — any language
└── pre-commit blocks the commit, scoped to the adoption listLevels 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. That is what one linter run cannot prove, but a repository shows.
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 CIESLint
// 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 |
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 runs 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. It covers what ESLint cannot see: .rs, .sql, filenames and directories, file length, comments in any language.
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 |
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.
The adoption list
Legacy code is not rewritten in one go. grain.synx has an adopt key listing the paths where GRAIN is mandatory:
adopt src/billingThe rule is simple: a new module is created to GRAIN and added to the list in the same commit. Existing code moves 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 migrating a module:
- Rename files by role and group them by domain — a mechanical change, visible in
git mv. - Run
grain <path> --force, fix names and comments. - Wire
grainConfigintoeslint.config.js, fixfind/getcontracts and function shape. - Add the path to
adoptand commit it as one change.
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 reason is mandatory — without it the checker complains about the escape itself. An escape without a reason, and GRAIN_SKIP in CI, both count as violations.
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.