Общие pre-commit hooks для всех монорепо volody (mcp, baton). check-file-length + check-model-comments. Подключение: repo: https://git.homedevlab.ru/senokosov/pre-commit-hooks
  • Python 56.2%
  • Shell 38.2%
  • TypeScript 4.3%
  • HTML 0.6%
  • CSS 0.3%
  • Other 0.4%
Find a file
2026-08-27 14:33:50 +07:00
.forgejo/workflows ci: гейт «версия в pyproject.toml против тега релиза» 2026-08-27 14:20:57 +07:00
examples fix: вердикт broken-repo-paths не зависит от личных настроек git; пробелы в TODO() 2026-08-27 14:33:11 +07:00
fixtures fix: вердикт broken-repo-paths не зависит от личных настроек git; пробелы в TODO() 2026-08-27 14:33:11 +07:00
scripts fix: вердикт broken-repo-paths не зависит от личных настроек git; пробелы в TODO() 2026-08-27 14:33:11 +07:00
.gitignore fix(issue-refs): regex-литералы как литералы; build/ вне репозитория 2026-08-26 17:31:15 +07:00
.pre-commit-hooks.yaml fix: вердикт broken-repo-paths не зависит от личных настроек git; пробелы в TODO() 2026-08-27 14:33:11 +07:00
pyproject.toml fix: вердикт broken-repo-paths не зависит от личных настроек git; пробелы в TODO() 2026-08-27 14:33:11 +07:00
README.md fix: вердикт broken-repo-paths не зависит от личных настроек git; пробелы в TODO() 2026-08-27 14:33:11 +07:00

pre-commit-hooks

Общие pre-commit hooks для монорепо volody (mcp, baton, …). Один источник истины для скриптов, чтобы каждая репа не плодила копии в своём scripts/.

Хуки

check-file-length

Гейт длины файла в строках. Большой файл — индикатор накопленного refactor-долга; ловим до того, как agent_runner.py отрастёт до 700 строк.

Категория Warn Fail
*.md (спека/доки) 500+ 700+
src/ (production) 300+ 500+
tests/ (включая conftest.py) 600+ 1000+

Markdown ловится по расширению (путь не важен), поэтому consumer может гейтить все .md сразу, исключив frozen-доки через exclude: — работает и во вложенных каталогах:

- id: check-file-length
  files: '\.md$'
  exclude: '^(docs/archive/|docs/interview/)'

Потолок 700 строк — чтобы агент прочитал файл за один проход (инструмент чтения обрезает ~25k токенов / 2000 строк; русская проза ~1525 токенов/строку).

FAIL — коммит запрещён. WARN — коммит разрешён, но это early-warning, начинайте разбивать заранее.

Grandfather: если файл уже был больше лимита в базовой ревизии и текущая правка его не увеличила (число строк не выросло), коммит разрешён (WARN(grandfather)). Иначе нельзя внести даже фикс ссылки в legacy-god-file, пока он ждёт своего split-PR. FAIL остаётся для случаев, где правка ухудшает картину: новый файл > лимита, файл, перешагнувший лимит этой правкой, и рост уже-большого файла.

Базовая ревизия зависит от способа запуска (с v0.13.0) — и это не деталь: от неё зависит, работает гейт или отдаёт зелёный вхолостую.

Запуск База Почему
git pre-commit hook HEAD новый коммит ещё не создан, HEAD:file — версия до правки
pre-commit run --from-ref X --to-ref Y (CI) merge-base X и Y дерево уже стоит на проверяемом коммите; HEAD:file вернул бы сам изменённый файл. Merge-base, а не вершина X: иначе split, приехавший в целевую ветку после ветвления, засчитается вам как рост
--all-files HEAD файлы не менялись, всё уже-большое проходит как grandfather — это режим аудита, гейта здесь нет

