- Python 56.2%
- Shell 38.2%
- TypeScript 4.3%
- HTML 0.6%
- CSS 0.3%
- Other 0.4%
|
|
||
|---|---|---|
| .forgejo/workflows | ||
| examples | ||
| fixtures | ||
| scripts | ||
| .gitignore | ||
| .pre-commit-hooks.yaml | ||
| pyproject.toml | ||
| README.md | ||
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 строк; русская проза ~15–25 токенов/строку).
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:
- Каждый
mapped_column(...)обязан содержатьcomment=. - Каждый класс с
__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-files — HEAD. Взять 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, blinker → blinker._utilities, nacl → nacl._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: script—entry:указывает на исполняемый файл, pre-commit запускает его какscript <changed-files…>. Сюда идётcheck-file-length(bash).language: python—entry:это 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: pygrep—entry:это 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.0–v0.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 падает не там, где надо».