fix(build): отрицательные сканы приёмки проверяют форму кода, а не прозу

Комментарий, объясняющий, почему чего-то больше нет, обязан называть это по
имени. Скан по голой подстроке такой комментарий от кода не отличает и падает
на документации к выполненной им же работе. Найдено три таких гейта, все на
пути ближайшей сборки:

    скан иконок             -> блочный комментарий в SvgIcon/sprite.ts
    скан имён раннеров      -> слово `systemd-run` в прозе firewall.ts
    скан cancelFirewall...  -> комментарий о разделении функции

Второй сломан моим же комментарием из 2259f7c. Третий сломан с момента своего
появления (330a63b) и не падал только потому, что сборка до него не доходила:
её останавливали более ранние гейты.

Исправления по форме, а не удалением комментариев:

- скан прежних имён раннеров требует, чтобы перед именем не стоял дефис. В
  JavaScript идентификатор после дефиса не начинается, поэтому исключение
  точное и ни один настоящий вызов не пропускает;
- скан cancelFirewallRollback ищет имя со скобкой, то есть объявление или
  вызов, а не упоминание;
- литеральный скан по virtual:svg-icons-register удалён: его роль исполняет
  более сильный и более ранний гейт — production `vite build`, где активный
  импорт неразрешимого виртуального модуля роняет сборку bundle. Оставлены две
  точные проверки: отсутствие плагина в package.json и вызов registerSvgIcons
  в main.ts.

В шапке acceptance.sh зафиксировано правило для отрицательных сканов и
ограничение code_without_comments: строчные комментарии он отбрасывает,
блочные — нет, и блок-парсер сознательно не заводится (наивный стриппер
спотыкается о `/*` внутри строк и регулярных выражений, а это ложный PASS).

