Enforcement

Last updated: 2026-08-18How 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
   ├── @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 list

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. That is what one linter run cannot prove, but a repository shows.

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

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

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:

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

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.

The adoption list

Legacy code is not rewritten in one go. grain.synx has an adopt key listing the paths where GRAIN is mandatory:

synx
adopt src/billing

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

  1. Rename files by role and group them by domain — a mechanical change, visible in git mv.
  2. Run grain <path> --force, fix names and comments.
  3. Wire grainConfig into eslint.config.js, fix find/get contracts and function shape.
  4. Add the path to adopt and commit it as one change.

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

Enforcement | AS Docs