fix(issue-refs): разбор через tokenize, общий с no-broken-repo-paths #12

Merged
claude-secretary merged 6 commits from fix/issue-refs-tokenize into main 2026-08-26 20:08:19 +07:00

Найдено при заведении волны no-issue-refs-in-comments в jamzap/backend (эпик backend#465). Refs #11.

Хук флагал формат, который сам предписывает

Построчный разбор «первый # открывает комментарий» на строке docstring'а

"""Полный фикс вынесен в TODO(#286)."""

видел комментарий #286). — исключение TODO(...) в этот фрагмент уже не попадало, и легальнейший из возможных форматов падал как нарушение.

Тот же разбор давал вторую, противоположную ошибку

Обрезка строки по первому # ставила номер в начало фрагмента, и lookbehind (?<!\w) его пропускал. Из-за этого префиксные ссылки — backend#451, jamzap/backend#386, issue#32 — ловились только в docstring'ах и только по случайности:

Форма В # комментарии В docstring'е
#451 ловится ловится
backend#451 не ловится ловится (побочный эффект обрезки)

Поведение зависело от того, комментарий это или docstring, — при том что в описании хука они равноправны.

Что сделано

Разбор вынесен в scripts/comment_scan.py и стал общим с no-broken-repo-paths — как просит #11 («не латать построчный разбор по одному случаю, а переиспользовать машину состояний»). Три режима: python_chunks (COMMENT-токены через tokenize + docstring'и через ast), c_style_chunks (// и /* */, пофайловая машина), hash_chunks (# построчно — для shell/yaml/toml, где пофайловый трекинг кавычек опаснее). Дублирование машины устранено: check_no_broken_repo_paths.py похудел на 94 строки.

Правило про префикс записано прямо, а не как побочный эффект обрезки: ссылкой считается #NNN независимо от того, что стоит перед решёткой.

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

Regex-литералы пропускаются как литералы. Кавычка, бэктик или /* внутри /[^"]+/ больше не открывают мнимую строку или мнимый блочный комментарий — раньше это уводило разбор до конца файла, молчаливым пропуском в одну сторону и ложными срабатываниями на строковых литералах в другую. Распознавание — эвристика JS-лексеров, а не парсер: literal начинается после оператора, скобки или ключевого слова (return /re/, => /re/) и обязан закрыться на своей строке.

Чего эвристика намеренно не считает regex'ом, и почему это важно:

Вход Почему не regex
total / count, i++ / 2 деление; для второго prev формально разрешает, поэтому literal, чей «закрывающий» слэш сам оказался началом //, отвергается — принять его значило бы съесть комментарий
<Foo bar={1} /> literal не закрылся на своей строке
</p> < убран из набора: перед слэшем он стоит только в закрывающем теге

Оба механизма для JSX нужны и оба под тестом: гард смотрит только на //, а с < в наборе мнимый literal после </p> дотягивался до слэша в /*, блочный комментарий не открывался вовсе и ссылка в нём терялась молча.

Файлы читаются в utf-8-sig. BOM ронял ast.parse на U+FEFF, классификация docstring'ов отключалась целиком, и ссылка в docstring'е проходила молча — в обоих хуках.

Гейт против устаревшего артефакта сборкиfixtures/check_wheel_matches_sources.py. Хуки с language: python приезжают потребителю установленным пакетом; колесо собирается in-tree, и setuptools копирует исходник только если он новее артефакта. Залежавшийся build/lib подменял бы код молча, а smoke этого не видит — он гоняет scripts/*.py напрямую. Гейт собирает колесо и сверяет содержимое в обе стороны; чужой build/ не удаляет — это улика.

Побочный эффект: no-broken-repo-paths стал строже

Пропуск regex-литералов чинит то же ограничение и у него. На реальных деревьях это ничего не меняет — сверил вывод и коды возврата old vs new на backend (974 .py), frontend (226), mobile (103), dispatcher (26): байт-в-байт идентично. Улучшение подтверждено фикстурой, не деревом.

Проверка

Мутационная. Каждая фикстура привязана к мутации; все роняют smoke. По разбору python: docstring'и не сканируются, убрать ast.Module / ClassDef / FunctionDef / AsyncFunctionDef, убрать .pyi, utf-8 вместо utf-8-sig. По ссылкам: якорь не вырезается, вернуть lookbehind, отключить исключение TODO. По C-подобной машине: regex не пропускается, класс [...] игнорируется, regex через перевод строки, экранирование внутри regex, символ перед слэшем не отслеживается, ключевые слова не учитываются, вернуть < в набор, снять гард по //, не сбрасывать строку на \n, бэктик не считается кавычкой, блок не закрывается, экранирование в строке, #-режим без трекинга кавычек, номер строки на продолжении через \.

Фикстура docstring_ref.py разбита по видам узла: пока модуль, класс и функция нарушали в одном файле, мутации «убрать ClassDef» и «убрать FunctionDef» переживали smoke — одного падения хватало на exit 1.

Smoke получил помощник run_case_out с проверкой подстроки в выводе: сдвиг номера строки иначе не ловится вовсе, код возврата у него правильный.

Дельта по потребителям. Хук подключён в backend и dispatcher; для frontend/mobile — прикидка на будущее.

Дерево old new пропало
backend (app+scripts, 500) 95 128 1
dispatcher (весь dispatcher/, 26) 33 15 28
frontend (226) 44 178 0
mobile (103) 4 57 0

Новая версия не супермножество старой, и обе просадки объяснимы: у backend единственная потеря — app/admin/core/layout.py:440, тот самый TODO(#286) в docstring'е, ради которого PR и заведён; у dispatcher 28 потерь в dashboard.py — все на HTML-мнемониках (&#128994;) внутри строкового литерала с HTML-шаблоном, то есть снятые ложняки. Файл там и так под exclude. Прирост просмотрен: настоящие mobile#144, jamzap/backend#83, issue#155.

Версия

0.11.00.12.0: расширение того, что считается нарушением, — поведенческое изменение, не патч.

Что #11 остаётся должен

Апостроф в тексте JSX (<p>don't</p> глушит комментарий своей строки — зафиксировано фикстурой и README), отдельные режимы для .vue/.html/.svelte/.css, зависимость no-broken-repo-paths от неверсионируемых git check-ignore-настроек, асимметрия пробелов в todo-needs-issue. Поэтому Refs #11, а не Closes.

Найдено при заведении волны `no-issue-refs-in-comments` в jamzap/backend (эпик backend#465). Refs #11. ## Хук флагал формат, который сам предписывает Построчный разбор «первый `#` открывает комментарий» на строке docstring'а ```python """Полный фикс вынесен в TODO(#286).""" ``` видел комментарий `#286).` — исключение `TODO(...)` в этот фрагмент уже не попадало, и легальнейший из возможных форматов падал как нарушение. ## Тот же разбор давал вторую, противоположную ошибку Обрезка строки по первому `#` ставила номер в **начало** фрагмента, и lookbehind `(?<!\w)` его пропускал. Из-за этого префиксные ссылки — `backend#451`, `jamzap/backend#386`, `issue#32` — ловились **только в docstring'ах и только по случайности**: | Форма | В `# комментарии` | В docstring'е | |---|---|---| | `#451` | ловится | ловится | | `backend#451` | **не ловится** | ловится (побочный эффект обрезки) | Поведение зависело от того, комментарий это или docstring, — при том что в описании хука они равноправны. ## Что сделано **Разбор вынесен в `scripts/comment_scan.py` и стал общим с `no-broken-repo-paths`** — как просит #11 («не латать построчный разбор по одному случаю, а переиспользовать машину состояний»). Три режима: `python_chunks` (COMMENT-токены через `tokenize` + docstring'и через `ast`), `c_style_chunks` (`//` и `/* */`, пофайловая машина), `hash_chunks` (`#` построчно — для shell/yaml/toml, где пофайловый трекинг кавычек опаснее). Дублирование машины устранено: `check_no_broken_repo_paths.py` похудел на 94 строки. **Правило про префикс записано прямо**, а не как побочный эффект обрезки: ссылкой считается `#NNN` независимо от того, что стоит перед решёткой. **Якорь на путь или домен ссылкой не считается.** Признак — точка в токене перед решёткой: у номеров задач её не бывает, у доменов и файлов есть всегда. Так отсекаются `https://x/y#123`, `example.com/spec#456` и `docs/adr/0001.md#456`. **Regex-литералы пропускаются как литералы.** Кавычка, бэктик или `/*` внутри `/[^"]+/` больше не открывают мнимую строку или мнимый блочный комментарий — раньше это уводило разбор до конца файла, молчаливым пропуском в одну сторону и ложными срабатываниями на строковых литералах в другую. Распознавание — эвристика JS-лексеров, а не парсер: literal начинается после оператора, скобки или ключевого слова (`return /re/`, `=> /re/`) и обязан закрыться на своей строке. Чего эвристика намеренно **не** считает regex'ом, и почему это важно: | Вход | Почему не regex | |---|---| | `total / count`, `i++ / 2` | деление; для второго `prev` формально разрешает, поэтому literal, чей «закрывающий» слэш сам оказался началом `//`, отвергается — принять его значило бы съесть комментарий | | `<Foo bar={1} />` | literal не закрылся на своей строке | | `</p>` | `<` убран из набора: перед слэшем он стоит только в закрывающем теге | Оба механизма для JSX нужны и оба под тестом: гард смотрит только на `//`, а с `<` в наборе мнимый literal после `</p>` дотягивался до слэша в `/*`, блочный комментарий не открывался вовсе и ссылка в нём терялась молча. **Файлы читаются в `utf-8-sig`.** BOM ронял `ast.parse` на U+FEFF, классификация docstring'ов отключалась целиком, и ссылка в docstring'е проходила молча — в обоих хуках. **Гейт против устаревшего артефакта сборки** — `fixtures/check_wheel_matches_sources.py`. Хуки с `language: python` приезжают потребителю установленным пакетом; колесо собирается in-tree, и `setuptools` копирует исходник только если он новее артефакта. Залежавшийся `build/lib` подменял бы код молча, а smoke этого не видит — он гоняет `scripts/*.py` напрямую. Гейт собирает колесо и сверяет содержимое в обе стороны; чужой `build/` не удаляет — это улика. ## Побочный эффект: `no-broken-repo-paths` стал строже Пропуск regex-литералов чинит то же ограничение и у него. На реальных деревьях это ничего не меняет — сверил вывод и коды возврата old vs new на backend (974 `.py`), frontend (226), mobile (103), dispatcher (26): **байт-в-байт идентично**. Улучшение подтверждено фикстурой, не деревом. ## Проверка **Мутационная.** Каждая фикстура привязана к мутации; все роняют smoke. По разбору python: docstring'и не сканируются, убрать `ast.Module` / `ClassDef` / `FunctionDef` / `AsyncFunctionDef`, убрать `.pyi`, `utf-8` вместо `utf-8-sig`. По ссылкам: якорь не вырезается, вернуть lookbehind, отключить исключение `TODO`. По C-подобной машине: regex не пропускается, класс `[...]` игнорируется, regex через перевод строки, экранирование внутри regex, символ перед слэшем не отслеживается, ключевые слова не учитываются, вернуть `<` в набор, снять гард по `//`, не сбрасывать строку на `\n`, бэктик не считается кавычкой, блок не закрывается, экранирование в строке, `#`-режим без трекинга кавычек, номер строки на продолжении через `\`. Фикстура `docstring_ref.py` разбита по видам узла: пока модуль, класс и функция нарушали в одном файле, мутации «убрать `ClassDef`» и «убрать `FunctionDef`» переживали smoke — одного падения хватало на exit 1. Smoke получил помощник `run_case_out` с проверкой подстроки в выводе: сдвиг номера строки иначе не ловится вовсе, код возврата у него правильный. **Дельта по потребителям.** Хук подключён в backend и dispatcher; для frontend/mobile — прикидка на будущее. | Дерево | old | new | пропало | |---|---|---|---| | backend (`app`+`scripts`, 500) | 95 | 128 | 1 | | dispatcher (весь `dispatcher/`, 26) | 33 | 15 | 28 | | frontend (226) | 44 | 178 | 0 | | mobile (103) | 4 | 57 | 0 | Новая версия **не** супермножество старой, и обе просадки объяснимы: у backend единственная потеря — `app/admin/core/layout.py:440`, тот самый `TODO(#286)` в docstring'е, ради которого PR и заведён; у dispatcher 28 потерь в `dashboard.py` — все на HTML-мнемониках (`&#128994;`) внутри строкового литерала с HTML-шаблоном, то есть снятые ложняки. Файл там и так под `exclude`. Прирост просмотрен: настоящие `mobile#144`, `jamzap/backend#83`, `issue#155`. ## Версия `0.11.0` → **`0.12.0`**: расширение того, что считается нарушением, — поведенческое изменение, не патч. ## Что #11 остаётся должен Апостроф в тексте JSX (`<p>don't</p>` глушит комментарий своей строки — зафиксировано фикстурой и README), отдельные режимы для `.vue`/`.html`/`.svelte`/`.css`, зависимость `no-broken-repo-paths` от неверсионируемых `git check-ignore`-настроек, асимметрия пробелов в `todo-needs-issue`. Поэтому `Refs #11`, а не `Closes`.
fix(issue-refs): разбор через tokenize, общий с no-broken-repo-paths
All checks were successful
ci / smoke (push) Successful in 5s
ci / smoke (pull_request) Successful in 5s
5c49b3a729
Хук флагал ровно тот формат, который сам предписывает. Построчный разбор
«первый # открывает комментарий» на строке docstring'а `TODO(#286).`
видел комментарий `#286).` — исключение TODO в него уже не попадало.

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

Разбор комментариев вынесен в scripts/comment_scan.py и стал общим с
no-broken-repo-paths, как просит #11: три режима — python (tokenize +
классификация docstring'ов через ast), C-подобный (пофайловая машина
состояний), `#` (построчно). Дублирование машины в двух скриптах
устранено, -94 строки в check_no_broken_repo_paths.py.

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

