refactor: чтение исходника — один слой read_source; skip не молчит #24

Merged
claude-secretary merged 1 commit from fix/read-source-helper into main 2026-08-27 13:48:23 +07:00

Закрывает #23.

Седьмая копия одной строки

open(path, encoding="utf-8-sig") + except (OSError, UnicodeDecodeError) жила в семи скриптах. Каждый раз, когда её забывали, хук получал одну и ту же дыру: BOM приезжает символом U+FEFF, парсер падает, перехват ошибки превращает это в «нарушений нет» — файл проходит мимо хука молча. Чинилось это четырьмя отдельными релизами — v0.12.0, v0.15.0, v0.17.0, v0.18.0 — потому что дыру находили по одному хуку за раз.

Теперь чтение — scripts/read_source.py. Проверяемая разница: мутация «убрать utf-8-sig» роняет четыре BOM-кейса разных хуков сразу, а не один.

Пропуск перестал быть невидимым

Недекодируемый файл пропускается со строкой в stderr — с именем хука и файла:

  (no-broad-except: assets/logo.png пропущен — не читается как utf-8. Файл в скоупе files:, но проверен не был.)

Exit-код при этом не меняется: один бинарник, попавший под слишком широкий files:, не должен ронять вердикт по остальным файлам (кейс read_source_skip_keeps_verdict это фиксирует). Файл, не читающийся с диска, по-прежнему пропускается тихо — он мог быть удалён тем же коммитом, а pre-commit всё равно передаёт его имя.

У no-broad-except чтение расплетено с ast.parse: раньше оба стояли под одним try, и except SyntaxError формально накрывал бы и ошибки чтения.

migrations/versions/ в check-file-length

alembic init migrations — не менее типовая раскладка, чем alembic/, и до сих пор такие миграции проверялись на длину. Django-форма <app>/migrations/0001_x.py намеренно не исключена: уровня versions/ там нет, а исключать любой migrations/ слишком широко — под ним держат и рукописный код. Оба решения закреплены кейсами (file_length_migrations_versions_skipped / file_length_django_migrations_checked) и записаны в README и шапке скрипта.

Смоук

Появился run_case_err — проверка stderr: до сих пор пиннился только stdout, а сообщение о пропуске по замыслу идёт в stderr.

Отдельно стоит отметить: первая редакция этого хелпера стояла в файле ниже места вызова, bash молча отдавал 127, и три новых кейса не выполнялись вовсе — при том что весь прогон был зелёный. Поймано на прогоне и исправлено; ирония в том, что это ровно тот класс молчаливого зелёного, против которого написан весь PR.

Мутации, каждая роняет smoke: убрать utf-8-sig в хелпере (4 кейса), убрать print в ветке UnicodeDecodeError (3 кейса), снять исключение migrations/versions.

Версия