Все отрицательные сканы приёмки прогнаны по текущему дереву: срабатываний
больше нет; новые шаблоны проверены на синтетическом регрессе — ловят.
This commit is contained in:
2026-09-01 04:44:23 +05:00
parent a1c74caa0c
commit d32811b804
3 changed files with 98 additions and 8 deletions
+21
View File
@@ -79,6 +79,27 @@ Hardening-проход перед релизом `1.0.0`. Основная те
первый же, кто убрал бы маскировку ошибок. Введён `code_has`, читающий код в первый же, кто убрал бы маскировку ошибок. Введён `code_has`, читающий код в
переменную отдельным оператором. переменную отдельным оператором.
- **Три отрицательных скана падали на прозе, а не на коде.** Комментарий,
объясняющий, почему чего-то больше нет, называет это по имени — и скан по
голой подстроке не отличает такой комментарий от кода:
```text
скан иконок -> блочный комментарий о замене плагина
скан имён раннеров -> слово `systemd-run` в прозе
скан cancelFirewall… -> комментарий о разделении функции
```
Все три исправлены по форме, а не удалением комментариев: скан раннеров
требует, чтобы перед именем не стоял дефис (в JavaScript идентификатор после
дефиса не начинается, поэтому ни один настоящий вызов не пропускается); скан
`cancelFirewallRollback` ищет имя со скобкой, то есть объявление или вызов;
литеральный скан по `virtual:svg-icons-register` удалён, потому что его роль
исполняет более сильный и более ранний гейт — production `vite build`, где
неразрешимый виртуальный модуль роняет сборку bundle.
Из них скан `cancelFirewallRollback` был сломан с момента появления и не
падал только потому, что сборка до него не доходила.
- **Несуществующий путь в скане означал успех.** `2>/dev/null || true` в - **Несуществующий путь в скане означал успех.** `2>/dev/null || true` в
`code_without_comments` превращал опечатку в имени файла в пустой вывод, а `code_without_comments` превращал опечатку в имени файла в пустой вывод, а
пустой вывод для проверки «запрещённой конструкции здесь нет» — это PASS. пустой вывод для проверки «запрещённой конструкции здесь нет» — это PASS.
+25
View File
@@ -1448,6 +1448,31 @@ hy2xs-orchestrator doctor
74. обещанное окно отката — контракт systemd, а не намерение: у транзиентного таймера явно задан `AccuracySec=1s`, иначе умолчание `AccuracySec=1min` превращало «45 секунд» в 45–105 74. обещанное окно отката — контракт systemd, а не намерение: у транзиентного таймера явно задан `AccuracySec=1s`, иначе умолчание `AccuracySec=1min` превращало «45 секунд» в 45–105
75. состояние guard читает один наблюдатель: `status` берёт его у того же кода, что и барьер, и сообщает `unknown` вместо тихого «guard'ов нет» при отказе systemd 75. состояние guard читает один наблюдатель: `status` берёт его у того же кода, что и барьер, и сообщает `unknown` вместо тихого «guard'ов нет» при отказе systemd
76. ни один релизный гейт не подаёт вывод в поиск с флагом `-q` через пайплайн: под `set -o pipefail` оборванный продюсер отдаёт 141, и «совпадение найдено» превращается в ненулевой код — для отрицательных проверок это ложный PASS. Сравнение идёт через here-string, и возврат пайплайна запрещён отдельной приёмкой 76. ни один релизный гейт не подаёт вывод в поиск с флагом `-q` через пайплайн: под `set -o pipefail` оборванный продюсер отдаёт 141, и «совпадение найдено» превращается в ненулевой код — для отрицательных проверок это ложный PASS. Сравнение идёт через here-string, и возврат пайплайна запрещён отдельной приёмкой
77. отрицательные сканы по дереву исходников формулируют **синтаксическую форму**, а не подстроку: вызов — имя со скобкой или обратной кавычкой, импорт — `import` со спецификатором, зависимость — ключ в `package.json`. Прозаическое упоминание удалённой вещи разрешено, иначе гейт запрещает документировать собственную работу
### Почему отрицательный скан не ищет подстроку
Комментарий, объясняющий, почему чего-то больше нет, обязан называть это по
имени. Скан по голой подстроке такой комментарий не отличает от кода и падает
ровно на документации к выполненной им же работе. В этом файле урок оплачен
пять раз: скан versions contract ловил сам себя на `/hui`; dead-route скан
падал на `router_test.go`, который перечисляет удалённые маршруты, чтобы
доказать их отсутствие; скан иконок — на блочном комментарии о замене плагина;
скан прежних имён раннеров — на слове `systemd-run` в прозе; скан
`cancelFirewallRollback` — на комментарии о её разделении.
`code_has` отбрасывает **строчные** комментарии (`//`, `#`), но не блочные
`/* … */`. Блок-парсер сознательно не заводится: наивный стриппер спотыкается
о `/*` внутри строк и регулярных выражений и может вычистить настоящий код —
а это ложный PASS, то есть лекарство хуже болезни. Для файлов с блочными
комментариями формулируется синтаксическая форма либо утверждение опирается на
более сильный гейт.
Пример последнего: литерального скана по `virtual:svg-icons-register` больше
нет. Его роль исполняет production `vite build`, который проходит раньше:
активный `import "virtual:svg-icons-register"` при отсутствующем плагине не
разрешается резолвером, и сборка bundle падает. Проверяется исполняемый импорт,
а не совпадение подстроки.
### Почему пайплайн в `grep -q` запрещён ### Почему пайплайн в `grep -q` запрещён
+52 -8
View File
@@ -8,10 +8,29 @@ set -euo pipefail
# — и проверка, ищущая просто подстроку, начинает падать ровно на той # — и проверка, ищущая просто подстроку, начинает падать ровно на той
# документации, которая описывает выполненную ею же работу. # документации, которая описывает выполненную ею же работу.
# #
# Урок в этом файле уже дважды оплачен: скан versions contract ловил сам себя на # Урок в этом файле оплачен уже пять раз: скан versions contract ловил сам себя
# строке '/hui', а dead-route скан падал на router_test.go, который ПЕРЕЧИСЛЯЕТ # на строке '/hui'; dead-route скан падал на router_test.go, который ПЕРЕЧИСЛЯЕТ
# удалённые маршруты, чтобы доказать их отсутствие. Помощник существует, чтобы # удалённые маршруты, чтобы доказать их отсутствие; скан иконок падал на
# третьего раза не было. # блочном комментарии, объясняющем замену плагина; скан прежних имён раннеров —
# на слове `systemd-run` в прозе; скан `cancelFirewallRollback` — на
# комментарии, описывающем её разделение.
#
# Отсюда правило для КАЖДОЙ отрицательной проверки по дереву исходников:
#
# формулируй синтаксическую форму, а не подстроку.
#
# Вызов — это имя со скобкой или с обратной кавычкой; импорт — это `import` со
# спецификатором; зависимость — это ключ в package.json. Прозаическое
# упоминание удалённой вещи обязано быть разрешено: комментарий, объясняющий,
# почему чего-то больше нет, называет это по имени — иначе он бесполезен.
#
# Ограничение помощника, о котором важно помнить: он отбрасывает СТРОЧНЫЕ
# комментарии (`//`, `#`), но НЕ блочные `/* ... */`. Блок-парсер здесь
# сознательно не заводится: наивный стриппер спотыкается о `/*` внутри строк и
# регулярных выражений и может вычистить настоящий код, а это ложный PASS —
# отказ хуже болезни. Для файлов с блочными комментариями формулируйте
# синтаксическую форму либо опирайтесь на более сильный гейт (сборка bundle,
# компилятор, тесты).
# #
# Вид комментария выбирается по расширению файла. # Вид комментария выбирается по расширению файла.
# #
@@ -260,10 +279,21 @@ run_fix20_acceptance_subset() {
# `Patched versions: <0.0.0` — исправленной версии не существует. # `Patched versions: <0.0.0` — исправленной версии не существует.
! grep -q '"vite-plugin-svg-icons"' apps/frontend/package.json \ ! grep -q '"vite-plugin-svg-icons"' apps/frontend/package.json \
|| fail "acceptance: неподдерживаемый vite-plugin-svg-icons вернулся в зависимости" || fail "acceptance: неподдерживаемый vite-plugin-svg-icons вернулся в зависимости"
! grep -rq 'virtual:svg-icons-register' apps/frontend/src \
|| fail "acceptance: виртуальный модуль удалённого плагина иконок вернулся"
grep -q 'registerSvgIcons' apps/frontend/src/main.ts \ grep -q 'registerSvgIcons' apps/frontend/src/main.ts \
|| fail "acceptance: спрайт локальных иконок не регистрируется при старте приложения" || fail "acceptance: спрайт локальных иконок не регистрируется при старте приложения"
# Литерального скана по `virtual:svg-icons-register` здесь СОЗНАТЕЛЬНО нет.
#
# Он ловил не код, а прозу: единственное вхождение строки в дереве — в
# блочном комментарии `components/SvgIcon/sprite.ts`, который объясняет, что
# именно заменено. Комментарий, называющий удалённую вещь по имени, обязан
# существовать — иначе он бесполезен, — и гейт, падающий на нём, запрещает
# документировать собственную работу.
#
# Утверждение при этом не потеряно, а обеспечено более сильной проверкой:
# production `vite build` выше по сборке уже прошёл. Активный
# `import "virtual:svg-icons-register"` при отсутствующем плагине не
# разрешается резолвером, и сборка bundle падает — то есть проверяется
# исполняемый импорт, а не совпадение подстроки.
# Каждая иконка обязана давать symbol с viewBox. # Каждая иконка обязана давать symbol с viewBox.
# #
@@ -410,7 +440,15 @@ run_clean_install_acceptance() {
|| fail "acceptance: process.ts must expose an explicit read-only runner" || fail "acceptance: process.ts must expose an explicit read-only runner"
grep -q 'export async function runMutating' orchestrator/src/lib/process.ts \ grep -q 'export async function runMutating' orchestrator/src/lib/process.ts \
|| fail "acceptance: process.ts must expose an explicit mutating runner" || fail "acceptance: process.ts must expose an explicit mutating runner"
! grep -rEq '(^|[^A-Za-z0-9_])(run|runVisible|runHidden|runSecret|runRawVisible)`' orchestrator/src \ # Дефис в списке исключённых предшествующих символов — не косметика.
#
# Скан ищет ВЫЗОВ прежнего раннера: имя, за которым сразу идёт обратная
# кавычка tagged template. Без дефиса под это описание попадала любая проза
# вида `systemd-run` — имя команды, взятое в кавычки в комментарии, — и гейт
# падал на строке документации, не содержащей никакого вызова. В JavaScript
# идентификатор не может начинаться после дефиса, поэтому исключение точное:
# ни один настоящий вызов оно не пропускает.
! grep -rEq '(^|[^-A-Za-z0-9_])(run|runVisible|runHidden|runSecret|runRawVisible)`' orchestrator/src \
|| fail "acceptance: the pre-split runner names must not come back" || fail "acceptance: the pre-split runner names must not come back"
# Порог считается от числа мутирующих раннеров, а не задан константой: новый # Порог считается от числа мутирующих раннеров, а не задан константой: новый
# раннер обязан приносить с собой и проверку guard'а, а не проходить под # раннер обязан приносить с собой и проверку guard'а, а не проходить под
@@ -1081,7 +1119,13 @@ run_single_owner_acceptance() {
|| fail "acceptance: снятие таймера автоотката не отделено от удаления резервных копий" || fail "acceptance: снятие таймера автоотката не отделено от удаления резервных копий"
grep -q 'export async function cleanupFirewallRollback' orchestrator/src/steps/firewall.ts \ grep -q 'export async function cleanupFirewallRollback' orchestrator/src/steps/firewall.ts \
|| fail "acceptance: удаление резервных копий firewall не выделено в отдельную операцию" || fail "acceptance: удаление резервных копий firewall не выделено в отдельную операцию"
! grep -rq 'cancelFirewallRollback' orchestrator/src \ # Ищется ОБЪЯВЛЕНИЕ или ВЫЗОВ, то есть имя со скобкой, а не упоминание.
#
# Скан по голой подстроке был сломан с самого своего появления: комментарий у
# `disarmFirewallRollback` объясняет разделение, называя прежнюю функцию по
# имени, и гейт падал бы на нём. Не падал он только потому, что до этого шага
# сборка не доходила — более ранние гейты останавливали её раньше.
! grep -rEq 'cancelFirewallRollback[[:space:]]*\(' orchestrator/src \
|| fail "acceptance: объединённая cancelFirewallRollback вернулась; она удаляла копии до фиксации успеха" || fail "acceptance: объединённая cancelFirewallRollback вернулась; она удаляла копии до фиксации успеха"
"$BUN_BIN" -e ' "$BUN_BIN" -e '
const fs = require("node:fs"); const fs = require("node:fs");