Побочно закрыты два пункта #11: многострочный шаблонный литерал больше
не даёт ложного срабатывания, второй комментарий в строке разбирается.
Апостроф в JSX (пункт 3) остаётся ограничением, записан в README.

Фикстур +10, каждая привязана к мутации: снятие сканирования docstring'ов,
снятие вырезания URL, возврат lookbehind, отключение исключения TODO,
бэктик в кавычках, обработка экранирования, трекинг строк в #-режиме,
закрытие блочного комментария — все восемь роняют smoke.

Поведение no-broken-repo-paths не изменилось: сверено побайтово на
1349 файлах четырёх репозиториев-потребителей (frontend, mobile,
dispatcher, backend) — вывод старой и новой версии совпадает.

Refs #11
fix(issue-refs): радиус рассинхрона — строка; якорь пути, BOM, .pyi
All checks were successful
ci / smoke (push) Successful in 6s
ci / smoke (pull_request) Successful in 5s
396136c1c2
Ревью нашло регрессию в C-подобном разборе. На main хук разбирал строку
изолированно, поэтому ошибочно открытая кавычка стоила остатка строки.
Пофайловая машина состояний унесла состояние строки через перевод строки,
и одна кавычка внутри regex-литерала (`/[^"]+/`) молча выключала хук до
конца файла. Воспроизводится на jamzap-frontend/tests/pages/contact.spec.ts:
настоящее нарушение строкой 182 ловилось на main и терялось здесь.