0.18.00.19.0. Теги v0.10.0v0.18.0 проставлены и запушены (#16 закрыт), так что пин из README снова резолвится.

Закрывает [#23](https://git.homedevlab.ru/senokosov/pre-commit-hooks/issues/23). ## Седьмая копия одной строки `open(path, encoding="utf-8-sig")` + `except (OSError, UnicodeDecodeError)` жила в семи скриптах. Каждый раз, когда её забывали, хук получал **одну и ту же дыру**: BOM приезжает символом `U+FEFF`, парсер падает, перехват ошибки превращает это в «нарушений нет» — файл проходит мимо хука молча. Чинилось это четырьмя отдельными релизами — `v0.12.0`, `v0.15.0`, `v0.17.0`, `v0.18.0` — потому что дыру находили по одному хуку за раз. Теперь чтение — `scripts/read_source.py`. Проверяемая разница: мутация «убрать `utf-8-sig`» роняет **четыре** BOM-кейса разных хуков сразу, а не один. ## Пропуск перестал быть невидимым Недекодируемый файл пропускается со строкой в stderr — с именем хука и файла: ``` (no-broad-except: assets/logo.png пропущен — не читается как utf-8. Файл в скоупе files:, но проверен не был.) ``` Exit-код при этом не меняется: один бинарник, попавший под слишком широкий `files:`, не должен ронять вердикт по остальным файлам (кейс `read_source_skip_keeps_verdict` это фиксирует). Файл, не читающийся с диска, по-прежнему пропускается тихо — он мог быть удалён тем же коммитом, а pre-commit всё равно передаёт его имя. У `no-broad-except` чтение расплетено с `ast.parse`: раньше оба стояли под одним `try`, и `except SyntaxError` формально накрывал бы и ошибки чтения. ## `migrations/versions/` в check-file-length `alembic init migrations` — не менее типовая раскладка, чем `alembic/`, и до сих пор такие миграции проверялись на длину. Django-форма `<app>/migrations/0001_x.py` намеренно **не** исключена: уровня `versions/` там нет, а исключать любой `migrations/` слишком широко — под ним держат и рукописный код. Оба решения закреплены кейсами (`file_length_migrations_versions_skipped` / `file_length_django_migrations_checked`) и записаны в README и шапке скрипта. ## Смоук Появился `run_case_err` — проверка stderr: до сих пор пиннился только stdout, а сообщение о пропуске по замыслу идёт в stderr. Отдельно стоит отметить: первая редакция этого хелпера стояла в файле **ниже** места вызова, bash молча отдавал 127, и три новых кейса не выполнялись вовсе — при том что весь прогон был зелёный. Поймано на прогоне и исправлено; ирония в том, что это ровно тот класс молчаливого зелёного, против которого написан весь PR. Мутации, каждая роняет smoke: убрать `utf-8-sig` в хелпере (4 кейса), убрать `print` в ветке `UnicodeDecodeError` (3 кейса), снять исключение `migrations/versions`. ## Версия `0.18.0` → **`0.19.0`**. Теги `v0.10.0`–`v0.18.0` проставлены и запушены ([#16](https://git.homedevlab.ru/senokosov/pre-commit-hooks/issues/16) закрыт), так что пин из README снова резолвится.
refactor: чтение исходника — один слой read_source; skip не молчит
All checks were successful
ci / smoke (push) Successful in 16s
ci / smoke (pull_request) Successful in 16s
9fe784cef7
Закрывает hooks#23.

Строка `open(path, encoding="utf-8-sig")` жила в семи скриптах, и каждый
раз, когда её забывали, хук получал одну и ту же дыру: BOM приезжает
символом U+FEFF, парсер падает, перехват ошибки превращает это в
«нарушений нет» — файл проходит мимо хука молча. Чинилось это четырьмя
отдельными релизами (v0.12.0, v0.15.0, v0.17.0, v0.18.0), потому что
дыру находили по одному хуку за раз. Восьмой хук написали бы с той же
ошибкой.

Теперь чтение — `scripts/read_source.py`, и мутация «убрать utf-8-sig»
роняет сразу четыре BOM-кейса разных хуков вместо одного.

Недекодируемый файл больше не пропускается молча: строка в stderr с
именем хука и файла. Exit-код не меняется — один бинарник под слишком
широким `files:` не должен ронять вердикт по остальным. Файл, не
читающийся с диска, по-прежнему пропускается тихо: он мог быть удалён
тем же коммитом.

У no-broad-except чтение расплетено с ast.parse: раньше оба стояли под
одним try, и `except SyntaxError` ловил бы в том числе ошибки чтения.

check-file-length: исключение для `migrations/versions/` — `alembic init
migrations` не менее типовая раскладка, чем `alembic/`. Django-форма
`<app>/migrations/0001_x.py` намеренно НЕ исключена (нет уровня
versions/, а весь `migrations/` исключать слишком широко) — кейс это
фиксирует.

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

Версия 0.18.0 → 0.19.0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
claude-secretary force-pushed fix/read-source-helper from 9fe784cef7
All checks were successful
ci / smoke (push) Successful in 16s
ci / smoke (pull_request) Successful in 16s
to 2ec71721a4
All checks were successful
ci / smoke (push) Successful in 17s
ci / smoke (pull_request) Successful in 17s
2026-08-27 13:47:44 +07:00
Compare
Author
Owner

Ревью (субагент, свежий контекст): 2 блокера, оба подтвердил и исправил.

B1 — восьмой читатель, написанный с той самой ошибкой. check_model_comments.py читал голым read_text(encoding="utf-8"): BOM давал ложный FAIL с SyntaxError на валидном файле, не-UTF-8 и файл без прав — сырой трейсбек. То есть README с фразой «все скрипты читают одним слоем» был ложным инвариантом ровно того сорта, против которого PR. Переведён на хелпер, добавлены кейсы model_not_utf8_skipped и model_bom_ok; мутация «вернуть read_text» роняет оба.

B2 — строка в stderr невидима в зелёном прогоне. Воспроизвёл на pre-commit 4.6.1: Passed, rc=0, ни слова; с --verbose строка появляется. Формально приёмка выполнялась, по смыслу — нет. Исправлено честно, а не переформулировкой: в README добавлен абзац «Где эту строку видно», а инстансу с широким скоупом в примере конфига проставлен verbose: true. Там же названо, что тем же ограничением давно страдают WARN/WARN(grandfather) в check-file-length — но как оговорка, а не как оправдание.

Ниты:

  • N1 + N3 закрыты одним движением: все восемь *_not_utf8_skipped переведены на run_case_err с пином id хука (раньше неверная метка в сообщении проходила незамеченной у четырёх), плюс заведены недостающие кейсы для no-plan-stage-refs и check-model-comments.
  • N2 — появился run_case_silent: несуществующий путь обязан давать exit 0 и пустой stderr. Мутация «печатать и в ветке OSError» теперь краснеет; до этого вторая половина контракта была только декларацией.
  • N4 — негативный кейс schema_migrations/versions/: якорь на слэш закреплён.
  • N6migrations/versions доехал в exclude-пример max-filename-words в README и в examples/.
  • N7 — формулировки исправлены на alembic/versions/** и migrations/versions/**: под каталогом пропускается всё содержимое, не только .py.
  • N8 — записано прямо: файл, который декодировался, но не парсится (BOM в середине → SyntaxError), остаётся тихим намеренно — его не пропустят ruff/mypy, и дублировать их вердикт хук не должен.
  • N9 — needle кейсов теперь пиннит id хука; текст причины проверяется в read_source_reports_skip_broad_except.
  • N10 — трёхстрочный комментарий на call-site'ах: смысловая часть живёт в докстринге read_source, на местах остался однострочник.

N5 (stderr против stdout) — оставляю как есть, осознанно: сообщение о пропуске это не вердикт, а сопутствующее предупреждение, и оно консистентно с WARN-строками check-file-length, которые тоже идут в stderr при exit 0. Порядок строк при пайпе действительно перемешивается — цена приемлемая; переносить вердикты в stderr ради этого не стоит.

Отдельно про находку, которой не было в списке: дважды за эту работу я поставил вызов нового хелпера выше его объявления. bash печатал «команда не найдена», возвращал 127 и шёл дальше — кейсы молча не исполнялись, а сюита оставалась зелёной. Второй раз это поймал прогон, но полагаться на внимательность здесь нельзя, поэтому в run_smoke.sh добавлен command_not_found_handle, валящий прогон на любой неизвестной команде. Мутация на это тоже есть.

Ревью (субагент, свежий контекст): 2 блокера, оба подтвердил и исправил. **B1 — восьмой читатель, написанный с той самой ошибкой.** `check_model_comments.py` читал голым `read_text(encoding="utf-8")`: BOM давал ложный FAIL с `SyntaxError` на валидном файле, не-UTF-8 и файл без прав — сырой трейсбек. То есть README с фразой «все скрипты читают одним слоем» был ложным инвариантом ровно того сорта, против которого PR. Переведён на хелпер, добавлены кейсы `model_not_utf8_skipped` и `model_bom_ok`; мутация «вернуть `read_text`» роняет оба. **B2 — строка в stderr невидима в зелёном прогоне.** Воспроизвёл на `pre-commit 4.6.1`: `Passed`, `rc=0`, ни слова; с `--verbose` строка появляется. Формально приёмка выполнялась, по смыслу — нет. Исправлено честно, а не переформулировкой: в README добавлен абзац «Где эту строку видно», а инстансу с широким скоупом в примере конфига проставлен `verbose: true`. Там же названо, что тем же ограничением давно страдают `WARN`/`WARN(grandfather)` в `check-file-length` — но как оговорка, а не как оправдание. **Ниты:** - **N1 + N3** закрыты одним движением: все восемь `*_not_utf8_skipped` переведены на `run_case_err` с пином id хука (раньше неверная метка в сообщении проходила незамеченной у четырёх), плюс заведены недостающие кейсы для `no-plan-stage-refs` и `check-model-comments`. - **N2** — появился `run_case_silent`: несуществующий путь обязан давать exit 0 и **пустой** stderr. Мутация «печатать и в ветке `OSError`» теперь краснеет; до этого вторая половина контракта была только декларацией. - **N4** — негативный кейс `schema_migrations/versions/`: якорь на слэш закреплён. - **N6** — `migrations/versions` доехал в exclude-пример `max-filename-words` в README и в `examples/`. - **N7** — формулировки исправлены на `alembic/versions/**` и `migrations/versions/**`: под каталогом пропускается всё содержимое, не только `.py`. - **N8** — записано прямо: файл, который декодировался, но не парсится (BOM в середине → `SyntaxError`), остаётся тихим намеренно — его не пропустят ruff/mypy, и дублировать их вердикт хук не должен. - **N9** — needle кейсов теперь пиннит id хука; текст причины проверяется в `read_source_reports_skip_broad_except`. - **N10** — трёхстрочный комментарий на call-site'ах: смысловая часть живёт в докстринге `read_source`, на местах остался однострочник. **N5 (stderr против stdout) — оставляю как есть**, осознанно: сообщение о пропуске это не вердикт, а сопутствующее предупреждение, и оно консистентно с `WARN`-строками `check-file-length`, которые тоже идут в stderr при exit 0. Порядок строк при пайпе действительно перемешивается — цена приемлемая; переносить вердикты в stderr ради этого не стоит. **Отдельно про находку, которой не было в списке:** дважды за эту работу я поставил вызов нового хелпера выше его объявления. bash печатал «команда не найдена», возвращал 127 и шёл дальше — кейсы **молча не исполнялись**, а сюита оставалась зелёной. Второй раз это поймал прогон, но полагаться на внимательность здесь нельзя, поэтому в `run_smoke.sh` добавлен `command_not_found_handle`, валящий прогон на любой неизвестной команде. Мутация на это тоже есть.
claude-secretary deleted branch fix/read-source-helper 2026-08-27 13:48:24 +07:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
senokosov/pre-commit-hooks!24
No description provided.