Пятьдесят семь публичных репозиториев отгружают файл агент-правил — тот самый CLAUDE.md или AGENTS.md, который кодовый агент читает перед каждой задачей, — и в нём прозой сказано: никогда не используй any из TypeScript. Я сверил каждый из них с линт-конфигом, лежащим рядом в том же коммите. Меньше половины — 46% — подкрепляют запрет жёсткой ошибкой, на которой CI бы упал. Остальные ставят его в предупреждение (которое гейтит мёрдж, только если ты вдобавок подключишь --max-warnings=0, а большинство не подключает), или такого правила у них нет вовсе, или ещё хуже. А файл, в честь которого названа эта статья, справляется хуже всех: среди репозиториев, где запрет живёт именно в CLAUDE.md, принуждение падает до трети.
Вот как это выглядит вблизи. CLAUDE.md у LibreChat под заголовком Type Safety говорит: никогда не используй any. А ESLint-конфиг, лежащий рядом, в той же директории, ставит @typescript-eslint/no-explicit-any в off. И вот старательный агент читает правило, прогоняет линтер — проверить себя, — видит зелёное; вот так (fileConfig as any) и сидит сегодня в клиенте, а в пайплайне не было ничего, что могло бы хоть раз возразить. Никто не нарушал. Файл, который читает твой агент, и файл, который реально гейтит мёрдж, — это два разных файла, и работает только один из них.
И это «или ещё хуже» — не фигура речи. Каждый восьмой из тех 57 репозиториев не просто не принуждает запрет — он отгружает конфиг, который явно выключает правило, пока проза говорит «никогда». ForumMagnum у LessWrong велит своему агенту «Never apply as any type casts»; его .eslintrc.js отключает no-explicit-any с комментарием «Some day… not today.». stagewise запрещает и any, и unknown; его biome.jsonc выключает то же правило — «We trust Vercelians to use any only when necessary.». intlayer и ArkhamCards — та же картина. Часть из них — честные решения про долг: правило, выключенное с комментарием «когда-нибудь», часто значит «не роняй CI на легаси-дереве, просто скажи новому коду делать лучше», и это вполне оправданная позиция. Но агент не читает намерение. Он читает «никогда не используй any», прогоняет линтер, видит зелёное — и срезает типы. Как бы конфиг ни докатился до этого, файл, которому агент подчиняется, и файл, который гейтит мёрдж, говорят противоположное — и это не модель, забывшая инструкцию на двухсотом ходу сессии. Это закоммичено, в репозитории, прямо сейчас.
И дело не только в тумблерах конфига. better-auth — библиотека аутентификации, весь смысл которой в сквозной типобезопасности, и чей файл агент-правил — отгруженный как AGENTS.md, с CLAUDE.md, который на него указывает, — капсом говорит NEVER use any. Потом её собственный код срезает ctx.body.permissions as any прямо в вызов hasPermission(), который решает, разрешён ли запрос: библиотека аутентификации срезает типы ровно с того payload, на котором держится её же решение о доступе, — в двух отдельных эндпоинтах. block/goose велит своим агентам не хвататься за .unwrap() в продовом Rust; а код анврапит сотни раз, в том числе внутри проверки прав, которая решает, считается ли вызов инструмента агентом «только на чтение».
Правило, написанное прозой, принуждается ровно одной вещью: доброй волей вероятностной модели на каждом конкретном токене. Назови это линтингом на честном слове. Работает чаще всего — и в этом вся проблема, потому что «чаще всего» не то свойство, на котором можно строить, когда агент за вечер выдаёт больше кода, чем ты отревьюишь за неделю.
Почему написанное правило игнорируется: я это измерил
Так почему правило, которое ты записал, игнорируется? Я это измерил, причём детерминированно — никакая модель ничего не оценивает. Синтетический CLAUDE.md, задача подстроена так, что ленивый ответ ломает правило, оценка — обычным кодом: 348 прогонов на двух моделях (маленькая выборка — три сида на ячейку, — но важные разрывы — ноль-против-всегда, а не прищур). Правила разделились на три судьбы.
Чёткое правило, которое воюет с привычкой, фиксирует намертво. «yarn, а не npm» дало три нарушающих прогона без правила и ноль — с ним, настоящий жёсткий бинд. Но посмотри, что это за правило: ровно тот класс, который линтер или хук принуждают бесплатно, на любой модели, при любой длине контекста. И механическое подкрепление ему необходимо, потому что фиксация не переживает погребения. Закопай то же правило среди двадцати пяти других — и модель послабее сползает обратно к своему умолчанию. Именно эта эрозия и приводит к тому, что better-auth запрещает каст, который его же код в проверке прав всё равно делает: запрет реальный, но лишь пока он свежий и короткий в окне, а не на двести файлов вглубь библиотеки аутентификации. Тот же склон мерили и другие: исследование 1650 реальных сессий Claude Code поймало, как соблюдение тихо оседает по мере того, как сессия тянется; чем больше инструкций набьёшь в один файл, тем хуже модель следует любой отдельной; а группа из ETH обнаружила, что репозиторные context-файлы чаще не делают кодовых агентов лучше, а стоят на 20%+ дороже.
Расплывчатое правило смещает. «Декомпозируй», «обрабатывай ошибки», «комментируй код» — ни одно из них не игнорируется. Стоит написать «комментируй код», и функция уходит с нуля строк комментариев куда-то между восемью и двадцатью тремя — считая строки, а не оценивая их. Это сдвинутое распределение, а не зафиксированное значение, и зафиксированным значением оно быть не может, потому что фиксировать нечего; в этом и есть почти весь смысл слова «расплывчатое».
А правило, которое воюет с вшитой привычкой, проигрывает. Самый ясный случай в моих прогонах: «никакого Co-Authored-By: Claude в сообщении коммита», поставленное в одиночку, прямо перед моделью, ничто не тянет внимание на себя — и сильная модель всё равно добавляла атрибуцию в двух третях случаев.
Поставь три судьбы рядом — и вываливается парадокс, самая неуютная находка всего этого текста. Правила, которые проза надёжно фиксирует, — механические: те, для которых проза тебе никогда и не была нужна, потому что линтер или хук держат их вечно, на любой модели, бесплатно. А правила, которым реально нужно суждение, — расплывчатые и те, что воюют с привычкой, — проза может только подтолкнуть. Твой CLAUDE.md — несущий ровно там, где он избыточен, и лишь подталкивает ровно там, где он незаменим. И это переосмысляет перепись: те 46%, кто подключил линтер, перенесли на машину единственные правила, которые проза могла удержать, — а файлу оставили те, что она удержать не может.
Очевидное решение тоже течёт
Очевидный ход — закрыть разрыв ещё большей моделью: заставить LLM превратить каждое прозаическое правило в линт-чекер. «Напиши мне линт-правило вот на это». Я попробовал это на примерно сотне реальных правил, вытащенных из отдельного корпуса в двадцать опенсорсных файлов агент-правил (не тех 57 из переписи выше), с независимым оценщиком, который подсаживал нарушения, про которые точно известно, что они ломают правило, плюс чистые обманки, рассчитанные на то, чтобы одурачить ленивый чекер. На курированном наборе из 99 правил 84% синтезированных чекеров молча потекли — пропустили реальное нарушение, ложно сработали на чистом коде или и то, и другое. На 50 правилах, намайненных прямо из живых репозиториев, — 96%; безупречными оказались двое из пятидесяти. А когда я дал проверить самого оценщика, он тоже потёк, пропуская нарушения, которые должен был поймать. Это модели, оценивающие модели, при маленьком n — черепахи какое-то время, — так что читай 84 и 96 как направление, а не как измерение. И направление это — не из тонких.
Режимы протечки скучные — именно поэтому они опасны. Чекер «нет console.log» матчит console.log( и пропускает const c = console; c.log('x'), потому что сматчил имя, а не вызов. Самый мрачно-смешной: чекер «нет eslint-disable» глушится ровно тем, за чем охотится, — блочный /* eslint-disable */ выключает в файле все правила, включая то, что за ним же и охотится. Наивный чекер матчит поверхность правила, а не его смысл, и проходит само-тест автора, потому что автор тоже тестировал поверхность. Vibe linting: попросить модель сгенерировать твой энфорсмент — и отгрузить чекер, ни разу не проверив его на нарушении, про которое точно знаешь, что оно реальное. Это хуже, чем чекера нет вовсе: на красную галочку, которой не доверяешь, ты всё равно смотришь; зелёная, которой доверять не стоило, заставляет тебя перестать смотреть.
▶Как я мерил чекеры и чего сюда не стоит вчитывать
Каждый чекер — это отдельный предикат: даёшь ему строку кода, он либо флажит нарушение, либо нет. Оценщик — отдельная модель, которая не видит самопроверок чекера. Для каждого правила она собирает состязательный gold-набор — реальные нарушения плюс чистые обманки (запрещённый паттерн внутри строки или комментария, похожий идентификатор, который на самом деле в порядке), прогоняет по нему чекер и считает precision и recall. «Течёт» = пропустил реальное нарушение или сработал на чистом коде.
Это не статья, так что читай направление, а не знаки после запятой. И автор чекера, и оценщик — модели, а не люди. Gold-набор размечен моделью, хотя на всём, что я перепроверял вручную, второй независимый рейтер согласился с метками. И n маленький: 99 курированных правил, 50 намайненных из реальных репозиториев.
Направь каждое правило на то, что способно его удержать
Итак, ни надежда, ни синтез не работают. Работает маршрутизация. Я вручную рассортировал 252 правила из того же корпуса в двадцать репозиториев по тому, как каждое реально принудить, — моё суждение, первый заход, так что смотри на форму, а не на третий знак после запятой:
Как раскладываются 252 реальных правила
252 правила · 20 репо
84% принудимо без прозы · 16% остаётся прозой
37% (92) Хук
не пушить в main · не трогать сгенерённое · тесты перед коммитом
27% (69) Кастомное линт-правило
нет голого fetch · этот слой не импортит тот
20% (51) Готовое линт-правило
нет console.log · нет any (просто включить)
16% (40) Чистая семантика
единственная ответственность · самодокументируемость
Большую часть того, что люди пишут, можно принудить детерминированно, и немалая доля этого ложится на правило, которое уже существует, обкатанное, с настоящим парсером под капотом. (Оба чекера, пережившие моего оценщика, опирались на настоящий TypeScript-AST, а не на регулярку.) Сама маршрутизация механическая:
- Никогда не используй
as any→ ESLint-правило, которое падает в CI - Никогда не пушь в main → pre-push-хук, который это блокирует
- Предпочитай понятные имена вычурным → остаётся прозой: ни один чекер честно этого не решит
Делать эту маршрутизацию руками по всему CLAUDE.md муторно, поэтому я собираю тул, который делает её сам: маршрутизирует на настоящее правило там, где оно есть, синтезирует только настоящий остаток, гейтит всё, что синтезируется, — так что чекер, который не может пройти тест, написанный не им самим, воздерживается, а не отгружает ложную уверенность, — а то, что принудить нельзя, возвращает прозой — но помеченной, — и никогда не подделывает зелёную галочку. Модель запускается один раз, на этапе авторинга; в CI после этого — чистый ESLint и хуки, ничего вероятностного в цикле.
Треть правил — вообще не линт. «Никогда не пушь напрямую в main». «Никогда не редактируй сгенерённые файлы». «Прогони тесты перед коммитом». Линтер не принудит ни одно из них: ESLint читает твой исходник и никогда не видит ни git-команду, которую ты вот-вот выполнишь, ни файл, который вот-вот затрёшь, ни шелл, который вот-вот запустишь. Правильный гейт здесь — хук: pre-commit или PreToolUse, если твой агент ходит через Claude Code, — который отказывает в действии, а не заваливает мёрдж. «Блокируй любой git push, нацеленный в main». «Блокируй запись внутрь generated/». Нельзя обойти дверь, которую тебе и не давали открыть. И то правило про со-авторство, проигравшее выше два к одному, — ровно случай для такого хука: запрет, проигрывающий вшитой привычке, — это то, ради чего жёсткие гейты и существуют. Эти гейты текут своим, куда более страшным образом, когда люди копипастят их как прозу, — но это тема отдельного поста.
Остаток под кастомные правила меньше и не такой страшный, как кажется: селектор и report. Моё первое было классикой — агенты постоянно добавляли console.log вместо кастомного логгера, который отправляет всё в Datadog, никакой CLAUDE.md это не чинил, а линт-правило на 10 строк прикончило это навсегда. (Перепись подтверждает, что этим правилом пренебрегают: из 22 репозиториев, чей файл агент-правил запрещает console.log, лишь около 23% принуждают его как ошибку.) Возьми запрет as any, правило, которое было просто предложением в CLAUDE.md: из коробки ты бы потянулся за @typescript-eslint/no-explicit-any — тем самым правилом, которое LibreChat выключил, — но руками это всего лишь вот:
export default {
create(context) {
return {
"TSAsExpression > TSAnyKeyword"(node) {
context.report({
node,
message: "No `as any`. Narrow the type or fix it upstream.",
})
},
}
},
}Ты матчишь конструкцию — узел каста, а не имя, — так что оно не течёт так, как течёт матч по имени console.log. Оно ловит x as any; формы <any>x в угловых скобках и x as any[] требуют более широкого селектора — ровно поэтому, когда готовое правило вроде no-explicit-any уже покрывает все три, ты сначала берёшь его, а руками пишешь только то, чего из коробки не видит ничто.
Ничего из этого не про JavaScript. RuboCop, Ruff, clippy, golangci-lint — идея та же, и ловушка та же. AGENTS.md у Prometheus говорит, что у каждого экспортируемого объекта должен быть doc-комментарий, и велит контрибьюторам чинить находки линтера, а не глушить их. А его конфиг golangci отключает проверку doc-комментариев для пакетов — с TODO прямо в конфиге, который признаёт, что дерево и так полно недостающих. И вот make lint светит зелёным поверх той части — про doc-комментарии пакетов, — что просит гайд, в Go, по той же причине: файл, который читает агент, и файл, который выполняется, — это два разных файла.
Одно предупреждение, прежде чем ты всё скомпилируешь: даже настоящее правило не спасёт, если запасной выход в одном комментарии. trpc сделал всё правильно на бумаге — запретил Symbol.dispose настоящим ESLint-правилом, которое падает с ошибкой «используй makeAsyncResource()». А потом makeAsyncResource, ровно то средство, на которое указывает ошибка, реализован через Symbol.asyncDispose, заглушённый // eslint-disable-next-line строкой выше.
Лимиты сложности, в трёх предложениях
Если после этой статьи ты включишь только одну вещь — пусть это будут лимиты, которые ESLint отгружает уже годами, а почти никто не включает: без них агент радостно вручит тебе одну функцию на 150 строк с шестью уровнями вложенности, которая компилируется, проходит тесты и становится хуже каждый раз, когда её трогает следующий агент:
{
"rules": {
"complexity": ["error", 10],
"max-depth": ["error", 3],
"max-lines-per-function": ["error", 40],
"max-params": ["error", 4],
"max-statements": ["error", 15]
}
}Это правило с самой большой отдачей из всех, что я знаю, и то, которое агент сильнее всего хочет обойти, — и это один и тот же факт: лимит, с которым генератор борется, — это лимит, который его реально ограничивает. Поэтому закрывай запасной выход тем же коммитом: запрети или отслеживай disable-комментарии (@eslint-community/eslint-comments), соедини с --max-warnings=0 — иначе агент обойдёт всю затею стороной любимым // eslint-disable-next-line и вручит тебе зелёную галочку, которой никто не должен доверять.
Где линтинг заканчивается
Линтинг статичен: он читает код, но запустить его не может. Он с радостью подтвердит, что твой middleware для рейт-лимитинга определён в файле, — и понятия не имеет, что его так и не подключили. Код вот он, просто не выполняется. Поймать это — уже другая дисциплина: верифицировать то, что агент произвёл, и моделировать состояние так, чтобы худших багов не могло быть вовсе. Про эту петлю я писал отдельно; не здесь.
Эта статья всегда была только про входную сторону — а входная сторона заканчивается домашним заданием. Открой свой CLAUDE.md. Открой линт-конфиг, лежащий рядом. Возьми самое чёткое правило из первого файла и пойди поищи его во втором. Одно из них наверняка врёт — и теперь ты знаешь, что дело не только в тебе: из 57 репозиториев, где все написали «никогда не используй any», правдой это сделали меньше половины.