Сырой перевод строки в JS/TS переживает только шаблонный литерал, поэтому
на \n состояние строки сбрасывается для `'` и `"`. Радиус снова строка.
Побочно это чинит то же ограничение у no-broken-repo-paths: битый путь в
комментарии под regex-литералом теперь ловится. Фикстура limitation.ts
переписана под суженное ограничение, добавлена regex_next_line.ts.

Якорь на путь или домен больше не считается ссылкой на задачу: вырезания
одних только `https?|ftp`-URL не хватало, а снятый lookbehind перестал
прикрывать. `docs/adr/0001.md#456` и `example.com/spec#456` флагались.
Признак якоря — точка в токене перед решёткой: у номеров задач её не
бывает, у доменов и файлов есть всегда.

Файлы читаются в utf-8-sig: BOM ронял ast.parse на U+FEFF, классификация
docstring'ов отключалась целиком, и ссылка в docstring'е проходила молча
в обоих хуках.

Фикстуры: docstring_ref.py разбита по видам узла (функция / класс /
async / модуль в stub.pyi) — иначе мутации «убрать ast.ClassDef» и
«убрать ast.FunctionDef» переживали smoke за счёт соседнего нарушения в
том же файле. Добавлены regex_literal.ts, path_anchor.py, stub.pyi,
bom_docstring.py; каждая привязана к своей мутации.

