mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-06 14:39:22 +00:00
Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6267da7e28 | ||
|
|
6cf6fc787e | ||
|
|
976b34467f | ||
|
|
9457690174 | ||
|
|
ddbd9c1900 | ||
|
|
59f51048c5 | ||
|
|
378d74fec4 | ||
|
|
a0cc0d849a | ||
|
|
1f432245db | ||
|
|
96ecf962ed |
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
```
|
||||
@@ -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
@@ -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, одна ветка, одно ревью.
|
||||
@@ -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) |
|
||||
|
||||
@@ -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, [
|
||||
|
||||
Executable
+410
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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');
|
||||
|
||||
Executable
+244
@@ -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
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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' });
|
||||
|
||||
|
||||
Executable
+225
@@ -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(), '');
|
||||
});
|
||||
@@ -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
@@ -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 });
|
||||
}
|
||||
|
||||
Executable
+222
@@ -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 });
|
||||
}
|
||||
});
|
||||
@@ -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, [], 'временный модуль удалён');
|
||||
});
|
||||
|
||||
@@ -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 про очередь не может быть последним шагом');
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user