Compare commits

...
Author SHA1 Message Date
claude[bot] 6267da7e28 docs: review document for #492
Issue: #492
User-Visible: no
2026-09-08 21:55:04 +00:00
Codex 6cf6fc787e docs: spec notes how the manifest categories are computed
Issue: #492
User-Visible: no
2026-09-09 00:42:02 +03:00
claude[bot]andCodex 976b34467f docs: review document for #492
Issue: #492
User-Visible: no
2026-09-09 00:42:02 +03:00
Codex 9457690174 docs: input manifest and exact-candidate merge in TESTING.md and PROCESS.md
Issue: #492
User-Visible: no
2026-09-09 00:42:01 +03:00
Codex ddbd9c1900 ci: merge the exact candidate; nightly waits for its Validate
scripts/merge-candidate.mjs owns the review pipeline's merge: when dev
moved during review, the rebased candidate is pushed to the issue branch,
its diff is compared to the reviewed one by patch-id, Validate on that SHA
is awaited, and only then dev is advanced with --force-with-lease on the
base the candidate was built on — a rejected lease restarts, at most three
times. Every non-merge outcome moves the label with a comment, so the
"label always changes" invariant holds. nightly.yml now finds the Validate
run it dispatched and inherits its conclusion. Three mutants guard this.

Issue: #492
User-Visible: no
2026-09-09 00:42:01 +03:00
Codex 59f51048c5 ci: mutant selection sees wrapper defaults, guard imports, fixtures and registry edits
guardInputs() replaces guardFiles() in selection and fingerprints: the
files named in the guard, the GUARD_INPUTS a wrapper declares (read
statically — the wrappers run on import), and the closure of imports and
path literals of every guard file, stopping at src/** which stays the
patch side. A diff that touches the registry itself selects every added or
changed definition against the base registry read from git. Five mutants
guard the manifest and this selection.

Issue: #492
User-Visible: no
2026-09-09 00:42:01 +03:00
Codex 378d74fec4 ci: one input manifest for job selection and reuse keys
scripts/check-inputs.mjs declares every Validate check with its roots and
entry points and computes the rest: imports and path literals of the
entries, transitively for code, as leaves for data. classify-changes and
gate-reuse both read it, so "which job runs" and "what its key hashes"
cannot disagree any more. An executable file no check knows widens the run
to the full set and is named in the summary; the coverage list makes such
a file a red unit test rather than a permanent widening. The workflow file
is a toolchain input of every job; backend no longer hashes src/**.

Issue: #492
User-Visible: no
2026-09-09 00:42:01 +03:00
Codex a0cc0d849a docs: AC6 gets its negative probe (spec review r1)
Issue: #492
User-Visible: no
2026-09-09 00:42:01 +03:00
claude[bot]andCodex 1f432245db docs: review document for #492
Issue: #492
User-Visible: no
2026-09-09 00:42:01 +03:00
Codex 96ecf962ed docs: spec for the exact integration candidate and the input manifest
Issue: #492
User-Visible: no
2026-09-09 00:42:01 +03:00
24 changed files with 2517 additions and 269 deletions
+28 -2
View File
@@ -13,6 +13,11 @@
# Красный ночной прогон — сигнал автору последних коммитов на dev, не гейт:
# гейт беты по-прежнему требует зелёный Validate на точном SHA кандидата, и
# там полный набор идёт заново.
#
# Сигнал обязан быть настоящим (#492 §7): до этой задачи job завершалась
# успехом в момент постановки Validate в очередь, и красный полный прогон не
# делал ночной workflow красным. Теперь job находит запущенный прогон и ждёт
# его: успешный dispatch — не успешная проверка.
name: Ночной полный прогон dev
on:
@@ -26,13 +31,34 @@ permissions:
jobs:
dispatch:
name: "Запустить Validate на dev с полным набором"
name: "Запустить Validate на dev с полным набором и дождаться результата"
runs-on: ubuntu-latest
timeout-minutes: 90
steps:
- env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
run: |
set -euo pipefail
since=$(date -u +%FT%TZ)
gh workflow run validate.yml --repo "$REPO" --ref dev -f full=true
echo "Validate(dev, full=true) поставлен в очередь: $(date -u +%FT%TZ)"
echo "Validate(dev, full=true) поставлен в очередь: $since"
# Найти именно этот прогон: workflow_dispatch на dev, созданный не
# раньше момента запуска. До трёх минут на появление в списке.
run_id=""
for _ in $(seq 1 18); do
sleep 10
run_id=$(gh run list --repo "$REPO" --workflow validate.yml --branch dev \
--event workflow_dispatch --json databaseId,createdAt --limit 5 \
--jq "[.[] | select(.createdAt >= \"$since\")] | sort_by(.createdAt) | last | .databaseId // empty")
[ -n "$run_id" ] && break
done
if [ -z "$run_id" ]; then
echo "::error::прогон Validate не появился за 3 минуты — dispatch не равен проверке"
exit 1
fi
url="${{ github.server_url }}/$REPO/actions/runs/$run_id"
echo "дочерний прогон: $url"
echo "- Validate(dev, full=true): $url" >> "$GITHUB_STEP_SUMMARY"
# Ждём завершения; красный дочерний прогон — красный ночной.
gh run watch "$run_id" --repo "$REPO" --exit-status --interval 30
+15 -59
View File
@@ -1012,16 +1012,19 @@ jobs:
# S8-merged утверждает, что код в dev. Значит слияние обязано произойти
# ДО метки, иначе она врёт в промежутке.
#
# При конфликте шаг НЕ падает и метку не оставляет на месте. Первая
# редакция делала именно так, и это оказалось тупиком: автор ждёт смену
# метки, метка не менялась, и он тридцать раз опрашивал впустую, чтобы
# затем отчитаться «лимит исчерпан» — при зелёном вердикте. Инвариант
# теперь жёстче: ПОСЛЕ ПРОГОНА РЕВЬЮ МЕТКА МЕНЯЕТСЯ ВСЕГДА.
# Слияние — точный кандидат (#492 §4, scripts/merge-candidate.mjs):
# ветка сверяется с материалом (#312); если dev не двигался — push с
# lease на текущую вершину; если двигался — ребейз, сравнение patch-id
# с проверенным диффом, публикация кандидата в ветку, ожидание
# Validate на этом SHA и только потом push в dev с lease. Повторное
# движение dev — новая попытка, не более трёх. Каждый исход, кроме
# успеха, ведёт в S6-in-progress/S7-code-review с комментарием, ПОСЛЕ
# ПРОГОНА МЕТКА МЕНЯЕТСЯ ВСЕГДА — инвариант тот же, что и раньше.
- name: Слить ветку в dev
id: merge
if: needs.guard.outputs.stage == 'code' && steps.decide.outputs.green == 'true'
env:
TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
HP_PROCESS_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
BRANCH: ${{ steps.branch.outputs.name }}
NUM: ${{ github.event.issue.number }}
@@ -1032,58 +1035,8 @@ jobs:
echo "merged=false" >> "$GITHUB_OUTPUT"
exit 0
fi
git fetch -q origin dev "$BRANCH"
# #312: сливается только проверенный код. Допустимые вершины ветки:
# сам SHA материала либо он же плюс ровно один коммит публикации
# документа ревью (дифф только docs/reviews/). Любой другой коммит —
# ветка уехала после ревью, вердикт к ней не применим: возврат в
# S6-in-progress через merged=false, как при конфликте.
actual=$(git rev-parse "origin/$BRANCH")
reviewed="$MATERIAL_SHA"
fresh=false
if [ "$actual" = "$reviewed" ]; then
fresh=true
elif [ "$(git rev-parse "$actual^" 2>/dev/null)" = "$reviewed" ] \
&& [ -z "$(git diff --name-only "$reviewed" "$actual" -- . ':!docs/reviews')" ]; then
fresh=true
fi
if [ "$fresh" != true ]; then
echo "merged=false" >> "$GITHUB_OUTPUT"
echo "::warning::ветка $BRANCH уехала после проверенного SHA $reviewed (сейчас $actual) — слияние отменено (#312)"
cat > /tmp/stale-verdict.md <<EOF
**Слияние отменено: ветка изменилась после проверенного материала (#312).**
Ревью выполнялось на \`$(git rev-parse --short "$reviewed")\`, а вершина ветки сейчас \`$(git rev-parse --short "$actual")\` — в ней есть коммиты, которых вердикт не покрывает. Зелёный вердикт остаётся в силе только для проверенного SHA.
Задача переведена в \`S6-in-progress\`. Дальше: убедиться, что вершина ветки — именно то, что должно ехать в dev, и вернуть метку \`S7-code-review\`. Если вершина отличается от проверенного материала только коммитами публикации документов ревью, новый заход применит зелёный вердикт повторно без вызова модели (#499).
EOF
gh issue comment "$NUM" --repo "${{ github.repository }}" --body-file /tmp/stale-verdict.md
exit 0
fi
git checkout -q -B merge-into-dev "$actual"
if ! git -c user.name="claude[bot]" \
-c user.email="209825114+claude[bot]@users.noreply.github.com" \
rebase origin/dev; then
git rebase --abort || true
echo "merged=false" >> "$GITHUB_OUTPUT"
echo "::warning::ветка $BRANCH не сливается в dev без конфликта"
cat > /tmp/conflict.md <<EOF
**Код-ревью зелёное — вердикт выше в силе, переделывать работу не нужно.** Не удалось только слияние: ветка \`$BRANCH\` конфликтует с \`dev\`.
Задача переведена в \`S6-in-progress\`, потому что работа вернулась к автору. Осталась не правка кода, а ребейз:
1. \`git fetch origin\`, затем \`git rebase origin/dev\` в ветке задачи, разрешить конфликт;
2. запушить ветку;
3. вернуть метку \`S7-code-review\`.
Повторный прогон ревью — не формальность: после ребейза на новый \`dev\` это другой код, и принимать его без проверки нельзя. Цикл считается по этапу, лимит на код-ревью тратится отдельно от ревью ТЗ.
EOF
gh issue comment "$NUM" --repo "${{ github.repository }}" --body-file /tmp/conflict.md
exit 0
fi
git push -q "https://x-access-token:$TOKEN@github.com/${{ github.repository }}" HEAD:dev
echo "merged=true" >> "$GITHUB_OUTPUT"
echo "слито в dev: $(git rev-parse --short HEAD)"
node scripts/merge-candidate.mjs --branch="$BRANCH" --material="$MATERIAL_SHA" \
--issue="$NUM" --repo="${{ github.repository }}"
- name: Переставить метку
if: steps.rebase.outputs.conflict != 'true'
@@ -1094,7 +1047,10 @@ jobs:
FROM: ${{ steps.decide.outputs.from }}
# Зелёное код-ревью без слияния ведёт не в S8-merged, а обратно к
# автору: метка утверждала бы, что код в dev, а его там нет.
TO: ${{ (needs.guard.outputs.stage == 'code' && steps.decide.outputs.green == 'true' && steps.merge.outputs.merged != 'true') && 'S6-in-progress' || steps.decide.outputs.to }}
# Точный кандидат (#492) сам называет исход: S8 после push, S6/S7 —
# когда кандидат не слит (конфликт, красный Validate, изменившийся
# patch-id, ушедший dev). Без исхода от скрипта — как раньше: S6.
TO: ${{ (needs.guard.outputs.stage == 'code' && steps.decide.outputs.green == 'true' && steps.merge.outputs.to != '') && steps.merge.outputs.to || ((needs.guard.outputs.stage == 'code' && steps.decide.outputs.green == 'true' && steps.merge.outputs.merged != 'true') && 'S6-in-progress' || steps.decide.outputs.to) }}
run: |
gh issue edit "$NUM" --repo "${{ github.repository }}" \
--add-label "$TO" --remove-label "$FROM"
+24 -9
View File
@@ -215,13 +215,13 @@ jobs:
perf_iso: ${{ steps.classify.outputs.perf_iso }} # #473 §5
perf_interaction: ${{ steps.classify.outputs.perf_interaction }}
mutants: ${{ steps.classify.outputs.mutants }}
unknown_inputs: ${{ steps.classify.outputs.unknown_inputs }}
# Тяжёлые job только на кандидате/по кнопке/на PR (#479), см. шаг heavy.
heavy: ${{ steps.heavy.outputs.heavy }}
base: ${{ steps.base.outputs.base }}
# Разные вещи под разными именами намеренно: `base` — до какого коммита
# классифицировать файлы ветки (#387), `range_base` — от какого коммита
# судить сам диапазон на dev (#388). Общее имя означало бы, что
# потребитель однажды молча возьмёт чужую базу.
# классифицировать файлы ветки (#387), `range_base` — от какого судить
# диапазон на dev (#388): общее имя — чужая база у потребителя.
range_base: ${{ steps.base.outputs.range_base }}
steps:
# `git diff --name-only` содержимого файлов не читает вовсе, поэтому
@@ -320,13 +320,28 @@ jobs:
fi
files=$(git diff --name-only "$base" "$HEAD_SHA")
printf '%s\n' "$files" | head -50
# Шаблоны — в scripts/classify-changes.mjs: там они проверяемы
# unit-тестом, здесь — только переписываются в выходы (#473 AC8).
printf '%s\n' "$files" | node scripts/classify-changes.mjs | tee -a "$GITHUB_OUTPUT"
# Решение — в scripts/classify-changes.mjs по единому manifest входов
# (#492 §5.2): там оно проверяемо unit-тестом, здесь — только
# переписывается в выходы (#473 AC8).
printf '%s\n' "$files" | node scripts/classify-changes.mjs > /tmp/classify.out
tee -a "$GITHUB_OUTPUT" < /tmp/classify.out
# Файл, которого нет в manifest ни одной проверки, — не «не влияет»,
# а «неизвестно»: прогон расширен до полного набора, и это видно в
# summary, а не только в логе.
unknown=$(sed -n 's/^unknown_inputs=//p' /tmp/classify.out)
if [ -n "$unknown" ]; then
{
echo "### Неизвестные входы (#492)"
echo "Файлы, которых нет в manifest ни одной проверки — прогон расширен до полного набора:"
for f in $unknown; do echo "- \`$f\`"; done
echo "Впишите файл в \`CHECKS\` или \`NOT_AN_INPUT\` (scripts/check-inputs.mjs) с причиной."
} >> "$GITHUB_STEP_SUMMARY"
fi
# Переиспользование результата тяжёлой job (#208). Ключ = входы поведения
# (sourceFingerprint: src/**, demo/fixtures, demo/golden/*.mjs, манифесты
# сборки) ПЛЮС оснастка именно этой job. Маркер в кэше пишет только успешный
# Переиспользование результата тяжёлой job (#208). Ключ = хеш содержимого
# всех входов job по единому manifest (#492, scripts/check-inputs.mjs):
# что собирает, что исполняет, что читает, чем запускается — включая
# `validate.yml`. Маркер в кэше пишет только успешный
# прогон с тем же ключом, поэтому попадание доказывает: job с побайтово теми
# же входами уже завершилась успешно.
#
+22
View File
@@ -933,6 +933,28 @@ S7-code-review → код-ревью → слияние в dev → S8-merged л
ветки и пушем автор мог запушить коммит, и слепой `--force` потерял бы его молча.
Расхождение lease — падение прогона, а не предупреждение.
**В `dev` уезжает точный кандидат, и только проверенный** (#492,
`scripts/merge-candidate.mjs`). Ревью длится десятки минут, `dev` за это время
двигается; ребейз после вердикта даёт дерево, которого никто не видел, — а чистый
ребейз ничего не доказывает: соседняя правка в `dev` меняет поведение без единого
конфликта. Шаг слияния поэтому:
- сверяет вершину ветки с материалом ревью (#312) — иначе `S6-in-progress`;
- если `dev` не двигался — push с `--force-with-lease` на текущую вершину;
- если двигался — ребейз (конфликт — `S6-in-progress`, как раньше), сравнение
patch-id проверенного и получившегося диффа (различие — `S7-code-review`: вердикт
к другому диффу не применим, §7.2), публикация кандидата в ветку задачи, ожидание
зелёного Validate **на этом SHA** и только затем push в `dev` с lease на ту
вершину, поверх которой кандидат собран. Отклонённый lease — `dev` двинулся снова
— новая попытка; после третьей — `S6-in-progress` с комментарием;
- красный Validate на кандидате или прогон, не появившийся за три минуты, —
`S6-in-progress` с ссылкой; `S8-merged` ставится только после push.
Проверка кандидата — обычный Validate ветки: лёгкий набор плюс диффозависимые
гейты. Тяжёлые гейты остаются за кандидатом релиза (#479): слияние не превращает
каждое движение `dev` в двадцатиминутный прогон, а проверяет ровно то, что
проверил бы пуш той же дельты.
Поэтому зелёное код-ревью с неудавшимся слиянием ведёт не в `S8-merged`, а в
`S6-in-progress`: работа действительно вернулась к автору, только осталась не
правка кода, а ребейз. Вердикт при этом в силе, переделывать нечего. После ребейза
+52 -5
View File
@@ -33,18 +33,65 @@
`--shard=i/4`), перед стабильным релизом и по понедельникам. Дешёвая половина
идёт с юнитами: `test/mutation-gate.test.mjs`. Локально для дельты задачи —
`node scripts/mutation-gate.mjs --changed origin/dev..HEAD`: гоняются только
мутанты, чьи patch-файлы или файлы гарда задеты диффом (#332, #475). Бандл
собирается только мутантам с браузерным гвардом; компиляция тестов в worktree
стартует с тёплого `test-build/` основного дерева.
мутанты, чьи patch-файлы или **входы гарда** задеты диффом (#332, #475, #492).
Входы гарда — это не только файлы, названные в команде: обёртка
(`scripts/*-guard.mjs`) объявляет запускаемые тесты в `export const
GUARD_INPUTS` (умолчание `backend-test-guard.mjs` —
`tests_backend/test_ha_import_export.py`, действует без третьего аргумента), а
от каждого файла гарда берётся замыкание импортов и путей: смок тянет
`demo/serve.mjs`, compat-хелперы и фикстуры, pytest-модуль — `conftest.py`.
`src/**` в замыкание не входит — это сторона патча (§6.4 ТЗ #492). Дифф,
трогающий сам реестр, дополнительно отбирает добавленные и изменённые
определения относительно реестра базы (`git show <base>:scripts/mutation-gate.mjs`).
Бандл собирается только мутантам с браузерным гвардом; компиляция тестов в
worktree стартует с тёплого `test-build/` основного дерева.
В CI `changed_mutants` добавляет `--ledger=<файл>` — журнал пойманных
свидетелей (#481): после каждого пойманного мутанта в файл пишется отпечаток
его входов (файлы патча и гарда, объявление мутанта; строка версии продукта
нормализована), и мутант с тем же отпечатком в следующем прогоне не гоняется.
его входов (файлы патча, все входы гарда по замыканию выше, объявление мутанта;
строка версии продукта нормализована), и мутант с тем же отпечатком в следующем
прогоне не гоняется.
Журнал живёт в кэше Actions по шарду, сохраняется при любом исходе шага, так
что отменённый пуш или таймаут не пропадают даром. Полный прогон и `--id`
журнал не читают; `--ledger` без `--changed` — ошибка.
## Manifest входов: какие job запускать и что хешировать (#492)
Один модуль, `scripts/check-inputs.mjs`, объявляет каждую проверку Validate
(`preflight`, `frontend`, `changed_mutants`, `integration`, `smoke`, `golden`,
`performance_smoke`, `backend`) через корни и точки входа, а остальное
вычисляет: от точек входа берётся замыкание — импорты транзитивно, строковые
пути как листья, каталог по строке — все текстовые файлы под ним. Из этого
manifest читают и `classify-changes.mjs` (job `changes`: job запускается,
если дифф задел хотя бы один её вход), и `gate-reuse.mjs` (ключ реюза =
хеш содержимого всех входов job). Два места не могут разойтись: до #492 у
бэкенда ключ не знал relay, converter и schema, а golden/perf — `serve.mjs`,
`demo.html` и compat-хелперов.
Правила, которые стоит знать:
- `validate.yml` — вход toolchain каждой job: правка workflow гоняет всё;
- `src/**` — вход только браузерных job; backend от UI не зависит, а
`custom_components/houseplan/manifest.json` (версия) держит правило «кандидат
релиза прогоняет всё» и для него;
- **неизвестный исполняемый вход** — файл под `scripts/`, `demo/`, `test/`,
`tests_backend/`, `.github/`, `custom_components/`, `src/`, которого нет в
manifest ни одной проверки, — расширяет прогон до полного набора и называется
в summary. Лист покрытия (`node scripts/check-inputs.mjs --coverage`,
`test/check-inputs.test.mjs`) требует, чтобы каждый такой файл был чьим-то
входом либо стоял в `NOT_AN_INPUT` с причиной: новый скрипт без записи —
красный юнит, не вечное расширение прогонов;
- `--check=<job>` печатает входы, `--why=<файл>` — цепочку, по которой файл
стал входом.
Отрицательные пробы (`test/gate-reuse.test.mjs`, `test/classify-changes.test.mjs`,
`test/check-inputs.test.mjs`) держат представителей каждой категории входов и
обратную пробу для UI ↔ backend; мутанты `manifest-drops-workflow-input`,
`classify-unknown-input-is-unaffected`, `reuse-backend-hashes-ui`,
`guard-inputs-ignore-wrapper-defaults`, `registry-diff-not-selected`,
`merge-pushes-unvalidated-candidate`, `merge-ignores-lease-rejection`,
`nightly-does-not-wait` держат сам протокол.
Чистые Python-контракты канонизации, которым не нужен Home Assistant, находятся
в `tests_backend/test_coordinate_canonicalization_pure.py`. Они обязаны реально
исполняться в локальном `pytest tests_backend`, а не исчезать за module-level
+116
View File
@@ -0,0 +1,116 @@
# CODE-REVIEW-492-r1
- **Issue:** #492 — CI: проверять точный кандидат интеграции и полный набор зависимостей selection/reuse
- **Материал ревью:** `6cf6fc787ebaec9db038a51163429586de7d76b8` (рабочая копия ветки `issue/492-exact-candidate-and-input-manifest` уже на нём)
- **Заход:** r1 · блокирующих циклов израсходовано 0/4 (спек-ревью r1/r2 бюджет код-ревью не тратит; после спек-ревью был возврат в S6 только из-за конфликта слияния — цикл ревью кода этим не образуется, §4)
- **Класс:** B/infra — ни одного файла класса A (`src/**`, `custom_components/**/*.py`, манифесты, i18n) в диффе; маршрутизация через S-метки без продуктового кода — прецедент #472/#475/#481, подтверждено `git diff --stat origin/dev...HEAD`
## Скоуп
`git diff origin/dev...HEAD` — 23 файла, +2399/-267:
- новые модули: `scripts/check-inputs.mjs` (единый manifest входов, §5 ТЗ), `scripts/merge-candidate.mjs` (слияние точного кандидата, §4 ТЗ);
- переписаны на manifest: `scripts/classify-changes.mjs`, `scripts/gate-reuse.mjs`;
- расширен `scripts/mutation-gate.mjs`: `guardInputs`/`wrapperInputs`/`registryDelta`/`selectForDiff`/`baseRegistry`, + 8 протокольных мутантов;
- `scripts/backend-test-guard.mjs`, `scripts/trail-resume-test-guard.mjs` — `GUARD_INPUTS`;
- `.github/workflows/process.yml` — шаг слияния вызывает `merge-candidate.mjs` вместо inline-shell;
- `.github/workflows/validate.yml` — job `changes` читает `unknown_inputs`, пишет summary;
- `.github/workflows/nightly.yml` — ждёт дочерний Validate и наследует его исход;
- тесты: `test/check-inputs.test.mjs`, `test/merge-candidate.test.mjs`, `test/nightly-workflow.test.mjs` (новые), `test/gate-reuse.test.mjs`, `test/classify-changes.test.mjs`, `test/mutation-gate.test.mjs`, `test/validate-workflow.test.mjs` (расширены);
- документация: `docs/TESTING.md`, `PROCESS.md` §10.3, `docs/specs/README.md`, спека и её ревью-документы (r1/r2 — уже приняты, не предмет этого этапа).
## Как проверялось
**Материал точно на заявленном SHA:** `git rev-parse HEAD` = `6cf6fc78…`, совпадает с меткой этапа; `git status` чист.
**Уже подтверждено CI на этом SHA** (Validate, [run 34282060597](https://github.com/Matysh/houseplan-card/actions/runs/34282060597), `conclusion: success`, проверено `gh run view --json headSha,conclusion` — `headSha` совпадает буквально): `typecheck`/`npm test`/`npm run build` (job «Фронтенд: типы, юниты, мутанты, синхрон бандла»), `hassfest`, `hacs`, `process-gate`/`provenance`/`docs` (preflight), и три шарда «Мутанты по диффу» — все `success`. Эти гейты я не перегонял.
**Разобрано дополнительно мной, за пределами того, что покрывает Validate:**
| Гейт | Команда | Результат |
|---|---|---|
| Новые/изменённые юнит-тесты целиком | `node --test test/check-inputs.test.mjs test/merge-candidate.test.mjs test/gate-reuse.test.mjs test/classify-changes.test.mjs test/nightly-workflow.test.mjs test/validate-workflow.test.mjs` | 75/75 pass |
| Реестр мутантов, юниты | `node --test test/mutation-gate.test.mjs` | 39/39 pass |
| Лист покрытия manifest на реальном дереве | `node scripts/check-inputs.mjs --coverage` | exit 0, пусто |
| Диффовый отбор мутантов на реальном диапазоне | `node scripts/mutation-gate.mjs --changed origin/dev..HEAD` | отобрано 83 из 590 (по файлам 83, по определениям 9) — числа согласуются с диффом; полный прогон гардов остановился на `No module named pytest` (в этом окружении нет backend-venv, ожидаемо по AGENTS.md) |
| **Почему CI это не скрывает:** тот же прогон в Validate этого SHA («Мутанты по диффу 1/3, 2/3, 3/3») выполнялся в среде с backend-venv и завершился `success` — отобранные мутанты реально прогнаны там, где я не смог |
| Реальное поведение reuse на этом SHA | `gh run view 34282060597 --json jobs` + логи шага «Ключи переиспользования»/«Маркер backend» | backend помечен `skipped`: ключ реюза (посчитанный НОВЫМ `gate-reuse.mjs` на этом SHA) совпал с ранее закэшированным маркером — легитимный reuse (предыдущий коммит этой же ветки, не тронувший backend-входы, уже прогнал backend с тем же ключом), не молчаливая дыра. `smoke`/`golden`/`performance_smoke` — `skipped` по `heavy=false` (обычный push не на `dev`, не релиз, не `full=true`) — это не относится к manifest и не должно быть иначе |
**Не гонял и почему:** `npm run golden:verify`, browser-смоки, `pytest tests_backend`, perf-профили — диф не трогает `src/**`, geometry, `layout`/`marker.space`/`open_spans`, рендер или видимое поведение; ни один AC не требует этих гейтов. `check-docs.mjs` не запускал — диф не касается `src/**`.
## Разбор AC (доказательство по каждому)
| AC | Чем доказан | Проверено |
|---|---|---|
| AC1 | `test/merge-candidate.test.mjs`: `decideMerge` таблица + `эксперимент аудита` (реальный git, воспроизводит «20 → 40» из ТЗ) + мутант `merge-pushes-unvalidated-candidate` | тестом, тест умеет падать (см. таблицу мутантов ниже) |
| AC2 | `dev ушёл снова после Validate: lease отклонён → новая попытка; трижды → S6` + мутант `merge-ignores-lease-rejection` | тестом |
| AC3 | `patch-id изменился при ребейзе — S7-code-review` | тестом |
| AC4 | `test/check-inputs.test.mjs` «§8.1 представители» на реальном дереве (все категории всех тяжёлых job) | тестом на реальных путях, не выдуманных |
| AC5 | `test/classify-changes.test.mjs` «неизвестный вход расширяет...», `test/check-inputs.test.mjs` «§5.5 лист покрытия» + мутант `classify-unknown-input-is-unaffected` | тестом |
| AC6 | `test/check-inputs.test.mjs` «§8.1 обратная проба», `test/gate-reuse.test.mjs` «backend не зависит от src/**» + мутант `reuse-backend-hashes-ui`; финальный код `CHECKS.backend.roots` не содержит `src/**` (прочитано) | тестом + чтением |
| AC7 | `test/mutation-gate.test.mjs` §6.1 (полнота: каждый файл-обёртка, реально названный в `guard:` реестра, обязан иметь `GUARD_INPUTS`) + §8.2 (10 обёрток без 3-го аргумента отбираются) + мутант `guard-inputs-ignore-wrapper-defaults` | тестом; проверено чтением, что `coordinate-write-barrier-guard.mjs`/`review-doc-guard.mjs` (два прочих `*-guard.mjs` в дереве) в реестре вызываются только как `node --test test/…test.mjs`, то есть уже видны `guardFiles` напрямую — вне действия §6.1, находкой не являются |
| AC8 | `#492 §6.4: дифф только по реестру отбирает…`, реальный прогон `mutation-gate.mjs --changed` (см. таблицу гейтов) + мутант `registry-diff-not-selected` | тестом + живым прогоном на репозитории |
| AC9 | `test/nightly-workflow.test.mjs` + мутант `nightly-does-not-wait`; по формату — текстовый контракт YAML/bash, как и существующий `validate-workflow.test.mjs` (то же соглашение, что уже принято в проекте для этого класса файлов) | тестом (контрактным, по прецеденту) |
| AC10 | 8 мутантов (не 6, как в §8.5 ТЗ, — превышение, не недостача) существуют, у каждого предметный guard-тест; `docs/TESTING.md` и `PROCESS.md` §10.3 описывают точный кандидат и manifest — прочитано, оба раздела на месте и соответствуют коду | тестом + чтением |
## Таблица «чем краснеет» — новые протокольные мутанты (§2.7 код-ревью)
Проверено чтением: каждый `patches[].find` совпадает буквально со строкой текущего файла (иначе мутатор молча не находит цель — but здесь все 8 патчей адресуют существующий код 1:1, сверено построчно с `git diff`), и каждый `guard` матчится реальным именем существующего теста (сверено `grep`).
| Мутант | Патч ломает | Guard-тест (падает без защиты) |
|---|---|---|
| `manifest-drops-workflow-input` | `WORKFLOW = []` | `test/gate-reuse.test.mjs`: «the workflow itself is a toolchain input of every job» |
| `classify-unknown-input-is-unaffected` | неизвестный вход не расширяет | `test/classify-changes.test.mjs`: «неизвестный исполняемый вход расширяет…» |
| `reuse-backend-hashes-ui` | возврат `src/**` в `backend.roots` | `test/gate-reuse.test.mjs`: «…a version bump changes all of them» (AC6) |
| `guard-inputs-ignore-wrapper-defaults` | `declared = []` | `test/mutation-gate.test.mjs`: «#492 §8.2» |
| `registry-diff-not-selected` | `byRegistry = []` | `test/mutation-gate.test.mjs`: «#492 §6.4: дифф только по реестру…» |
| `merge-pushes-unvalidated-candidate` | `waitValidate` не вызывается | `test/merge-candidate.test.mjs`: «эксперимент аудита…» |
| `merge-ignores-lease-rejection` | `pushed \|\| true` | `test/merge-candidate.test.mjs`: «…lease отклонён…» |
| `nightly-does-not-wait` | `gh run watch` заменён на `echo` | `test/nightly-workflow.test.mjs`: «nightly ждёт запущенный Validate…» |
Для каждой строки я прочитал сам гард-тест (не только его имя) и убедился, что мутация действительно провалила бы конкретное утверждение (не просто «тест существует») — см. разбор в теле ревью выше по каждому пункту. Реально исполнить эти 8 мутаций (as configured, через `mutation-gate.mjs`) я не смог локально для двух (`nightly-does-not-wait` — правит workflow YAML не через `git apply` тестируемого кода, а `merge-pushes-unvalidated-candidate`/остальные — через find/replace на реальном файле), но их логика проверена сравнением diff↔patch руками; фактический прогон реестра мутантов на этом SHA в CI (три зелёных шарда) косвенно подтверждает, что ни один из добавленных мутантов не сломал существующий раннер.
## Находки
Нет High. Нет Medium в скоупе. Одна Low — не блокирует, фиксирую с решением «оставить, не чинить в этом заходе»:
**Low: `mergeCandidate()` — необязательный аргумент `attempt/maxAttempts` не передаётся decideMerge в ветке `!devMoved`.** `scripts/merge-candidate.mjs:182-187`. Внутри цикла попыток при `!devMoved` вызов `decideMerge({ fresh: true, devMoved: false, leaseRejected: !pushed })` не несёт `attempt`/`maxAttempts` — при непрерывном отклонении lease (dev формально «не двигался» по merge-base, но кто-то извне пушит прямо в `dev`, минуя конвейер, — сценарий, который правила процесса и так запрещают вне hotfix/владельца) цикл `for` исчерпывает три попытки через `continue`, не встретив `return` внутри `if (!devMoved)`-ветки, и падает в фолбэк после цикла, который жёстко подставляет `devMoved: true` в `decideMerge`. Результат (`give-up` → `S6-in-progress`) при этом безопасен и корректен — задача не подтверждается смерженной без Validate, — но текст комментария («dev движется быстрее слияния») в этом узком случае технически неточен (dev на самом деле не двигался по критерию `merge-base`), и путь не покрыт отдельным тестом. Не продуктовый риск: описанный триггер («кто-то пушит прямо в `dev` в обход конвейера») сам запрещён процессом вне `hotfix`/действий владельца (`AGENTS.md`: «Не мержить в `dev` руками»). Решение ревьюера: не возвращать в скоуп этого захода — исправление стоит дороже находки (уточнение сообщения плюс тест на состязание, которое процесс и так не допускает); если всплывёт живьём, заводится обычный баг-issue.
## Что проверено и корректно
- Ни одного файла класса A в диффе (подтверждено `git diff --stat`); все коммиты несут `Issue: #492`, `User-Visible: no` (инфраструктурная задача, корректно — нет видимого пользователю поведения).
- `scripts/check-inputs.mjs`: glob→regexp, `referencesOf` (импорты/пути/comments-strip для JS и Python), `closure` (транзитивность кода, лист данных, раскрытие каталога, остановка на class-D) — все разобраны юнитами на виртуальном дереве и подтверждены на реальном.
- `scripts/gate-reuse.mjs` избавлен от `sourceFingerprint`/`HARNESS`, ключ считается по manifest; `sourceFingerprint` осознанно остаётся у бандла/скриншотов (прочитано — используется в `check-docs.mjs`, `docs-accept.mjs`, `bundle-freshness.mjs`, не выпал из кодовой базы).
- `scripts/classify-changes.mjs`: `CHECK_OF_OUTPUT`/`PERF_PROFILES` разделены верно — job-выбор из manifest, перф-профили остаются регэкспами внутри `frontend`(как решение §5.2 явно оговаривает).
- `scripts/mutation-gate.mjs`: `guardInputs`/`wrapperInputs` корректно разводят «явный файл в команде» и «умолчание обёртки» (проверено тестом «явный файл отменяет умолчание»); `GUARD_CLOSURE_STOP` держит `src/**`/скопированный бандл вне замыкания гарда (AC7/§6.4 «сторона патча остаётся точечной»); `registryDelta`/`baseRegistry` читают реестр базы через `git show` во временный модуль и подчищают его в `finally` (проверено тестом «временный модуль удалён»).
- `merge-candidate.mjs`/`process.yml`: инвариант «после прогона метка меняется всегда» — прослежено по всем финальным `decideMerge`-исходам, каждый несёт `to`; `TO`-выражение в «Переставить метку» шаге читает `steps.merge.outputs.to` с фолбэком на прежнюю логику — совместимо с случаем, когда шаг слияния пропущен (`stage != 'code'` или вердикт не зелёный).
- `nightly.yml`: находит именно свой прогон по `workflow_dispatch` + `branch dev` + `createdAt >= since`, ждёт его, наследует код возврата.
- Живое поведение на этом SHA (реальный CI-прогон) подтверждает, что механизм реюза и диффового отбора мутантов работает не только в тестах, но и в проде: backend легитимно переиспользован, три шарда мутантов по диффу зелёные.
## Чего не проверял
- Golden/browser-смоки/perf-профили/`pytest tests_backend` — не требуются этим диффом (не трогает `src/**`, geometry, рендер); backend реально прогнан в CI ранее на этой же ветке (см. reuse-маркер) и переиспользован здесь корректно.
- Полное исполнение всех 8 новых мутантов «вживую» через `mutation-gate.mjs --id=<...>` — окружение ревью без backend-venv (`No module named pytest`); опирался на зелёные три шарда «Мутанты по диффу» в Validate этого SHA плюс ручную сверку patch↔find и guard↔имя теста.
- Реальное состязание двух параллельных пушей в `dev` во время работы `merge-candidate.mjs` (описано в находке Low) — оценено по коду и логике, не воспроизводилось.
- Поведение `scripts/coordinate-write-barrier-guard.mjs`/`review-doc-guard.mjs` как таковых (не предмет диффа — их защитная логика не менялась, только уточнено, что они вне действия §6.1 в этой задаче).
## Вердикт
Зелёный. AC1–AC10 доказаны тестами, которые я прочитал и (для JS-стороны) прогнал, плюс живым прогоном CI и ручной сверкой на реальном репозитории. Единственная находка — Low, безопасна по факту и осознанно не возвращается в работу.
---
<!-- material-anchors: сгенерировано конвейером (#414) -->
## Материал раунда
- Ветка: `issue/492-exact-candidate-and-input-manifest`, коммит `6cf6fc787eba` — ребейз его осиротит, и это нормально: ниже якоря, которые ребейз не меняет.
- Дерево материала: `9979182a713d6d2f5dd5cb0b9af8f9cf0b819b74`
```
git log --all --format='%H %T' | grep 9979182a713d
```
- ТЗ `docs/specs/492-exact-candidate-and-input-manifest.md`, блоб `71847c16c8c1b9377a808c25fb87007dbd9a81ed`
```
git log --all --find-object=71847c16c8c1b9377a808c25fb87007dbd9a81ed -- docs/specs/492-exact-candidate-and-input-manifest.md
```
- Вердикт конвейера: `green` · High 0
+223
View File
@@ -0,0 +1,223 @@
# SPEC-REVIEW-492-r1
Issue: #492 «CI: проверять точный кандидат интеграции и полный набор зависимостей selection/reuse»
Этап: ТЗ на ревью (PROCESS.md §2.4). Трек: полный (не `small`) — ТЗ живёт в
`docs/specs/492-exact-candidate-and-input-manifest.md`.
Заход: r1 · блокирующих циклов израсходовано 0 из 4.
## Скоуп
Предмет ревью — файл `docs/specs/492-exact-candidate-and-input-manifest.md`
(174 строки) и обновление `docs/specs/README.md`, оба из коммита `941c7cff`
на ветке `issue/492-exact-candidate-and-input-manifest`. Материал не менялся
между заходами (r1, предыдущих раундов не было — единственный комментарий в
issue — аналитика S2, не вердикт ревью).
Класс задачи по AGENTS.md: issue заявлен `infra`/`P1`/`tech-debt`, ни одного
файла класса A ни в ТЗ, ни в перечне «Затронутых файлов» (§12 ТЗ). По
механическому признаку AGENTS.md такая задача могла бы идти вне S-флоу вовсе;
автор явно называет это в комментарии S2 и ведёт задачу по меткам ради
ревью конвейером, ссылаясь на прецедент того же рода — #472, #475, #481
(все — gate-инфраструктура, все проверены: #472 и #481 закрыты через полный
S1…S8, #475 — `small`, полный S1…S8 тоже пройден, класс файлов везде B/C/D).
Прецедент подтверждён напрямую (`gh issue view`), это корректная и уже
устоявшаяся практика репозитория, а не самовольное расширение флоу. Первый
вопрос «какую строку Core user jobs закрывает задача» здесь неприменим по
предмету: `docs/SCOPE.md` описывает продуктовые обязательства перед тремя
персонами, а #492 правит собственный процесс проверки (`PROCESS.md`,
`.github/workflows/**`), у которого пользователя-персоны в смысле SCOPE.md
нет — сценарий и адресаты корректно названы в §1.1 ТЗ как «автор задачи,
ревьюер, обслуживающий чат, владелец, читающий статусы».
## Как проверялось
- Прочитаны целиком `docs/SCOPE.md`, `AGENTS.md`, `PROCESS.md` (весь файл,
включая §1–§10.4: классы файлов, лёгкий/полный трек, лимит циклов §4,
обязательные разделы ТЗ §7.1, шаблоны вердиктов §7.2, гейты §8).
- Прочитано тело issue #492 и единственный комментарий (аналитика владельца,
S2), где подтверждены источники аудита по SHA.
- **Каждое фактическое техническое утверждение §1 ТЗ проверено чтением кода на
цитируемом SHA `ea6061e9`, а не принято на слово:**
- п.1 (слияние): `.github/workflows/process.yml` на `ea6061e9` — шаг «Слить
ветку в dev» действительно делает `git rebase origin/dev`, затем сразу
`git push … HEAD:dev` без какой-либо проверки нового дерева между ребейзом
и push; шаг «dev ушёл вперёд» только комментирует, не блокирует (строки
~917–969 файла на этом SHA). Подтверждено.
- п.2 (реюз бэкенда): `scripts/gate-reuse.mjs` на `ea6061e9` —
`HARNESS.backend.roots` = `['tests_backend', 'custom_components',
'pytest.ini', 'scripts/backend-coverage-baseline.txt', 'pyproject.toml']`
— не включает `scripts/support-relay/**`, `scripts/sh3d-convert/**`,
`scripts/config-schema.json`, `scripts/dump-config-schema.py`; при этом
`reuseKey` подмешивает `sourceFingerprint(root)` (полный `src/**`) во
**все** job без исключения, включая `backend`. `scripts/classify-changes.mjs`
на том же SHA: регэксп `backend` содержит `scripts/support-relay/`, но не
`pyproject.toml` и не `scripts/sh3d-convert/`. Оба утверждения — и «relay
классифицируется как backend, но не входит в ключ реюза», и «pyproject/
sh3d-convert не классифицируются вовсе» — подтверждены буквально.
- п.4 (мутанты): `scripts/backend-test-guard.mjs` на `ea6061e9` — третий
аргумент (`testFile`) действительно опционален с дефолтом
`tests_backend/test_ha_import_export.py`; `grep` по `mutation-gate.mjs`
даёт 35 использований этого гарда, что согласуется с «10 из 35 без
третьего аргумента» (точное число 10 не пересчитывалось построчно —
порядок величины и факт существования дефолта подтверждены, этого
достаточно для проверки формулировки проблемы на этапе ТЗ).
- Ни одно техническое утверждение раздела «Проблема» не оказалось догадкой,
выданной за факт — все проверяемые пункты подтвердились чтением
процитированного SHA.
- Сверены оба направления связи issue ↔ ТЗ: issue #492 не содержит прямой
ссылки на файл ТЗ в теле (обычно это делает владелец/автор отдельной правкой
либо ссылка остаётся в `docs/specs/README.md` — она на месте), сам файл ТЗ
ссылается на issue первой строкой; `docs/specs/README.md` содержит строку
`#492` → `492-exact-candidate-and-input-manifest.md`. Связь двусторонняя по
факту (README + текст ТЗ), проверено чтением диффа коммита `941c7cff`.
- Проверено соответствие обязательных разделов §7.1 PROCESS.md: сценарий
(§1.1), что человек увидит до/после (§1.2), проблема (§1), скоуп/не-скоуп
(§2/§3), контракт поведения (§4–§8), UX/модель данных/i18n (§10.1, явное
«не затрагиваются» — корректно для infra), критерии приёмки (§10, AC1–AC10),
риски (§10.2), откат (§9), release-артефакты (§11) — все присутствуют.
Отдельного раздела «план автотестов» под таким заголовком нет, но по
содержанию план распределён по §8 (пять групп отрицательных тестов с
конкретными представителями и файлами) и §12 (перечень новых/правимых
`test/*.test.mjs`) — это не пропуск, а другая раскладка того же
содержания; не самостоятельная находка.
- Проверена внутренняя согласуемость: таблица категорий манифеста (§5.1)
сверена построчно со списком представителей §8.1 — каждая категория
(`source`/`tests`/`fixtures`/`config`/`toolchain`/`protocol`) имеет хотя бы
один представитель в негативных тестах, кроме `tests` (уже покрыта
существующим корнем `tests_backend` в текущем `HARNESS`, поэтому не входит
в список **новых** гарантий). Список из «шести мутантов на протокол» в §13
и §8.5 совпадает поимённо (6 пунктов).
- По каждому AC1–AC10 отдельно проверено, к какому пункту §8 (или §7) он
привязан как доказательство — см. находку ниже, единственный разрыв на
AC6.
- Дешёвые гейты не прогонялись отдельно: Validate на `941c7cff` зелёный
(ссылка дана в постановке задачи), а класс изменений — только `docs/**`
(класса C), код не менялся. `typecheck`/`test`/`build`/`check-docs`
нерелевантны этапу ТЗ и этому диффу.
## Находки
### Medium (в скоупе задачи) — AC6 не называет способ доказательства
**Файл:** `docs/specs/492-exact-candidate-and-input-manifest.md`, AC6 (раздел
10) и §5.3.
AC6 формулирует защитное утверждение: «`backend` не зависит от `src/**` —
включено последним коммитом после AC4–AC5». Это ровно тот тип критерия,
для которого PROCESS.md §2.5 требует явно назвать способ доказательства
(`unit`/`backend`/`smoke`/`golden`/«ревью кода»), а последующее код-ревью
(§2.7) потребует таблицу «AC · чем доказан · чем краснеет» — но в
доступном виде оно уже должно быть заложено на этапе ТЗ, как это сделано
для AC1–AC5, AC7–AC9 через §8.1–§8.5.
Для AC1–AC5 и AC7–AC10 каждое утверждение имеет прямую привязку к
конкретному пункту §8 (представитель, отрицательный тест или таблица
случаев). Для AC6 такой привязки нет: §8.1 перечисляет представителей всех
шести проверок (`frontend` через `src/**` там не фигурирует ни разу как
объект отрицательного теста), а §5.3 лишь описывает механизм («ключ = хеш
`inputsOf(job)`, `sourceFingerprint` уходит») без утверждения, что где-то
проверяется обратное: что правка файла из `src/**` **не** меняет ключ
`backend` после финального коммита.
**Сценарий отказа:** реализация выполняет AC1–AC5, AC7–AC10 с тестами, но
финальный коммит AC6 сводится к удалению строки `source:
sourceFingerprint(root)` из вычисления ключа `backend` без теста. Регресс
(кто-то по невнимательности вернёт эту строку в будущей правке
`check-inputs.mjs`, или `inputsOf('backend')` по ошибке продолжит включать
`src/**` через общий `source`-манифест) не поймает ни один автотест —
ровно то поведение, которое всё ТЗ ставит целью исключить («Зелёный вердикт
обязан означать, что именно этот код проверен»), окажется непроверенным
для собственного шестого критерия.
**Почему это Medium, а не High:** AC6 не блокирует остальные девять
критериев и не делает ТЗ невыполнимым — пробел локален и дёшево чинится:
одна строка в §8.1 (представитель — файл `src/**`, например
`src/houseplan-card.ts`; ожидание — ключ `backend` **не** меняется, ключ
`frontend`/`smoke` меняется) плюс одноимённый пункт в перечне тестов §12
(`test/gate-reuse.test.mjs`). Находка в скоупе задачи (сам AC6 уже в §10) —
чинится в этом же ТЗ, отдельный issue не заводится.
## Что проверено и корректно
- Все пять пунктов раздела «Проблема» (§1) фактически подтверждены чтением
кода на процитированном SHA `ea6061e9` — ни одно не оказалось домыслом
(детали — выше, «Как проверялось»).
- Скоуп/не-скоуп (§2/§3) чётко разграничивают протокол проверки от
содержания самих проверок и от продуктового кода; при обнаружении
продуктового дефекта явно предписан отдельный issue — соответствует
правилу «Medium вне скоупа = новый issue» на будущее код-ревью.
- §4 (точный кандидат) корректно устраняет реальный, только что
подтверждённый чтением дефект — push без проверки между ребейзом и push;
алгоритм п.1–7 покрывает все ветвления (dev не двигался / двигался с
равным patch-id / двигался с другим patch-id / Validate красный / lease
отклонён / попытки исчерпаны), и каждое ветвление имеет счётчик выхода в
статус (`S6`/`S7`/`S8`) без зависания.
- §6 (замыкание входов гарда) адресует ровно найденный технический дефект
(10 из 35 гардов без третьего аргумента, `trail-resume-test-guard.mjs`
скрывает пути тестов) явно объявляемым `GUARD_INPUTS` и статическим
замыканием импортов — реализуемо без исполнения кода на этапе отбора.
Точечность выбора по стороне патча внутри `src/**` явно вынесена в
не-скоуп (§3, §6.4) — не оставлена недосказанной.
- §7 (ночной прогон) — минимальная точечная правка, устраняющая
единственный названный дефект (успех в момент постановки в очередь).
- §9 (откат) корректен для infra-задачи без продуктового кода: revert
коммитов, отдельно назван риск устаревания старых маркеров реюза (один
лишний полный прогон, не сбой).
- Раздел «Принятые предположения» (§13) содержит только технические решения
(эквивалентность Validate на ветке проверке дерева; PAT остаётся PAT;
порядок реализации) и явно помечен как «принято предположительно,
поменять свободно» — соответствует PROCESS.md §7.1: продуктовых вопросов
к владельцу нет и не должно быть, поскольку задача не имеет
пользовательской поверхности в смысле `docs/SCOPE.md`.
- AC1–AC5, AC7–AC10 однозначны и проверяемы, каждый имеет прямую привязку к
конкретному отрицательному тесту или таблице случаев в §8.
- Формат ТЗ и связь issue ↔ ТЗ ↔ README соблюдены; трек (полный, не
`small`) выбран верно — задача касается четырёх поверхностей протокола
CI одновременно, что явно нарушает критерий «одна поверхность» лёгкого
трека (сам ТЗ называет это прямо: «Трек: полный … без файлов класса A»).
## Чего не проверял
- Реализацию по существу — на этом этапе её нет, в diff'е только
`docs/specs/**` и `docs/specs/README.md` (оба класса C). Код-ревью
впереди.
- Точный подсчёт «10 из 35» гардов без третьего аргумента (§1 п.4) — вручную
не пересчитывал каждое из 35 вхождений `backend-test-guard.mjs` в
`mutation-gate.mjs`; подтверждён факт существования дефолтного значения и
порядок величины через `grep -c`, этого достаточно для проверки
формулировки проблемы, но не является независимым пересчётом точного
числа.
- Достижимость таймингов §10.2 (5–8 мин ожидания Validate внутри ревью-job,
лимит 45 мин, «десятки минут в 3 шардах» для расширенного отбора
мутантов) — это оценки автора на будущее, эксплуатационная проверка
относится к пост-мержу/код-ревью, не к ТЗ.
- Тяжёлые гейты (`golden`, `smoke`, `performance_smoke`, backend pytest) —
не прогонялись: diff класса C, изменений в `src/**`/`custom_components/**`
нет, гейты нерелевантны этапу и типу изменений.
- Прецеденты #472/#475/#481 проверены только по метаданным issue
(`gh issue view`: заголовок, метки, состояние) и списку файлов
`docs/reviews/` — содержимое их спек-документов построчно не сверялось с
#492, кроме одного (`SPEC-REVIEW-481-r1.md`), прочитанного как образец
формата и калибровки серьёзности находок для infra-специфики.
## Вердикт
Жёлтый. High: 0, Medium: 1 (в скоупе задачи — чинится в этом же ТЗ, отдельный
issue не заводится). Пробел дешёвый: одна строка в §8.1 плюс один тестовый
файл в §12.
---
<!-- material-anchors: сгенерировано конвейером (#414) -->
## Материал раунда
- Ветка: `issue/492-exact-candidate-and-input-manifest`, коммит `941c7cff7a1b` — ребейз его осиротит, и это нормально: ниже якоря, которые ребейз не меняет.
- Дерево материала: `b9b9b1a2171c2cafd5f24cf83b1d6c5baa2fbb6b`
```
git log --all --format='%H %T' | grep b9b9b1a2171c
```
- ТЗ `docs/specs/492-exact-candidate-and-input-manifest.md`, блоб `2fcaeb946e984a30e0188a15ebed904eb3d31b7d`
```
git log --all --find-object=2fcaeb946e984a30e0188a15ebed904eb3d31b7d -- docs/specs/492-exact-candidate-and-input-manifest.md
```
+158
View File
@@ -0,0 +1,158 @@
# SPEC-REVIEW-492-r2
Issue: #492 «CI: проверять точный кандидат интеграции и полный набор зависимостей selection/reuse»
Этап: ТЗ на ревью (PROCESS.md §2.4). Трек: полный — ТЗ в
`docs/specs/492-exact-candidate-and-input-manifest.md`.
Заход: r2 · блокирующих циклов израсходовано 1 из 4.
## Скоуп
Предыдущий раунд (r1) прошёл на коммите `941c7cff` (материал ревью, ветка
`issue/492-exact-candidate-and-input-manifest`) и получил жёлтый вердикт с
одной находкой Medium в скоупе задачи: AC6 не назвал способ доказательства
(PROCESS.md §2.5). SHA базы r1 в самом тексте вердикта на issue не назван —
там процитирован только `ea6061e9` (SHA аудита, источник «Проблемы» ТЗ), а
не SHA проверенного дерева ТЗ; SHA ревью восстановлен из раздела «Материал
раунда» документа `SPEC-REVIEW-492-r1.md` (коммит `941c7cff`, дерево
`b9b9b1a2`), который для этого и существует (#414). Отмечаю как процесс-
наблюдение, не как находку по существу этого раунда — механизм привязки
сработал, вердикт этого раунда явно называет свой SHA (`8c1b1728`).
Правка после r1 — один коммит `8c1b1728` («docs: AC6 gets its negative
probe»), диф: 2 строки в одном файле,
`docs/specs/492-exact-candidate-and-input-manifest.md` (§8.1 и AC6 в §10).
Ни ребейза на ушедший вперёд `dev`, ни смены контракта поведения, ни новой
подсистемы — делта локальна и целиком укладывается в единственную находку
r1. Разбор сужен до этой делты; всё остальное наследуется из r1 (раздел
ниже).
## Как проверялось
- `git diff 941c7cff..8c1b1728 -- docs/specs/492-exact-candidate-and-input-manifest.md`
— единственное изменение: в §8.1 к списку представителей добавлено
предложение «**Обратная проба (AC6):** представитель
`src/houseplan-card.ts` — ключ `backend` **не** меняется и
`checksAffectedBy` **не** содержит `backend`; тест в
`test/gate-reuse.test.mjs` и `test/classify-changes.test.mjs`…»; в §10
формулировка AC6 дополнена придаточным «доказано обратной пробой §8.1
(правка `src/houseplan-card.ts` не меняет ключ `backend` и не
классифицируется как `backend`)».
- Проверено закрытие находки r1 по существу, а не по факту правки строки:
требование PROCESS.md §2.5 — назвать способ доказательства для
защитного/отрицательного утверждения. Теперь AC6 называет: (а) конкретный
файл-представитель (`src/houseplan-card.ts`, реально лежит в `src/**`,
подходящий представитель категории `source`, которую AC6 и отрицает для
`backend`), (б) ожидаемый результат в двух измерениях — не меняется ключ
реюза И не входит в `checksAffectedBy`, (в) конкретные тестовые файлы.
Это устраняет ровно сценарий отказа, названный в r1: «финальный коммит
AC6 сводится к удалению строки без теста — регресс не поймает ни один
автотест».
- Проверена согласованность добавленного текста с остальным ТЗ:
`test/gate-reuse.test.mjs` и `test/classify-changes.test.mjs` уже
числятся в §12 «Затронутые файлы» как существующие (не новые) файлы —
добавление обратной пробы не требует правки §12, расхождения нет. Оба
файла существуют в дереве (`test/gate-reuse.test.mjs`,
`test/classify-changes.test.mjs`), значит тест добавляется в реальные
файлы, а не в несуществующий путь.
- Перепроверена только формулировка AC6 в §10 — остальные AC1–AC5, AC7–AC10
делтой не задеты, повторно не разбирались (см. «Унаследовано из r1»).
- Дешёвые гейты — зелёного Validate на `8c1b1728` нет, прогнал сам:
`npx tsc --noEmit` — чисто, без ошибок; `npm test` — 2275 тестов, 2274
pass, 1 skipped (уже пропущенный ранее, не новый), 0 fail; `npm run
build` — успешно (`tsc --noEmit && rollup -c`, `dist` собран за 18.4с).
`node scripts/check-docs.mjs` не прогонял — диф не трогает `src/**`
(только `docs/specs/**`), условие прогона явно не выполнено.
`git status` после build — рабочее дерево чистое (`dist` в `.gitignore`),
побочных изменений нет.
- Тяжёлые/специфичные гейты (`golden`, смоки, backend pytest, инварианты
модели, performance) не прогонялись — диф не трогает `src/**`,
`custom_components/**`, геометрию/`layout`/толщину/`open_spans`, они
нерелевантны и на r1 были нерелевантны по той же причине.
## Находки
Нет. Делта закрывает единственную находку r1 полностью, новых находок в
изменённых 2 строках не обнаружено.
## Закрытие раунда r1
| Находка r1 | Чем закрыта | Где это видно |
|---|---|---|
| Medium (в скоупе): AC6 не называет способ доказательства — защитное утверждение «`backend` не зависит от `src/**`» не привязано ни к одному пункту §8, регресс (случайный возврат `sourceFingerprint` в ключ `backend`) не поймает ни один автотест | В §8.1 добавлена «Обратная проба (AC6)»: представитель `src/houseplan-card.ts`, двойное ожидание (ключ `backend` не меняется + `checksAffectedBy` не содержит `backend`), названы тестовые файлы `test/gate-reuse.test.mjs` и `test/classify-changes.test.mjs`; формулировка AC6 в §10 дополнена ссылкой на эту пробу | `docs/specs/492-exact-candidate-and-input-manifest.md`, коммит `8c1b1728`, строки §8.1 (список представителей) и §10 (пункт AC6); `git diff 941c7cff..8c1b1728` — единственный диф раунда |
## Унаследовано из r1
Без повторной проверки в этом раунде принято всё содержание
`docs/reviews/SPEC-REVIEW-492-r1.md` (документ ревью r1, база — коммит
`941c7cff`), кроме пункта AC6, разобранного заново выше:
- Скоуп и класс задачи: infra/P1/tech-debt, маршрутизация через S-метки
без файлов класса A — прецедент #472/#475/#481 подтверждён в r1 прямым
запросом к issue-метаданным.
- Все пять утверждений раздела «Проблема» (§1 ТЗ) — проверены чтением кода
на `ea6061e9` в r1, ни одно не оказалось домыслом.
- Двусторонняя связь issue ↔ ТЗ ↔ `docs/specs/README.md`.
- Полнота обязательных разделов §7.1 PROCESS.md (сценарий, до/после,
проблема, скоуп/не-скоуп, контракт, UX/модель данных/i18n — явно «не
затрагиваются», критерии приёмки, риски, откат, release-артефакты).
- Внутренняя согласованность §5.1 (категории manifest) с §8.1 (представители)
и §13/§8.5 (список из шести мутантов протокола).
- AC1–AC5, AC7–AC10 — каждый однозначен и привязан к конкретному пункту §8
как способу доказательства; делта их не касалась.
- §4 (точный кандидат слияния), §6 (замыкание входов гарда), §7 (ночной
прогон), §9 (откат) — оценены в r1 как корректно устраняющие
подтверждённые дефекты, без пробелов.
- §13 «Принятые предположения» — только технические решения, помечены как
свободно изменяемые; продуктовых вопросов владельцу нет и не должно
быть (задача не имеет пользовательской поверхности в смысле
`docs/SCOPE.md`).
- Выбор полного трека (не `small`) — верен, четыре поверхности протокола
CI одновременно.
## Что проверено и корректно
- Единственная находка r1 закрыта по существу (не косметически): способ
доказательства AC6 теперь явный, конкретный и указывает на реальные
тестовые файлы, уже числящиеся в §12.
- Делта не вносит противоречий с остальным текстом ТЗ (§12, §5.1, другие
AC не затронуты и не разъехались с новой формулировкой).
- Дешёвые гейты на `8c1b1728` зелёные: typecheck, полный набор `npm test`
(2274/2275, 1 плановый skip), `npm run build`.
## Чего не проверял
- AC1–AC5, AC7–AC10 и разделы §1–§7, §9, §13 — не переразбирал, делта их
не касается; унаследовано из r1 (раздел выше).
- Реализацию по существу — на этапе ТЗ её нет, diff — только
`docs/specs/**` (класс C).
- Тяжёлые/специфичные гейты (`golden`, `smoke`, backend pytest, инварианты
модели, performance-профили) — diff не трогает `src/**`,
`custom_components/**` или геометрию/толщину/`layout`/`open_spans`,
нерелевантны этапу и типу изменений.
- `node scripts/check-docs.mjs` — diff не трогает `src/**`, условие
прогона не выполнено.
- Точный пересчёт «10 из 35» гардов (§1 п.4 ТЗ) — уже отмечено как не
пересчитанное построчно в r1, делта этого числа не касается.
## Вердикт
Зелёный. High: 0, Medium: 0. Единственная находка r1 закрыта делтой
`8c1b1728` по существу.
---
---
<!-- material-anchors: сгенерировано конвейером (#414) -->
## Материал раунда
- Ветка: `issue/492-exact-candidate-and-input-manifest`, коммит `8c1b1728fc10` — ребейз его осиротит, и это нормально: ниже якоря, которые ребейз не меняет.
- Дерево материала: `64ce76085c6ceee7e01d1a0d072f67490a542e9e`
```
git log --all --format='%H %T' | grep 64ce76085c6c
```
- ТЗ `docs/specs/492-exact-candidate-and-input-manifest.md`, блоб `4292bb38834882bb26425be67e995a06fed747e7`
```
git log --all --find-object=4292bb38834882bb26425be67e995a06fed747e7 -- docs/specs/492-exact-candidate-and-input-manifest.md
```
+186
View File
@@ -0,0 +1,186 @@
# #492 — Точный кандидат интеграции и полный manifest входов selection/reuse
- **Issue:** https://github.com/Matysh/houseplan-card/issues/492
- **Тип / приоритет:** infra, tech-debt / P1
- **Трек:** полный — четыре поверхности протокола CI (слияние, реюз, классификация, отбор мутантов), без файлов класса A
- **Оценка:** ценность для разработки 9/10; сложность 6/10; риск 4/10 (протокол, который сам себя проверяет, — ошибка в нём делает зелёный ничего не значащим)
- **Связано:** аудит 2026-09-08 §11 п.3 (I1/I2); #208 (реюз), #312 (материал ревью), #364 (dev ушёл), #430 (оснастка смоков), #475 (отбор по гарду), #479 (тяжёлые гейты), #481 (журнал), `PROCESS.md` §7.2, §8, §10.3, §11.4
## 1. Проблема
Зелёный вердикт и зелёный Validate обязаны означать одно: *именно этот код* проверен *всеми проверками, чьи входы менялись*. Сейчас это верно не всегда — четыре механизма, подтверждённые чтением `ea6061e9`.
**1. Сливается не то дерево, что проверено.** `process.yml` фиксирует материал ревью (`steps.material`), после зелёного вердикта сверяет вершину ветки с материалом (#312), затем делает `git rebase origin/dev` и сразу `push HEAD:dev`. Если `dev` продвинулся за время ревью (28 августа — четыре раза за день), результат ребейза — новое дерево, которое никто не проверял: ни ревью (материал другой), ни Validate (на этот SHA он не бежал). Предупреждение #364 пишет комментарий и не блокирует. Минимальный git-эксперимент аудита: чистый ребейз, вердикт принят, поведение `20 → 40`.
**2. Реюз бэкенда не видит часть своих входов.** Job `backend` исполняет: pytest `tests_backend/`, `python -m unittest discover -s scripts/support-relay/tests`, ruff/mypy по `pyproject.toml`, порог покрытия. Тесты читают `scripts/sh3d-convert/golden/*.json`, `scripts/sh3d-convert/convert.mjs` (константы версий), `scripts/dump-config-schema.py`, `scripts/config-schema.json`. Ключ реюза (`HARNESS.backend` в `gate-reuse.mjs`) знает `tests_backend`, `custom_components/**/*.py`, `pytest.ini`, baseline и `pyproject.toml` — и **не знает** relay, converter, schema. Прямой случай: правка только `scripts/support-relay/relay.py` даёт `backend=true` в `changes` (регэксп классификатора), но ключ реюза не меняется — job отменяется как переиспользованная, и тесты relay не бегут. Классификатор `backend` при этом не видит `pyproject.toml` и `scripts/sh3d-convert/`: правка только их не запускает job вовсе.
**3. Golden и перф не видят протокол харнеса.** `demo/golden/run.mjs` импортирует `../serve.mjs` и `../bundle-freshness.mjs`; бенчмарки — `serve.mjs`, `editor-runtime-compat.mjs`, `bundle-freshness.mjs`; `serve.mjs` отдаёт страницу `demo/srv/demo.html`. Ключи `golden` и `performance_smoke` держат только `demo/golden/**` и `demo/performance/** + два benchmark`; `serve.mjs` есть лишь у `smoke` (#430), `demo/srv/demo.html` и compat-хелперы — ни у кого. Обратный перекос: `sourceFingerprint` (`src/**`) подмешан во **все** ключи, и любая правка UI сбрасывает реюз бэкенда, который UI не исполняет.
**4. Отбор мутантов теряет обёртки и новые определения.** `guardFiles()` берёт из строки гарда только токены-пути. 10 из 35 гардов `scripts/backend-test-guard.mjs` не передают третий аргумент и работают по умолчанию с `tests_backend/test_ha_import_export.py` — правка этого файла не отбирает их (#475 промахивается ровно так, как описано в аудите). `scripts/trail-resume-test-guard.mjs` держит пути тестов внутри себя — свидетели `vacuum-trail-*` не отбираются и не меняют отпечаток журнала (#481) при правке `test_trails.py`. Смоки импортируют `serve.mjs` и фикстуры — их изменение тоже не отбирает. При диффе только по `scripts/mutation-gate.mjs` job `changed_mutants` запускается (#475 r1), но `selectChangedMutants` пересекает дифф с файлами патчей и гардов — новый мутант, не трогающий их, не выбирается: «реестр изменился» не превращается в «новый свидетель прогнан».
**5. Ночной прогон рапортует об очереди, не о результате.** `nightly.yml` делает `gh workflow run validate.yml … -f full=true` и завершается успехом в момент постановки в очередь; красный полный прогон не делает ночной workflow красным.
## 1.1. Сценарий
Персоны: автор задачи, ревьюер, обслуживающий чат, владелец, читающий статусы. Все они читают «зелёный» как «проверено». Задача — сделать это чтение верным на четырёх швах, не удлиняя обычный путь: ветка, не пересекающаяся с движением `dev`, и правка, не трогающая входов job, проходят так же быстро, как сейчас.
## 1.2. Что человек увидит до и после
До: вердикт зелёный → `S8-merged`, в `dev` — ребейзнутое дерево без прогона; правка relay зелёная за секунды, потому что тесты relay не бежали; новый мутант в реестре зелёный, потому что не выбран.
После: если `dev` ушёл — конвейер публикует точный кандидат в ветку, ждёт Validate на этом SHA и только потом двигает `dev` с `--force-with-lease` на ожидаемую базу; в issue — строка «кандидат `abc1234` (ребейз на `dev@def5678`), Validate зелёный, слито». Правка relay/converter/schema/serve.mjs/demo.html запускает и не переиспользует те job, которые их исполняют. Новый или изменённый мутант бежит на первом же пуше. Ночной workflow красный, когда красный Validate.
## 2. Скоуп
1. **Слияние точного кандидата** (§4): проверка нового дерева перед push в `dev`, безопасная обработка повторного движения `dev`, равенство patch-id проверенного и сливаемого диффа.
2. **Единый manifest входов** (§5): один модуль объявляет входы каждой проверки по категориям source / tests / fixtures / config / toolchain / protocol; из него выводятся и классификация (`changes`), и ключ реюза; неизвестный исполняемый вход расширяет проверки; лишняя зависимость бэкенда от `src/**` снимается после доказанной полноты.
3. **Замыкание входов гарда мутанта** (§6): обёртки объявляют свои входы, импорты и фикстуры гардов входят в отбор и отпечаток; изменённые/новые определения реестра отбираются явно.
4. **Ночной прогон ждёт результат** (§7).
5. **Отрицательные тесты протокола** (§8): по каждому представительному входу доказано отсутствие false-green.
## 3. Не-скоуп
- Продуктовый код (`src/**`, `custom_components/**/*.py`) — если замыкание найдёт дефект продукта, он заводится отдельным issue.
- Перечень и содержание самих проверок Validate (какие job существуют, их бюджеты).
- Замена ревью-конвейера или изменение правил §7.2 о повторном ревью: при чистом ребейзе с равным patch-id вердикт остаётся в силе; при изменившемся диффе — повторное ревью по существующим правилам.
- Полный ночной набор (#479) остаётся полным; реюз на кандидатах по-прежнему невозможен (версия входит в manifest source).
- Отбор мутантов по транзитивным зависимостям **внутри `src/**`** — сторона патча остаётся точечной (§6.4).
## 4. Слияние точного кандидата
### 4.1. Решение выносится в скрипт
Логика шага «Слить ветку в dev» переезжает из shell в `scripts/merge-candidate.mjs` с чистой функцией `decideMerge(state)` и исполняющей обёрткой; `process.yml` вызывает скрипт. Состояние: `material` (SHA материала), `materialBase` (merge-base материала с `dev` на момент фиксации), `actual` (вершина ветки), `devNow`, `candidate` (результат ребейза `actual` на `devNow`), patch-id диффов `materialBase..material` и `devNow..candidate`, результат Validate на `candidate`, номер попытки.
### 4.2. Алгоритм
1. Проверка #312 как сейчас: `actual` = `material` либо `material` + один коммит документа ревью.
2. `devNow == materialBase` → `dev` не двигался, `candidate == actual` → push `HEAD:dev` c `--force-with-lease=refs/heads/dev:$devNow` (fast-forward). Как сейчас, плюс lease.
3. `dev` двигался → ребейз (конфликт — как сейчас, `S6-in-progress`). Сравнение patch-id: если дифф ветки после ребейза отличается от проверенного (контекст/содержимое патча изменились из-за соседних правок), кандидат публикуется в ветку, задача возвращается в `S7-code-review` с комментарием «дифф изменился при ребейзе, нужен новый заход ревью (§7.2)» — без слияния.
4. Patch-id равен → кандидат публикуется в ветку задачи (`--force-with-lease=refs/heads/$BRANCH:$actual`); пуш PAT-ом запускает Validate на `candidate`. Скрипт находит прогон по SHA (`gh run list --workflow validate.yml --commit`) и ждёт завершения (`gh run watch --exit-status`, лимит 45 мин; отсутствие прогона в течение 3 мин — ошибка, не «зелёный»).
5. Validate красный → `S6-in-progress`, комментарий с ссылкой на прогон: «кандидат после ребейза на `dev@…` красный».
6. Validate зелёный → `git push --force-with-lease=refs/heads/dev:$devNow candidate:dev`. Lease отклонён (`dev` двинулся снова) → `fetch`, новая попытка с п.3; не более **3** попыток, после — `S6-in-progress` с комментарием «dev движется быстрее слияния, повторить».
7. `S8-merged` — только после успешного push (инвариант «метка не врёт» сохраняется).
Что считается проверкой нового дерева: Validate на ветке — лёгкий набор плюс диффозависимые гейты (`changed_mutants` по `dev..candidate`, перф-профили по диффу). Тяжёлые гейты по #479 остаются за кандидатом релиза; это сознательно: слияние не превращает каждый ребейз в 20-минутный прогон, а проверяет ровно то, что проверяет обычный пуш той же дельты.
### 4.3. Что видно в issue
Один комментарий на слияние: `материал abc1234 · dev@def5678 → кандидат 0123abc · Validate <ссылка> зелёный · слито`. Комментарий #364 («dev продвинулся») остаётся — теперь он предваряет проверку, а не констатирует риск.
## 5. Единый manifest входов
### 5.1. Модуль
`scripts/check-inputs.mjs` экспортирует `CHECKS` — по одной записи на проверку Validate: `frontend`, `backend`, `changed_mutants`, `integration` (hacs + hassfest), `smoke`, `golden`, `performance_smoke`, `docs`. Каждая запись — категории входов, каждая категория — список путей/предикатов:
| категория | что это | примеры |
|---|---|---|
| `source` | код, который проверка исполняет или собирает | `src/**` для frontend/smoke/golden/perf; `custom_components/**/*.py` для backend/integration; `scripts/sh3d-convert/*.mjs`, `scripts/support-relay/**/*.py` для backend |
| `tests` | сами тесты и их обёртки | `test/**`, `tests_backend/**`, `scripts/support-relay/tests/**`, `scripts/*-guard.mjs` |
| `fixtures` | данные, которые тесты читают | `demo/fixtures/**`, `demo/golden/**` (сценарии и эталоны), `scripts/sh3d-convert/golden/**`, `scripts/config-schema.json` |
| `config` | конфиги инструментов | `pyproject.toml`, `pytest.ini`, `tsconfig*.json`, `rollup.config.mjs`, `scripts/backend-coverage-baseline.txt`, `demo/performance/budgets-*.json` |
| `toolchain` | что определяет среду и версии | `package.json`, `package-lock.json`, `tests_backend/requirements.txt`, сам `.github/workflows/validate.yml` (секция job — см. 5.4) |
| `protocol` | харнес и протокол job | `demo/serve.mjs`, `demo/srv/**` (кроме `assets/` класса D), `demo/bundle-freshness.mjs`, `demo/*-runtime-compat.mjs`, `demo/guard/**`, `scripts/gate-reuse.mjs`, `scripts/classify-changes.mjs`, `scripts/check-inputs.mjs`, `scripts/mutation-gate.mjs` для `changed_mutants` |
Функции: `inputsOf(check) → string[]` (развёрнутый по `git ls-files` список), `checksAffectedBy(files) → Set<check>`, `coverage(files) → { covered, unknown }`.
### 5.2. Классификация из manifest
`classify-changes.mjs` перестаёт держать свои регэкспы: выход `X = 'true'` ⇔ `checksAffectedBy(diff)` содержит X. Подклассификаторы `perf_iso` / `perf_interaction` остаются как есть (фильтры по `source` перф-смока). **Неизвестный исполняемый вход** — изменённый файл, который `coverage` не относит ни к одной проверке и который по расширению/пути исполняем (`*.mjs|*.js|*.ts|*.py|*.json|*.yml|*.yaml|*.html|*.toml|*.txt` под `scripts/`, `demo/`, `test/`, `tests_backend/`, `.github/`, `custom_components/`, `src/`) — включает **все** выходы (`classifyAll`) и пишет в summary «неизвестный вход: <файл> — расширен до полного набора». Документация (`docs/**`, `*.md` вне `docs`) и class-D копии бандла — известные *не*-входы, они не расширяют.
### 5.3. Ключ реюза из manifest
`gate-reuse.mjs`: ключ job = `job:` + хеш содержимого файлов `inputsOf(job)` (канонизация переводов строк как сейчас). `HARNESS` и подмешивание `sourceFingerprint` уходят; `sourceFingerprint` остаётся у бандла и скриншотов (#245) — там его место. Следствие: `backend` перестаёт зависеть от `src/**`, но это включается **последним коммитом**, после того как §8.3 (полнота) зелёный, и с записью в CHANGELOG-у-инфры (`docs/TESTING.md`). Маркер падения (#386) и маркеры успеха не меняют формат — меняется только способ вычисления ключа, старые маркеры просто не совпадут один раз.
### 5.4. Секция job в toolchain
Правка `validate.yml` меняет протокол только той job, чью секцию правят. `inputsOf` для категории `toolchain` включает `validate.yml` целиком — простое и честное решение: правка workflow гоняет всё. Отдельная «секция job» не вырезается (YAML-парсинг ради экономии одного прогона — лишняя сложность).
### 5.5. Полнота
`test/check-inputs.test.mjs` держит **лист покрытия**: каждый файл из `git ls-files`, исполняемый по критерию §5.2, обязан входить в manifest хотя бы одной проверки или в явный список `NOT_AN_INPUT` (с причиной: `demo/docs/**` — съёмка документации вне Validate, `scripts/release-*.mjs` — релизные, `scripts/*.md`). Новый скрипт в `scripts/` без записи в manifest — красный тест, не тихое расширение.
## 6. Замыкание входов гарда мутанта
### 6.1. Обёртки объявляют входы
Каждый `scripts/*-guard.mjs`, используемый в `guard:` реестра, экспортирует `export const GUARD_INPUTS = [...]` — файлы, которые он запускает или читает (для `backend-test-guard.mjs` — умолчание `tests_backend/test_ha_import_export.py` **плюс** третий аргумент, если передан). `guardInputs(guard)` = файлы-токены из строки (как `guardFiles`) ∪ `GUARD_INPUTS` каждой найденной обёртки (читаются статически регэкспом, без исполнения). Тест реестра: у каждой обёртки из гардов есть `GUARD_INPUTS`, и умолчание в коде совпадает с объявлением.
### 6.2. Импорты и фикстуры гарда
Для каждого файла гарда `.mjs` — транзитивное замыкание относительных `import`/`export … from` внутри `test/`, `demo/`, `scripts/` (не `src/`, не `node_modules`); для `.py` — `from tests_backend.x import`, `import tests_backend.x` и всегда `tests_backend/conftest.py`. Фикстуры — строковые литералы путей `demo/fixtures/…`, `demo/golden/…`, `scripts/sh3d-convert/golden/…`, `test/fixtures/…` в файлах замыкания. Замыкание кэшируется на прогон (реестр — 582 мутанта, сотни гардов; чтение файлов один раз).
### 6.3. Отбор и отпечаток
`selectChangedMutants` и `witnessFingerprint` используют `guardInputs` + замыкание §6.2. Отпечаток включает содержимое всех файлов замыкания — правка `test_trails.py` меняет отпечаток `vacuum-trail-*`, правка `serve.mjs` — всех смок-свидетелей.
### 6.4. Изменённые определения реестра
Если дифф содержит `scripts/mutation-gate.mjs`, отбор дополнительно берёт мутантов, чьё определение (`id`, `guard`, `patches`, `because`) добавлено или изменено относительно базы: реестр базы читается через `git show <base>:scripts/mutation-gate.mjs` во временный файл и импортируется как модуль (реестр — данные, побочных эффектов при импорте нет — это закреплено тестом). Удалённые мутанты — предупреждение в summary. Сторона патча остаётся точечной (не-скоуп): мутант, патчащий `a.ts`, не отбирается правкой `b.ts` — это зона полного прогона (ночь/кандидат), как и было.
## 7. Ночной прогон
`nightly.yml` после `gh workflow run` находит запущенный прогон (по `workflow_dispatch`, ветке `dev`, времени ≥ момента запуска; до 3 мин ожидания появления) и `gh run watch <id> --exit-status` с лимитом job 90 мин. Красный Validate → красный nightly; сообщение о падении в summary — ссылка на дочерний прогон.
## 8. Отрицательные тесты протокола
8.1. **Manifest**: для каждой проверки и каждой непустой категории — представительный реальный файл; тест подменяет его содержимое (через инъекцию `read` в функцию ключа) и утверждает: ключ реюза меняется, `checksAffectedBy` содержит проверку. Список представителей: `scripts/support-relay/relay.py`, `scripts/sh3d-convert/golden/*.json`, `scripts/config-schema.json`, `pyproject.toml`, `demo/serve.mjs`, `demo/srv/demo.html`, `demo/editor-runtime-compat.mjs`, `demo/golden/baselines/*.png`, `demo/performance/budgets-*.json`, `tests_backend/requirements.txt`, `.github/workflows/validate.yml`. **Обратная проба (AC6):** представитель `src/houseplan-card.ts` — ключ `backend` **не** меняется и `checksAffectedBy` **не** содержит `backend`; тест в `test/gate-reuse.test.mjs` и `test/classify-changes.test.mjs`, чтобы случайный возврат `sourceFingerprint` в ключ бэкенда был пойман, а не молча удлинял прогоны.
8.2. **Мутанты**: правка `tests_backend/test_ha_import_export.py` отбирает все 10 обёрток без третьего аргумента; правка `tests_backend/test_trails.py` отбирает `vacuum-trail-*` и меняет их отпечаток; правка `demo/serve.mjs` отбирает все смок-свидетели; дифф только по реестру с новым мутантом отбирает ровно его.
8.3. **Полнота**: лист покрытия §5.5 зелёный на текущем `git ls-files`.
8.4. **Слияние**: `decideMerge` — таблица случаев: `dev` не двигался → push; двигался, patch-id равен, Validate зелёный → push с lease; lease отклонён дважды → повтор, трижды → `S6`; patch-id отличается → `S7`; Validate красный → `S6`; прогон не найден → ошибка, не push. Плюс git-эксперимент аудита в temp-репозитории (`20 → 40`): скрипт требует Validate, не пушит.
8.5. **Мутанты на протокол** (реестр `mutation-gate.mjs`, каждый с отрицательным прогоном): `manifest-drops-protocol-category` (ключ без `protocol` → 8.1 падает), `classify-unknown-input-is-unaffected` (неизвестный вход не расширяет → 8.1 падает), `guard-inputs-ignore-wrapper-defaults` (→ 8.2 падает), `registry-diff-not-selected` (→ 8.2 падает), `merge-pushes-unvalidated-candidate` (→ 8.4 падает), `nightly-does-not-wait` — контрактный тест workflow (`test/nightly-workflow.test.mjs`) по образцу `validate-workflow.test.mjs`.
## 9. Совместимость и откат
Маркеры реюза старого формата ключа не совпадают — один лишний полный прогон после слияния, дальше как обычно. `process.yml` читается конвейером из ветки по умолчанию — **зеркало в `main` только после слияния в `dev`** (правило #454). Откат — revert коммитов инфры; продуктового кода нет.
## 10. Критерии приёмки
- AC1. При `dev`, продвинувшемся за время ревью, в `dev` попадает SHA, для которого есть зелёный Validate; без него слияния нет; `S8-merged` ставится только после push.
- AC2. Повторное движение `dev` между Validate и push не приводит к силовой перезаписи: lease, повтор, лимит 3, затем `S6-in-progress` с комментарием.
- AC3. Дифф с изменившимся patch-id после ребейза не сливается — задача в `S7-code-review`.
- AC4. Ключ реюза каждой job вычисляется из manifest; для каждого представителя §8.1 доказано изменение ключа и классификации.
- AC5. Неизвестный исполняемый вход расширяет прогон до полного набора и виден в summary; лист покрытия §5.5 зелёный.
- AC6. `backend` не зависит от `src/**` — включено последним коммитом после AC4–AC5; доказано обратной пробой §8.1 (правка `src/houseplan-card.ts` не меняет ключ `backend` и не классифицируется как `backend`).
- AC7. Обёртки гардов объявляют `GUARD_INPUTS`; отбор и отпечаток видят обёртки, импорты и фикстуры (§8.2).
- AC8. Новый/изменённый мутант при диффе только по реестру отбирается и бежит.
- AC9. `nightly.yml` красный при красном дочернем Validate.
- AC10. Шесть мутантов §8.5 пойманы штатным раннером; `docs/TESTING.md` и `PROCESS.md` §10.3 описывают точный кандидат и manifest.
## 10.1. UX, модель данных, i18n
Не затрагиваются.
## 10.2. Риски и меры
- Ожидание Validate внутри ревью-job удлиняет слияние на 5–8 мин **только** когда `dev` двигался; лимит ожидания 45 мин с возвратом в `S6`, не зависанием.
- Пуш кандидата в ветку PAT-ом запускает Validate — если PAT когда-нибудь заменят на `GITHUB_TOKEN`, прогон не стартует; шаг ждёт 3 мин и падает с явной ошибкой «прогон не появился», не с ложным зелёным.
- Замыкание импортов расширяет отбор мутантов: правка `serve.mjs` отберёт все смок-свидетели (десятки минут в 3 шардах). Это цена честности; журнал #481 не смягчает — отпечаток тоже меняется. Оценить фактическое время на первом прогоне; при необходимости — четвёртый шард отдельным issue.
- Лист покрытия начнёт краснеть на каждом новом скрипте — намеренно: запись в manifest занимает одну строку.
## 11. Release-артефакты
Не user-visible. `docs/TESTING.md` (реюз, manifest, отбор мутантов), `PROCESS.md` §10.3 (слияние точного кандидата), зеркало `process.yml`/`nightly.yml` в `main` после слияния.
## 12. Затронутые файлы
`scripts/check-inputs.mjs` (новый), `scripts/classify-changes.mjs`, `scripts/gate-reuse.mjs`, `scripts/mutation-gate.mjs` (guardInputs, замыкание, registry diff, шесть мутантов), `scripts/backend-test-guard.mjs`, `scripts/trail-resume-test-guard.mjs` (+ остальные обёртки — `GUARD_INPUTS`), `scripts/merge-candidate.mjs` (новый), `.github/workflows/process.yml`, `.github/workflows/nightly.yml`, `.github/workflows/validate.yml` (summary неизвестных входов), `test/check-inputs.test.mjs` (новый), `test/merge-candidate.test.mjs` (новый), `test/nightly-workflow.test.mjs` (новый), `test/gate-reuse.test.mjs`, `test/classify-changes.test.mjs`, `test/mutation-gate.test.mjs`, `docs/TESTING.md`, `PROCESS.md`.
## 12.1. Уточнение по реализации (S6)
Категории §5.1 в коде не перечисляются руками: `CHECKS` объявляет у проверки
корни (`roots`, что читается не из кода: `src/**` у сборки, `tests_backend/**` у
pytest, `demo/golden/**` у эталонов, `validate.yml` у всех) и точки входа
(`entries`), а source/tests/fixtures/config/protocol выводятся замыканием точек
входа по импортам (транзитивно) и строковым путям (как листья). Это даёт те же
представители §8.1, но без ручного списка, который отстаёт от кода. Реестр
мутантов — лист замыкания (его гарды называют сотни путей как данные, не как
зависимости); обёртки гардов объявляют `GUARD_INPUTS`, их собственные ссылки не
читаются, чтобы явный аргумент отменял умолчание.
## 13. Принятые предположения
- Validate на ветке (лёгкий набор + диффозависимые гейты) — достаточная «проверка нового дерева и затронутых взаимодействий» для слияния; тяжёлые гейты — за кандидатом релиза (#479).
- `HP_PROCESS_TOKEN` остаётся PAT (пуш им запускает workflow).
- Порядок реализации: §5 + §8.1/8.3 → §6 + §8.2 → §4 + §8.4 → §7 → AC6 последним. Один issue, одна ветка, одно ревью.
+1
View File
@@ -28,6 +28,7 @@ GitHub Issues и GitHub Projects (v2) остаются единственным
| Issue | ТЗ |
|---|---|
| [#492](https://github.com/Matysh/houseplan-card/issues/492) Точный кандидат интеграции и полный manifest входов selection/reuse | [492-exact-candidate-and-input-manifest.md](492-exact-candidate-and-input-manifest.md) |
| [#490](https://github.com/Matysh/houseplan-card/issues/490) Атомарный recovery и live-состояние сводной панели | [490-summary-recovery-live-state.md](490-summary-recovery-live-state.md) |
| [#486](https://github.com/Matysh/houseplan-card/issues/486) Панель House Plan в боковом меню HA | [486-house-plan-panel.md](486-house-plan-panel.md) |
| [#489](https://github.com/Matysh/houseplan-card/issues/489) Объявленный `data-hp`-контракт для UI и E2E | [489-data-hp-contract.md](489-data-hp-contract.md) |
+8 -1
View File
@@ -6,7 +6,14 @@ if (!pattern) {
console.error('usage: node scripts/backend-test-guard.mjs <pytest-k-pattern>');
process.exit(2);
}
const testFile = process.argv[3] || 'tests_backend/test_ha_import_export.py';
/**
* Входы обёртки для отбора мутантов (#492 §6.1): без третьего аргумента гард
* бежит по этому файлу, и правка файла обязана отбирать таких свидетелей.
* Явно переданный файл попадает в отбор из самой строки гарда.
*/
export const GUARD_INPUTS = ['tests_backend/test_ha_import_export.py'];
const testFile = process.argv[3] || GUARD_INPUTS[0];
const python = process.env.PYTHON || (process.platform === 'win32' ? 'python' : 'python3');
const result = spawnSync(python, [
+410
View File
@@ -0,0 +1,410 @@
// Единый manifest входов проверок Validate (#492 §5).
//
// Одна проверка (job) — один ответ на вопрос «от каких файлов зависит её
// результат». Раньше ответов было три и они расходились: регэкспы job
// `changes` (что запускать), `HARNESS` в gate-reuse (что хешировать) и молчание
// там, где файл не попал ни туда, ни туда — relay, converter, schema,
// `serve.mjs`, `demo.html`. Здесь ответ один и по возможности ВЫЧИСЛЯЕТСЯ,
// а не перечисляется: у проверки есть точки входа (тесты, скрипты job), и
// всё, что они импортируют или читают по пути, — её вход. Явные корни
// остаются для того, что читается не из кода: бандл собирается из `src/**`,
// pytest обходит `tests_backend/**`, unittest — `scripts/support-relay/tests`.
//
// Из manifest выводятся обе прежние вещи: классификация (`classify-changes`:
// job запускается, если дифф задел хотя бы один её вход) и ключ реюза
// (`gate-reuse`: хеш содержимого всех входов). Неизвестный исполняемый вход —
// файл, который ни одна проверка не считает своим, — расширяет прогон до
// полного набора (§5.2): «не знаю» не равно «не влияет».
//
// Лист покрытия (§5.5, test/check-inputs.test.mjs): каждый отслеживаемый
// исполняемый файл обязан входить в manifest хотя бы одной проверки либо в
// NOT_AN_INPUT с причиной. Новый скрипт без записи — красный тест, не тихое
// расширение прогонов навсегда.
import { execFileSync } from 'node:child_process';
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
import { dirname, join, posix, relative, resolve } from 'node:path';
/** Корни, внутри которых файл считается исполняемым входом (§5.2). */
export const EXECUTABLE_ROOTS = ['scripts', 'demo', 'test', 'tests_backend', '.github', 'custom_components', 'src'];
export const EXECUTABLE_EXT = /\.(mjs|cjs|js|ts|py|json|ya?ml|html|toml|txt|sh)$/;
/** Копии бандла и результаты сборки: класс D, входом не являются. */
export const BUILD_OUTPUT = [
'dist/**', 'custom_components/houseplan/frontend/**', 'demo/srv/assets/**',
];
/**
* Не входы Validate — с причиной. Каждая запись отвечает на вопрос «кто это
* исполняет и почему не Validate».
*/
export const NOT_AN_INPUT = [
['demo/stand/**', 'seed демо-стенда: ставится на стенде, Validate не исполняет'],
['demo/shot_*.mjs', 'ручные съёмки для документации и разбора, не гейты'],
['demo/capture_*.mjs', 'ручные съёмки эталонов, запускаются человеком'],
['demo/screencast_visual_continuity.mjs', 'ручной скринкаст'],
['demo/gen_icons.mjs', 'генератор иконок демо-страницы, запускается вручную; результат в demo/srv/assets (класс D)'],
['demo/downgrade_open_passage.mjs', 'ручной инструмент миграции фикстур'],
['scripts/dev/**', 'локальные утилиты разработчика'],
['scripts/install-hooks.mjs', 'установка git-хуков при npm ci'],
['scripts/pre-push-gate.mjs', 'локальный pre-push набор (HP_PREPUSH_GATE), в CI не исполняется'],
['scripts/golden-accept.mjs', 'приёмка эталонов человеком, после прогона (#344)'],
['scripts/golden-container.mjs', 'локальная съёмка в пиновом образе (#334), ручной запуск'],
['scripts/inventory.mjs', 'отчёт для аудита, не гейт'],
['scripts/benchmark-wall-segment-model.mjs', 'ручной бенчмарк (npm run benchmark:wall-model)'],
['scripts/wall-strip-containment.mjs', 'ручной гейт внешних бэкапов планов (docs/WALL-THICKNESS.md)'],
['scripts/sh3d-convert/cli.mjs', 'CLI конвертера для человека'],
['scripts/sh3d-convert/make-fixtures.mjs', 'генератор фикстур конвертера, ручной'],
['scripts/support-relay/deploy/**', 'деплой relay на стенд'],
['.github/workflows/*.yml', 'другие workflow: у каждого свой запуск; validate.yml — вход toolchain всех проверок, объявлен явно'],
['.github/ISSUE_TEMPLATE/**', 'шаблоны issue GitHub, не исполняются'],
['.githooks/**', 'локальные хуки'],
];
// ---------------------------------------------------------------------------
// glob → regexp: `**` — любой путь, `*` — сегмент без «/», остальное буквально.
export const globToRegExp = (glob) => {
let re = '';
for (let i = 0; i < glob.length; i += 1) {
const ch = glob[i];
if (ch === '*') {
if (glob[i + 1] === '*') {
re += '.*';
i += 1;
if (glob[i + 1] === '/') i += 1;
} else re += '[^/]*';
} else if ('.+?^${}()|[]\\'.includes(ch)) re += `\\${ch}`;
else re += ch;
}
return new RegExp(`^${re}$`);
};
const matchesAny = (file, globs) => globs.some((glob) => globToRegExp(glob).test(file));
/** Отслеживаемые файлы (git), либо обход дерева там, где git недоступен. */
export function trackedFiles(root) {
try {
const out = execFileSync('git', ['-C', root, 'ls-files', '-z'], { encoding: 'utf8' });
const files = out.split('\0').filter(Boolean);
if (files.length) return files.sort();
} catch { /* не git — обходим дерево */ }
const walk = (dir) => readdirSync(dir).sort().flatMap((name) => {
if (name === 'node_modules' || name === '.git') return [];
const path = join(dir, name);
return statSync(path).isDirectory() ? walk(path) : [relative(root, path).replaceAll('\\', '/')];
});
return walk(root);
}
// ---------------------------------------------------------------------------
// Ссылки из файла: импорты и строковые пути. Чтение, не исполнение.
const TOP = '(?:scripts|demo|docs|src|custom_components|tests_backend|test|\\.github)';
const PATH_LITERAL = new RegExp(`['"\`](${TOP}/[\\w./@-]+)['"\`]`, 'g');
const TOP_RE = new RegExp(`^${TOP}/`);
const ROOT_FILE_LITERAL = /['"`](package\.json|package-lock\.json|hacs\.json|PROCESS\.md|README\.md|README\.ru\.md|pyproject\.toml|pytest\.ini|rollup\.config\.mjs|tsconfig[\w.]*\.json)['"`]/g;
const JS_IMPORT = /(?:^|[^\w$])(?:import|export)\s*(?:[^'"`;]*?\s+from\s*)?['"](\.\.?\/[^'"]+)['"]/g;
const JS_DYNAMIC = /import\(\s*['"](\.\.?\/[^'"]+)['"]\s*\)/g;
const JS_REQUIRE = /require\(\s*['"](\.\.?\/[^'"]+)['"]\s*\)/g;
const PY_FROM = /^\s*from\s+([\w.]+)\s+import/gm;
const PY_IMPORT = /^\s*import\s+([\w.]+)/gm;
const PY_PATH_JOIN = /((?:"[\w.-]+"\s*\/\s*)+"[\w.-]+")/g;
const REL_EXEC_LITERAL = /['"]((?:\.\.?\/)*[\w.-]+(?:\/[\w.-]+)*\.(?:mjs|py))['"]/g;
const toPosix = (p) => p.replaceAll('\\', '/');
const BINARY = /\.(png|jpe?g|gif|webp|svg|ico|woff2?|ttf|pdf|zip)$/i;
/** `test-build/foo.js` — скомпилированный `src/foo.ts` (tsconfig.test.json). */
const mapTestBuild = (rel) => {
const m = /^test-build\/(.+)\.js$/.exec(rel);
return m ? `src/${m[1]}.ts` : rel;
};
function resolveJsSpecifier(fromFile, spec) {
const base = toPosix(posix.normalize(posix.join(posix.dirname(fromFile), spec)));
// с расширением — ровно этот файл; без — обычные кандидаты Node/TS
const candidates = /\.(mjs|cjs|js|ts|json)$/.test(base)
? [base] : [`${base}.mjs`, `${base}.js`, `${base}.ts`, `${base}/index.mjs`, `${base}/index.js`];
return candidates.map(mapTestBuild);
}
function pyModuleCandidates(fromFile, mod) {
const parts = mod.split('.');
const out = [];
const asPath = parts.join('/');
out.push(`${asPath}.py`, `${asPath}/__init__.py`);
// относительно каталога файла (support-relay: `from hp_relay import …`)
const dir = posix.dirname(fromFile);
out.push(`${dir}/${asPath}.py`, `${dir}/${asPath}/__init__.py`);
// пакет тестов relay лежит на уровень выше своих тестов
out.push(`${posix.dirname(dir)}/${asPath}.py`, `${posix.dirname(dir)}/${asPath}/__init__.py`);
return out;
}
const EXEC_LITERAL = new RegExp(`(?:node|python3?|tsx)\\s+((?:scripts|demo|tests_backend)/[\\w./-]+\\.(?:mjs|py))`, 'g');
/**
* Ссылки одного файла: `code` — импорты и запускаемые процессом файлы (по ним
* идёт обход), `data` — строковые пути к файлам и каталогам (листья).
*/
/** Комментарии и docstring'и — не ссылки: путь в пояснении не делает файл входом. */
export function stripComments(file, text) {
if (/\.(mjs|cjs|js|ts)$/.test(file)) {
return text.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:'"`\\])\/\/[^\n]*/g, '$1');
}
if (file.endsWith('.py')) {
return text.replace(/\"\"\"[\s\S]*?\"\"\"/g, '').replace(/'''[\s\S]*?'''/g, '').replace(/(^|\s)#[^\n]*/g, '$1');
}
return text;
}
export function referencesOf(file, rawText) {
const text = stripComments(file, rawText);
const code = new Set();
const data = new Set();
const norm = (p) => toPosix(posix.normalize(p));
if (/\.(mjs|cjs|js|ts)$/.test(file)) {
for (const re of [JS_IMPORT, JS_DYNAMIC, JS_REQUIRE]) {
for (const m of text.matchAll(re)) resolveJsSpecifier(file, m[1]).forEach((p) => code.add(norm(p)));
}
}
if (file.endsWith('.py')) {
// pytest подхватывает conftest.py каталога без импорта — это код теста
if (/^tests_backend\/test_[\w-]+\.py$/.test(file)) code.add('tests_backend/conftest.py');
for (const m of text.matchAll(PY_FROM)) pyModuleCandidates(file, m[1]).forEach((p) => code.add(norm(p)));
for (const m of text.matchAll(PY_IMPORT)) pyModuleCandidates(file, m[1]).forEach((p) => code.add(norm(p)));
for (const m of text.matchAll(PY_PATH_JOIN)) {
data.add(norm(m[1].split('/').map((s) => s.trim().replace(/^"|"$/g, '')).join('/')));
}
}
// Файл под исполняемым корнем — код только там, где он запускается
// процессом (строка со spawn/exec/sh(/run(), а не в подсказке человеку.
for (const line of text.split('\n')) {
if (!/\b(spawn|spawnSync|exec|execSync|execFileSync|sh|run)\s*\(/.test(line)) continue;
for (const m of line.matchAll(EXEC_LITERAL)) code.add(norm(m[1]));
for (const m of line.matchAll(PATH_LITERAL)) if (/\.(mjs|py)$/.test(m[1])) code.add(norm(m[1]));
}
for (const m of text.matchAll(PATH_LITERAL)) data.add(norm(m[1].replace(/[.:,;]+$/, '')));
for (const m of text.matchAll(ROOT_FILE_LITERAL)) data.add(m[1]);
// Относительный путь к исполняемому файлу рядом (`'guard_x.mjs'`,
// `'../benchmark_x.mjs'` у verify-guard) — код, если такой файл есть в дереве.
if (/\.(mjs|cjs|js|ts)$/.test(file)) {
for (const m of text.matchAll(REL_EXEC_LITERAL)) {
if (TOP_RE.test(m[1])) continue; // путь от корня уже разобран выше
const rel = norm(posix.join(posix.dirname(file), m[1]));
if (!rel.startsWith('..')) code.add(rel);
}
}
for (const c of code) data.delete(c);
return { code: [...code], data: [...data] };
}
/**
* Транзитивное замыкание от точек входа по ссылкам внутри репозитория.
*
* Правила обхода (§6.2): импорты кода идут транзитивно; строковый путь —
* ДАННЫЕ, лист замыкания: по нему дальше не идут, даже если это .mjs, кроме
* случая, когда файл под исполняемым корнем запускается как процесс
* (`node demo/benchmark_*.mjs`, `python scripts/x.py`) — тогда он код.
* Ссылка на каталог раскрывается во все отслеживаемые файлы под ним как в
* данные. `parents` хранит, откуда файл пришёл, — для объяснения «почему это
* вход» (`--why`).
*/
/**
* Файлы, чьи ссылки не читаются: реестр мутантов называет в гардах и патчах
* сотни путей, но для того, кто его импортирует (тесты реестра), это данные,
* а не зависимости — иначе одна правка любого теста отбирала бы весь реестр.
*/
export const LEAF_FILES = new Set(['scripts/mutation-gate.mjs']);
export function closure(root, entries, { tracked = trackedFiles(root), stopAt = () => false, read, parents } = {}) {
const trackedSet = new Set(tracked);
const readText = read || ((rel) => {
const abs = join(root, rel);
if (!existsSync(abs) || statSync(abs).isDirectory()) return '';
return readFileSync(abs, 'utf8');
});
const isDir = (rel) => tracked.some((f) => f.startsWith(`${rel}/`));
const seen = new Set();
const note = (child, parent) => { if (parents && !parents.has(child)) parents.set(child, parent); };
const queue = [...entries].filter((e) => trackedSet.has(e));
for (const e of queue) note(e, null);
while (queue.length) {
const file = queue.shift();
if (seen.has(file)) continue;
seen.add(file);
if (stopAt(file) || LEAF_FILES.has(file)) continue;
if (!/\.(mjs|cjs|js|ts|py)$/.test(file)) continue;
let text;
try { text = readText(file); } catch { continue; }
const { code, data } = referencesOf(file, text);
for (const ref of code) {
if (trackedSet.has(ref)) { note(ref, file); if (!seen.has(ref)) queue.push(ref); }
}
for (const ref of data) {
if (trackedSet.has(ref)) { note(ref, file); seen.add(ref); continue; }
// каталог по строке — данные; бинарные файлы под ним код по строке не
// читает (эталоны golden входят в свою проверку явным корнем)
if (isDir(ref)) for (const f of tracked) if (f.startsWith(`${ref}/`) && !BINARY.test(f)) { note(f, file); seen.add(f); }
}
}
return [...seen].sort();
}
// ---------------------------------------------------------------------------
// Проверки Validate и их входы.
/** Что собирает бандл: те же входы, что у sourceFingerprint (#245). */
const BUILD_INPUTS = ['src/**', 'package.json', 'package-lock.json', 'rollup.config.mjs', 'tsconfig.json',
'scripts/source-fingerprint.mjs', 'scripts/css-template-minifier.mjs', 'scripts/bundle-manifest.mjs',
'scripts/bundle-sync.mjs', 'scripts/bundle-tree.mjs'];
/** Протокол браузерного харнеса: страница, сервер, гард исключений, compat-хелперы. */
const BROWSER_PROTOCOL = ['demo/serve.mjs', 'demo/srv/demo.html', 'demo/bundle-freshness.mjs',
'demo/editor-runtime-compat.mjs', 'demo/iso-runtime-compat.mjs', 'demo/guard/**'];
const WORKFLOW = ['.github/workflows/validate.yml'];
/** Протокол реюза: кто считает ключ, тот и вход (§5.1 protocol). */
const REUSE_PROTOCOL = ['scripts/gate-reuse.mjs', 'scripts/check-inputs.mjs'];
export const CHECKS = {
preflight: {
// документация, провенанс, процесс — всегда запускается; реюза нет
entries: ['scripts/check-docs.mjs', 'scripts/process-gate.mjs', 'scripts/validate-commit-provenance.mjs',
'scripts/classify-base.mjs', 'scripts/classify-changes.mjs', 'scripts/check-inputs.mjs',
'scripts/docs-freshness.mjs', 'scripts/review-doc-guard.mjs'],
roots: ['docs/**', 'README.md', 'README.ru.md', 'PROCESS.md', 'AGENTS.md', 'CONTRIBUTING.md', ...WORKFLOW],
},
frontend: {
// npm run typecheck, npm test, npm run build, bundle:budget
entries: ['test/*.test.mjs', 'scripts/no-new-any.mjs', 'scripts/bundle-budget.mjs', 'scripts/fix-test-build.mjs'],
roots: [...BUILD_INPUTS, 'test/**', 'tsconfig*.json', ...WORKFLOW],
},
changed_mutants: {
entries: ['scripts/mutation-gate.mjs', 'scripts/*-guard.mjs', 'test/*.test.mjs', 'demo/smoke_*.mjs', 'tests_backend/**/*.py'],
roots: [...BUILD_INPUTS, 'custom_components/**/*.py', ...BROWSER_PROTOCOL, ...WORKFLOW],
},
integration: {
entries: [],
roots: ['custom_components/houseplan/manifest.json', 'hacs.json', 'custom_components/**/*.py',
'custom_components/**/translations/**', 'custom_components/**/strings.json', ...WORKFLOW],
},
smoke: {
entries: ['demo/smoke_*.mjs', 'demo/guard/verify-guard.mjs', 'scripts/smoke-select.mjs', 'demo/benchmark_*.mjs'],
roots: [...BUILD_INPUTS, ...BROWSER_PROTOCOL, ...REUSE_PROTOCOL, ...WORKFLOW],
reuse: true,
},
golden: {
entries: ['demo/golden/run.mjs'],
roots: [...BUILD_INPUTS, 'demo/golden/**', ...BROWSER_PROTOCOL, ...REUSE_PROTOCOL, ...WORKFLOW],
reuse: true,
},
performance_smoke: {
entries: ['demo/benchmark_glow.mjs', 'demo/benchmark_large_house.mjs', 'demo/benchmark_junction_limits.mjs',
'demo/benchmark_wall_draw_click.mjs', 'demo/performance/compare.mjs'],
roots: [...BUILD_INPUTS, 'demo/performance/**', ...BROWSER_PROTOCOL, ...REUSE_PROTOCOL, ...WORKFLOW],
reuse: true,
},
backend: {
// pytest tests_backend, unittest scripts/support-relay/tests, ruff/mypy, порог покрытия
entries: ['tests_backend/**/*.py', 'scripts/support-relay/tests/**/*.py', 'scripts/dump-config-schema.py'],
// manifest.json несёт версию: кандидат релиза обязан прогнать backend заново
roots: ['custom_components/**/*.py', 'custom_components/houseplan/manifest.json', 'scripts/support-relay/**/*.py',
'tests_backend/**', 'pyproject.toml', 'pytest.ini', 'scripts/backend-coverage-baseline.txt',
'scripts/config-schema.json', ...REUSE_PROTOCOL, ...WORKFLOW],
reuse: true,
},
};
export const CHECK_NAMES = Object.keys(CHECKS);
export const REUSE_JOBS = CHECK_NAMES.filter((name) => CHECKS[name].reuse);
const expandGlobs = (globs, tracked) => tracked.filter((f) => matchesAny(f, globs));
const isBuildOutput = (f) => matchesAny(f, BUILD_OUTPUT);
/** Развёрнутый список входов проверки: корни ∪ замыкание точек входа. */
export function inputsOf(check, root = process.cwd(), { tracked = trackedFiles(root), read } = {}) {
const spec = CHECKS[check];
if (!spec) throw new Error(`неизвестная проверка: ${check}. Известны: ${CHECK_NAMES.join(', ')}`);
const roots = expandGlobs(spec.roots, tracked);
const entries = expandGlobs(spec.entries, tracked);
const reached = closure(root, entries, { tracked, read, stopAt: isBuildOutput });
return [...new Set([...roots, ...reached])].filter((f) => !isBuildOutput(f)).sort();
}
/** Все проверки со входами — один обход на прогон. */
export function manifest(root = process.cwd(), options = {}) {
const tracked = options.tracked || trackedFiles(root);
const out = {};
for (const name of CHECK_NAMES) out[name] = new Set(inputsOf(name, root, { ...options, tracked }));
return out;
}
/** Исполняемый вход по критерию §5.2 — то, что обязано быть чьим-то. */
export const isExecutableInput = (file) => EXECUTABLE_ROOTS.some((r) => file === r || file.startsWith(`${r}/`))
&& EXECUTABLE_EXT.test(file) && !isBuildOutput(file);
export const isDeclaredNotAnInput = (file) => NOT_AN_INPUT.some(([glob]) => globToRegExp(glob).test(file));
/**
* Проверки, задетые списком файлов, и неизвестные входы среди них.
* Неизвестный исполняемый вход расширяет до всех проверок (§5.2).
*/
export function checksAffectedBy(files, root = process.cwd(), options = {}) {
const man = options.manifest || manifest(root, options);
const affected = new Set();
const unknown = [];
for (const file of files) {
let known = false;
for (const name of CHECK_NAMES) if (man[name].has(file)) { affected.add(name); known = true; }
if (!known && isExecutableInput(file) && !isDeclaredNotAnInput(file)) unknown.push(file);
}
if (unknown.length) for (const name of CHECK_NAMES) affected.add(name);
return { affected, unknown };
}
/** Лист покрытия (§5.5): исполняемые файлы, которые никто не считает своими. */
export function coverage(root = process.cwd(), options = {}) {
const tracked = options.tracked || trackedFiles(root);
const man = options.manifest || manifest(root, { ...options, tracked });
const covered = new Set();
for (const name of CHECK_NAMES) for (const f of man[name]) covered.add(f);
const unknown = tracked.filter((f) => isExecutableInput(f) && !covered.has(f) && !isDeclaredNotAnInput(f));
// запись NOT_AN_INPUT лишняя, если ВСЕ её файлы и так чьи-то входы
const declaredButCovered = NOT_AN_INPUT.map(([glob]) => glob).filter((glob) => {
const hits = tracked.filter((f) => globToRegExp(glob).test(f));
return hits.length > 0 && hits.every((f) => covered.has(f));
});
return { covered, unknown, declaredButCovered };
}
// ---------------------------------------------------------------------------
// CLI: `node scripts/check-inputs.mjs --check=backend` печатает входы;
// `--coverage` — лист покрытия; `--affected` читает список файлов из stdin.
const invokedDirectly = process.argv[1] && import.meta.url === new URL(`file://${process.argv[1]}`).href;
if (invokedDirectly) {
const argv = process.argv.slice(2);
const root = process.cwd();
const checkArg = argv.find((a) => a.startsWith('--check='))?.slice(8);
const whyArg = argv.find((a) => a.startsWith('--why='))?.slice(6);
if (checkArg && whyArg) {
const parents = new Map();
const tracked = trackedFiles(root);
const spec = CHECKS[checkArg];
closure(root, expandGlobs(spec.entries, tracked), { tracked, stopAt: isBuildOutput, parents });
let cur = whyArg;
if (!parents.has(cur)) { console.log(matchesAny(cur, spec.roots) ? `${cur}: корень manifest` : `${cur}: не вход ${checkArg}`); process.exit(0); }
while (cur) { console.log(cur); cur = parents.get(cur); }
} else if (checkArg) {
for (const f of inputsOf(checkArg, root)) console.log(f);
} else if (argv.includes('--coverage')) {
const { unknown, declaredButCovered } = coverage(root);
for (const f of unknown) console.log(`неизвестный вход: ${f}`);
for (const g of declaredButCovered) console.log(`NOT_AN_INPUT лишний (уже покрыт): ${g}`);
process.exit(unknown.length ? 1 : 0);
} else if (argv.includes('--affected')) {
const files = readFileSync(0, 'utf8').split('\n').map((s) => s.trim()).filter(Boolean);
const { affected, unknown } = checksAffectedBy(files, root);
console.log(JSON.stringify({ affected: [...affected].sort(), unknown }, null, 2));
} else {
console.error('usage: check-inputs.mjs --check=<name> | --coverage | --affected < files');
process.exit(2);
}
}
+55 -26
View File
@@ -1,11 +1,11 @@
// Классификация изменённых файлов для job `changes` в validate.yml (#473 AC8).
//
// Шаблоны жили inline в shell-шаге `classify` как `has('regex')`. Пока выходов
// было три, это терпимо; с диффозависимыми профилями перф-смока (#473) их
// пять, и вопрос «запустит ли правка src/iso-x.ts изометрический профиль»
// стал вопросом к workflow, на который нельзя ответить тестом — shell в YAML
// не исполняется локально. Теперь ответ даёт функция, а shell только
// переписывает её вывод в `$GITHUB_OUTPUT`.
// Шаблоны жили inline в shell-шаге `classify` как `has('regex')`, потом —
// регэкспами здесь (#473). С #492 выбор job идёт из единого manifest входов
// (scripts/check-inputs.mjs): того же, из которого считается ключ реюза.
// Регэкспы остались только у профилей перф-смока — это выбор набора внутри
// job, не самой job. Shell по-прежнему только переписывает вывод функции в
// `$GITHUB_OUTPUT`.
//
// Контракт неизменен: каждый выход — строка 'true'/'false', как её и читают
// условия `if: needs.changes.outputs.X == 'true'`. Fallback «полный прогон
@@ -16,36 +16,63 @@
import { readFileSync } from 'node:fs';
export const CLASSIFIERS = {
frontend: /^(src\/|demo\/|test\/|dist\/|custom_components\/houseplan\/frontend\/|package(-lock)?\.json$|rollup\.config\.mjs$|tsconfig)/,
backend: /^(custom_components\/.*\.py$|tests_backend\/|scripts\/support-relay\/|pytest\.ini$)/,
integration: /^(custom_components\/houseplan\/manifest\.json$|hacs\.json$|custom_components\/.*\.py$|custom_components\/.*\/translations\/)/,
// Перф-смок (#473 §5): изометрический профиль — при правке изометрии,
// профиль взаимодействия — при правке живого пути и оркестраторов кадра.
// Только `src/**`: тесты и демо кадр не замедляют.
perf_iso: /^src\/iso-[^/]+\.ts$/,
perf_interaction: /^src\/(live-[^/]+|render-[^/]+|houseplan-render-lifecycle|houseplan-card)\.ts$/,
// Реестр мутантов сам по себе — вход гейта по диффу (#475 ревью r1): новый
// свидетель без правки в src/test иначе не проверялся бы до понедельника.
mutants: /^scripts\/mutation-gate\.mjs$/,
import { checksAffectedBy } from './check-inputs.mjs';
/**
* Выходы job `changes` → проверка manifest (#492 §5.2). Job запускается, если
* дифф задел хотя бы один её вход по `scripts/check-inputs.mjs`; прежние
* регэкспы по путям заменены тем же источником, из которого считается ключ
* реюза, — два места больше не расходятся.
*/
export const CHECK_OF_OUTPUT = {
frontend: 'frontend',
backend: 'backend',
integration: 'integration',
mutants: 'changed_mutants',
};
export const OUTPUTS = Object.keys(CLASSIFIERS);
/**
* Профили перф-смока (#473 §5): изометрический — при правке изометрии,
* профиль взаимодействия — при правке живого пути и оркестраторов кадра.
* Только `src/**`: тесты и демо кадр не замедляют. Это НЕ выбор job, а выбор
* набора внутри неё, поэтому остаётся фильтром по путям.
*/
export const PERF_PROFILES = {
perf_iso: /^src\/iso-[^/]+\.ts$/,
perf_interaction: /^src\/(live-[^/]+|render-[^/]+|houseplan-render-lifecycle|houseplan-card)\.ts$/,
};
/** Список файлов → выходы job `changes` ('true'/'false' по каждому ключу). */
export function classifyChanges(files) {
/** Совместимость с прежним экспортом: имя выхода → предикат по файлу. */
export const CLASSIFIERS = {
...Object.fromEntries(Object.keys(CHECK_OF_OUTPUT).map((name) => [name, null])),
...PERF_PROFILES,
};
export const OUTPUTS = [...Object.keys(CHECK_OF_OUTPUT), ...Object.keys(PERF_PROFILES)];
/**
* Список файлов → выходы job `changes` ('true'/'false' по каждому ключу) плюс
* `unknown` — неизвестные исполняемые входы, из-за которых прогон расширен
* до полного набора (§5.2).
*/
export function classifyChanges(files, { root = process.cwd(), manifest } = {}) {
const list = (Array.isArray(files) ? files : String(files).split('\n'))
.map((file) => file.trim()).filter(Boolean);
const { affected, unknown } = checksAffectedBy(list, root, manifest ? { manifest } : {});
const result = {};
for (const [name, pattern] of Object.entries(CLASSIFIERS)) {
result[name] = list.some((file) => pattern.test(file)) ? 'true' : 'false';
for (const [name, check] of Object.entries(CHECK_OF_OUTPUT)) {
result[name] = affected.has(check) ? 'true' : 'false';
}
for (const [name, pattern] of Object.entries(PERF_PROFILES)) {
result[name] = unknown.length || list.some((file) => pattern.test(file)) ? 'true' : 'false';
}
result.unknown = unknown;
return result;
}
/** Fallback без классификации: всё прогоняется. */
export function classifyAll() {
return Object.fromEntries(OUTPUTS.map((name) => [name, 'true']));
return { ...Object.fromEntries(OUTPUTS.map((name) => [name, 'true'])), unknown: [] };
}
/**
@@ -71,9 +98,11 @@ export function hasReleaseTrailer(message) {
return /^Release:\s*v?\d+\.\d+\.\d+\S*\s*$/m.test(String(message || ''));
}
/** Формат `$GITHUB_OUTPUT`. */
/** Формат `$GITHUB_OUTPUT`; неизвестные входы — отдельной строкой через пробел. */
export function formatOutputs(outputs) {
return OUTPUTS.map((name) => `${name}=${outputs[name]}`).join('\n') + '\n';
const lines = OUTPUTS.map((name) => `${name}=${outputs[name]}`);
lines.push(`unknown_inputs=${(outputs.unknown || []).join(' ')}`);
return lines.join('\n') + '\n';
}
const invokedDirectly = process.argv[1]
+26 -86
View File
@@ -5,10 +5,11 @@
// скрипты, где бандл и оснастка побайтово те же. Ключ здесь отвечает на один
// вопрос: «менялось ли хоть что-то, от чего результат этой job зависит».
//
// Ключ = sourceFingerprint (входы поведения: src/**, demo/fixtures,
// demo/golden/*.mjs, package.json, lock, rollup, tsconfig) ПЛЮС хеш собственной
// оснастки job. Совпал ключ с прогоном, который завершился успешно, — повторять
// нечего; не совпал — гоняем.
// Ключ = хеш содержимого ВСЕХ входов job по единому manifest (#492,
// scripts/check-inputs.mjs): исходники, которые job собирает, тесты и их
// обёртки, фикстуры, конфиги инструментов, toolchain и протокол харнеса.
// Совпал ключ с прогоном, который завершился успешно, — повторять нечего;
// не совпал — гоняем.
//
// Почему это не фильтры путей из job `changes` (на dev они намеренно
// отключены): там объём прогона угадывается по путям, и «зелёный» начинает
@@ -16,111 +17,50 @@
// пишет только успешный прогон с тем же ключом.
//
// Свойство, которое стоит знать: релизный кандидат (бета или стабильный релиз)
// бампает версию, а `CARD_VERSION` и `package.json` входят в sourceFingerprint.
// бампает версию — `package.json`/`CARD_VERSION` у браузерных job и
// `custom_components/houseplan/manifest.json` у backend входят в manifest.
// Значит ключ кандидата заведомо новый и полный набор гейтов прогоняется всегда.
// Переиспользование физически не может ослабить релизный гейт.
import { createHash } from 'node:crypto';
import { appendFileSync, existsSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
import { relative, resolve } from 'node:path';
import { appendFileSync, existsSync, readFileSync, writeFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { sourceFingerprint } from './source-fingerprint.mjs';
import { REUSE_JOBS, inputsOf } from './check-inputs.mjs';
/**
* Оснастка каждой job: файлы, от которых её результат зависит помимо входов
* поведения. `scripts/**` целиком сюда не берётся намеренно — он меняется
* часто и почти всегда не в той части, которую job исполняет; берутся только
* фактически исполняемые файлы (см. package.json).
* Входы каждой job — из единого manifest (#492 §5.3). Прежний `HARNESS`
* перечислял оснастку руками и молчал о том, чего не знал: relay, converter,
* schema у backend; `serve.mjs`, `demo.html`, compat-хелперы у golden/perf.
* Теперь список ВЫЧИСЛЯЕТСЯ: корни проверки плюс замыкание её точек входа по
* импортам и путям (см. check-inputs.mjs). `src/**` входит только туда, где
* бандл собирается и исполняется, — backend от UI больше не зависит.
*/
export const HARNESS = {
smoke: {
// Всё, что job «Смоки в браузере» ИСПОЛНЯЕТ, а не только сами смоки (#430).
//
// До этой задачи ключ держал ровно `demo/smoke_*.mjs`. Из него выпадали:
// `demo/serve.mjs` — сама оснастка, включая гард исключений; `demo/guard/**`
// — отрицательные пробы этого гарда и запускающий их `verify-guard.mjs`;
// benchmark, который одна из проб запускает с `--guard-probe`.
//
// Стоило это ровно того, чего и должно было. Прогон #2371 (ee678352)
// добавлял в `verify-guard.mjs` пробу гарда benchmark — и job со смоками
// была ПРОПУЩЕНА как переиспользованная: правка файла, который исполняется
// только в ней, её ключ не меняла. Проба уехала в `dev`, ни разу не
// запустившись. Пропущенная проверка выглядит точно как пройденная — и это
// тот же дефект, против которого заведён весь #430.
//
// Benchmark'и берутся все, а не по имени: любой из них может быть подключён
// к пробам позже, а платить за лишний прогон дешевле, чем за молчание.
// `demo/fixtures/**` в ключ не входит — он в корпусе `sourceFingerprint`,
// который уже подмешан в ключ строкой выше.
roots: ['demo'],
keep: (rel) => /^demo\/smoke_[^/]+\.mjs$/.test(rel)
|| rel === 'demo/serve.mjs'
|| /^demo\/guard\//.test(rel)
|| /^demo\/benchmark_[^/]+\.mjs$/.test(rel),
},
golden: {
// demo/golden/** целиком: и сценарии, и эталоны — эталон тоже вход
// сравнения, его подмена обязана менять ключ.
roots: ['demo/golden'],
keep: () => true,
},
performance_smoke: {
roots: ['demo'],
keep: (rel) => /^demo\/performance\//.test(rel)
|| /^demo\/benchmark_(glow|large_house)\.mjs$/.test(rel),
},
backend: {
// #42: порог покрытия и конфиг линтеров — прямые входы job
// (`head -1 baseline` в шаге сравнения; ruff/mypy читают pyproject): их
// изменение без правок тестов обязано сбрасывать реюз, иначе baseline-bump
// молча пройдёт по старому зелёному маркеру. Пины зависимостей
// (tests_backend/requirements.txt, #392) покрыты корнем tests_backend.
roots: ['tests_backend', 'custom_components', 'pytest.ini',
'scripts/backend-coverage-baseline.txt', 'pyproject.toml'],
// Внутри custom_components/** значим только Python: собранный фронтенд
// лежит там же и меняется от любой сборки, а backend его не исполняет.
keep: (rel) => !rel.startsWith('custom_components/') || rel.endsWith('.py'),
},
};
export const JOBS = REUSE_JOBS;
export const JOBS = Object.keys(HARNESS);
/** Все файлы под путём (файл — сам путь), относительными путями через «/». */
const walk = (root, entry) => {
const abs = resolve(root, entry);
if (!existsSync(abs)) return [];
if (!statSync(abs).isDirectory()) return [relative(root, abs).replaceAll('\\', '/')];
return readdirSync(abs).sort().flatMap((name) =>
walk(root, relative(root, resolve(abs, name)).replaceAll('\\', '/')));
};
/** Файлы оснастки job в порядке, не зависящем от файловой системы. */
/** Файлы, от которых зависит результат job, в порядке, не зависящем от ФС. */
export function harnessFiles(root, job) {
const spec = HARNESS[job];
if (!spec) throw new Error(`неизвестная job: ${job}. Известны: ${JOBS.join(', ')}`);
const seen = new Set();
for (const entry of spec.roots) {
for (const rel of walk(root, entry)) if (spec.keep(rel)) seen.add(rel);
}
return [...seen].sort((a, b) => a.localeCompare(b));
if (!JOBS.includes(job)) throw new Error(`неизвестная job: ${job}. Известны: ${JOBS.join(', ')}`);
return inputsOf(job, root);
}
/**
* Ключ переиспользования. Пустая оснастка не молчит: она означала бы, что job
* зависит только от входов поведения, и такую подмену лучше заметить.
* Ключ переиспользования: имя job + содержимое всех её входов. Пустой список
* не молчит: он означал бы job без входов, и такую подмену лучше заметить.
*/
export function reuseKey(root, job) {
const files = harnessFiles(root, job);
if (!files.length) throw new Error(`оснастка job ${job} пуста — проверьте HARNESS`);
if (!files.length) throw new Error(`входы job ${job} пусты — проверьте CHECKS в check-inputs.mjs`);
const hash = createHash('sha256');
hash.update(`job:${job}\0`);
hash.update(`source:${sourceFingerprint(root)}\0`);
for (const rel of files) {
const abs = resolve(root, rel);
if (!existsSync(abs)) continue;
hash.update(rel);
hash.update('\0');
// Текст канонизируется по переводам строк, бинарное берётся как есть:
// иначе Windows и Linux дали бы разные ключи на одном дереве.
const raw = readFileSync(resolve(root, rel));
const raw = readFileSync(abs);
const text = raw.includes(0) ? raw : Buffer.from(raw.toString('utf8').replace(/\r\n?/g, '\n'));
hash.update(text);
hash.update('\0');
+244
View File
@@ -0,0 +1,244 @@
#!/usr/bin/env node
// Слияние точного кандидата (#492 §4).
//
// До этой задачи шаг «Слить ветку в dev» ревью-конвейера после зелёного
// вердикта делал `git rebase origin/dev` и сразу `push HEAD:dev`. Если `dev`
// продвинулся за время ревью (28 августа — четыре раза за день), в `dev`
// уезжало дерево, которого не видел никто: ни ревью (материал другой), ни
// Validate (на этот SHA он не бежал). Чистый ребейз не доказывает
// совместимость: соседняя правка в `dev` меняет поведение без единого
// конфликта (эксперимент аудита: 20 → 40).
//
// Правило теперь: в `dev` попадает только SHA, для которого есть зелёный
// Validate, и попадает атомарно — `--force-with-lease` на ту вершину `dev`,
// поверх которой кандидат собран. Движение `dev` во время проверки не
// перезаписывает чужое: lease отклоняется, кандидат собирается заново, не
// более трёх раз. Дифф, изменившийся при ребейзе (patch-id), — не предмет
// этого шага: вердикт к нему не применим, задача возвращается на ревью.
//
// Решение (`decideMerge`) отделено от git и gh (`ops`), чтобы таблица
// случаев §8.4 была юнит-тестом, а не верой в shell.
import { spawnSync } from 'node:child_process';
import { appendFileSync } from 'node:fs';
export const MAX_ATTEMPTS = 3;
export const VALIDATE_APPEAR_MS = 3 * 60 * 1000;
export const VALIDATE_TOTAL_MS = 45 * 60 * 1000;
/**
* Чистое решение по состоянию одной попытки. Возвращает действие и, где
* применимо, статусную метку, к которой ведёт это действие.
*
* @param {object} s
* @param {boolean} s.fresh вершина ветки = материал (+ документ ревью), #312
* @param {boolean} s.devMoved dev не равен базе материала
* @param {boolean} s.conflict ребейз на dev упал
* @param {boolean} s.patchIdEqual дифф после ребейза совпадает с проверенным
* @param {'green'|'red'|'missing'|null} s.validate результат Validate на кандидате
* @param {boolean} s.leaseRejected push в dev отклонён: dev двинулся снова
* @param {number} s.attempt номер попытки, с 1
*/
export function decideMerge(s) {
if (!s.fresh) return { action: 'reject-stale', to: 'S6-in-progress' };
if (s.conflict) return { action: 'conflict', to: 'S6-in-progress' };
if (!s.devMoved) {
if (s.leaseRejected) return { action: 'retry' };
return { action: 'fast-forward', to: 'S8-merged' };
}
if (!s.patchIdEqual) return { action: 'rereview', to: 'S7-code-review' };
if (s.validate === null || s.validate === undefined) return { action: 'validate' };
if (s.validate === 'missing') return { action: 'validation-missing', to: 'S6-in-progress' };
if (s.validate === 'red') return { action: 'validation-red', to: 'S6-in-progress' };
if (s.leaseRejected) {
if ((s.attempt ?? 1) >= (s.maxAttempts ?? MAX_ATTEMPTS)) return { action: 'give-up', to: 'S6-in-progress' };
return { action: 'retry' };
}
return { action: 'push', to: 'S8-merged' };
}
/** Тексты комментариев в issue — один на исход. */
export function commentFor(action, ctx) {
const short = (sha) => String(sha || '').slice(0, 8);
switch (action) {
case 'reject-stale':
return `**Слияние отменено: ветка изменилась после проверенного материала (#312).**\n\n`
+ `Ревью выполнялось на \`${short(ctx.material)}\`, а вершина ветки сейчас \`${short(ctx.actual)}\` — в ней есть коммиты, которых вердикт не покрывает. Зелёный вердикт остаётся в силе только для проверенного SHA.\n\n`
+ `Задача переведена в \`S6-in-progress\`. Дальше: убедиться, что вершина ветки — именно то, что должно ехать в dev, и вернуть метку \`S7-code-review\`. Если вершина отличается от проверенного материала только коммитами публикации документов ревью, новый заход применит зелёный вердикт повторно без вызова модели (#499).`;
case 'conflict':
return `**Код-ревью зелёное — вердикт выше в силе, переделывать работу не нужно.** Не удалось только слияние: ветка \`${ctx.branch}\` конфликтует с \`dev\`.\n\n`
+ `Задача переведена в \`S6-in-progress\`, потому что работа вернулась к автору. Осталась не правка кода, а ребейз:\n\n`
+ `1. \`git fetch origin\`, затем \`git rebase origin/dev\` в ветке задачи, разрешить конфликт;\n2. запушить ветку;\n3. вернуть метку \`S7-code-review\`.\n\n`
+ `Повторный прогон ревью — не формальность: после ребейза на новый \`dev\` это другой код, и принимать его без проверки нельзя. Цикл считается по этапу, лимит на код-ревью тратится отдельно от ревью ТЗ.`;
case 'rereview':
return `**Дифф изменился при ребейзе на \`dev@${short(ctx.devNow)}\` — вердикт к нему не применим (§7.2, #492).**\n\n`
+ `Материал ревью \`${short(ctx.material)}\` и кандидат \`${short(ctx.candidate)}\` дают разные patch-id: соседние правки в \`dev\` изменили содержимое патча. Кандидат опубликован в ветку; задача возвращена в \`S7-code-review\` — новый заход ревью читает актуальный код.`;
case 'validation-red':
return `**Кандидат после ребейза на \`dev@${short(ctx.devNow)}\` красный (#492).**\n\n`
+ `Вердикт ревью на \`${short(ctx.material)}\` в силе, но точный кандидат \`${short(ctx.candidate)}\` не прошёл Validate: ${ctx.runUrl || 'прогон не найден'}. Задача переведена в \`S6-in-progress\`: разобраться с прогоном на ветке, затем вернуть \`S7-code-review\`.`;
case 'validation-missing':
return `**Validate на кандидате \`${short(ctx.candidate)}\` не появился за ${Math.round(VALIDATE_APPEAR_MS / 60000)} мин (#492).**\n\n`
+ `Кандидат опубликован в ветку, но прогон не стартовал — проверьте токен конвейера и очередь Actions. Слияние без проверки не выполняется; задача в \`S6-in-progress\`, после зелёного Validate на этом SHA вернуть \`S7-code-review\`.`;
case 'give-up':
return `**\`dev\` движется быстрее слияния: ${ctx.attempt} попытки собрать и проверить кандидата, каждый раз \`dev\` уходил до push (#492).**\n\n`
+ `Последний проверенный кандидат \`${short(ctx.candidate)}\` опубликован в ветку. Задача в \`S6-in-progress\`; вернуть \`S7-code-review\`, когда \`dev\` успокоится.`;
case 'push':
case 'fast-forward':
return `материал \`${short(ctx.material)}\` · dev@\`${short(ctx.devNow)}\` → кандидат \`${short(ctx.candidate)}\``
+ (action === 'push' ? ` · Validate ${ctx.runUrl} зелёный` : ' · dev не двигался')
+ ' · слито';
default:
return '';
}
}
// ---------------------------------------------------------------------------
// Исполнение: git + gh через `ops`, чтобы тест подменял их целиком.
const sh = (cmd, args, opts = {}) => {
const r = spawnSync(cmd, args, { encoding: 'utf8', ...opts });
return { status: r.status ?? 1, stdout: (r.stdout || '').trim(), stderr: (r.stderr || '').trim() };
};
export function realOps({ repo, token, workflow = 'validate.yml', sleep = (ms) => new Promise((r) => setTimeout(r, ms)), now = Date.now }) {
const pushUrl = `https://x-access-token:${token}@github.com/${repo}`;
const git = (...args) => sh('git', args);
const must = (r, what) => { if (r.status !== 0) throw new Error(`${what}: ${r.stderr || r.stdout}`); return r.stdout; };
return {
fetch: (...refs) => must(git('fetch', '-q', 'origin', ...refs), 'git fetch'),
revParse: (ref) => must(git('rev-parse', ref), `rev-parse ${ref}`),
mergeBase: (a, b) => must(git('merge-base', a, b), 'merge-base'),
diffNames: (from, to, pathspec = []) => must(git('diff', '--name-only', from, to, '--', ...pathspec), 'diff').split('\n').filter(Boolean),
patchId: (from, to) => {
const diff = must(git('diff', '--full-index', from, to), 'diff');
const r = spawnSync('git', ['patch-id', '--stable'], { input: diff, encoding: 'utf8' });
return (r.stdout || '').trim().split(' ')[0] || 'empty';
},
rebaseOnto: (branchTip, onto) => {
must(git('checkout', '-q', '-B', 'merge-into-dev', branchTip), 'checkout');
const r = spawnSync('git', ['-c', 'user.name=claude[bot]', '-c', 'user.email=209825114+claude[bot]@users.noreply.github.com', 'rebase', onto], { encoding: 'utf8' });
if (r.status !== 0) { spawnSync('git', ['rebase', '--abort']); return null; }
return must(git('rev-parse', 'HEAD'), 'rev-parse HEAD');
},
pushWithLease: (sha, ref, expected) => {
const r = git('push', '-q', `--force-with-lease=refs/heads/${ref}:${expected}`, pushUrl, `${sha}:refs/heads/${ref}`);
if (r.status === 0) return true;
if (/stale info|rejected|fetch first|lease/i.test(r.stderr)) return false;
throw new Error(`git push ${ref}: ${r.stderr}`);
},
waitValidate: async (sha) => {
const started = now();
let runId = null;
while (now() - started < VALIDATE_TOTAL_MS) {
const r = sh('gh', ['run', 'list', '--repo', repo, '--workflow', workflow, '--commit', sha, '--json', 'databaseId,status,conclusion,url', '--limit', '5']);
const runs = r.status === 0 && r.stdout ? JSON.parse(r.stdout) : [];
const run = runs.find((x) => x.databaseId === runId) || runs[0];
if (run) {
runId = run.databaseId;
if (run.status === 'completed') return { result: run.conclusion === 'success' ? 'green' : 'red', url: run.url };
} else if (now() - started > VALIDATE_APPEAR_MS) {
return { result: 'missing', url: null };
}
await sleep(20_000);
}
return { result: 'red', url: runId ? `run ${runId} (timeout)` : null };
},
comment: (issue, body) => {
const r = spawnSync('gh', ['issue', 'comment', String(issue), '--repo', repo, '--body-file', '-'], { input: body, encoding: 'utf8' });
if (r.status !== 0) throw new Error(`gh issue comment: ${r.stderr}`);
},
log: (line) => console.log(line),
};
}
/**
* Слияние по алгоритму §4.2. Возвращает { merged, to, action, candidate }.
*/
export async function mergeCandidate({ branch, material, issue, ops, maxAttempts = MAX_ATTEMPTS }) {
ops.fetch('dev', branch);
const actual = ops.revParse(`origin/${branch}`);
const reviewedFresh = actual === material
|| (safe(() => ops.revParse(`${actual}^`)) === material
&& ops.diffNames(material, actual, ['.', ':!docs/reviews']).length === 0);
const ctx = { branch, material, actual, issue };
const finish = (decision, extra = {}) => {
const body = commentFor(decision.action, { ...ctx, ...extra, attempt: extra.attempt });
if (body) ops.comment(issue, body);
const merged = decision.action === 'push' || decision.action === 'fast-forward';
ops.log(`решение: ${decision.action} → ${decision.to || '(метка по вердикту)'}`);
return { merged, to: decision.to, action: decision.action, candidate: extra.candidate || actual };
};
if (!reviewedFresh) return finish(decideMerge({ fresh: false }));
let tip = actual;
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
ops.fetch('dev');
const devNow = ops.revParse('origin/dev');
const materialBase = ops.mergeBase(material, 'origin/dev');
const devMoved = devNow !== materialBase;
ops.log(`попытка ${attempt}: dev@${devNow.slice(0, 8)}, база материала ${materialBase.slice(0, 8)}, dev ${devMoved ? 'двигался' : 'на месте'}`);
if (!devMoved) {
const pushed = ops.pushWithLease(tip, 'dev', devNow);
const decision = decideMerge({ fresh: true, devMoved: false, leaseRejected: !pushed });
if (decision.action === 'retry') continue;
return finish(decision, { candidate: tip, devNow });
}
const candidate = ops.rebaseOnto(tip, 'origin/dev');
if (!candidate) return finish(decideMerge({ fresh: true, devMoved: true, conflict: true }), { devNow });
const patchIdEqual = ops.patchId(materialBase, material) === ops.patchId(devNow, candidate);
// кандидат публикуется в ветку в любом случае: он и есть то, что должно
// ехать в dev, и Validate стартует именно от этого push
if (!ops.pushWithLease(candidate, branch, tip)) {
// ветку задачи подвинули, пока шло ревью или ребейз — это #312, не наш случай
return finish(decideMerge({ fresh: false }), { candidate, devNow });
}
tip = candidate;
if (!patchIdEqual) return finish(decideMerge({ fresh: true, devMoved: true, patchIdEqual: false }), { candidate, devNow });
ops.log(`Validate на кандидате ${candidate.slice(0, 8)} — ждём`);
const { result, url } = await ops.waitValidate(candidate);
let decision = decideMerge({ fresh: true, devMoved: true, patchIdEqual: true, validate: result, attempt, maxAttempts });
if (decision.action !== 'push') return finish(decision, { candidate, devNow, runUrl: url });
const pushed = ops.pushWithLease(candidate, 'dev', devNow);
decision = decideMerge({ fresh: true, devMoved: true, patchIdEqual: true, validate: result, leaseRejected: !pushed, attempt, maxAttempts });
if (decision.action === 'retry') { ops.log('dev двинулся снова — ещё попытка'); continue; }
return finish(decision, { candidate, devNow, runUrl: url, attempt });
}
return finish(decideMerge({ fresh: true, devMoved: true, patchIdEqual: true, validate: 'green', leaseRejected: true, attempt: maxAttempts, maxAttempts }), { candidate: tip, attempt: maxAttempts });
}
const safe = (fn) => { try { return fn(); } catch { return null; } };
const arg = (name) => process.argv.find((a) => a.startsWith(`--${name}=`))?.slice(name.length + 3);
if (process.argv[1] && import.meta.url === new URL(`file://${process.argv[1]}`).href) {
const branch = arg('branch');
const material = arg('material');
const issue = arg('issue');
const repo = arg('repo') || process.env.GITHUB_REPOSITORY;
const token = process.env.HP_PROCESS_TOKEN || process.env.TOKEN;
if (!branch || !material || !issue || !repo || !token) {
console.error('usage: merge-candidate.mjs --branch=<issue branch> --material=<sha> --issue=<n> [--repo=owner/name]; HP_PROCESS_TOKEN in env');
process.exit(2);
}
const ops = realOps({ repo, token });
mergeCandidate({ branch, material, issue, ops }).then((r) => {
const out = `merged=${r.merged}\nto=${r.to || ''}\naction=${r.action}\ncandidate=${r.candidate}\n`;
if (process.env.GITHUB_OUTPUT) appendFileSync(process.env.GITHUB_OUTPUT, out);
process.stdout.write(out);
}, (err) => {
// Инвариант конвейера: после прогона метка меняется всегда. Сбой самого
// слияния — не повод оставить задачу висеть в S7: S6 и внятный комментарий.
console.error(err);
try {
ops.comment(issue, `**Слияние не выполнено: сбой шага слияния (#492).**\n\n\`\`\`\n${String(err && err.message || err).slice(0, 1500)}\n\`\`\`\n\nВердикт ревью в силе. Задача в \`S6-in-progress\`; после разбора сбоя вернуть \`S7-code-review\`.`);
} catch (e) { console.error(e); }
if (process.env.GITHUB_OUTPUT) appendFileSync(process.env.GITHUB_OUTPUT, 'merged=false\nto=S6-in-progress\naction=error\n');
process.exit(0);
});
}
+207 -9
View File
@@ -35,10 +35,11 @@ import {
} from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { createHash } from 'node:crypto';
import { mkdirSync } from 'node:fs';
import { withoutProductVersion } from './source-fingerprint.mjs';
import { closure, trackedFiles } from './check-inputs.mjs';
const repoRoot = fileURLToPath(new URL('..', import.meta.url));
@@ -3559,6 +3560,94 @@ const MUTANT_DEFINITIONS = [
replace: " ledger.caught[mutant.id] = fingerprint;\n mkdirSync(dirname(file), { recursive: true });\n if (Object.keys(ledger.caught).length > 1) writeFileSync(file,",
}],
},
{
id: 'manifest-drops-workflow-input',
guard: 'node --test --test-name-pattern="toolchain input of every job" test/gate-reuse.test.mjs',
because: 'validate.yml decides what a job checks; a job whose key ignores its own workflow is '
+ 'reused after a step is added and the new step never runs (#492 §5.4, the #430 class)',
patches: [{
file: 'scripts/check-inputs.mjs',
find: "const WORKFLOW = ['.github/workflows/validate.yml'];",
replace: 'const WORKFLOW = []; // mutant: the workflow is nobody\'s input',
}],
},
{
id: 'classify-unknown-input-is-unaffected',
guard: 'node --test --test-name-pattern="неизвестный исполняемый вход расширяет" test/classify-changes.test.mjs',
because: 'a changed executable file no check claims must widen the run to the full set; treating '
+ '"unknown" as "unaffected" is exactly how relay tests stayed unrun (#492 §5.2)',
patches: [{
file: 'scripts/check-inputs.mjs',
find: ' if (unknown.length) for (const name of CHECK_NAMES) affected.add(name);',
replace: ' // mutant: unknown inputs widen nothing',
}],
},
{
id: 'reuse-backend-hashes-ui',
guard: 'node --test --test-name-pattern="a version bump changes all of them" test/gate-reuse.test.mjs',
because: 'backend does not execute the UI; hashing src/** into its key reverts the AC6 closure '
+ 'and rebuilds the "every frontend edit reruns pytest" skew (#492 AC6)',
patches: [{
file: 'scripts/check-inputs.mjs',
find: " roots: ['custom_components/**/*.py', 'custom_components/houseplan/manifest.json', 'scripts/support-relay/**/*.py',",
replace: " roots: ['src/**', 'custom_components/**/*.py', 'custom_components/houseplan/manifest.json', 'scripts/support-relay/**/*.py',",
}],
},
{
id: 'guard-inputs-ignore-wrapper-defaults',
guard: 'node --test --test-name-pattern="#492 §8.2" test/mutation-gate.test.mjs',
because: 'ten backend wrappers run tests_backend/test_ha_import_export.py by default without naming '
+ 'it; dropping the declared default hides that test from selection and fingerprints again (#492 §6.1)',
patches: [{
file: 'scripts/mutation-gate.mjs',
find: ' const declared = explicit ? [] : wrappers' + '.flatMap((file) => wrapperInputs(file, read));',
replace: ' const declared = []; // mutant: wrapper defaults are invisible',
}],
},
{
id: 'registry-diff-not-selected',
guard: 'node --test --test-name-pattern="#492 §6.4: дифф только по реестру" test/mutation-gate.test.mjs',
because: 'a new or changed mutant definition must run on the push that adds it; selecting only by '
+ 'patch and guard files leaves a registry-only diff green without running the witness (#492 §6.4)',
patches: [{
file: 'scripts/mutation-gate.mjs',
find: ' byRegistry = mutants' + '.filter((m) => delta.changed.includes(m.id));',
replace: ' byRegistry = []; // mutant: definitions never select',
}],
},
{
id: 'merge-pushes-unvalidated-candidate',
guard: 'node --test --test-name-pattern="эксперимент аудита" test/merge-candidate.test.mjs',
because: 'after a clean rebase onto a moved dev the candidate is a tree nobody checked; pushing '
+ 'it without a green Validate on that SHA is the false-green the audit reproduced (#492 §4)',
patches: [{
file: 'scripts/merge-candidate.mjs',
find: ' const { result, url } = await ops.waitValidate(candidate);',
replace: " const { result, url } = { result: 'green', url: 'skipped' }; // mutant: no validation",
}],
},
{
id: 'merge-ignores-lease-rejection',
guard: 'node --test --test-name-pattern="lease отклонён" test/merge-candidate.test.mjs',
because: 'dev moving again between Validate and push must restart the candidate, not be '
+ 'reported as merged: a rejected lease treated as success leaves S8-merged on code not in dev (#492 AC2)',
patches: [{
file: 'scripts/merge-candidate.mjs',
find: " const pushed = ops.pushWithLease(candidate, 'dev', devNow);\n decision = decideMerge(",
replace: " const pushed = ops.pushWithLease(candidate, 'dev', devNow) || true; // mutant: lease never rejected\n decision = decideMerge(",
}],
},
{
id: 'nightly-does-not-wait',
guard: 'node --test --test-name-pattern="nightly ждёт запущенный Validate" test/nightly-workflow.test.mjs',
because: 'a nightly that returns green at dispatch time hides a red full run; the job must wait '
+ 'for the child and inherit its conclusion (#492 §7)',
patches: [{
file: '.github/workflows/nightly.yml',
find: ' gh run watch "$run_id" --repo "$REPO" --exit-status --interval 30',
replace: ' echo "watching skipped" # mutant: dispatch counted as success',
}],
},
{
id: 'changed-selection-ignores-guard-files',
guard: 'node --test --test-name-pattern="#475 AC2" test/mutation-gate.test.mjs',
@@ -3568,7 +3657,7 @@ const MUTANT_DEFINITIONS = [
file: 'scripts/mutation-gate.mjs',
// Реестр живёт в том же файле, что и код: якорь собирается из двух
// частей, иначе --check найдёт его дважды — в коде и здесь.
find: ' || guardFiles(m.guard, exists)' + '.some((file) => changed.has(file)));',
find: ' || inputsOf(m.guard)' + '.some((file) => changed.has(file)));',
replace: ' || false);',
}],
},
@@ -7859,6 +7948,55 @@ export function guardFiles(guard, exists = (file) => existsSync(join(repoRoot, f
return [...files];
}
/**
* Обёртки гардов объявляют, что запускают (#492 §6.1): `export const
* GUARD_INPUTS = [...]` читается статически, без исполнения — обёртка при
* импорте сразу бежит и падает на usage. Файл гарда без объявления не
* считается обёрткой: его входы — только импорты и пути (§6.2).
*/
const GUARD_INPUTS_RE = /export const GUARD_INPUTS = \[([^\]]*)\]/;
export function wrapperInputs(file, read = (f) => (existsSync(join(repoRoot, f)) ? readFileSync(join(repoRoot, f), 'utf8') : '')) {
if (!/^scripts\/[\w-]+-guard\.mjs$/.test(file)) return [];
const m = GUARD_INPUTS_RE.exec(String(read(file)));
if (!m) return [];
return [...m[1].matchAll(/['"]([^'"]+)['"]/g)].map((x) => x[1]);
}
/** Сторона патча остаётся точечной (#492 §6.4): замыкание в `src/**` не идёт. */
const GUARD_CLOSURE_STOP = (file) => file.startsWith('src/') || file.startsWith('custom_components/houseplan/frontend/');
let trackedCache = null;
const tracked = () => (trackedCache ??= trackedFiles(repoRoot));
/**
* Все входы гарда (#492 §6): файлы из строки команды ∪ объявленные входы
* обёрток ∪ транзитивное замыкание по импортам и путям — смок тянет
* `serve.mjs` и фикстуры, pytest-модуль — `conftest.py`, обёртка — свои
* тесты. `src/**` исключён: это сторона патча, не гарда.
*/
export function guardInputs(guard, {
exists = (file) => existsSync(join(repoRoot, file)),
read,
files = tracked(),
} = {}) {
const named = guardFiles(guard, exists);
// Объявленное умолчание обёртки действует, когда гард не назвал файл сам:
// третий аргумент backend-test-guard уже стоит в строке и отбирается оттуда.
const isWrapper = (file) => /^scripts\/[\w-]+-guard\.mjs$/.test(file);
const wrappers = named.filter(isWrapper);
const explicit = named.some((file) => !isWrapper(file));
const declared = explicit ? [] : wrappers.flatMap((file) => wrapperInputs(file, read));
// Сама обёртка — вход (её текст в отпечатке), но её ссылки не читаются:
// умолчание уже учтено выше, а перечитывать его из текста значило бы
// отменять явный аргумент.
const entries = [...new Set([...named, ...declared])].filter(exists);
const reached = closure(repoRoot, entries.filter((file) => !wrappers.includes(file)),
{ tracked: files, read, stopAt: GUARD_CLOSURE_STOP });
return [...new Set([...entries, ...reached])]
.filter((file) => !GUARD_CLOSURE_STOP(file))
.sort();
}
/**
* Отпечаток свидетеля (#481): содержимое файлов патча и гарда плюс само
* объявление мутанта. Строка версии продукта нормализуется, как в
@@ -7879,7 +8017,7 @@ export function witnessFingerprint(mutant, {
hash.update('\0');
const files = new Set([
...mutant.patches.map((patch) => patch.file),
...guardFiles(mutant.guard, exists),
...guardInputs(mutant.guard, { exists, read }),
]);
for (const file of [...files].sort()) {
hash.update(file);
@@ -7944,10 +8082,57 @@ export function splitByLedger(mutants, ledger, fingerprintOf = (m) => witnessFin
* четыре мутанта пережили свои гарды и обнаружились лишь полным прогоном
* перед v1.72.0 (#466, #467).
*/
export function selectChangedMutants(mutants, changedFiles, exists) {
export function selectChangedMutants(mutants, changedFiles, exists, options = {}) {
const changed = new Set(changedFiles);
const inputsOf = options.guardInputs || ((guard) => guardInputs(guard, { exists, ...options }));
return mutants.filter((m) => m.patches.some((patch) => changed.has(patch.file))
|| guardFiles(m.guard, exists).some((file) => changed.has(file)));
|| inputsOf(m.guard).some((file) => changed.has(file)));
}
/**
* Определения реестра, добавленные или изменённые относительно базы (#492
* §6.4): реестр базы читается через `git show` во временный модуль рядом с
* этим файлом (относительные импорты обязаны разрешаться) и импортируется —
* реестр данные, побочных эффектов при импорте нет (закреплено тестом).
* Удалённые id возвращаются отдельно: гонять их нечем, но сказать стоит.
*/
export function registryDelta(current, base) {
const shape = (m) => JSON.stringify({ guard: m.guard, patches: m.patches, because: m.because });
const before = new Map(base.map((m) => [m.id, shape(m)]));
const changed = current.filter((m) => before.get(m.id) !== shape(m)).map((m) => m.id);
const removed = base.filter((m) => !current.some((c) => c.id === m.id)).map((m) => m.id);
return { changed, removed };
}
/**
* Отбор для диффа (#332, #475, #492): по файлам патчей и входам гардов плюс —
* когда дифф трогает сам реестр — по добавленным/изменённым определениям
* относительно реестра базы (`base`, null — база не прочитана).
*/
export function selectForDiff(mutants, files, base, options = {}) {
const byFiles = selectChangedMutants(mutants, files, options.exists, options);
let byRegistry = [];
let removed = [];
if (files.includes('scripts/mutation-gate.mjs') && base) {
const delta = registryDelta(mutants, base);
byRegistry = mutants.filter((m) => delta.changed.includes(m.id));
removed = delta.removed;
}
const ids = new Set([...byFiles, ...byRegistry].map((m) => m.id));
return { selected: mutants.filter((m) => ids.has(m.id)), byFiles, byRegistry, removed };
}
export async function baseRegistry(baseRef) {
const shown = spawnSync('git', ['-C', repoRoot, 'show', `${baseRef}:scripts/mutation-gate.mjs`], { encoding: 'utf8' });
if (shown.status !== 0) return null;
const temp = join(repoRoot, 'scripts', `.mutation-gate.base-${process.pid}.mjs`);
writeFileSync(temp, shown.stdout);
try {
const mod = await import(`${pathToFileURL(temp).href}?t=${Date.now()}`);
return mod.MUTANTS;
} finally {
rmSync(temp, { force: true });
}
}
/**
@@ -7961,7 +8146,7 @@ export function shardMutants(mutants, index, total) {
return ordered.filter((_, position) => position % total === index - 1);
}
function main(argv) {
async function main(argv) {
const idArg = argv.find((a) => a.startsWith('--id='))?.slice(5);
let selected = idArg ? MUTANTS.filter((m) => m.id === idArg) : MUTANTS;
if (idArg && !selected.length) {
@@ -7987,9 +8172,19 @@ function main(argv) {
}
const files = diff.stdout.split('\n').filter(Boolean);
const before = selected.length;
selected = selectChangedMutants(selected, files);
// #492 §6.4: правка реестра отбирает добавленные и изменённые определения
// явно — новый свидетель не обязан трогать чужие patch/guard-файлы.
let base = null;
if (files.includes('scripts/mutation-gate.mjs')) {
const baseRef = range.includes('..') ? range.split('..')[0] : range;
base = await baseRegistry(baseRef);
if (!base) console.log(`реестр базы ${baseRef} не прочитан — отбор по определениям пропущен`);
}
const picked = selectForDiff(selected, files, base);
if (picked.removed.length) console.log(`удалены из реестра: ${picked.removed.join(', ')}`);
selected = picked.selected;
console.log(`дифф-режим ${range}: файлов в диффе ${files.length}, `
+ `мутантов затронуто ${selected.length} из ${before}`);
+ `мутантов затронуто ${selected.length} из ${before} (по файлам ${picked.byFiles.length}, по определениям ${picked.byRegistry.length})`);
if (!selected.length) {
console.log('дифф не задевает ни одного patch.file — гонять нечего; '
+ 'полный реестр остаётся предрелизным контрактом');
@@ -8084,5 +8279,8 @@ function main(argv) {
}
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
process.exit(main(process.argv.slice(2)));
main(process.argv.slice(2)).then((code) => process.exit(code), (err) => {
console.error(err);
process.exit(2);
});
}
+4 -2
View File
@@ -8,10 +8,12 @@ const localPython = process.platform === 'win32'
: join(process.cwd(), '.venv', 'bin', 'python');
const python = process.env.PYTHON
|| (existsSync(localPython) ? localPython : (process.platform === 'win32' ? 'python' : 'python3'));
/** Входы обёртки для отбора мутантов (#492 §6.1): запускаемые pytest-модули. */
export const GUARD_INPUTS = ['tests_backend/test_trails.py', 'tests_backend/test_trail_recorder.py'];
const result = spawnSync(python, [
'-m', 'pytest',
'tests_backend/test_trails.py',
'tests_backend/test_trail_recorder.py',
...GUARD_INPUTS,
'-q', '-k', 'resume or short_available',
], { stdio: 'inherit' });
+225
View File
@@ -0,0 +1,225 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { execFileSync } from 'node:child_process';
import {
CHECKS, CHECK_NAMES, NOT_AN_INPUT, REUSE_JOBS, checksAffectedBy, closure, coverage, globToRegExp,
inputsOf, isDeclaredNotAnInput, isExecutableInput, manifest, referencesOf, stripComments,
} from '../scripts/check-inputs.mjs';
// Единый manifest входов (#492 §5). Две группы доказательств: чистая механика
// (glob, ссылки, замыкание) на виртуальном дереве и представительные входы
// §8.1 на РЕАЛЬНОМ репозитории — для каждой тяжёлой job и каждой категории
// названный файл обязан быть её входом, а UI — не входом backend (AC6).
const ROOT = process.cwd();
const MANIFEST = manifest(ROOT);
// Пути, которых в manifest быть не должно, собираются из кусков — литерал в
// этом файле сделал бы их входом frontend (тесты читают то, что называют).
const p = (...parts) => parts.join('/');
test('glob: ** — любой путь, * — сегмент, точка буквальна', () => {
assert.ok(globToRegExp('src/**').test('src/a/b.ts'));
assert.ok(globToRegExp('src/**').test('src/a.ts'));
assert.ok(!globToRegExp('src/**').test('srcx/a.ts'));
assert.ok(globToRegExp('demo/smoke_*.mjs').test('demo/smoke_alpha.mjs'));
assert.ok(!globToRegExp('demo/smoke_*.mjs').test('demo/guard/smoke_alpha.mjs'));
assert.ok(globToRegExp('custom_components/**/*.py').test('custom_components/houseplan/store.py'));
assert.ok(!globToRegExp('custom_components/**/*.py').test('custom_components/houseplan/frontend/x.js'));
assert.ok(!globToRegExp('package.json').test('packageXjson'));
});
test('ссылки: импорты JS — код, строковые пути — данные, комментарии — ничего', () => {
const text = `
import a from './a.mjs';
import { b } from '../lib/b.mjs';
export * from './c.mjs';
const d = await import('./d.mjs');
const e = require('./e.mjs');
// import x from './comment.mjs';
/* readFileSync('demo/fixtures/comment.json') */
const f = readFileSync('demo/fixtures/f.json');
const g = 'docs/g.md';
const h = spawnSync('node', ['demo/benchmark_h.mjs', '--guard-probe']);
const hint = 'run node demo/benchmark_hint.mjs by hand';
`;
const refs = referencesOf('test/x.test.mjs', text);
assert.deepEqual(refs.code.sort(), [
'demo/benchmark_h.mjs', 'lib/b.mjs', 'test/a.mjs', 'test/c.mjs', 'test/d.mjs', 'test/e.mjs',
]);
// путь внутри фразы-подсказки — не ссылка: строка обязана быть путём целиком
assert.deepEqual(refs.data.sort(), ['demo/fixtures/f.json', 'docs/g.md']);
assert.ok(!stripComments('x.mjs', text).includes('comment.mjs'));
});
test('ссылки: test-build/*.js — это src/*.ts, компилируемый tsconfig.test.json', () => {
const refs = referencesOf('test/x.test.mjs', "import { f } from '../test-build/space-geometry.js';\n");
assert.deepEqual(refs.code, ['src/space-geometry.ts']);
});
test('ссылки: Python — пакеты репозитория, относительные модули relay, Path-цепочки', () => {
const text = `
from custom_components.houseplan.validation import CONFIG_SCHEMA
import tests_backend.pure_imports
from hp_relay.app import main
""" from custom_components.houseplan.ghost import x """
GOLDEN = REPO / "scripts" / "sh3d-convert" / "golden"
schema = (REPO / "scripts" / "config-schema.json").read_text()
`;
const refs = referencesOf('scripts/support-relay/tests/test_relay.py', text);
assert.ok(refs.code.includes('custom_components/houseplan/validation.py'));
assert.ok(refs.code.includes('tests_backend/pure_imports.py'));
assert.ok(refs.code.includes('scripts/support-relay/hp_relay/app.py'));
assert.ok(!refs.code.some((f) => f.includes('ghost')), 'docstring — не импорт');
assert.ok(refs.data.includes('scripts/sh3d-convert/golden'));
assert.ok(refs.data.includes('scripts/config-schema.json'));
});
test('замыкание: код транзитивно, данные — листья, каталог — все текстовые файлы под ним', () => {
const files = {
'demo/smoke_a.mjs': "import './serve.mjs';\nconst x = 'demo/fixtures';\n",
'demo/serve.mjs': "import './compat.mjs';\n",
'demo/compat.mjs': "import '../scripts/helper.mjs';\n",
'scripts/helper.mjs': "export const h = 1; // import './never.mjs'\n",
'scripts/never.mjs': '',
'demo/fixtures/one.mjs': "import '../deep.mjs';\n",
'demo/fixtures/two.json': '{}',
'demo/fixtures/pic.png': 'binary',
'demo/deep.mjs': '',
};
const tracked = Object.keys(files).sort();
const parents = new Map();
const reached = closure('/virtual', ['demo/smoke_a.mjs'], { tracked, read: (f) => files[f], parents });
assert.deepEqual(reached, [
'demo/compat.mjs', 'demo/fixtures/one.mjs', 'demo/fixtures/two.json', 'demo/serve.mjs',
'demo/smoke_a.mjs', 'scripts/helper.mjs',
]);
// фикстура — данные: её собственный импорт (deep.mjs) не читается,
// картинка под каталогом не берётся, комментарий не ссылка
assert.ok(!reached.includes('demo/deep.mjs'));
assert.ok(!reached.includes('demo/fixtures/pic.png'));
assert.ok(!reached.includes('scripts/never.mjs'));
assert.equal(parents.get('scripts/helper.mjs'), 'demo/compat.mjs');
});
test('замыкание останавливается на копиях бандла (класс D)', () => {
const files = {
'demo/smoke_a.mjs': "import '../custom_components/houseplan/frontend/houseplan-card.js';\n",
'custom_components/houseplan/frontend/houseplan-card.js': "import './houseplan-assets/x.js';\n",
'custom_components/houseplan/frontend/houseplan-assets/x.js': '',
};
const tracked = Object.keys(files).sort();
const spec = { entries: ['demo/smoke_a.mjs'], roots: [] };
const saved = CHECKS.__virtual;
CHECKS.__virtual = spec;
try {
const inputs = inputsOf('__virtual', '/virtual', { tracked, read: (f) => files[f] });
assert.deepEqual(inputs, ['demo/smoke_a.mjs']);
} finally {
if (saved) CHECKS.__virtual = saved; else delete CHECKS.__virtual;
}
});
test('каждая проверка объявлена, у тяжёлых job включён реюз, у остальных нет', () => {
assert.deepEqual(REUSE_JOBS, ['smoke', 'golden', 'performance_smoke', 'backend']);
for (const name of CHECK_NAMES) {
assert.ok(MANIFEST[name].size > 0, `${name}: пустой manifest`);
assert.ok(Array.isArray(CHECKS[name].entries) && Array.isArray(CHECKS[name].roots), name);
}
assert.throws(() => inputsOf('nope', ROOT), /неизвестная проверка/);
});
test('§8.1 представители: каждая категория каждой тяжёлой job — её вход', () => {
const expect = {
backend: {
source: ['custom_components/houseplan/websocket_api.py', 'scripts/support-relay/relay.py',
'scripts/support-relay/hp_relay/app.py', 'scripts/sh3d-convert/convert.mjs', 'scripts/dump-config-schema.py'],
tests: ['tests_backend/test_ha_websocket.py', 'tests_backend/conftest.py', 'scripts/support-relay/tests/test_relay.py'],
fixtures: ['scripts/sh3d-convert/golden/two-levels.space-1.json', 'scripts/config-schema.json',
'test/fixtures/real-plan-first-floor.json'],
config: ['pyproject.toml', 'pytest.ini', 'scripts/backend-coverage-baseline.txt'],
toolchain: ['tests_backend/requirements.txt', 'custom_components/houseplan/manifest.json', '.github/workflows/validate.yml'],
protocol: ['scripts/gate-reuse.mjs', 'scripts/check-inputs.mjs'],
},
smoke: {
source: ['src/houseplan-card.ts', 'src/logic.ts'],
tests: ['demo/smoke_infinite_canvas.mjs', 'demo/guard/verify-guard.mjs', 'demo/benchmark_glow.mjs'],
fixtures: ['demo/fixtures/large-house.mjs', 'demo/fixtures/wall-draw-click.mjs'],
config: ['rollup.config.mjs', 'tsconfig.json'],
toolchain: ['package.json', 'package-lock.json', '.github/workflows/validate.yml'],
protocol: ['demo/serve.mjs', 'demo/srv/demo.html', 'demo/bundle-freshness.mjs',
'demo/editor-runtime-compat.mjs', 'demo/iso-runtime-compat.mjs', 'scripts/smoke-select.mjs'],
},
golden: {
source: ['src/houseplan-card.ts'],
tests: ['demo/golden/run.mjs', 'demo/golden/matrix.mjs', 'demo/golden/harness.mjs'],
fixtures: ['demo/golden/baselines/geometry-view-dark-fit.png', 'demo/golden/baselines/baselines-index.json',
'demo/fixtures/visual-matrix.mjs'],
config: ['rollup.config.mjs', 'tsconfig.json'],
toolchain: ['package.json', '.github/workflows/validate.yml'],
protocol: ['demo/serve.mjs', 'demo/srv/demo.html', 'demo/bundle-freshness.mjs', 'demo/editor-runtime-compat.mjs'],
},
performance_smoke: {
source: ['src/houseplan-card.ts'],
tests: ['demo/benchmark_glow.mjs', 'demo/benchmark_large_house.mjs', 'demo/performance/compare.mjs'],
fixtures: ['demo/fixtures/large-house.mjs', 'demo/performance/budgets-glow-smoke.json',
'demo/performance/budgets-isometric-smoke.json', 'demo/performance/budgets-interaction-smoke.json'],
config: ['rollup.config.mjs'],
toolchain: ['package.json', '.github/workflows/validate.yml'],
protocol: ['demo/serve.mjs', 'demo/srv/demo.html', 'demo/editor-runtime-compat.mjs', 'demo/performance/evaluate.mjs'],
},
};
for (const [job, categories] of Object.entries(expect)) {
for (const [category, files] of Object.entries(categories)) {
for (const file of files) {
assert.ok(MANIFEST[job].has(file), `${job}/${category}: ${file} не вход`);
const { affected, unknown } = checksAffectedBy([file], ROOT, { manifest: MANIFEST });
assert.ok(affected.has(job), `${job}/${category}: ${file} не классифицируется`);
assert.deepEqual(unknown, [], file);
}
}
}
});
test('§8.1 обратная проба (AC6): UI — не вход backend, backend — не вход браузерных job без причины', () => {
for (const file of ['src/houseplan-card.ts', 'src/houseplan-editor-runtime.ts', 'src/iso-overlays.ts', 'package.json']) {
assert.ok(!MANIFEST.backend.has(file), `backend зависит от ${file}`);
assert.ok(!checksAffectedBy([file], ROOT, { manifest: MANIFEST }).affected.has('backend'), file);
}
// smoke_infinite_canvas запускает backend-валидацию как процесс — это
// честная зависимость; но tests_backend/** браузерным job не нужны
for (const job of ['smoke', 'golden', 'performance_smoke']) {
assert.ok(![...MANIFEST[job]].some((f) => f.startsWith('tests_backend/')), `${job} читает tests_backend`);
}
});
test('§5.5 лист покрытия: ни одного неизвестного исполняемого файла, ни одной лишней записи NOT_AN_INPUT', () => {
const { unknown, declaredButCovered } = coverage(ROOT, { manifest: MANIFEST });
assert.deepEqual(unknown, [], 'исполняемый файл без хозяина: впишите в CHECKS или в NOT_AN_INPUT с причиной');
assert.deepEqual(declaredButCovered, [], 'запись NOT_AN_INPUT лишняя — файл и так вход');
for (const [glob, reason] of NOT_AN_INPUT) assert.ok(reason.length > 10, `${glob}: нужна причина`);
});
test('неизвестный вход расширяет до всех проверок и называется; документация и не-входы — нет', () => {
const ghost = p('scripts', 'ghost-gate.mjs');
const { affected, unknown } = checksAffectedBy([ghost], ROOT, { manifest: MANIFEST });
assert.deepEqual(unknown, [ghost]);
assert.deepEqual([...affected].sort(), [...CHECK_NAMES].sort());
assert.ok(isExecutableInput(ghost));
assert.ok(!isExecutableInput(p('docs', 'ghost.md')));
assert.ok(isDeclaredNotAnInput(p('demo', 'shot_sun.mjs')));
const quiet = checksAffectedBy([p('docs', 'ghost.md'), p('demo', 'shot_sun.mjs')], ROOT, { manifest: MANIFEST });
assert.deepEqual(quiet.unknown, []);
assert.equal(quiet.affected.size, 0);
});
test('CLI: --check печатает входы, --why объясняет цепочку, --coverage зелёный на текущем дереве', () => {
const script = new URL('../scripts/check-inputs.mjs', import.meta.url).pathname;
const backend = execFileSync('node', [script, '--check=backend'], { encoding: 'utf8' }).trim().split('\n');
assert.ok(backend.includes('scripts/support-relay/relay.py'));
const why = execFileSync('node', [script, '--check=backend', '--why=scripts/sh3d-convert/convert.mjs'], { encoding: 'utf8' });
assert.match(why, /^scripts\/sh3d-convert\/convert\.mjs\n/);
assert.match(why, /tests_backend\/test_sh3d_convert\.py/);
const cov = execFileSync('node', [script, '--coverage'], { encoding: 'utf8' });
assert.equal(cov.trim(), '');
});
+73 -27
View File
@@ -1,74 +1,120 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { execFileSync } from 'node:child_process';
import { classifyChanges, classifyAll, formatOutputs, OUTPUTS, CLASSIFIERS } from '../scripts/classify-changes.mjs';
import {
CHECK_OF_OUTPUT, CLASSIFIERS, OUTPUTS, PERF_PROFILES, classifyAll, classifyChanges, formatOutputs,
} from '../scripts/classify-changes.mjs';
import { manifest } from '../scripts/check-inputs.mjs';
// #473 AC8: диффозависимость перф-смока доказана на самой функции
// классификации, которую исполняет job `changes`.
// Классификация идёт из единого manifest входов (#492 §5.2) на РЕАЛЬНОМ
// дереве репозитория: тест доказывает решения job `changes` для настоящих
// файлов, а не для выдуманных путей. Manifest считается один раз.
//
// Пути, которые НЕ должны быть входами, собираются из кусков: строковый
// литерал в этом файле сам сделал бы их входом frontend (тесты читают то, что
// называют), и проверка стала бы самосбывающейся.
const MANIFEST = manifest(process.cwd());
const classify = (files) => classifyChanges(files, { manifest: MANIFEST });
const p = (...parts) => parts.join('/');
test('дифф по изометрии включает perf_iso и только его из перф-выходов (#473 AC8)', () => {
const out = classifyChanges(['src/iso-x.ts']);
const out = classify(['src/iso-overlays.ts']);
assert.equal(out.perf_iso, 'true');
assert.equal(out.perf_interaction, 'false');
assert.equal(out.frontend, 'true');
assert.deepEqual(out.unknown, []);
});
test('дифф по живому пути и оркестраторам кадра включает perf_interaction (#473 AC8)', () => {
for (const file of ['src/live-x.ts', 'src/render-invalidation.ts',
for (const file of ['src/live-hover.ts', 'src/render-invalidation.ts',
'src/houseplan-render-lifecycle.ts', 'src/houseplan-card.ts']) {
const out = classifyChanges([file]);
const out = classify([file]);
assert.equal(out.perf_interaction, 'true', file);
assert.equal(out.perf_iso, 'false', file);
}
});
test('дифф по документации не включает ни одного перф-профиля (#473 AC8)', () => {
const out = classifyChanges(['docs/x.md']);
test('дифф по документации не включает ни одного перф-профиля и ни одной job (#473 AC8)', () => {
const out = classify([p('docs', 'SUN.md')]);
assert.equal(out.perf_iso, 'false');
assert.equal(out.perf_interaction, 'false');
assert.equal(out.frontend, 'false');
assert.equal(out.backend, 'false');
});
test('тесты и демо перф-профили не включают: кадр они не замедляют', () => {
const out = classifyChanges(['test/iso-scene-render.test.mjs', 'demo/benchmark_large_house.mjs', 'src/iso-x.test.ts.md']);
const out = classify(['test/iso-scene-render.test.mjs', 'demo/benchmark_large_house.mjs']);
assert.equal(out.perf_iso, 'false');
assert.equal(out.perf_interaction, 'false');
});
test('правка реестра мутантов даёт mutants=true и только его (#475 r1)', () => {
const out = classifyChanges(['scripts/mutation-gate.mjs']);
test('правка реестра мутантов даёт mutants=true; юниты реестра — тоже вход frontend (#475 r1, #492)', () => {
const out = classify(['scripts/mutation-gate.mjs']);
assert.equal(out.mutants, 'true');
assert.equal(out.frontend, 'false');
assert.equal(classifyChanges(['scripts/mutation-gate-report.mjs']).mutants, 'false');
assert.equal(classifyChanges(['src/x.ts']).mutants, 'false');
assert.equal(out.frontend, 'true', 'test/mutation-gate.test.mjs читает реестр');
assert.equal(out.backend, 'false');
assert.equal(classify(['src/color.ts']).mutants, 'true', 'патчи мутантов лежат в src/**');
});
test('прежние три выхода классифицируются как в inline-shell до выноса', () => {
assert.deepEqual(classifyChanges(['custom_components/houseplan/frontend_registration.py']),
{ frontend: 'false', backend: 'true', integration: 'true', perf_iso: 'false', perf_interaction: 'false', mutants: 'false' });
assert.deepEqual(classifyChanges(['custom_components/houseplan/frontend/houseplan-card.js']),
{ frontend: 'true', backend: 'false', integration: 'false', perf_iso: 'false', perf_interaction: 'false', mutants: 'false' });
assert.deepEqual(classifyChanges(['hacs.json', 'tsconfig.json']),
{ frontend: 'true', backend: 'false', integration: 'true', perf_iso: 'false', perf_interaction: 'false', mutants: 'false' });
assert.equal(classifyChanges(['scripts/support-relay/x.py']).backend, 'true');
assert.equal(classifyChanges(['']).frontend, 'false');
test('#492 §1.2: relay, converter, schema и pyproject запускают backend', () => {
for (const file of ['scripts/support-relay/relay.py', 'scripts/support-relay/hp_relay/app.py',
'scripts/sh3d-convert/convert.mjs', 'scripts/sh3d-convert/golden/two-levels.space-1.json',
'scripts/config-schema.json', 'scripts/dump-config-schema.py', 'pyproject.toml',
'custom_components/houseplan/frontend_registration.py']) {
const out = classify([file]);
assert.equal(out.backend, 'true', file);
assert.deepEqual(out.unknown, [], file);
}
});
test('#492 AC6: правка UI не классифицируется как backend', () => {
const out = classify(['src/houseplan-card.ts', 'src/houseplan-editor-runtime.ts']);
assert.equal(out.backend, 'false');
assert.equal(out.frontend, 'true');
});
test('интеграция: манифест, hacs.json, переводы (как в inline-shell до выноса)', () => {
assert.equal(classify(['custom_components/houseplan/manifest.json']).integration, 'true');
assert.equal(classify(['hacs.json']).integration, 'true');
assert.equal(classify(['custom_components/houseplan/translations/ru.json']).integration, 'true');
assert.equal(classify(['src/color.ts']).integration, 'false');
assert.equal(classify(['']).frontend, 'false');
});
test('копия бандла — не вход ни одной проверки (класс D)', () => {
const out = classify(['custom_components/houseplan/frontend/houseplan-card.js', 'dist/houseplan-card.js']);
for (const name of Object.keys(CHECK_OF_OUTPUT)) assert.equal(out[name], 'false', name);
assert.deepEqual(out.unknown, []);
});
test('#492 §5.2: неизвестный исполняемый вход расширяет прогон до полного набора и называется', () => {
const out = classify(['scripts/brand-new-gate.mjs']);
for (const name of OUTPUTS) assert.equal(out[name], 'true', name);
assert.deepEqual(out.unknown, ['scripts/brand-new-gate.mjs']);
// Объявленный не-вход и документация не расширяют.
const quiet = classify([p('demo', 'stand', 'README.md'), p('demo', 'shot_sun.mjs'), p('docs', 'new-page.md')]);
assert.deepEqual(quiet.unknown, []);
for (const name of OUTPUTS) assert.equal(quiet[name], 'false', name);
});
test('fallback --all выставляет каждый известный выход, включая перф-профили', () => {
const all = classifyAll();
assert.deepEqual(Object.keys(all), OUTPUTS);
assert.deepEqual(Object.keys(all).filter((k) => k !== 'unknown'), OUTPUTS);
assert.ok(OUTPUTS.every((name) => all[name] === 'true'));
assert.deepEqual(Object.keys(CLASSIFIERS), OUTPUTS);
assert.deepEqual(Object.keys(PERF_PROFILES), ['perf_iso', 'perf_interaction']);
});
test('CLI пишет формат $GITHUB_OUTPUT: stdin — список файлов, --all — всё true', () => {
const script = new URL('../scripts/classify-changes.mjs', import.meta.url).pathname;
const fromStdin = execFileSync('node', [script], { input: 'src/iso-x.ts\ndocs/x.md\n', encoding: 'utf8' });
assert.equal(fromStdin, formatOutputs(classifyChanges(['src/iso-x.ts', 'docs/x.md'])));
const doc = p('docs', 'SUN.md');
const fromStdin = execFileSync('node', [script], { input: `src/iso-overlays.ts\n${doc}\n`, encoding: 'utf8' });
assert.equal(fromStdin, formatOutputs(classify(['src/iso-overlays.ts', doc])));
assert.match(fromStdin, /^perf_iso=true$/m);
assert.match(fromStdin, /^perf_interaction=false$/m);
assert.match(fromStdin, /^unknown_inputs=$/m);
const all = execFileSync('node', [script, '--all'], { input: '', encoding: 'utf8' });
assert.equal(all, OUTPUTS.map((name) => `${name}=true`).join('\n') + '\n');
assert.equal(all, OUTPUTS.map((name) => `${name}=true`).join('\n') + '\nunknown_inputs=\n');
});
// #479: тяжёлые job идут на кандидате беты, по кнопке, на PR и по расписанию —
+95 -41
View File
@@ -7,13 +7,14 @@ import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import {
HARNESS, JOBS, harnessFiles, inheritedFailureNote, parseFailureMarker, reuseKey,
JOBS, harnessFiles, inheritedFailureNote, parseFailureMarker, reuseKey,
} from '../scripts/gate-reuse.mjs';
/**
* Дерево, минимально достаточное для sourceFingerprint плюс оснастка каждой
* тяжёлой job. Реальные каталоги, а не подмены: ключ обязан отражать файловую
* систему так же, как в CI.
* Дерево, минимально достаточное для manifest каждой тяжёлой job (#492):
* корни проверок, точки входа и то, что они импортируют. Реальные каталоги,
* а не подмены: ключ обязан отражать файловую систему так же, как в CI.
* Без `.git` manifest обходит дерево сам.
*/
const makeTree = () => {
const dir = mkdtempSync(join(tmpdir(), 'hp-reuse-'));
@@ -26,48 +27,63 @@ const makeTree = () => {
put('package-lock.json', '{"lockfileVersion":3}\n');
put('rollup.config.mjs', 'export default {};\n');
put('tsconfig.json', '{}\n');
put('.github/workflows/validate.yml', 'name: Validate\n');
put('scripts/source-fingerprint.mjs', '// pinned by the real repo copy\n');
put('scripts/gate-reuse.mjs', '// reuse protocol\n');
put('scripts/check-inputs.mjs', '// manifest\n');
put('src/card.ts', "export const CARD_VERSION = '1.0.0';\n");
put('demo/serve.mjs', "import './bundle-freshness.mjs';\n");
put('demo/bundle-freshness.mjs', 'export const fresh = 1;\n');
put('demo/srv/demo.html', '<div id="host"></div>\n');
put('demo/fixtures/one.mjs', 'export const fixture = 1;\n');
put('demo/smoke_alpha.mjs', 'console.log(1);\n');
put('demo/smoke_beta.mjs', 'console.log(2);\n');
put('demo/benchmark_glow.mjs', 'export const glow = 1;\n');
put('demo/golden/run.mjs', 'export const run = 1;\n');
put('demo/smoke_alpha.mjs', "import { launch } from './serve.mjs';\nimport '../scripts/model-invariants.mjs';\nconsole.log(1);\n");
put('demo/smoke_beta.mjs', "import { launch } from './serve.mjs';\nconsole.log(2);\n");
put('scripts/model-invariants.mjs', 'export const invariants = 1;\n');
put('demo/guard/verify-guard.mjs', '// probes\n');
put('demo/benchmark_glow.mjs', "import './serve.mjs';\nexport const glow = 1;\n");
put('demo/benchmark_large_house.mjs', "import './serve.mjs';\nimport './fixtures/one.mjs';\n");
put('demo/golden/run.mjs', "import '../serve.mjs';\nexport const run = 1;\n");
put('demo/golden/baselines/one.png', Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x00, 0x01]));
put('demo/performance/compare.mjs', 'export const cmp = 1;\n');
put('tests_backend/test_pure.py', 'def test_x():\n assert True\n');
put('demo/performance/budgets-glow-smoke.json', '{"hardMaxMs":1}\n');
put('tests_backend/test_pure.py', 'from custom_components.houseplan.store import VERSION\n\ndef test_x():\n assert True\n');
put('tests_backend/requirements.txt', 'pytest\n');
put('custom_components/houseplan/store.py', 'VERSION = 1\n');
put('custom_components/houseplan/manifest.json', '{"domain":"houseplan","version":"1.0.0"}\n');
put('custom_components/houseplan/frontend/houseplan-card.js', 'built bundle\n');
put('scripts/support-relay/relay.py', 'from hp_relay.app import main\n');
put('scripts/support-relay/hp_relay/app.py', 'def main():\n pass\n');
put('scripts/support-relay/tests/test_relay.py', 'from hp_relay.app import main\n');
put('scripts/config-schema.json', '{}\n');
put('pytest.ini', '[pytest]\n');
put('pyproject.toml', '[tool.ruff]\n');
put('scripts/backend-coverage-baseline.txt', '80.0\n');
put('docs/STATUS.md', 'status\n');
return { dir, put };
};
const keys = (dir) => Object.fromEntries(JOBS.map((job) => [job, reuseKey(dir, job)]));
test('every heavy job has a non-empty harness and its own key', () => {
test('every heavy job has non-empty inputs and its own key', () => {
const { dir } = makeTree();
try {
assert.deepEqual(JOBS, ['smoke', 'golden', 'performance_smoke', 'backend']);
const k = keys(dir);
for (const job of JOBS) assert.ok(harnessFiles(dir, job).length > 0, job);
// Ключи различаются между job: иначе правка чужой оснастки гасила бы чужой
// прогон, а совпадение ключей маскировало бы это как «то же самое».
const set = new Set(Object.values(keys(dir)));
assert.equal(set.size, JOBS.length);
assert.equal(new Set(Object.values(k)).size, JOBS.length, 'ключи job обязаны различаться');
assert.throws(() => reuseKey(dir, 'frontend'), /неизвестная job/);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test('documentation, workflows and unit tests leave every key untouched (#208)', () => {
test('documentation, the process canon and tooling nobody executes leave every key untouched (#208)', () => {
// Именно этот случай и оплачивал полный прогон: коммит, не меняющий ни одного
// входа поведения и ни одной оснастки.
// входа ни одной тяжёлой job.
const { dir, put } = makeTree();
try {
const before = keys(dir);
put('docs/STATUS.md', 'status changed\n');
put('.github/workflows/validate.yml', 'name: Validate\n');
put('test/some.test.mjs', 'import test from "node:test";\n');
put('scripts/process-gate.mjs', '// unrelated tooling\n');
put('PROCESS.md', 'canon\n');
@@ -77,19 +93,35 @@ test('documentation, workflows and unit tests leave every key untouched (#208)',
}
});
test('a behaviour input changes every key, including a version bump (#208)', () => {
test('the workflow itself is a toolchain input of every job (#492 §5.4)', () => {
// Правка шага job меняет, ЧТО проверяется; пропустить такую job как
// переиспользованную — тот же дефект, что #430, этажом выше.
const { dir, put } = makeTree();
try {
const before = keys(dir);
put('.github/workflows/validate.yml', 'name: Validate\n# step added\n');
for (const job of JOBS) assert.notEqual(reuseKey(dir, job), before[job], job);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test('a behaviour input changes every browser key and a version bump changes all of them (#208, #492 AC6)', () => {
const { dir, put } = makeTree();
try {
const before = keys(dir);
put('src/card.ts', "export const CARD_VERSION = '1.0.0';\n// behaviour\n");
for (const job of JOBS) assert.notEqual(reuseKey(dir, job), before[job], job);
for (const job of ['smoke', 'golden', 'performance_smoke']) assert.notEqual(reuseKey(dir, job), before[job], job);
// Обратная проба AC6: бэкенд UI не исполняет — его ключ на месте.
assert.equal(reuseKey(dir, 'backend'), before.backend, 'backend не зависит от src/**');
// Релизный кандидат бампает версию, поэтому его ключи заведомо новые и
// полный набор гейтов прогоняется всегда — переиспользование не может
// ослабить релизный гейт.
// Релизный кандидат бампает версию в package.json (браузерные job) и в
// manifest.json интеграции (backend): ключи кандидата заведомо новые, и
// полный набор гейтов прогоняется всегда.
const bumped = keys(dir);
put('src/card.ts', "export const CARD_VERSION = '1.1.0';\n// behaviour\n");
put('package.json', '{"name":"x","version":"1.1.0"}\n');
put('custom_components/houseplan/manifest.json', '{"domain":"houseplan","version":"1.1.0"}\n');
for (const job of JOBS) assert.notEqual(reuseKey(dir, job), bumped[job], job);
} finally {
rmSync(dir, { recursive: true, force: true });
@@ -104,19 +136,41 @@ test('harness edits are isolated to their own job (#208)', () => {
apply();
const after = keys(dir);
for (const job of JOBS) {
if (job === changed) assert.notEqual(after[job], before[job], `${job} должен меняться`);
if (changed.includes(job)) assert.notEqual(after[job], before[job], `${job} должен меняться`);
else assert.equal(after[job], before[job], `${job} меняться не должен`);
}
};
};
try {
only('smoke')(() => put('demo/smoke_alpha.mjs', 'console.log(3);\n'));
only(['smoke'])(() => put('demo/smoke_alpha.mjs', "import { launch } from './serve.mjs';\nconsole.log(3);\n"));
// Эталон — вход сравнения, его подмена обязана менять ключ golden.
only('golden')(() => put('demo/golden/baselines/one.png',
only(['golden'])(() => put('demo/golden/baselines/one.png',
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x00, 0x02])));
only('performance_smoke')(() => put('demo/performance/compare.mjs', 'export const cmp = 2;\n'));
only('backend')(() => put('custom_components/houseplan/store.py', 'VERSION = 2\n'));
only(['performance_smoke'])(() => put('demo/performance/compare.mjs', 'export const cmp = 2;\n'));
only(['backend'])(() => put('custom_components/houseplan/store.py', 'VERSION = 2\n'));
// Протокол браузерного харнеса общий для трёх job (#492 §5.1 protocol).
only(['smoke', 'golden', 'performance_smoke'])(() => put('demo/serve.mjs', "import './bundle-freshness.mjs';\n// harness\n"));
only(['smoke', 'golden', 'performance_smoke'])(() => put('demo/srv/demo.html', '<div id="host"></div>\n<!-- page -->\n'));
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test('#492 backend inputs the old HARNESS did not know: relay, schema, converter, pyproject', () => {
const { dir, put } = makeTree();
const bumps = (rel, text) => {
const before = reuseKey(dir, 'backend');
put(rel, text);
assert.notEqual(reuseKey(dir, 'backend'), before, `${rel}: правка не меняет ключ backend`);
};
try {
bumps('scripts/support-relay/relay.py', 'from hp_relay.app import main\n# changed\n');
bumps('scripts/support-relay/hp_relay/app.py', 'def main():\n return 1\n');
bumps('scripts/config-schema.json', '{"v":2}\n');
bumps('pyproject.toml', '[tool.ruff]\nline-length = 100\n');
bumps('scripts/backend-coverage-baseline.txt', '81.0\n');
bumps('tests_backend/requirements.txt', 'pytest==9\n');
} finally {
rmSync(dir, { recursive: true, force: true });
}
@@ -139,7 +193,7 @@ test('the key is canonical across line endings', () => {
const { dir, put } = makeTree();
try {
const before = keys(dir);
put('demo/smoke_alpha.mjs', 'console.log(1);\r\n');
put('demo/smoke_alpha.mjs', "import { launch } from './serve.mjs';\r\nimport '../scripts/model-invariants.mjs';\r\nconsole.log(1);\r\n");
assert.deepEqual(keys(dir), before, 'CRLF не должен рождать другой ключ');
} finally {
rmSync(dir, { recursive: true, force: true });
@@ -183,24 +237,26 @@ test('#430 ключ смоков покрывает всё, что эта job и
// разу. Здесь закреплено, что так больше не выйдет.
const { dir, put } = makeTree();
try {
put('demo/serve.mjs', '// harness\n');
put('demo/guard/verify-guard.mjs', '// probes\n');
put('demo/guard/guard_tail_exception.mjs', '// probe\n');
put('demo/benchmark_backdrop_decode.mjs', '// benchmark\n');
const files = harnessFiles(dir, 'smoke');
for (const rel of [
'demo/serve.mjs',
'demo/bundle-freshness.mjs',
'demo/srv/demo.html',
'demo/guard/verify-guard.mjs',
'demo/guard/guard_tail_exception.mjs',
'demo/benchmark_backdrop_decode.mjs',
'scripts/model-invariants.mjs',
]) {
assert.ok(files.includes(rel), `${rel} вне ключа смоков — его правка будет реюзнута`);
}
// И ключ обязан меняться от правки каждого из них: список файлов сам по
// себе ничего не гарантирует, если хэш их не читает.
for (const rel of files) {
if (rel === 'demo/golden/baselines/one.png') continue;
const before = reuseKey(dir, 'smoke');
put(rel, '// changed\n');
put(rel, readFileSync(join(dir, rel), 'utf8') + '// changed\n');
assert.notEqual(reuseKey(dir, 'smoke'), before, `${rel}: правка не меняет ключ`);
}
} finally {
@@ -208,20 +264,18 @@ test('#430 ключ смоков покрывает всё, что эта job и
}
});
test('HARNESS keeps scripts/** out of the keys on purpose', () => {
// Инфраструктурная работа правит scripts/** постоянно. Если бы каталог
// целиком попал в ключ, переиспользование не срабатывало бы никогда — ровно
// тот случай, ради которого #208 и заводился.
test('scripts/** enter a key only when the job actually reaches them (#208, #492)', () => {
// Инфраструктурная работа правит scripts/** постоянно. В ключ попадает не
// каталог, а то, что job импортирует или запускает: process-gate.mjs — нет,
// model-invariants.mjs (импорт смока) — да.
const { dir, put } = makeTree();
try {
put('scripts/process-gate.mjs', '// tooling\n');
for (const [job, spec] of Object.entries(HARNESS)) {
assert.ok(!spec.roots.includes('scripts'), `${job}: scripts в корнях обхода`);
assert.ok(
!harnessFiles(dir, job).some((rel) => rel.startsWith('scripts/')),
`${job}: scripts попал в оснастку`,
);
for (const job of JOBS) {
assert.ok(!harnessFiles(dir, job).includes('scripts/process-gate.mjs'), `${job}: process-gate в оснастке`);
}
assert.ok(harnessFiles(dir, 'smoke').includes('scripts/model-invariants.mjs'));
assert.ok(!harnessFiles(dir, 'backend').includes('scripts/model-invariants.mjs'));
} finally {
rmSync(dir, { recursive: true, force: true });
}
+222
View File
@@ -0,0 +1,222 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { execFileSync, spawnSync } from 'node:child_process';
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { MAX_ATTEMPTS, commentFor, decideMerge, mergeCandidate, realOps } from '../scripts/merge-candidate.mjs';
// #492 §4 / §8.4: слияние точного кандидата. Таблица решений — на чистой
// функции; последовательность операций — на фальшивых git/gh; эксперимент
// аудита «20 → 40» — на настоящем git в temp-репозитории.
test('§8.4 таблица решений decideMerge', () => {
assert.deepEqual(decideMerge({ fresh: false }), { action: 'reject-stale', to: 'S6-in-progress' });
assert.deepEqual(decideMerge({ fresh: true, conflict: true }), { action: 'conflict', to: 'S6-in-progress' });
// dev не двигался — fast-forward с lease
assert.deepEqual(decideMerge({ fresh: true, devMoved: false }), { action: 'fast-forward', to: 'S8-merged' });
assert.deepEqual(decideMerge({ fresh: true, devMoved: false, leaseRejected: true }), { action: 'retry' });
// dev двигался: сначала patch-id, потом Validate, потом push с lease
assert.deepEqual(decideMerge({ fresh: true, devMoved: true, patchIdEqual: false }), { action: 'rereview', to: 'S7-code-review' });
assert.deepEqual(decideMerge({ fresh: true, devMoved: true, patchIdEqual: true, validate: null }), { action: 'validate' });
assert.deepEqual(decideMerge({ fresh: true, devMoved: true, patchIdEqual: true, validate: 'missing' }), { action: 'validation-missing', to: 'S6-in-progress' });
assert.deepEqual(decideMerge({ fresh: true, devMoved: true, patchIdEqual: true, validate: 'red' }), { action: 'validation-red', to: 'S6-in-progress' });
assert.deepEqual(decideMerge({ fresh: true, devMoved: true, patchIdEqual: true, validate: 'green' }), { action: 'push', to: 'S8-merged' });
assert.deepEqual(decideMerge({ fresh: true, devMoved: true, patchIdEqual: true, validate: 'green', leaseRejected: true, attempt: 1 }), { action: 'retry' });
assert.deepEqual(decideMerge({ fresh: true, devMoved: true, patchIdEqual: true, validate: 'green', leaseRejected: true, attempt: 2 }), { action: 'retry' });
assert.deepEqual(decideMerge({ fresh: true, devMoved: true, patchIdEqual: true, validate: 'green', leaseRejected: true, attempt: MAX_ATTEMPTS }), { action: 'give-up', to: 'S6-in-progress' });
// ни один исход не ведёт в S8 без зелёного Validate при движении dev
for (const validate of [null, 'missing', 'red']) {
assert.notEqual(decideMerge({ fresh: true, devMoved: true, patchIdEqual: true, validate }).to, 'S8-merged', String(validate));
}
assert.equal(MAX_ATTEMPTS, 3);
});
test('каждый исход, меняющий метку, объясняется комментарием; успех — одной строкой', () => {
const ctx = { material: 'a'.repeat(40), actual: 'b'.repeat(40), candidate: 'c'.repeat(40), devNow: 'd'.repeat(40), branch: 'issue/1-x', runUrl: 'https://run', attempt: 3 };
for (const action of ['reject-stale', 'conflict', 'rereview', 'validation-red', 'validation-missing', 'give-up']) {
const body = commentFor(action, ctx);
assert.ok(body.length > 80, action);
assert.match(body, /S6-in-progress|S7-code-review/, action);
}
assert.match(commentFor('push', ctx), /^материал `aaaaaaaa` · dev@`dddddddd` → кандидат `cccccccc` · Validate https:\/\/run зелёный · слито$/);
assert.match(commentFor('fast-forward', ctx), /dev не двигался · слито$/);
assert.equal(commentFor('validate', ctx), '');
});
/**
* Фальшивые git/gh: `base` — merge-base материала с dev, `devTips` — вершины
* dev по порядку (следующая после каждого отклонённого lease), ответы
* Validate — по порядку кандидатов.
*/
function fakeOps({ base = 'dev0', devTips = ['dev0'], validate = [], leaseRejects = 0, patchIds = {}, branchTip, material, conflictOnce = false }) {
const calls = [];
let devIndex = 0;
let validateIndex = 0;
let rejects = leaseRejects;
let conflict = conflictOnce;
const dev = () => devTips[Math.min(devIndex, devTips.length - 1)];
return {
calls,
fetch: (...refs) => { calls.push(['fetch', ...refs]); },
revParse: (ref) => {
if (ref === 'origin/dev') return dev();
if (ref.startsWith('origin/issue')) return branchTip;
if (ref.endsWith('^')) return material;
return ref;
},
mergeBase: () => base,
diffNames: () => [],
patchId: (from, to) => patchIds[`${from}..${to}`] || 'same',
rebaseOnto: (tip, onto) => {
calls.push(['rebase', tip, onto]);
if (conflict) { conflict = false; return null; }
return `cand-${tip}-on-${dev()}`;
},
pushWithLease: (sha, ref, expected) => {
calls.push(['push', sha, ref, expected]);
if (ref === 'dev' && rejects > 0) { rejects -= 1; devIndex += 1; return false; }
return true;
},
waitValidate: async (sha) => {
calls.push(['validate', sha]);
const result = validate[Math.min(validateIndex, validate.length - 1)] || 'green';
validateIndex += 1;
return { result, url: `https://run/${sha}` };
},
comment: (issue, body) => { calls.push(['comment', body.split('\n')[0]]); },
log: () => {},
};
}
test('dev не двигался: push кандидата как есть, с lease на текущий dev', async () => {
const ops = fakeOps({ devTips: ['dev0'], branchTip: 'mat', material: 'mat' });
const r = await mergeCandidate({ branch: 'issue/1-x', material: 'mat', issue: 1, ops });
assert.equal(r.action, 'fast-forward');
assert.equal(r.merged, true);
assert.deepEqual(ops.calls.filter((c) => c[0] === 'push'), [['push', 'mat', 'dev', 'dev0']]);
assert.ok(!ops.calls.some((c) => c[0] === 'validate'), 'без движения dev Validate не ждётся');
});
test('эксперимент аудита: dev двигался, ребейз чистый, patch-id равен — Validate ОБЯЗАТЕЛЕН до push', async () => {
const ops = fakeOps({ devTips: ['dev1'], branchTip: 'mat', material: 'mat' });
const r = await mergeCandidate({ branch: 'issue/1-x', material: 'mat', issue: 1, ops });
assert.equal(r.action, 'push');
assert.equal(r.merged, true);
const order = ops.calls.map((c) => c[0]);
const validateAt = order.indexOf('validate');
const devPushAt = ops.calls.findIndex((c) => c[0] === 'push' && c[2] === 'dev');
assert.ok(validateAt >= 0 && validateAt < devPushAt, `Validate (${validateAt}) раньше push в dev (${devPushAt})`);
// кандидат сначала опубликован в ветку (от этого push стартует Validate)
assert.deepEqual(ops.calls.find((c) => c[0] === 'push'), ['push', 'cand-mat-on-dev1', 'issue/1-x', 'mat']);
assert.deepEqual(ops.calls.find((c) => c[0] === 'push' && c[2] === 'dev'), ['push', 'cand-mat-on-dev1', 'dev', 'dev1']);
});
test('красный Validate на кандидате — S6, без push в dev', async () => {
const ops = fakeOps({ devTips: ['dev1'], branchTip: 'mat', material: 'mat', validate: ['red'] });
const r = await mergeCandidate({ branch: 'issue/1-x', material: 'mat', issue: 1, ops });
assert.equal(r.action, 'validation-red');
assert.equal(r.to, 'S6-in-progress');
assert.equal(r.merged, false);
assert.ok(!ops.calls.some((c) => c[0] === 'push' && c[2] === 'dev'));
});
test('patch-id изменился при ребейзе — S7-code-review, без Validate и без push в dev', async () => {
const ops = fakeOps({ devTips: ['dev1'], branchTip: 'mat', material: 'mat', patchIds: { 'dev0..mat': 'p1', 'dev1..cand-mat-on-dev1': 'p2' } });
const r = await mergeCandidate({ branch: 'issue/1-x', material: 'mat', issue: 1, ops });
assert.equal(r.action, 'rereview');
assert.equal(r.to, 'S7-code-review');
assert.ok(!ops.calls.some((c) => c[0] === 'validate'));
assert.ok(!ops.calls.some((c) => c[0] === 'push' && c[2] === 'dev'));
});
test('dev ушёл снова после Validate: lease отклонён → новая попытка; трижды → S6', async () => {
const twice = fakeOps({ devTips: ['dev1', 'dev2', 'dev3'], branchTip: 'mat', material: 'mat', leaseRejects: 2 });
const ok = await mergeCandidate({ branch: 'issue/1-x', material: 'mat', issue: 1, ops: twice });
assert.equal(ok.action, 'push');
assert.equal(twice.calls.filter((c) => c[0] === 'validate').length, 3, 'каждый новый кандидат проверен заново');
assert.equal(twice.calls.filter((c) => c[0] === 'push' && c[2] === 'dev').length, 3);
const always = fakeOps({ devTips: ['dev1', 'dev2', 'dev3', 'dev4'], branchTip: 'mat', material: 'mat', leaseRejects: 99 });
const giveUp = await mergeCandidate({ branch: 'issue/1-x', material: 'mat', issue: 1, ops: always });
assert.equal(giveUp.action, 'give-up');
assert.equal(giveUp.to, 'S6-in-progress');
assert.equal(giveUp.merged, false);
});
test('ветка уехала после материала — #312, без ребейза и push', async () => {
const ops = fakeOps({ devTips: ['dev0'], branchTip: 'other', material: 'mat' });
ops.revParse = (ref) => (ref === 'origin/dev' ? 'dev0' : ref.startsWith('origin/issue') ? 'other' : ref.endsWith('^') ? 'zzz' : ref);
const r = await mergeCandidate({ branch: 'issue/1-x', material: 'mat', issue: 1, ops });
assert.equal(r.action, 'reject-stale');
assert.ok(!ops.calls.some((c) => c[0] === 'push' || c[0] === 'rebase'));
});
test('конфликт при ребейзе — S6 с инструкцией, без push', async () => {
const ops = fakeOps({ devTips: ['dev1'], branchTip: 'mat', material: 'mat', conflictOnce: true });
const r = await mergeCandidate({ branch: 'issue/1-x', material: 'mat', issue: 1, ops });
assert.equal(r.action, 'conflict');
assert.ok(!ops.calls.some((c) => c[0] === 'push'));
});
// --- настоящий git: «20 → 40» -----------------------------------------------
const git = (cwd, ...args) => execFileSync('git', ['-C', cwd, ...args], { encoding: 'utf8' }).trim();
test('на настоящем git: чистый ребейз с равным patch-id и изменённым поведением идёт через Validate, не мимо', async () => {
const dir = mkdtempSync(join(tmpdir(), 'hp-merge-'));
try {
const bare = join(dir, 'origin.git');
execFileSync('git', ['init', '-q', '--bare', bare]);
const work = join(dir, 'work');
execFileSync('git', ['clone', '-q', bare, work]);
const cfg = ['-c', 'user.name=t', '-c', 'user.email=t@x'];
const commit = (msg) => execFileSync('git', ['-C', work, ...cfg, 'commit', '-q', '-am', msg]);
writeFileSync(join(work, 'a.mjs'), 'export const a = 20;\n');
writeFileSync(join(work, 'b.mjs'), 'export const b = 1;\n');
git(work, 'add', '.');
commit('base');
git(work, 'branch', '-M', 'dev');
git(work, 'push', '-q', '-u', 'origin', 'dev');
// ветка задачи: b = a * 2 (проверено ревью при a = 20 → 40)
git(work, 'checkout', '-q', '-b', 'issue/7-double');
writeFileSync(join(work, 'b.mjs'), "import { a } from './a.mjs';\nexport const b = a * 2;\n");
commit('double');
const material = git(work, 'rev-parse', 'HEAD');
git(work, 'push', '-q', '-u', 'origin', 'issue/7-double');
// dev уходит вперёд: a = 40 — другой файл, конфликта нет, поведение b: 40 → 80
git(work, 'checkout', '-q', 'dev');
writeFileSync(join(work, 'a.mjs'), 'export const a = 40;\n');
commit('a is 40 now');
git(work, 'push', '-q', 'origin', 'dev');
const calls = [];
const ops = realOps({ repo: 'x/y', token: 'none' });
ops.pushWithLease = (sha, ref, expected) => {
calls.push(['push', ref, expected]);
const r = spawnSync('git', ['-C', work, 'push', '-q', `--force-with-lease=refs/heads/${ref}:${expected}`, 'origin', `${sha}:refs/heads/${ref}`], { encoding: 'utf8' });
return r.status === 0;
};
ops.waitValidate = async (sha) => { calls.push(['validate', sha]); return { result: 'green', url: 'https://run/1' }; };
ops.comment = (issue, body) => { calls.push(['comment', body.slice(0, 40)]); };
ops.log = () => {};
const inWork = (fn) => (...args) => { const cwd = process.cwd(); process.chdir(work); try { return fn(...args); } finally { process.chdir(cwd); } };
for (const name of ['fetch', 'revParse', 'mergeBase', 'diffNames', 'patchId', 'rebaseOnto']) ops[name] = inWork(ops[name]);
const r = await mergeCandidate({ branch: 'issue/7-double', material, issue: 7, ops });
assert.equal(r.action, 'push', JSON.stringify(calls));
const validateAt = calls.findIndex((c) => c[0] === 'validate');
const devPushAt = calls.findIndex((c) => c[0] === 'push' && c[1] === 'dev');
assert.ok(validateAt >= 0 && validateAt < devPushAt, 'без Validate кандидат в dev не уходит');
const devTip = git(work, 'rev-parse', 'origin/dev');
assert.equal(devTip, r.candidate, 'в dev ровно проверенный кандидат');
assert.match(git(work, 'show', `${devTip}:a.mjs`), /a = 40/);
assert.match(git(work, 'show', `${devTip}:b.mjs`), /a \* 2/);
// lease: dev ждали на вершине «a is 40 now»
const lease = calls.find((c) => c[0] === 'push' && c[1] === 'dev')[2];
assert.equal(lease, git(work, 'rev-parse', `${devTip}^`));
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
+87
View File
@@ -464,3 +464,90 @@ test('#499: ни один гвард реестра не собирает бан
const source = readFileSync(new URL('../scripts/mutation-gate.mjs', import.meta.url), 'utf8');
assert.match(source, /гвард сам собирает бандл — сборку делает раннер \(#499\)/);
});
// --- #492 §6: замыкание входов гарда, обёртки, определения реестра -----------
import {
baseRegistry, guardInputs, registryDelta, selectForDiff, wrapperInputs,
} from '../scripts/mutation-gate.mjs';
test('#492 §6.1: обёртки объявляют GUARD_INPUTS, и объявление читается статически', () => {
assert.deepEqual(wrapperInputs('scripts/backend-test-guard.mjs'), ['tests_backend/test_ha_import_export.py']);
assert.deepEqual(wrapperInputs('scripts/trail-resume-test-guard.mjs'),
['tests_backend/test_trails.py', 'tests_backend/test_trail_recorder.py']);
assert.deepEqual(wrapperInputs('test/x.test.mjs'), [], 'не обёртка — нет объявления');
// умолчание в коде обёртки совпадает с объявлением
const wrapper = readFileSync(join(repoRoot, 'scripts/backend-test-guard.mjs'), 'utf8');
assert.match(wrapper, /process\.argv\[3\] \|\| GUARD_INPUTS\[0\]/);
// каждая обёртка, которую использует реестр, объявляет входы
const wrappers = new Set(MUTANTS.flatMap((m) => guardFiles(m.guard)).filter((f) => /^scripts\/[\w-]+-guard\.mjs$/.test(f)));
for (const file of wrappers) assert.ok(wrapperInputs(file).length > 0, `${file}: нет GUARD_INPUTS`);
});
test('#492 §6.1: умолчание обёртки действует без третьего аргумента и уступает явному файлу', () => {
const byDefault = guardInputs('node scripts/backend-test-guard.mjs some_pattern');
assert.ok(byDefault.includes('tests_backend/test_ha_import_export.py'));
assert.ok(byDefault.includes('tests_backend/conftest.py'), 'pytest подхватывает conftest');
assert.ok(byDefault.includes('scripts/backend-test-guard.mjs'), 'текст обёртки — в отпечатке');
const explicit = guardInputs('node scripts/backend-test-guard.mjs some_pattern tests_backend/test_ha_websocket.py');
assert.ok(explicit.includes('tests_backend/test_ha_websocket.py'));
assert.ok(!explicit.includes('tests_backend/test_ha_import_export.py'), 'явный файл отменяет умолчание');
const trail = guardInputs('node scripts/trail-resume-test-guard.mjs');
assert.ok(trail.includes('tests_backend/test_trails.py') && trail.includes('tests_backend/test_trail_recorder.py'));
});
test('#492 §6.2: смок-гард тянет serve.mjs, compat-хелперы и фикстуры; src/** остаётся стороной патча', () => {
const inputs = guardInputs('node demo/smoke_zigbee_topology_hover.mjs');
for (const file of ['demo/serve.mjs', 'demo/editor-runtime-compat.mjs', 'demo/bundle-freshness.mjs']) {
assert.ok(inputs.includes(file), `${file} вне входов смок-гарда`);
}
assert.ok(!inputs.some((f) => f.startsWith('src/')), 'src/** — не вход гарда (§6.4)');
assert.ok(!inputs.some((f) => f.includes('/frontend/')), 'копия бандла — не вход');
});
test('#492 §8.2: правка запускаемого теста отбирает свидетелей обёрток и меняет их отпечаток', () => {
const defaults = MUTANTS.filter((m) => /^node scripts\/backend-test-guard\.mjs \S+$/.test(m.guard));
assert.ok(defaults.length >= 10, `обёрток без третьего аргумента: ${defaults.length}`);
const selected = new Set(selectChangedMutants(MUTANTS, ['tests_backend/test_ha_import_export.py']).map((m) => m.id));
for (const m of defaults) assert.ok(selected.has(m.id), `${m.id} не отобран правкой import-export теста`);
const explicitOther = MUTANTS.find((m) => m.guard.includes('tests_backend/test_ha_websocket.py'));
assert.ok(!selected.has(explicitOther.id), 'явный websocket-гард не отбирается правкой import-export');
const trail = MUTANTS.find((m) => m.id === 'vacuum-trail-resume-disabled');
assert.ok(selectChangedMutants(MUTANTS, ['tests_backend/test_trails.py']).some((m) => m.id === trail.id));
const before = witnessFingerprint(trail);
const after = witnessFingerprint(trail, {
read: (file) => (file === 'tests_backend/test_trails.py' ? '# changed\n' : (existsSync(join(repoRoot, file)) ? readFileSync(join(repoRoot, file), 'utf8') : '')),
});
assert.notEqual(after, before, 'правка test_trails.py обязана менять отпечаток trail-свидетеля');
const smokes = MUTANTS.filter((m) => /^node demo\/smoke_/.test(m.guard));
const byServe = new Set(selectChangedMutants(MUTANTS, ['demo/serve.mjs']).map((m) => m.id));
for (const m of smokes) assert.ok(byServe.has(m.id), `${m.id}: смок-свидетель не отобран правкой serve.mjs`);
});
test('#492 §6.4: дифф только по реестру отбирает добавленные и изменённые определения', () => {
const a = { id: 'a', guard: 'node --test test/a.test.mjs', patches: [{ file: 'src/a.ts', find: '1', replace: '2' }], because: 'a' };
const b = { id: 'b', guard: 'node --test test/b.test.mjs', patches: [{ file: 'src/b.ts', find: '1', replace: '2' }], because: 'b' };
const bChanged = { ...b, patches: [{ file: 'src/b.ts', find: '1', replace: '3' }] };
const c = { id: 'c', guard: 'node --test test/c.test.mjs', patches: [{ file: 'src/c.ts', find: '1', replace: '2' }], because: 'c' };
assert.deepEqual(registryDelta([a, bChanged, c], [a, b, { ...c, id: 'gone' }]), { changed: ['b', 'c'], removed: ['gone'] });
const picked = selectForDiff([a, bChanged, c], ['scripts/mutation-gate.mjs'], [a, b], { guardInputs: () => [] });
assert.deepEqual(picked.selected.map((m) => m.id), ['b', 'c']);
assert.deepEqual(picked.byFiles, []);
// без реестра базы отбор по определениям невозможен — и это не «ничего не изменилось»
const blind = selectForDiff([a, bChanged, c], ['scripts/mutation-gate.mjs'], null, { guardInputs: () => [] });
assert.deepEqual(blind.selected, []);
// дифф без реестра — определения не смотрятся
const plain = selectForDiff([a, bChanged, c], ['src/a.ts'], [a, b], { guardInputs: () => [] });
assert.deepEqual(plain.selected.map((m) => m.id), ['a']);
});
test('#492 §6.4: реестр базы читается из git без побочных эффектов', async () => {
const base = await baseRegistry('HEAD');
assert.ok(Array.isArray(base) && base.length > 500, 'реестр HEAD прочитан');
assert.ok(!existsSync(join(repoRoot, 'scripts')) || !readFileSync(join(repoRoot, 'scripts/mutation-gate.mjs'), 'utf8').includes('\0'));
assert.equal(await baseRegistry('0000000000000000000000000000000000000000'), null, 'нет такой базы — null, не бросок');
const leftovers = (await import('node:fs')).readdirSync(join(repoRoot, 'scripts')).filter((f) => f.startsWith('.mutation-gate.base-'));
assert.deepEqual(leftovers, [], 'временный модуль удалён');
});
+29
View File
@@ -0,0 +1,29 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
// #492 §7: ночной workflow обязан ждать дочерний Validate и наследовать его
// исход — успешный dispatch не равен успешной проверке.
const read = (name) => readFileSync(new URL(`../.github/workflows/${name}`, import.meta.url), 'utf8');
test('nightly ждёт запущенный Validate и падает вместе с ним (#492 §7)', () => {
const nightly = read('nightly.yml');
assert.match(nightly, /gh workflow run validate\.yml --repo "\$REPO" --ref dev -f full=true/);
// найти именно свой прогон: dispatch на dev, созданный не раньше запуска
assert.match(nightly, /gh run list --repo "\$REPO" --workflow validate\.yml --branch dev/);
assert.match(nightly, /--event workflow_dispatch/);
assert.match(nightly, /createdAt >= /);
// отсутствие прогона — ошибка, не тихий успех
assert.match(nightly, /прогон Validate не появился[^\n]*\n\s+exit 1/);
// ждать с наследованием кода возврата
assert.match(nightly, /gh run watch "\$run_id" --repo "\$REPO" --exit-status/);
assert.match(nightly, /timeout-minutes: 90/);
assert.match(nightly, /set -euo pipefail/);
});
test('ночная job носит русское имя и не выдаёт очередь за результат (#327, #492)', () => {
const nightly = read('nightly.yml');
assert.match(nightly, /name: "Запустить Validate на dev с полным набором и дождаться результата"/);
assert.ok(!/поставлен в очередь[^\n]*\n\s*$/.test(nightly), 'echo про очередь не может быть последним шагом');
});
+7 -2
View File
@@ -179,8 +179,13 @@ test('перф-смок добавляет профиль ровно при св
const changes = workflow.slice(workflow.indexOf('\n changes:\n'), workflow.indexOf('\n reuse:\n'));
assert.match(changes, /perf_iso: \$\{\{ steps\.classify\.outputs\.perf_iso \}\}/);
assert.match(changes, /perf_interaction: \$\{\{ steps\.classify\.outputs\.perf_interaction \}\}/);
// Выходы пишет скрипт, а не inline-shell: шаблоны проверяются unit-тестом (AC8).
assert.match(changes, /printf '%s\\n' "\$files" \| node scripts\/classify-changes\.mjs \| tee -a "\$GITHUB_OUTPUT"/);
// Выходы пишет скрипт, а не inline-shell: решение проверяется unit-тестом
// (AC8); #492 §5.2 — тот же вывод читается и для summary неизвестных входов.
assert.match(changes, /printf '%s\\n' "\$files" \| node scripts\/classify-changes\.mjs > \/tmp\/classify\.out/);
assert.match(changes, /tee -a "\$GITHUB_OUTPUT" < \/tmp\/classify\.out/);
assert.match(changes, /unknown_inputs: \$\{\{ steps\.classify\.outputs\.unknown_inputs \}\}/);
assert.match(changes, /sed -n 's\/\^unknown_inputs=\/\/p' \/tmp\/classify\.out/);
assert.match(changes, /Неизвестные входы \(#492\)/);
// Все три fallback-а «без классификации» идут через тот же скрипт с --all:
// новый выход не может выпасть из fallback-а.
const fallbacks = changes.split('node scripts/classify-changes.mjs --all >> "$GITHUB_OUTPUT"').length - 1;