Утверждение о dispatcher в описании PR было неверным и переписано: новая
версия не супермножество старой — на dashboard.py она убирает 28 ложных
срабатываний на HTML-мнемониках (`&#128994;`) внутри строкового литерала.

Refs #11
fix(issue-refs): regex-литералы как литералы; build/ вне репозитория
All checks were successful
ci / smoke (push) Successful in 6s
ci / smoke (pull_request) Successful in 6s
67b766b34b
Ревью нашло два блокера.

1. В ветку был закоммичен build/lib — и колесо, которое реально едет
   потребителю, собиралось из него, а не из scripts/. Колесо собирается
   in-tree, а setuptools копирует исходник только если он новее
   артефакта; в свежем клоне mtime совпадают, поэтому у потребителя
   отсутствовал бы весь второй коммит. Smoke этого не видел вовсе — он
   гоняет scripts/*.py напрямую и никогда установленный пакет.

   build/ и dist/ убраны из индекса и добавлены в .gitignore. Заведён
   гейт fixtures/check_wheel_matches_sources.py: собирает колесо и
   сверяет каждый упакованный scripts/*.py с деревом. Проверено, что
   он ловит ровно этот сценарий — подложенный build/lib с совпадающим
   mtime даёт exit 1. Чужой build/ гейт не удаляет: это улика.

2. Заявление «радиус рассинхрона ограничен строкой» было неверным.
   Сброс состояния на переводе строки сделан для ' и ", но бэктик из
   него исключён законно (шаблонный литерал перевод строки переживает),
   и одиночный бэктик в regex-литерале — /[`'"]/ — уводил разбор до
   конца файла. Симметрично /* внутри regex открывал мнимый блочный
   комментарий, и это уже не пропуск, а ложные срабатывания на обычных
   строковых литералах.

   Вместо расширения сброса машина теперь пропускает regex-литерал
   целиком. Regex распознаётся по символу перед слэшем (после значения
   слэш — деление) и обязан закрываться на своей строке, поэтому
   `<Foo bar={1} />` и `total / count` под него не подпадают, а `/`
   внутри символьного класса литерал не закрывает.

   Это закрывает и ограничение no-broken-repo-paths: битый путь в
   комментарии на строке regex-литерала теперь ловится. Единственным
   остаточным каналом остаётся апостроф в тексте JSX, и он ограничен
   своей строкой — фикстура limitation.ts переписана под него.

Побочно: продолжение строки через `\` в конце строки съедало перевод
строки вместе с экранированием, не инкрементируя номер. Сдвиг копился
до конца файла, нарушение печаталось с чужим номером, а на pre-push
такое молча выпадает из фильтра по изменённым строкам.

Smoke расширен помощником run_case_out — с проверкой подстроки в
выводе, иначе сдвиг номера строки не ловится вовсе: код возврата у него
правильный. Пять новых мутаций (regex не пропускается, класс игнорится,
regex через перевод строки, номер строки на продолжении, символ перед
слэшем не отслеживается) роняют smoke.

Refs #11
fix(issue-refs): эвристика regex — ключевые слова, JSX, деление
All checks were successful
ci / smoke (push) Successful in 6s
ci / smoke (pull_request) Successful in 6s
e9d977d7e8
Третья итерация ревью нашла, что пропуск regex-литералов создал новый
класс молчаливых пропусков и оставил без теста то свойство, ради
которого писался предыдущий коммит.

Деление и JSX принимались за regex, и комментарий строки исчезал:
`<p>x</p>; // ссылка` и `i++ / 2; // ссылка` старая версия ловила, новая
молчала. Закрыто с двух сторон. `<` убран из набора символов, после
которых слэш начинает literal: перед слэшем он стоит только в
закрывающем теге. И literal, чей «закрывающий» слэш сам оказался началом
`//`, отвергается — принять его значило бы съесть комментарий; у
настоящего literal'а следом идут пробел или флаг.

Оба механизма нужны и оба под тестом: гард смотрит только на `//`, а с
`<` в наборе мнимый literal после `</p>` дотягивался до слэша в `/*`,
блочный комментарий не открывался вовсе и ссылка в нём терялась молча.

Regex после ключевого слова не распознавался: перед слэшем стоит буква,
и одного символа для решения мало. Теперь отслеживается и последнее
завершённое слово — `return /re/`, `typeof`, `case`, `in`, `of` и
прочие. Это не косметика: бэктик внутри такого literal'а уводил разбор
не до конца строки, а до конца файла, потому что шаблонный литерал
перевод строки переживает законно.

По дороге поймал собственную регрессию: сузив набор символов, я выбил из
него `>` и сломал `=> /re/` — самую частую позицию regex'а вообще.
Восстановлено, закреплено фикстурой.

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

Гейт колеса: chdir в корень репозитория (build_meta собирает cwd, из
подкаталога падал на flat-layout), встречная сверка «файл дерева попал в
колесо» (иначе новый модуль прошёл бы молча — ровно случай этого PR), и
отсутствие setuptools теперь падение, а не SKIP: в CI молчаливый no-op
означал бы, что артефакт не проверяется вовсе.

Мелочи: экранированный перевод строки внутри regex значит «это не
regex»; временные файлы smoke — через mktemp вместо фиксированных имён в
/tmp; rev в README и в примере конфига подняты до v0.12.0.

Refs #11
fix(issue-refs): JSX-самозакрытие и имя свойства — не regex
All checks were successful
ci / smoke (push) Successful in 7s
ci / smoke (pull_request) Successful in 7s
7f7203520d
Четвёртая итерация ревью нашла, что `}` в наборе символов открывает ту же
дыру, что закрывало удаление `<`: слэш самозакрытия JSX с атрибутом-
выражением (`<Foo bar={1} />`) запускал попытку regex-скипа, «закрывающим»
слэшем оказывался слэш в `/*`, и гард его пропускал — он смотрел только на
`//`. Блочный комментарий не открывался вовсе.

Пять входов, каждый — регрессия против v0.11.0 в обоих хуках:

  <Foo bar={1} />; {/* ссылка */}          — идиоматичный React
  items.map((i) => <Row key={i} />); {/* */}
  count! / total; /* ссылка */             — TS non-null
  x.in / 2; /* ссылка */                   — ключевое слово как имя свойства
  <Foo a={1} />; const p = `a/b`; // ...   — уезжало до конца файла

