Внедрение
Стандарт, который держится на памяти автора, умирает за месяц. GRAIN проверяется машинно на трёх уровнях, и все три читают один и тот же файл словарей.
grain.synx словари как данные: глаголы, единицы, роли, пределы, уровни, строгость
grain.local.synx локальный слой проекта: правила, которые знают его инфраструктуру
├── @as/style ESLint-плагин: 22 правила по AST для TypeScript
├── grain чекер: сам запускает эти правила по AST плюс имена файлов,
│ комментарии, ключи, Rust, SQL — любой язык
├── baseline.synx долг, принятый как данность: гейт валит только новое, храповик его уменьшает
└── pre-commit блокирует коммит по списку внедренияЧекер не полагается на то, что в проекте настроен ESLint: правила он подгружает сам. ESLint в проекте нужен для подсветки в редакторе, но проверка живёт в хуке и в CI независимо от него.
Уровни и бейдж
Проект объявляет уровень ключом level в своём grain.synx. Чекер проверяет ровно правила этого уровня — и не выдаст бейдж, если проект заявил больше, чем держит.
bun grain --all --level 1 # проверить только L1
bun grain --all --badge # markdown-бейдж, если чистоL3 отличается от L2 не правилами, а гарантией: проверка в CI, блокирующий pre-commit, adopt покрывает весь код, ни одного GRAIN_SKIP в истории. Первые три проверяет grain --audit. Четвёртое не докажет никакой инструмент — пропущенный хук оставляет след только в истории оболочки, — поэтому проверка в CI идёт сама по себе и от хука не зависит.
Команды
bun grain # проиндексированные файлы — то же, что делает хук
bun grain --all # весь репозиторий по списку внедрения
bun grain --all --force # весь репозиторий, игнорируя список внедрения
bun grain src/billing # конкретный путь, без гейта
bun grain --json # машинный вывод для CI
bun grain --all --baseline # записать текущий долг
bun grain --audit # заслужен ли объявленный уровень
bun grain --all --stat # карта долга по модулям
bun grain --explain <правило> # что проверяет правило
bun grain --fix # механические правки: автофиксы, переименование глаголов
bun grain --all --status # сводка по модулям в STATUS.mdДолг: baseline
Стандарт нельзя включить на живом проекте «с завтрашнего дня»: код, написанный до него, нарушает его тысячами мест, и гейт, валящий всё подряд, снимают на второй день. Поэтому долг записывается числами — baseline.synx, строка <файл> <правило> <сколько>:
src/billing/invoice.ts comment-form 8Гейт пропускает ровно записанное и валит всё сверх: новый код держит стандарт с первого дня, старый гасится по мере того, как до него доходят руки. Номера строк не хранятся намеренно — они едут при любой правке выше и сделали бы долг источником ложных срабатываний. Погасив часть, пересоберите долг: grain --all --baseline.
Строгость и храповик
У правила есть строгость. Ошибка валит гейт и пишется в долг. Правило из rules_warn — предупреждение: видно в отчёте, но коммит не останавливает и в долг не попадает. Так новое правило входит в проект, не останавливая работу: сначала предупреждение, потом — когда ясно, сколько его в коде и нет ли ложных срабатываний, — записанный долг и перевод в ошибки.
rules_new_only спрашивается только с новых файлов. Это для правил, долг по которым в старом коде не гасится разумно: вёрстку с тысячей литеральных цветов никто не перепишет ради линтера, а новый экран сразу пишется на токенах.
Pre-commit работает храповиком: если у проверенного файла нарушений стало меньше записанного, строка долга уменьшается тем же коммитом. Погашенное не возвращается — следующая правка упрётся в новое, меньшее число.
--fix
Механическое чинится машиной: автофиксы ESLint (no-redundant-temp, no-else-return, no-lonely-if) и запрещённые глаголы с однозначной заменой — calculate/compute → derive, generate/build/prepare → make, determine → resolve. Переименование идёт вместе со всеми ссылками проекта через LanguageService TypeScript, как «Rename» в редакторе; при коллизии имени файл не трогается. fetch → read или load — это решение автора, а не механика, и --fix его не принимает.
Сразу после правки
Проверку можно получать не на коммите, а в момент записи файла: хук редактора или ассистента зовёт grain <файл> --json и показывает только то, что правка добавила сверх долга. Чем раньше видно нарушение, тем дешевле его починить: на коммите работа уже «готова» и её приходится переделывать.
Локальный слой
Публичный стандарт не знает инфраструктуру конкретного проекта. Правила, которые знают (схемы и роли БД, миграции, шина событий, дизайн-токены), кладутся в grain.local.synx рядом с grain.synx: его ключи дописываются к ключам стандарта, так что локальное правило подключается тем же level_2, а строгость задаётся теми же rules_warn и rules_new_only. Хорошее локальное правило — след реального отказа в проде, а не вкус.
Заслужен ли уровень
grain --audit проверяет не код, а обещание: для L3 обязаны существовать блокирующий pre-commit, workflow, запускающий проверку, и список внедрения, накрывающий весь код. Объявить уровень, которого проект не держит, нельзя — аудит валится и называет причину.
ESLint
// eslint.config.js
import { grainConfig } from '@as/style'
export default [grainConfig]Для сервиса, который мигрируется постепенно, — grainMigrationConfig: собственные правила GRAIN остаются ошибками, встроенные ограничения формы понижаются до предупреждений.
| Правило | Что ловит |
|---|---|
grain/verb-lexicon | Глагол не из словаря или запрещённый |
grain/find-get-contract | find* без null в типе, get* с null |
grain/boolean-prefix | Булево без is/has/can/should/was/will/must |
grain/unit-suffix | Число без единицы измерения |
grain/no-vague-name | Пустые слова, сокращения, самопохвала в именах |
grain/comment-form | Комментарий без тега, TODO, разделители, эмодзи |
grain/no-emoji-log | Эмодзи в логах |
grain/catch-must-act | catch, который логирует и продолжает |
grain/no-redundant-temp | Переменная на одну строку до return (автофикс) |
grain/no-default-export | Экспорт по умолчанию |
grain/fail-code | Код отказа не в форме домен.предмет.причина |
grain/file-role | Роль в имени файла и kebab-case |
grain/no-dump-file | Файлы и каталоги-свалки (utils, helpers, common) |
grain/dir-depth | Иерархия каталогов глубже предела |
grain/boundary-effect | Время, случайность, process.env внутри домена |
grain/inward-import | Импорт наружу: домен узнал про I/O или про свою границу |
grain/explicit-parallel | Независимые await по очереди вместо Promise.all |
grain/collection-contract | list* возвращает не коллекцию, count* — не число или null |
grain/promise-unhandled | Цепочка .then/.finally без .catch и без обработчика отказа |
grain/switch-exhaustive | switch по вариантам без default (предупреждение, только новые файлы) |
grain/sql-unsafe | Значение, вклеенное в текст unsafe() шаблоном или + |
grain/raw-html | dangerouslySetInnerHTML без санитайзера (предупреждение) |
Плюс встроенные правила ESLint, задающие форму: max-depth, max-params, max-lines-per-function, max-lines, no-else-return, no-nested-ternary, no-lonely-if — их пределы берутся из grain.synx, а не пишутся руками.
Чекер grain
Запускается без установленных зависимостей (собственный ридер SYNX) — поэтому годится для pre-commit в свежем клоне: без них выполняется текстовая часть, а правила по AST подключаются, когда установлен ESLint. Покрывает то, чего ESLint не видит: .rs, .sql, имена файлов и каталогов, длину файлов, комментарии в любом языке и ключи вендоров (secret-literal, сигнатуры — ключ secret_patterns).
Что машина проверить не может
Стандарт, который врёт про свои возможности, разваливается на первом ложном срабатывании. Поэтому честно:
| Правило | Состояние проверки |
|---|---|
| Мёртвый код | Частично. no-dead-export находит экспорт, который никто не упоминает в этом же репозитории, и только в режиме --all. Имя, вызываемое из другого сервиса или динамически, здесь неотличимо от мёртвого |
| Публичная поверхность по требованию | Нет. Требует знания всех потребителей пакета — проверяется на ревью |
| Комментарий отвечает «почему», а не «что» | Частично. Машина видит отсутствие тега, но не может отличить осмысленное «почему» от бессмысленного |
Воронка guard → acquire → derive → effect | Косвенно. Через вложенность, длину и число аргументов |
find* / get* без аннотации типа | Нет. Без объявленного типа возврата контракт не виден машине |
list* / count* с алиасом типа | Нет. TrackList без проверки типов не раскрыть — collection-contract судит только по явной форме |
Необработанный промис без .then | Нет. promise-unhandled видит цепочки; голый вызов async-функции без await требует проверки типов |
Pre-commit
git config core.hooksPath .githooks # один раз на клонХук проверяет только проиндексированные файлы и только пути из списка внедрения. Аварийный обход — GRAIN_SKIP=1 git commit …; обход остаётся в истории оболочки, а не в коде.
Где стандарта нет
Ключ paths_ignored перечисляет пути, на которых GRAIN неприменим: зависимости, сборка и сгенерированный код. Главный случай — миграции БД: применённую миграцию нельзя переписать, это застывшая история, а имена индексов и служебные комментарии в ней пишет генератор, а не автор. Без этого ключа каждая новая миграция валила бы коммит.
Список внедрения
В grain.synx есть ключ adopt — пути, на которых GRAIN обязателен. С появлением долга он накрывает весь код:
adopt srcПравило простое: новый модуль создаётся сразу по GRAIN, и долг на него не записан — значит, он держит стандарт целиком с первого коммита. Существующий гасит свой долг, когда до него доходят руки, — модуль целиком, не по файлу: полумигрированный каталог хуже немигрированного, потому что перестаёт быть предсказуемым.
Порядок погашения долга:
- Переименовать файлы по ролям и разложить по доменам — механическая правка, её видно в
git mv. - Прогнать
grain <путь>, починить имена, комментарии и контракты. - Пересобрать долг —
grain --all --baseline; строки этого модуля должны из него исчезнуть. - Закоммитить правки и обновлённый долг одним изменением: диффом видно, сколько погашено.
Escape-хатч
Правило можно нарушить осознанно, но не молча:
// grain:allow unit-suffix — поле внешнего контракта Stripe, переименовать нельзяКомментарий стоит на строке находки или строкой выше и гасит ровно названное правило в этом месте. Причина обязательна — без неё чекер ругается на сам escape, и такой escape считается нарушением. GRAIN_SKIP обходит только локальный хук: CI его не читает и проверяет код целиком в любом случае. До 1.3 escape проверялся только на форму и находку не гасил.
Изменение стандарта
grain.synx и документы стандарта меняются одним коммитом. Пополнение словаря — минорная версия; удаление глагола, роли или единицы — мажорная, потому что ломает существующий код.