Внедрение

Last updated: 2026-10-01Как стандарт перестаёт быть текстом: словари как данные, ESLint-плагин, чекер для любого языка, блокирующий pre-commit, уровни и бейдж.

Внедрение

Стандарт, который держится на памяти автора, умирает за месяц. 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. Чекер проверяет ровно правила этого уровня — и не выдаст бейдж, если проект заявил больше, чем держит.

bash
bun grain --all --level 1   # проверить только L1
bun grain --all --badge     # markdown-бейдж, если чисто

L3 отличается от L2 не правилами, а гарантией: проверка в CI, блокирующий pre-commit, adopt покрывает весь код, ни одного GRAIN_SKIP в истории. Первые три проверяет grain --audit. Четвёртое не докажет никакой инструмент — пропущенный хук оставляет след только в истории оболочки, — поэтому проверка в CI идёт сама по себе и от хука не зависит.

Команды

bash
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

js
// eslint.config.js
import { grainConfig } from '@as/style'

export default [grainConfig]

Для сервиса, который мигрируется постепенно, — grainMigrationConfig: собственные правила GRAIN остаются ошибками, встроенные ограничения формы понижаются до предупреждений.

ПравилоЧто ловит
grain/verb-lexiconГлагол не из словаря или запрещённый
grain/find-get-contractfind* без 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-actcatch, который логирует и продолжает
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-contractlist* возвращает не коллекцию, count* — не число или null
grain/promise-unhandledЦепочка .then/.finally без .catch и без обработчика отказа
grain/switch-exhaustiveswitch по вариантам без default (предупреждение, только новые файлы)
grain/sql-unsafeЗначение, вклеенное в текст unsafe() шаблоном или +
grain/raw-htmldangerouslySetInnerHTML без санитайзера (предупреждение)

Плюс встроенные правила 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

bash
git config core.hooksPath .githooks   # один раз на клон

Хук проверяет только проиндексированные файлы и только пути из списка внедрения. Аварийный обход — GRAIN_SKIP=1 git commit …; обход остаётся в истории оболочки, а не в коде.

Где стандарта нет

Ключ paths_ignored перечисляет пути, на которых GRAIN неприменим: зависимости, сборка и сгенерированный код. Главный случай — миграции БД: применённую миграцию нельзя переписать, это застывшая история, а имена индексов и служебные комментарии в ней пишет генератор, а не автор. Без этого ключа каждая новая миграция валила бы коммит.

Список внедрения

В grain.synx есть ключ adopt — пути, на которых GRAIN обязателен. С появлением долга он накрывает весь код:

synx
adopt src

Правило простое: новый модуль создаётся сразу по GRAIN, и долг на него не записан — значит, он держит стандарт целиком с первого коммита. Существующий гасит свой долг, когда до него доходят руки, — модуль целиком, не по файлу: полумигрированный каталог хуже немигрированного, потому что перестаёт быть предсказуемым.

Порядок погашения долга:

  1. Переименовать файлы по ролям и разложить по доменам — механическая правка, её видно в git mv.
  2. Прогнать grain <путь>, починить имена, комментарии и контракты.
  3. Пересобрать долг — grain --all --baseline; строки этого модуля должны из него исчезнуть.
  4. Закоммитить правки и обновлённый долг одним изменением: диффом видно, сколько погашено.

Escape-хатч

Правило можно нарушить осознанно, но не молча:

ts
// grain:allow unit-suffix — поле внешнего контракта Stripe, переименовать нельзя

Комментарий стоит на строке находки или строкой выше и гасит ровно названное правило в этом месте. Причина обязательна — без неё чекер ругается на сам escape, и такой escape считается нарушением. GRAIN_SKIP обходит только локальный хук: CI его не читает и проверяет код целиком в любом случае. До 1.3 escape проверялся только на форму и находку не гасил.

Изменение стандарта

grain.synx и документы стандарта меняются одним коммитом. Пополнение словаря — минорная версия; удаление глагола, роли или единицы — мажорная, потому что ломает существующий код.

Внедрение | AS Docs