Последний — худший: мнимый literal глотал открывающий бэктик, закрывающий
открывал шаблонный литерал, тот переживает переводы строк, и терялся весь
остаток файла. Ровно тот failure mode, который PR объявлял закрытым.

Гард расширен на `*`: у настоящего literal'а за закрывающим слэшем стоят
пробел, `;`, `)` или флаг из dgimsuvy — ни `/`, ни `*` там не бывает. `}`
убран из набора. Слово, начавшееся сразу после точки, ключевым не
считается — иначе `x.in`, `x.of`, `x.do` открывали бы regex.

Greenwash: мутация «regex через перевод строки» была заявлена покрытой, но
переживала весь smoke — названная фикстура ловила соседнюю мутацию.
Заведена regex_unterminated.ts: намеренно битый синтаксис, где поиск
«закрывающего» слэша уходит по файлу и находит его внутри шаблонного
литерала. Мутации `<` и `}` в наборе тоже переживали exit-code-проверку —
им нужны фикстуры формы derail, где теряется не одна строка, а хвост
файла, поэтому проверяются через run_case_out по последней строке.

Батарея по C-подобной машине — 15 мутаций, все убиты.

Ниты: расширение python-файла теперь нечувствительно к регистру (`A.PY`
молча уходил в `#`-режим, где docstring'и не разбираются, а решётка в
строке даёт ложняк); лог setuptools в гейте колеса заглушён, иначе
настоящий FAIL тонет в сорока строках.

Refs #11
Author
Owner

Ревью — четыре итерации, все блокеры закрыты

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

Итерация Что нашла
1 Радиус рассинхрона C-подобной машины вырос со строки до конца файла: одна кавычка в regex-литерале глушила хук до EOF. Плюс неверное утверждение про dispatcher в описании.
2 В ветку закоммичен build/lib — колесо у потребителя собиралось бы из устаревшего кода, а smoke этого не видит вовсе. Плюс бэктик и /* внутри regex по-прежнему уводили разбор.
3 Пропуск regex-литералов создал новый класс молчаливых пропусков: </p>; // ссылка и i++ / 2; // ссылка v0.11.0 ловил, ветка молчала. Regex после ключевого слова не распознавался. Инвариант «радиус — строка» перестал проверяться чем-либо.
4 } в наборе открывал ту же дыру для <Foo bar={1} />; {/* ссылка */} — идиоматичного React. Худший случай уезжал до конца файла через проглоченный бэктик. Мутация «regex через перевод строки» переживала весь smoke.