До v0.13.0 база была жёстко HEAD во всех режимах, поэтому в CI гейт не ловил ни рост god-файла, ни новый файл сверх порога и показывал Passed (volody/dispatcher#186). Консьюмеру, который зовёт хук из CI, обязательно нужны --from-ref/--to-ref. Вне git (нет базовой версии) большой файл по-прежнему FAIL.

Shallow-клон. Если merge-base не резолвится (git clone --depth 1), хук откатывается на вершину X — так же, как в этом случае поступает сам pre-commit при отборе файлов. Обещание из таблицы тогда не действует: сжатие файла в целевой ветке после ветвления засчитается вам как рост, и MR покраснеет там, где не должен. Ошибка безопасная (FAIL, не зелёный), но чинится она на стороне консьюмера — полным клоном в CI-джобе (GIT_DEPTH: "0" у GitLab).

Что ужесточилось с v0.13.0. Кроме собственно роста, в CI теперь падает чистое переименование grandfathered-файла: по новому пути его нет в базовой ревизии, old = 0, и срабатывает правило «новый файл > лимита». В commit-хуке так было всегда — CI просто догнал его. Перекладывая god-файл в подпакет, разбивайте его тем же MR.

Пропускаются: alembic/versions/** и migrations/versions/** (immutable миграции — каталог называет сам разработчик, alembic init alembic против alembic init migrations), __init__.py (re-exports), proto/generated/** (autogen) — на любой глубине, включая корень репозитория. Под каталогом исключения пропускается всё его содержимое, не только .py. Имя каталога сверяется целиком: schema_migrations/versions/ под исключение не попадает. Django-раскладка <app>/migrations/0001_x.py под исключение не попадает: уровня versions/ там нет, а исключать любой migrations/ слишком широко — под ним держат и рукописный код. До v0.18.0 первые два исключения требовали хотя бы один ведущий каталог, и миграции в корневом alembic/versions/ проверялись на длину наравне с обычным кодом.

Пороги можно переопределить через env: SPEC_WARN, SPEC_FAIL, SRC_WARN, SRC_FAIL, TESTS_WARN, TESTS_FAIL.

check-model-comments

Проверяет SQLAlchemy 2.x ORM-модели на наличие SQL COMMENT ON COLUMN / COMMENT ON TABLE:

  1. Каждый mapped_column(...) обязан содержать comment=.
  2. Каждый класс с __tablename__ обязан иметь __table_args__ = {"comment": ...} (или tuple с dict в конце).

Зачем: SQL-комменты — единственный способ объяснить колонку DBA / аналитику без чтения Python-кода. AST-парсер, без рантайма — не требует установки SQLAlchemy.

Применять только к проектам с ORM (files: фильтр обязателен у consumer'а).

no-agent-rules-in-code (с v0.2.0)

Запрет ссылок на AGENTS.md / CLAUDE.md / GEMINI.md из production-кода. Pygrep-regex, без скриптов.

Зачем: эти файлы — внутренние правила для AI-агентов. Production-код должен быть самодостаточным: пиши «почему» прямо в комментарии рядом с кодом, не отсылая к внешнему документу, который может переехать.

Документация (docs/, README.md, сам AGENTS.md) — exclude: в consumer-репе.

todo-needs-issue (с v0.2.0)

TODO обязан содержать ссылку на open issue в формате TODO(#N); несколько ссылок — через запятую, TODO(#12, #34). Pygrep-regex с negative lookahead.

Зачем: маркер без issue зависает без owner'а и срока. Ссылка делает каждое отложенное решение трекабельным.

Пробелы допускаются везде одинаково — TODO (#456), TODO( #456 ), TODO(#12, #34) равноправны (с v0.23.0; до этого пробел перед скобкой принимался, а внутри скобок — нет, и нигде это описано не было). Правило про формат ссылки, а не про пунктуацию вокруг неё; то же исключение и в no-issue-refs-in-comments — два хука обязаны говорить об одном формате одно и то же. Граница: пробел между # и номером (TODO( # 456 )) исключением не считается — # 456 это не ссылка.

С v0.11.0 формат строгий: только голый номер. Префикс вида TODO(ns/proj#N) больше не принимается — TODO живёт внутри своего репозитория, кросс-проектных не заводим. Зависимость от чужой задачи оформляется своей issue, а она уже ссылается на чужую: так связь видна в трекере, а не только в комментарии. Потребителям, у которых префиксы уже есть, обновление rev: покрасит их на первом же касании файла — это ожидаемо и лечится переписыванием пунктуации.

Документация и сам .pre-commit-config.yaml (которые цитируют формат) — exclude: в consumer-репе.

no-underscore-filenames (fail с v0.4.0)

.py-файлы не должны начинаться с _ (кроме dunder: __init__.py, __main__.py). Принадлежность выражается через директории: orchestrator/helpers.py, не _orchestrator_helpers.py.

Зачем: _-префикс в имени файла дублирует то, что уже выражает каталог, и мешает навигации. FAIL — коммит запрещён.

max-filename-words (fail с v0.14.0)

Имя .py-модуля не длиннее N значимых слов (env MAX_FILENAME_WORDS, дефолт 2). Стем без .py, ведущий test_ отброшен, split по _, пустые сегменты не в счёт. Dunder (__init__.py, …) и conftest.py — skip.

Меряет число слов, не символов: короткое слитное taskinstance ок, длинная композиция org_repo_host_config — нет; иерархию выражает каталог (repo_host/config.py).

Grandfather от базовой ревизии (тот же механизм, что у check-file-length): FAIL только на файле, НОВОМ относительно базы (добавлен или переименован-в-длинное). Уже существующий там длинный файл — WARN(grandfather), коммит разрешён: переименование legacy-модуля — отдельный рефактор импортов/DI.

База зависит от способа запуска ровно так же, как в check-file-length: в CI-инвокации pre-commit run --from-ref X --to-ref Y — merge-base X и Y, в обычном commit-хуке и при --all-filesHEAD. Взять HEAD в CI нельзя: рабочее дерево уже стоит на проверяемом коммите, любой добавленный веткой файл там присутствует, и новый длинноимённый модуль проехал бы зелёным. Консьюмеру, который зовёт хук из CI, обязательно нужны --from-ref/--to-ref. Вне git (нет базовой версии) длинное имя — FAIL.

Перенос grandfathered-модуля в другой каталог без переименования — тоже FAIL в CI: по новому пути файла в базовой ревизии нет. Это то же ужесточение, что у check-file-length, и оно бьёт ровно по модульному рефактору — переименовывайте такой файл тем же MR, которым его переносите.

Скоуп и точечные исключения — files: / exclude: у consumer'а. FAIL — коммит запрещён.

no-private-imports (fail с v0.4.0)

Импорт вида from x.y import _func запрещён везде — и в коде, и в тестах. Dunder-символы (__version__, __all__) — исключение.

С v0.15.0 разбор через ast, а не построчной регуляркой. Регулярка ошибалась в обе стороны, и обе ошибки видны на реальном коде:

  • многострочный импорт в скобках проходил молча — совпадение искалось в строке с from ... import, а имена стоят ниже. На jamzap/backend так пропускались 30 нарушений из 51;
  • алиас считался импортируемым символом, поэтому from x import public_name as _local падало, хотя импортируется публичное имя. Подчёркивание в алиасе — обычная разметка «не реэкспортируем из этого модуля».

Проверяется именно импортируемое имя: from x import _y as y — нарушение, from x import y as _y — нет. Голое _ (from app.core.i18n import _) нарушением не считается — это имя gettext-функции, а не пометка «внутреннее». Относительный (from . import _x) и отложенный (внутри функции, под if TYPE_CHECKING) импорты ловятся наравне с обычными.

С v0.20.0 приватным считается любой сегмент пути, а не только импортируемое имя: from mypkg._internal import public_thing и import mypkg._private.sub — такие же нарушения, как from . import _private. До этого правило было асимметричным: слабая форма падала, а строго более сильная проходила молча.

Поблажки внутрипакетным импортам нет: from ._helpers import build_payload — тоже FAIL, хотя относительный импорт по определению не выходит за пределы своего пакета и «нарушитель» здесь — владелец приватного модуля. Причина не в том, что это пробой инкапсуляции, а в том, что в наших репозиториях такого класса не возникает: соседний хук no-underscore-filenames запрещает _*.py вовсе, и приватных модулей просто нет. Поблажка для level > 0 вернула бы асимметрию — from . import _private падает, а строго более сильное from ._private import x проходило бы.

Это стоит знать консьюмеру, который включает no-private-imports без no-underscore-filenames: там _-модули есть, и правило обойдётся дорого. Точечно такой импорт не гасится — exclude: в pre-commit фильтрует по пути файла, а не по строке, то есть исключить придётся файл целиком. Замер на живых пакетах (/usr/lib/python3/dist-packages, 3259 файлов): из 1738 новых срабатываний 96% — пакет, читающий собственные внутренности (PIL._binary, blinkerblinker._utilities, naclnacl._sodium), и лишь 4% — импорт чужих внутренностей. Для C-расширений первая форма вообще безальтернативна: скомпилированный модуль по конвенции называется _rust/_speedups, и обёртка обязана его импортировать.

Allowlist: документированные модули с историческим подчёркиванием — _thread (stdlib) и _typeshed (рекомендованная mypy идиома from _typeshed import Incomplete). Приватные модули библиотек (pip._internal, nacl._sodium) сюда не входят: там подчёркивание значит ровно то, что значит.

Звёздочка проверяется наполовину: from mypkg.public import * чист, а from mypkg._internal import * — нарушение. Путь смотрится как обычно, а имена, приезжающие из звёздочки, — нет: что там придёт, знает только импортируемый модуль через свой __all__.

Зачем: импорт приватного символа пробивает инкапсуляцию модуля. Нужен снаружи — убрать _ и сделать публичным. FAIL — коммит запрещён.

no-private-method-calls (fail с v0.4.0)

Тесты не должны обращаться к приватным методам напрямую (obj._method()). Применять только к тест-файлам (files: фильтр у consumer'а).

С v0.15.0 разбор через ast. Регулярка искала ._name( в «коде», отрезая всё после первой решётки, и ошибалась в обе стороны: упоминание Class._method(...) в docstring'е считалось вызовом (на jamzap/backend так падали 4 файла, у которых весь «вызов» — заголовок теста), а решётка внутри строкового литерала обрезала строку и прятала настоящий вызов за ней. Заодно ловится вызов, разложенный по строкам, а dunder (obj.__init__()) исключён — это протокол, а не приватный интерфейс.

Правило шире, чем «чужой объект»: self._helper() внутри самого тест-класса — тоже FAIL, хотя это частый pytest-паттерн. Приватный хелпер теста стоит держать модульной функцией или фикстурой. Под правило попадают и obj.__mangled() (name-mangled, без хвостовых __), и псевдоприватный stdlib-API (nt._replace(), nt._asdict() у namedtuple) — регулярка их пропускала, ast ловит. Где такой вызов законен, точечный exclude: у consumer'а.

Зачем: тест на приватный метод цементирует реализацию и ломается при любом рефакторинге. Тест проверяет только публичный контракт. FAIL — коммит запрещён.

no-issue-refs-in-comments (fail с v0.4.0)

Ссылки на номера задач (#123) в комментариях и docstring'ах запрещены. Исключение — TODO(#N) / TODO(#N, #M).

С v0.11.0 хук переносим за пределы python. Синтаксис комментария выбирается по расширению файла: // и /* */ для C-подобных (.ts, .tsx, .js, .go, .rs и прочих), # для остальных. Выбор по расширению, а не «все сразу», намеренно: в .ts символ # начинает приватное поле класса (#count), и трактовка его как комментария давала бы ложные срабатывания. Строковые литералы (включая шаблонные) комментарием не считаются, поэтому "https://example.com" не выглядит началом //-комментария.

С v0.12.0 разбор комментариев общий с no-broken-repo-paths — модуль scripts/comment_scan.py; с v0.21.0 режим выбирается там же одной точкой (chunks_for), а не каждым хуком по-своему. Для .py это значит tokenize: комментарии берутся как COMMENT-токены, docstring'и модуля/класса/функции классифицируются через ast, а обычные строковые литералы поясняющим текстом не считаются. Прежний построчный разбор «первый # открывает комментарий» на python-файлах давал две ошибки сразу:

  • строка docstring'а вида TODO(#286). разбиралась как комментарий #286)., в который исключение TODO уже не попадало — хук флагал ровно тот формат, который сам предписывает;
  • и наоборот, префиксные ссылки (backend#451, jamzap/backend#386, issue#32) ловились лишь по случайности: обрезка строки по первому # ставила номер в начало фрагмента, и lookbehind (?<!\w) его пропускал. В обычном #-комментарии та же ссылка не ловилась вовсе.

Поэтому правило записано прямо: ссылкой считается #NNN независимо от того, что стоит перед решёткой. Кросс-проектная форма в комментарии запрещена ровно так же, как своя — TODO кросс-проектными не бывают (см. todo-needs-issue), и ссылка на чужой трекер в комментарии протухает быстрее собственной. URL с числовым якорем (.../page#123) ссылкой не считается: URL вырезаются из текста до поиска.

Исключение для TODO сузилось вместе с todo-needs-issue: легальны только TODO(#N) и TODO(#N, #M), свободное содержимое скобок исключением уже не считается.

Ссылка, стоящая якорем на путь или домен, ссылкой на задачу не считается и вырезается до поиска: https://x/y#123, example.com/spec#456, docs/adr/0001.md#456. Признак якоря — точка в токене перед решёткой: у номеров задач её не бывает (#451, backend#451, jamzap/backend#386), у доменов и файлов она есть всегда.

Regex-литералы JS машина пропускает как литералы: кавычка, бэктик или /* внутри /[^"]+/ больше не открывают мнимую строку или мнимый блочный комментарий. Распознавание — эвристика JS-лексеров, а не полный парсер: regex начинается после оператора, открывающей скобки или ключевого слова (return /re/, => /re/) и обязан закрыться на своей строке. Поэтому деление (total / count, i++ / 2), самозакрывающийся тег (<Foo bar={1} />) и закрывающий тег JSX (</p>) под него не подпадают: < и } из набора убраны, а literal, чей «закрывающий» слэш сам оказался началом // или /*, отвергается — принять его значило бы съесть комментарий. Слово после точки (x.in / 2) ключевым не считается.

Эвристика не полна и не претендует: она гарантирует лишь, что промах не выходит за пределы строки. Уехать дальше можно единственным способом — проглотив открывающий бэктик шаблонного литерала, потому что он законно переживает переводы строк. Известные пути к этому закрыты фикстурами; открытым остаётся один экзотический — regex, оканчивающийся бэктиком вплотную перед комментарием (const r = /\/// note`).

Оставшееся ограничение C-подобного режима (общее с no-broken-repo-paths): апостроф в тексте JSX (<p>don't</p>) открывает мнимый строковый литерал, и комментарий на той же строке не сканируется. Дальше строки это не уходит — на переводе строки состояние '/" сбрасывается (шаблонный литерал перевод строки переживает законно и потому исключён).

Тот же апостроф — ограничение и #-режима (echo it's fine # см #456 пропускается). И исключение TODO(#N) матчится в пределах строки: перенос вида TODO(#286, / #287) через две строки docstring'а флагается как нарушение.

Вырезание якорей жадное до пробела, поэтому прилипшая пунктуация уносит и ссылку: # https://example.com,#456 и # версия 1.2#456 не сработают. Направление отказа безопасное (пропуск), случаи редкие. И наоборот: hex-цвет в комментарии C-подобного файла (// раньше фон был #336699) — ложное срабатывание; для .css расширение поэтому и не включено в C-подобные, но в .ts theme-файлах то же возможно — гасится exclude-ом у потребителя.

С v0.21.0 у стилей и разметки свой режим, и подключать их можно:

  • .css — только /* */. // там не комментарий, а часть url(//cdn…), поэтому строчных комментариев режим не знает намеренно;
  • .scss, .sass, .less/* */ плюс //, но не внутри url();
  • .html, .htm, .vue, .svelte, .xml, .svg<!-- -->, а встроенные <script> и <style> разбираются своими сканерами, с номерами строк относительно всего файла; <style lang="scss"> получает правила препроцессора.

Ограничения разметочного режима: атрибуты не разбираются, поэтому <div data-tpl="<!--"> переведёт остаток файла в «комментарий» и даст ложное срабатывание на тексте страницы (отказ громкий, не молчаливый). Блок <script> или <style> без закрывающего тега не сканируется вовсе — зато и не глушит разбор дальше: молчаливый пропуск здесь был бы хуже недосканированного блока.

До этого такие файлы падали в #-фолбэк, где решётка открывает комментарий, и хук флагал каждый hex-цвет (color: #336699), id-селектор (#main) и текст страницы (<p>Задача #456</p>) — подключить их было нельзя вовсе.

#-режим остаётся фолбэком для shell/yaml/toml и прочих неизвестных расширений.

Заодно снят types: [python] из определения хука: скоуп задаёт потребитель через files:, как у no-broken-repo-paths. Раньше жёсткий types молча отфильтровывал весь не-python, и хук, прописанный в TS-репо, проходил вхолостую. В .md не включать: там # — заголовок.

Зачем: #NNN в комментарии описывает историю, а не суть; через полгода ведёт в закрытый трекер. Пиши, что делает код. Трекаемые отложенные решения — через TODO(#N) (см. todo-needs-issue). FAIL — коммит запрещён.

no-session-marks-in-code (fail с v0.7.0)

Tracking-метки «заглавная буква + сразу цифры» (M2/45, M1/27d — план сессий; R31/R52 — правила; Q45 — вопросы; O12 — open-questions) запрещены — и в коде, и в markdown спеки/доков. Брат no-issue-refs-in-comments, но ловит и docstring'и (метки прятались и в них), и прозу спеки.

Зачем: эти метки — back-reference на внутренний трекинг (сессии/правила/вопросы); для читателя это шум, который ничего не значит. Пиши суть, не номер.

Правило намеренно широкое: матчит любую одиночную заглавную букву с цифрами ([A-Z]\d+, \w-границы отсекают идентификаторы и многобуквенные токены вроде GPT4/SHA256). Философия — «проще ловить всё, исключения добавлять по факту», а не сужать regex.

Исключения двух видов:

  • allowlist ALLOWED_MARKS в скрипте — только реальные общепринятые в мире токены (S3 — AWS, K8s — Kubernetes). НЕ версии (их пишут строчными: v1/v2) и не самодельные коды.
  • линтер-коды в # noqa: E501, N802 / # nosec: B101 — исключаются автоматически (хук noqa-aware), но именно перечисленные коды, а не вся строка.

Что сканируется, зависит от языка файла (с v0.17.0):

  • .py — текст COMMENT/STRING-токенов через tokenize: деление a / b в обычном коде меткой не считается;
  • .ts/.tsx/.js и прочие C-подобные — только комментарии // и /* */, общей машиной состояний с no-issue-refs-in-comments. До v0.17.0 они сканировались построчно, как markdown, и наводить хук на фронт было нельзя: широкое правило валило сам код — дженерик T1, параметры Map<K1, V2>, литерал "H2";
  • markdown и всё остальное — построчно: код-токенов там нет, а проза и есть предмет проверки.

Известные ограничения C-подобного режима:

  • метка внутри строкового литерала (const s = "M2/45") не ловится, хотя в .py такая ловится. Асимметрия намеренная — в TS/JS литерал сплошь и рядом несёт разметку ("H2", "P1"), и скан литералов дал бы ложняки на пустом месте;
  • текст JSX не сканируется: <p>Этап M2/45 закрыт</p> пройдёт, потому что это не комментарий и не литерал. Если метки живут в пользовательском тексте фронта, их ловит не этот хук;
  • апостроф в тексте JSX (<p>don't</p>) глушит комментарий на своей строке — унаследовано от общей машины comment_scan, дальше строки не уходит.

С v0.21.0 стили и разметку тоже можно скоупить: .css/.scss и .html/.vue/.svelte разбираются своими режимами (описаны в no-issue-refs-in-comments — слой общий), и селектор grid-area: A1 больше не считается меткой. До этого они шли построчно, как markdown, и ложняки на коде были гарантированы.

Построчный режим остаётся у markdown и у неизвестных расширений (.sql, .yaml): там код-токенов нет либо язык не опознан. Для .sql это по-прежнему означает ложняки на коде — такие расширения не скоупь или гаси exclude-ом.

Для целого файла/каталога — exclude: у consumer'а.

Скоуп (.py / .ts / .md) задаёт consumer через files: — разбивка по языкам описана выше. Документация, цитирующая формат (README.md, CHANGELOG.md), и frozen-архив — exclude: у consumer'а. FAIL — коммит запрещён.

no-mvp-phase-language (fail с v0.8.0)

Запрет фаз-слова MVP в коде и доках. Pygrep-regex, без скриптов.

Зачем: MVP — фазовая рамка («в MVP статично», «после MVP», «post-MVP»), которая делит целевую систему на «сейчас урезанно / потом полноценно». Спека и код описывают целевую прод-систему без фаз, поэтому слово запрещено. Особенно вредно в файлах, которые грузятся в стартовый контекст агента (AGENTS.md/CLAUDE.md/skills): фаз-слово там разворачивает решения агента в сторону «пока можно урезанно». Принцип — лучше не упоминать, чем явно запрещать: переформулируй («по умолчанию», «при росте объёма», backlog), а не пиши «MVP не делаем».

Паттерн (?<![A-Za-z])MVP(?![A-Za-z]) — ASCII-буквенные guard'ы вместо Unicode-\b: ловит в MVP, post-MVP, MVP-поблажек, Baton MVP и даже кириллицу вплотную (постMVP), но не вложение в ASCII-слово (IMVProvider — clean). Identifier-форму MVP_MODE ловит намеренно (broad по той же философии «проще ловить всё»).

Exclude — минимальный: только то, что не правят и не читают, обычно docs/archive/ (frozen-история). Даже там, где слово сейчас легитимно (формулировка самого запрета, пользовательский пример в спеке), его НЕ исключают: при первом касании файла хук сработает и слово переформулируют по месту — «лучше не упоминать, чем явно запрещать». FAIL — коммит запрещён.

no-plan-stage-refs (fail с v0.16.0)

Запрет ссылок на номера этапов плана в коде: Этап 6, Этапа 3, Фаза 2.5, Milestone 2, Sprint 4, Этап «Preview Tool».

Зачем: номер этапа — back-reference на план разработки, а не на поведение кода. Из «Этап 8» не следует ничего: через полгода план закрыт, а читатель не знает, что это был за этап. Особенно вредно в файлах, которые грузятся в стартовый контекст агента: фразу вида «На Этапе 3 реально используется только list_display» агент читает как описание текущего состояния и проверить не может.

Признак нарушения — номер, а не слово. Слово «фаза» легитимно, когда описывает фазы алгоритма: Фаза A: конкурентные сетевые пробы / Фаза B: последовательное применение решений — тот же термин, другой смысл, и такие строки обязаны проходить. Разделитель между словом и номером обязателен, поэтому идентификаторы Stage2Runner и stage_2 не ловятся, как и слово, лишь начинающееся с ключевого (prestage 2, Этапирование 3).

Оговорка важная: легитимна именно буквенная нумерация фаз. Фаза 1 и Stage 1 дефолтным набором покраснеют — отличить «первая фаза алгоритма» от «первый этап плана» по тексту нельзя. Если в репозитории фазы алгоритма нумеруют цифрами, это лечится у потребителя: --allow на конкретную формулировку или --keywords без соответствующего слова.

Словарь и исключения задаёт consumer через args: — они у каждого репозитория свои, и списка фраз в скрипте нет:

Аргумент Что делает
--keyword СЛОВО добавляет ключевое слово к дефолтному набору (повторяемый)
--keywords a,b,c заменяет дефолтный набор целиком
--pattern REGEX добавляет свою запрещённую форму (повторяемый) — ею же ловится буквенная фаза там, где она не легитимна: --pattern 'Фаза\s+[A-Z]\b'
--allow REGEX строка, матчащая regex, нарушением не считается (повторяемый)

--allow снимает строку целиком, а не найденный фрагмент: широкое --allow воронк погасит и настоящее нарушение, если оно оказалось на той же строке. Пишите исключение по возможности узко (--allow 'Этап \d+ воронк').

Дефолтный набор: Этап, Фаза, Milestone, Sprint, Phase, Stage. Падежи ловятся хвостом словоформы (Этапа, Этапе, Этапов), регистр не важен.

Родственник no-mvp-phase-language — та же фазовая рамка, но там она привязана к одному слову, а здесь к номеру в плане. Отдельный id, а не расширение того хука: скоуп no-mvp-phase-language у потребителей включает docs/**, а в плановом документе перечень этапов — предмет документа. Этот хук наводят только на код, дефолтного files: у него нет.

Мисконфиг в args: — это FAIL с объяснением, а не трейсбек и не молчаливый зелёный: кривой regex в --pattern/--allow печатает, какой аргумент не скомпилировался, а пустой словарь (--keywords '' или опечатка --keywords=) — отдельный вердикт. Хук без единого правила выглядел бы в конфиге работающим, пропуская всё.

Скан построчный по всему файлу, а не только по комментариям: фраза «(Этап 6)» одинаково вредна в комментарии, в docstring'е и в тексте, который код отдаёт наружу (verbose_name, description). FAIL — коммит запрещён.

no-broken-repo-paths (fail с v0.9.0)

«Код»-половина markdown link-checker'а (lychee --offline): тот ловит висячие ссылки внутри *.md, этот — пути на файлы репозитория, зашитые в комментарии и docstring'и исходников (docs/spec/04-architecture.md § «Unit of Work», backend/baton/...). При ренейме/переносе/архивации файла такая ссылка протухает, и агент, читая комментарий, идёт за контекстом в несуществующий путь.

Зачем: после реструктуризации спеки из плоских docs/spec/NN-slug.md в папки (см. ADR-0001) плоские пути в комментариях кода стали битыми массово, но никакой автопроверки не было — ловили руками.

«Ссылкой» считается токен, который: содержит хотя бы один /, оканчивается файловым расширением и чей первый сегмент — реально существующий каталог репозитория (резолв от cwd; pre-commit стартует из корня). Последнее условие отсекает прозу (что-то/иное.txt, где что-то/ — не каталог) и внешние URL (github.com/.../docs/x.md: перед docs стоит /, lookbehind не даёт начать матч). Якорь § N / #fragment после пути не входит в класс символов — отсекается, проверяется только существование файла (как lychee --offline).

Скоуп намеренно узкий — только комментарии и docstring'и, не произвольные строковые литералы. Иначе синтетические пути в тестовых данных (pr_changed_files=["backend/main.py"]) ловились бы как битые ссылки. В .py это COMMENT-токены (tokenize) плюс docstring'и модуля/класса/функции (классификация через ast); в .ts/.tsx//-строчные и /* */-блочные комментарии (машина состояний пропускает строковые литералы, чтобы // в URL не считался комментарием); с v0.21.0 — ещё стили и разметка, теми же режимами, что у no-issue-refs-in-comments.

Это поведенческое изменение: до v0.21.0 любой не-python файл разбирался как C-подобный независимо от того, чем он был, поэтому <!-- см. docs/old.md --> в .html хук не видел. После бампа консьюмеру, скоупящему разметку и стили, стоит ждать новых срабатываний ровно там. А вот #-фолбэк этому хуку намеренно не подключён: путь в shell- или yaml-комментарии — не тот долг, ради которого он заводился, и включение расширило бы гейт молча. Относительные пути (../...) не ловятся: их резолв зависит от расположения файла-источника.

Вердикт не зависит от личных настроек git (с v0.23.0). Хук спрашивает git check-ignore, а тот учитывает и глобальный core.excludesFile — то есть путь, добавленный в личный игнор-файл, переводил бы FAIL в pass, не оставляя следа ни в диффе, ни в конфиге репозитория. Теперь git вызывается с отключёнными глобальным и системным конфигом: гейт, который у двух людей отвечает по-разному на один и тот же коммит, — не гейт.

Ручка гасится через git -c, а не отключением конфига целиком: -c перебивает все уровни — системный, глобальный и локальный .git/config, — но оставляет на месте safe.directory. Снести его нельзя: он читается только из protected-конфига, и без него в контейнере с UID-mismatch (обычный docker-CI) любой вызов git отвечает 128 «dubious ownership», а хук трактует ненулевой код как «путь не игнорируется» — вышла бы волна ложных FAIL'ов вместо одной недетерминированности.

Остаётся .git/info/exclude: git читает его всегда, отключить это нельзя. Файл лежит внутри репозитория и не версионируется, так что расхождение между машинами он всё ещё даёт — но, в отличие от настроек пользователя, он локален для клона и никуда не приезжает.

С v0.11.0 служебные и игнорируемые каталоги ссылками не считаются. Каталог .git/ содержимым репозитория не является ни в одном репозитории, но существует всегда — без отсечки любое упоминание рантайм-файла (.git/config.lock в комментарии про конкурентный доступ) считалось висячей ссылкой. Для остального спрашиваем сам git: если путь под .gitignore (node_modules/, dist/, .venv/), это артефакт, а не файл репо, и его отсутствие нарушением не является. Свой список имён не держим — у каждого потребителя он свой и уже описан в его .gitignore.

Скоуп строк — только правки ветки (с v0.9.1): на стадии pre-push pre-commit передаёт файлы всего диапазона push'а, включая влитые git merge'ем коммиты интеграционной ветки. Их висячие ссылки — чужой долг, а не правка текущей ветки; пере-флагать их при каждом merge dev — ложное срабатывание (блокирует merge feature-веток, пока в dev есть незакрытый path-долг). Поэтому на pre-push (детектируется по env PRE_COMMIT_FROM_REF/PRE_COMMIT_TO_REF) нарушения сужаются до строк, изменённых относительно origin/HEAD (merge-base интеграционной ветки и HEAD) — влитый из неё код в diff не попадает. На стадии commit env-диапазона нет, файл проверяется целиком (строже: тронул файл — почини и старую висячку). git-diff недоступен → скоуп не сужается (strict fallback).

Известное ограничение: иллюстративный путь-плейсхолдер под существующим каталогом в docstring'е (например docs/old.md как пример входных данных) будет помечен. Используй заведомо-несуществующий верхний каталог (example/...) для таких примеров. Скоуп (.py / .ts / .tsx / стили / разметка) задаёт consumer через files:. FAIL — коммит запрещён.

Как хуки читают файлы

Все восемь скриптов, читающих файлы, делают это одним слоем — scripts/read_source.py (с v0.19.0; до этого строка была скопирована по скриптам, и каждый раз, когда её забывали, хук получал одну и ту же дыру).

  • BOM снимается (utf-8-sig). Иначе U+FEFF приезжает первым символом, ast.parse/tokenize падает, а перехват ошибки превращает это в «нарушений нет» — файл проходит мимо хука молча. Именно так дыра и жила: чинилась она по одному хуку за раз, в v0.12.0, v0.15.0, v0.17.0 и v0.18.0.
  • Недекодируемый файл пропускается со строкой в stderr, а не молча: он был в скоупе files: потребителя и проверен не был. Exit-код при этом не меняется — один бинарник, попавший под слишком широкий files:, не должен ронять вердикт по остальным файлам.
  • Файл, не читающийся с диска, пропускается тихо: он мог быть удалён тем же коммитом, а pre-commit всё равно передаёт его имя.

Где эту строку видно. pre-commit печатает вывод хука только когда тот упал, изменил файлы или у него стоит verbose: true. В зелёном прогоне сообщение о пропуске не показывается — если скоуп хука может зацепить бинарники, ставьте таким инстансам verbose: true (в примере конфига так и сделано). То же ограничение действует на WARN и WARN(grandfather) в check-file-length: они тоже идут в stderr при exit 0.

Есть и случай, который остаётся тихим намеренно: файл, который декодировался, но не парсится (SyntaxError — например, BOM в середине файла). Такой файл пропускают все ast-хуки без сообщения: его всё равно не пропустят ruff/mypy, и дублировать их вердикт хук не должен.

no-broad-except (fail с v0.10.0)

Голый except:, except Exception и except BaseException запрещены — включая форму в кортеже, except (ValueError, Exception).

Зачем: fail-loud политика. Широкий catch маскирует системные сбои (потеря БД, сетевой разрыв), которые должны крашить процесс видимо, а не накапливаться молча. Ловить нужно конкретные ожидаемые исключения — те, что отвечают на вопрос «что случилось и как исправить». Непредвиденное пусть пробрасывается.

Анализ через ast, поэтому упоминание Exception в строке или комментарии срабатывания не даёт. С v0.18.0 файл читается в utf-8-sig: раньше BOM приезжал символом U+FEFF, ast.parse падал, а перехват SyntaxError превращал это в «нарушений нет» — одного BOM'а хватало, чтобы файл прошёл мимо хука молча. Потребителям с BOM-файлами после бампа стоит ждать новых срабатываний ровно там.

Непарсящийся файл по-прежнему пропускается молча: его не пропустят ruff/mypy. FAIL — коммит запрещён.

Структура репы

.
├── .pre-commit-hooks.yaml      ← манифест всех 14 hook (это его pre-commit читает)
├── scripts/
│   ├── check_file_length.sh    ← bash-скрипт для check-file-length
│   ├── check_model_comments.py ← Python AST-парсер для check-model-comments
│   ├── check_no_*.py           ← запреты: _-имена, приватные импорты/методы, #NNN, M-метки, битые пути, broad except
│   ├── check_max_filename_words.py  ← потолок слов в имени модуля
│   ├── comment_scan.py         ← общий разбор комментариев/docstring'ов (не хук)
│   └── read_source.py          ← общее чтение исходника хуками (не хук)
├── examples/
│   └── .pre-commit-config.example.yaml   ← готовый consumer-конфиг для copy-paste
├── fixtures/                   ← smoke-кейсы для CI
│   └── run_smoke.sh
└── .forgejo/workflows/ci.yml   ← гоняет smoke на каждом push/PR

Почему скриптов меньше, чем hook'ов?

Pre-commit поддерживает несколько language: режимов:

  • language: scriptentry: указывает на исполняемый файл, pre-commit запускает его как script <changed-files…>. Сюда идёт check-file-length (bash).
  • language: pythonentry: это console-script из pyproject.toml; pre-commit ставит пакет в изолированный venv. Сюда идут check-model-comments и девять запретов (no-underscore-filenames, max-filename-words, no-private-imports, no-private-method-calls, no-issue-refs-in-comments, no-session-marks-in-code, no-broken-repo-paths, no-broad-except, no-plan-stage-refs) — их код в scripts/check_*.py.
  • language: pygrepentry: это regex напрямую в YAML, pre-commit сам матчит его против каждого изменённого файла. Скрипт не нужен и не пишется. Сюда идут no-agent-rules-in-code, todo-needs-issue и no-mvp-phase-language — их «реализация» это одна строка в .pre-commit-hooks.yaml (см. эту секцию entry:). Docs: https://pre-commit.com/#pygrep.

То есть «отсутствие файла в scripts/» для двух последних — это фича, а не пропуск: regex-правила инлайнятся в манифест без отдельного исполняемого артефакта. CI проверяет их через grep -P в fixtures/run_smoke.sh (тоже без отдельного скрипта-обёртки).

Подключение

Готовый шаблон лежит в examples/.pre-commit-config.example.yaml — копируйте в корень своей репы как .pre-commit-config.yaml и правьте files/exclude под структуру проекта.

В .pre-commit-config.yaml consumer-репы:

repos:
  - repo: https://git.homedevlab.ru/senokosov/pre-commit-hooks
    rev: v0.23.0
    hooks:
      - id: check-file-length
        files: '^(src|tests)/.*\.(py|ts|tsx|proto)$'

      - id: check-model-comments
        files: '^backend/.*/models/.*\.py$'

      - id: no-agent-rules-in-code
        files: '^(src|tests)/.*\.(py|toml|yaml|yml|sh|ts|tsx|js|jsx|sql|proto)$'

      - id: todo-needs-issue
        files: '\.(py|toml|yaml|yml|sh|ts|tsx|js|jsx|sql|proto)$'
        exclude: |
          (?x)^(
            AGENTS\.md|
            CLAUDE\.md|
            README\.md|
            CHANGELOG\.md|
            docs/.*|
            \.pre-commit-config\.yaml
          )$

      - id: no-underscore-filenames
        files: '^(src|tests)/.*\.py$'

      - id: max-filename-words
        files: '^src/.*\.py$'          # тесты (^tests/) не скоупим
        exclude: |
          (?x)^src/(
            db/models/.*|              # имя таблицы — не композиция
            (.*/)?alembic/versions/.*|    # автоген rev-имена многословны
            (.*/)?migrations/versions/.*|  # то же под `alembic init migrations`
            (.*/)?migrations/versions/.*|  # то же под `alembic init migrations`
            (.*/)?proto/generated/.*      # сгенерированные stub'ы
          )$

      - id: no-private-imports
        files: '^(src|tests)/.*\.py$'

      - id: no-private-method-calls
        files: '^tests/.*\.py$'

      - id: no-issue-refs-in-comments
        # types снят с v0.11.0 — скоуп задаёшь сам. Работает и в TS:
        # files: '^(src|app)/.*\.(ts|tsx)$'
        files: '^(src|tests)/.*\.py$'

      - id: no-session-marks-in-code
        # И код, и markdown спеки/доков. Доку с цитатой формата и
        # frozen-архив — exclude. Фронт (.ts/.tsx) скоупится с v0.17.0:
        # там сканируются только комментарии, а не строка целиком.
        files: '^(src|tests)/.*\.(py|ts|tsx)$|\.md$'
        exclude: |
          (?x)^(
            README\.md|
            CHANGELOG\.md|
            docs/archive/.*
          )$

      - id: no-mvp-phase-language
        # И код, и markdown. Срабатывает на ВСЁ; exclude — только то, что
        # не правят и не читают (frozen-архив). Даже легитимные сейчас
        # вхождения (формулировка запрета, пример) чистятся по месту при
        # касании файла.
        files: '^(src|tests)/.*\.py$|\.md$'
        exclude: '^docs/archive/.*'

Установка локально: uv run pre-commit install (или pre-commit install). Прогон на всех файлах: pre-commit run --all-files.

Версионирование

SemVer. Breaking changes (новые fail-условия, переименование hook'а) — major bump. Новые hooks — minor. Bug-fix-only — patch. Consumer фиксирует rev: vX.Y.Z, бамп — через PR в consumer-репе.

Версия и тег сверяются в CI (с v0.22.0). Без этой проверки версия и релиз расходятся молча: так девять версий (v0.10.0v0.18.0) прожили невыпущенными, и rev: из этого README у консьюмера не резолвился вовсе.

Прогон Что требуется
пуш тега vX.Y.Z версия в pyproject.toml совпадает с тегом — это и есть релиз
main тег для текущей версии существует и стоит на коммите с той же версией
ветка версия новая (тега ещё нет) и больше последней выпущенной

Порядок выпуска: смержить PR → git tag -a vX.Y.Z -m '…' на merge-коммит → git push origin vX.Y.Z. Прогон main, отработавший между мержем и тегом, останется красным — тега тогда ещё не было; после пуша тега его можно перезапустить, а зелёным сигналом для релизного коммита служит прогон самого тега.

Следствия, о которых стоит знать заранее:

  • любой PR обязан бампать версию, включая docs-only: иначе после выпуска предыдущего релиза его ветка краснеет «версия уже выпущена». Это плата за то, что два параллельных PR не займут один номер;
  • ветки поддержки не поддержаны: хотфикс 0.19.1 при последнем теге v0.22.0 гейт отвергнет как «не больше выпущенной». Если такие ветки понадобятся, правило придётся смягчать;
  • пререлизные суффиксы (0.22.0-rc1) не годятся: sort -V ставит rc1 старше релиза, вопреки SemVer;
  • два PR, оба бампающие на один и тот же номер, остаются зелёными, пока первый не смержен и не протегирован — гейт сравнивает с выпущенным, а не с main.

Пока версия 0.x, major-бампа не делаем: ломающие правки едут в minor. Так репозиторий вёл себя с самого начала (v0.4.0 — «fail с v0.4.0», v0.7.0, v0.9.0 — тоже новые fail-условия), и правило выше описывало намерение после 1.0.0, а не практику. Следствие для потребителя прямое: бамп minor может покрасить код, и обновлять rev: стоит отдельным PR, а не попутно. Что именно ужесточилось — в описании каждого хука отметкой «с vX.Y.Z».

CI

.forgejo/workflows/ci.yml гоняет smoke-тесты в fixtures/run_smoke.sh на fixture-файлах. PR не merge'ится без зелёного CI — это страховка от «hook падает не там, где надо».