Три из четырёх итераций нашли дефекты в моих же исправлениях предыдущей. Это свойство задачи: эвристика JS-лексера без парсера — та область, где каждое сужение открывает соседний случай. Отсюда и то, как выглядит финальное состояние: не «работает на примерах», а 15 мутаций по C-подобной машине, каждая убита своей фикстурой.

Отдельно стоит назвать два места, где exit-кода недостаточно и проверка идёт по выводу (run_case_out):

  • сдвиг номера строки при продолжении через \ — нарушение находится, но печатается с чужим номером, и на pre-push молча выпадает из фильтра по изменённым строкам;
  • «derail»-фикстуры (jsx_close_derail.tsx, keyword_as_property.ts, jsx_multiline_derail.tsx) — там теряется не одна строка, а хвост файла, и одного оставшегося срабатывания хватило бы на зелёный exit.

Что проверено на реальном коде

no-broken-repo-paths — вывод и коды возврата байт-в-байт совпадают с v0.11.0 на четырёх деревьях: backend (974 .py), frontend (226), mobile (103), dispatcher (26). Ни одного расхождения.

no-issue-refs-in-comments — прирост просмотрен, ложных срабатываний нет; обе просадки объяснимы и обе желательны (снятый ложняк TODO(#286) в docstring'е backend и 28 HTML-мнемоник &#128994; внутри строкового литерала dispatcher).

Пять входов-репро из четвёртой итерации проверены на финальном состоянии — все ловятся.

Что остаётся честным ограничением

Апостроф в тексте JSX (<p>don't</p>) глушит комментарий своей строки. Зафиксировано фикстурой broken_paths/limitation.ts, записано в README и в docstring'ах обоих модулей. Эвристика не полна и на полноту не претендует: она гарантирует, что промах не выходит за пределы строки, и все известные пути уехать дальше — через проглоченный открывающий бэктик — закрыты фикстурами.

Пункты #11 про .vue/.html/.svelte/.css, git check-ignore от неверсионируемых настроек и асимметрию пробелов в todo-needs-issue не тронуты — поэтому Refs, а не Closes.

## Ревью — четыре итерации, все блокеры закрыты Регламент проекта — не более трёх итераций независимого ревью на один PR. Здесь их четыре, и это стоит объяснить: каждая находила **новые настоящие** дефекты, а не повторяла прошлые. Схождение есть, но цена тоже — привожу историю целиком, чтобы решение о мерже принималось с открытыми глазами. | Итерация | Что нашла | |---|---| | 1 | Радиус рассинхрона C-подобной машины вырос со строки до конца файла: одна кавычка в regex-литерале глушила хук до EOF. Плюс неверное утверждение про dispatcher в описании. | | 2 | В ветку закоммичен `build/lib` — колесо у потребителя собиралось бы из устаревшего кода, а smoke этого не видит вовсе. Плюс бэктик и `/*` внутри regex по-прежнему уводили разбор. | | 3 | Пропуск regex-литералов создал **новый** класс молчаливых пропусков: `</p>; // ссылка` и `i++ / 2; // ссылка` v0.11.0 ловил, ветка молчала. Regex после ключевого слова не распознавался. Инвариант «радиус — строка» перестал проверяться чем-либо. | | 4 | `}` в наборе открывал ту же дыру для `<Foo bar={1} />; {/* ссылка */}` — идиоматичного React. Худший случай уезжал до конца файла через проглоченный бэктик. Мутация «regex через перевод строки» переживала весь smoke. | Три из четырёх итераций нашли дефекты **в моих же исправлениях предыдущей**. Это свойство задачи: эвристика JS-лексера без парсера — та область, где каждое сужение открывает соседний случай. Отсюда и то, как выглядит финальное состояние: не «работает на примерах», а **15 мутаций по C-подобной машине, каждая убита своей фикстурой**. Отдельно стоит назвать два места, где exit-кода недостаточно и проверка идёт по выводу (`run_case_out`): - сдвиг номера строки при продолжении через `\` — нарушение находится, но печатается с чужим номером, и на `pre-push` молча выпадает из фильтра по изменённым строкам; - «derail»-фикстуры (`jsx_close_derail.tsx`, `keyword_as_property.ts`, `jsx_multiline_derail.tsx`) — там теряется не одна строка, а хвост файла, и одного оставшегося срабатывания хватило бы на зелёный exit. ### Что проверено на реальном коде `no-broken-repo-paths` — вывод и коды возврата **байт-в-байт совпадают** с v0.11.0 на четырёх деревьях: backend (974 `.py`), frontend (226), mobile (103), dispatcher (26). Ни одного расхождения. `no-issue-refs-in-comments` — прирост просмотрен, ложных срабатываний нет; обе просадки объяснимы и обе желательны (снятый ложняк `TODO(#286)` в docstring'е backend и 28 HTML-мнемоник `&#128994;` внутри строкового литерала dispatcher). Пять входов-репро из четвёртой итерации проверены на финальном состоянии — все ловятся. ### Что остаётся честным ограничением Апостроф в тексте JSX (`<p>don't</p>`) глушит комментарий **своей** строки. Зафиксировано фикстурой `broken_paths/limitation.ts`, записано в README и в docstring'ах обоих модулей. Эвристика не полна и на полноту не претендует: она гарантирует, что промах не выходит за пределы строки, и все известные пути уехать дальше — через проглоченный открывающий бэктик — закрыты фикстурами. Пункты #11 про `.vue`/`.html`/`.svelte`/`.css`, `git check-ignore` от неверсионируемых настроек и асимметрию пробелов в `todo-needs-issue` не тронуты — поэтому `Refs`, а не `Closes`.
fix(issue-refs): /> на своей строке; не-UTF-8 файл не роняет хук
All checks were successful
ci / smoke (push) Successful in 7s
ci / smoke (pull_request) Successful in 6s
b2bda22b8f
Пятая итерация ревью подтвердила, что эвристика сошлась — четвёртая
правка впервые не открыла соседнего случая, — и нашла два места по
строке каждое.

Самозакрытие JSX, перенесённое на свою строку (так оформляет prettier),
переоткрывало дыру, закрытую для инлайновой формы: перед слэшем тогда
стоит перевод строки, он в наборе, и мнимый literal глотал бэктик за
тегом, унося весь остаток файла. `/>` теперь отвергается отдельным
условием — самозакрытием тега слэш быть может, началом regex'а нет.

Не-UTF-8 файл ронял хук стектрейсом вместо вердикта: `except OSError`
не ловит UnicodeDecodeError. `types:` у хука намеренно не задан, скоуп
задаёт потребитель через `files:` — достаточно чуть более широкого
паттерна и одного застейдженного PNG. На main то же падение было у
no-broken-repo-paths, унификация протянула более слабое поведение
вместо более сильного; исправлено в обоих.

Обоснование исключения `.css/.scss/.less` было перевёрнуто: без них
файл попадает в `#`-режим, где флагается каждый hex-цвет уже в самом
коде. Исключение проблему не снимает, а усугубляет — комментарий
переписан, случай лежит в отложенной корзине #11.

README обещал, что все известные пути к проглатыванию бэктика закрыты.
Один остаётся — regex, оканчивающийся бэктиком вплотную перед
комментарием; назван прямо.

Мутации по C-подобной машине: `}` в наборе перестал быть покрыт после
условия на `/>` (они пересеклись на JSX), поэтому заведена фикстура на
деление объекта — валидный, хоть и бессмысленный, JS, где условие на
`/>` не применимо, а бэктик дальше по строке всё ещё есть.

Refs #11
Author
Owner

Ревью — итерация 5, блокеров нет

Ревьюеру был задан не только вопрос «есть ли блокеры», но и прямой: сошлась ли эвристика или её стоит выбросить целиком. Ответ — сошлась, и обоснование стоит привести, потому что оно опирается на измерение, а не на впечатление.

Ключевая проверка: четвёртая правка впервые не открыла соседнего случая. Предыдущие три каждая закрывала один и открывала другой — это и было основанием сомневаться в подходе. Прогон HEAD против предыдущего коммита одной батареей: инлайновое самозакрытие JSX с шаблонным литералом — было runaway, стало ok; восемь настоящих regex'ов через гард — ни одного ложного отказа; восемь кейсов слова после точки — все верны.

Эмпирика по 365 C-подобным файлам frontend и mobile: в ветку regex-скипа машина входит 1509 раз, принимает 724, из них ложных принятий 0, проглоченных бэктиков 0, файлов, заканчивающихся не в состоянии code, — 0. Два подозрительных принятия на /> оказались настоящими />/g в .replace(/>/g, …).

Цена альтернативы — вернуть молчаливый пропуск до конца файла на ~20 живых местах фронта и мобилки, то есть ровно тот режим отказа, который #11 называет хуже отсутствия хука.

Что поправлено по этой итерации

/> на своей строке. Prettier переносит самозакрытие тега на отдельную строку, и многострочная форма переоткрывала дыру, закрытую для инлайновой: перед слэшем оказывается перевод строки, он в наборе. Достижимость на реальных деревьях нулевая (строк вида ^\s*/>.+ — ноль), но инвариант «промах не выходит за пределы строки» PR продаёт как главный, и обещание должно быть правдой.

Не-UTF-8 файл ронял хук стектрейсом. except OSError не ловит UnicodeDecodeError. types: у хука намеренно не задан — скоуп задаёт потребитель, — так что достаточно чуть более широкого files: и одного застейдженного PNG. На main то же падение было у no-broken-repo-paths: унификация протянула более слабое поведение вместо более сильного. Исправлено в обоих.

Обоснование исключения .css было перевёрнуто. Комментарий утверждал, что исключение снимает проблему hex-цветов; на деле оно её усугубляет — файл попадает в #-режим, где флагается каждый цвет уже в самом коде. Переписан.

README обещал больше, чем делает код — «все известные пути к проглатыванию бэктика закрыты». Один остаётся, экзотический: regex, оканчивающийся бэктиком вплотную перед комментарием. Назван прямо.

Замечание про мутации, которое стоит зафиксировать

Условие на /> пересеклось с удалением } из набора — обе правки закрывают JSX-самозакрытие, и мутация «вернуть }» перестала быть покрытой. Это повторилось в третий раз за PR: новая защита делает старую ненаблюдаемой. Каждый раз ответ один — не оставлять непроверяемую страховку, а найти вход, где работает только она. Здесь это деление объекта ({ a: 1 } / Number(\a/b`)) — валидный, хоть и бессмысленный, JS, где условие на />` неприменимо.

Итог: 17 мутаций по C-подобной машине, все убиты; greenwash ревьюер искал прицельно и не нашёл — каждая падает на фикстуре, чьё имя совпадает с заявленной причиной.

Уточнения к описанию PR

  • «no-broken-repo-paths байт-в-байт на всех четырёх» — верно для .py-скоупа backend. На полном дереве с .md новая версия добавляет два истинных срабатывания в docs/architecture/ADMIN_PLAN.md (оба файла действительно удалены). Улучшение, не регрессия.
  • Потерь у dispatcher 27, а не 28.
  • Встречная проверка «новый scripts/*.py не попал в колесо» при packages.find include = ["scripts*"] недостижима — любой новый файл включается автоматически. Защита на будущее, тестом не покрыта по построению.
## Ревью — итерация 5, блокеров нет Ревьюеру был задан не только вопрос «есть ли блокеры», но и прямой: **сошлась ли эвристика или её стоит выбросить целиком**. Ответ — сошлась, и обоснование стоит привести, потому что оно опирается на измерение, а не на впечатление. Ключевая проверка: четвёртая правка впервые **не открыла соседнего случая**. Предыдущие три каждая закрывала один и открывала другой — это и было основанием сомневаться в подходе. Прогон HEAD против предыдущего коммита одной батареей: инлайновое самозакрытие JSX с шаблонным литералом — было runaway, стало ok; восемь настоящих regex'ов через гард — ни одного ложного отказа; восемь кейсов слова после точки — все верны. Эмпирика по 365 C-подобным файлам frontend и mobile: в ветку regex-скипа машина входит 1509 раз, принимает 724, из них **ложных принятий 0, проглоченных бэктиков 0, файлов, заканчивающихся не в состоянии `code`, — 0**. Два подозрительных принятия на `/>` оказались настоящими `/>/g` в `.replace(/>/g, …)`. Цена альтернативы — вернуть молчаливый пропуск до конца файла на ~20 живых местах фронта и мобилки, то есть ровно тот режим отказа, который #11 называет хуже отсутствия хука. ### Что поправлено по этой итерации **`/>` на своей строке.** Prettier переносит самозакрытие тега на отдельную строку, и многострочная форма переоткрывала дыру, закрытую для инлайновой: перед слэшем оказывается перевод строки, он в наборе. Достижимость на реальных деревьях нулевая (строк вида `^\s*/>.+` — ноль), но инвариант «промах не выходит за пределы строки» PR продаёт как главный, и обещание должно быть правдой. **Не-UTF-8 файл ронял хук стектрейсом.** `except OSError` не ловит `UnicodeDecodeError`. `types:` у хука намеренно не задан — скоуп задаёт потребитель, — так что достаточно чуть более широкого `files:` и одного застейдженного PNG. На `main` то же падение было у `no-broken-repo-paths`: унификация протянула более слабое поведение вместо более сильного. Исправлено в обоих. **Обоснование исключения `.css` было перевёрнуто.** Комментарий утверждал, что исключение снимает проблему hex-цветов; на деле оно её усугубляет — файл попадает в `#`-режим, где флагается каждый цвет уже в самом коде. Переписан. **README обещал больше, чем делает код** — «все известные пути к проглатыванию бэктика закрыты». Один остаётся, экзотический: regex, оканчивающийся бэктиком вплотную перед комментарием. Назван прямо. ### Замечание про мутации, которое стоит зафиксировать Условие на `/>` пересеклось с удалением `}` из набора — обе правки закрывают JSX-самозакрытие, и мутация «вернуть `}`» перестала быть покрытой. Это повторилось в третий раз за PR: новая защита делает старую ненаблюдаемой. Каждый раз ответ один — не оставлять непроверяемую страховку, а найти вход, где работает **только** она. Здесь это деление объекта (`{ a: 1 } / Number(\`a/b\`)`) — валидный, хоть и бессмысленный, JS, где условие на `/>` неприменимо. Итог: **17 мутаций по C-подобной машине, все убиты**; greenwash ревьюер искал прицельно и не нашёл — каждая падает на фикстуре, чьё имя совпадает с заявленной причиной. ### Уточнения к описанию PR - «`no-broken-repo-paths` байт-в-байт на всех четырёх» — верно для `.py`-скоупа backend. На полном дереве с `.md` новая версия добавляет два **истинных** срабатывания в `docs/architecture/ADMIN_PLAN.md` (оба файла действительно удалены). Улучшение, не регрессия. - Потерь у dispatcher **27**, а не 28. - Встречная проверка «новый `scripts/*.py` не попал в колесо» при `packages.find include = ["scripts*"]` недостижима — любой новый файл включается автоматически. Защита на будущее, тестом не покрыта по построению.
claude-secretary deleted branch fix/issue-refs-tokenize 2026-08-26 20:08:19 +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!12
No description provided.