diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 7aa6f23d..7a50a42c 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -450,6 +450,68 @@ jobs: - name: Hassfest validation uses: home-assistant/actions/hassfest@master + # Мутанты по диффу (#475). Свидетель гниёт двумя способами: изменился + # файл, который он патчит, либо изменился его гард. `--check` этого не + # видит — он проверяет существование якоря, а не достижимость ветки. + # Полный прогон раз в неделю ловит гниль с опозданием до релиза (#466, + # #467). Здесь гоняются только затронутые мутанты — единицы, минуты — + # с той же базы диапазона, что и остальные гейты ветки (#387/#388). + changed_mutants: + name: "Мутанты по диффу: затронутые свидетели краснеют" + needs: changes + if: needs.changes.outputs.frontend == 'true' || needs.changes.outputs.backend == 'true' + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + - uses: actions/setup-node@v7 + with: + node-version: 22 + cache: npm + - run: npm ci + - uses: actions/setup-python@v7 + with: + python-version: '3.14' + - name: Backend test dependencies + run: pip install -r tests_backend/requirements.txt + - name: Кэш браузеров Playwright + id: pw + uses: actions/cache@v6 + with: + path: ~/.cache/ms-playwright + key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }} + - name: Установить Chromium + if: steps.pw.outputs.cache-hit != 'true' + run: npx playwright install --with-deps chromium + - name: Затронутые мутанты ловятся + env: + EVENT_NAME: ${{ github.event_name }} + BEFORE_SHA: ${{ github.event.before }} + PROVEN_BASE: ${{ needs.changes.outputs.range_base }} + HEAD_SHA: ${{ github.sha }} + REF: ${{ github.ref }} + run: | + set -euo pipefail + git fetch -q origin dev + zero=$(printf '%040d' 0) + base="" + if [ "$EVENT_NAME" != "pull_request" ] && [ "$REF" = "refs/heads/dev" ]; then + base="${PROVEN_BASE:-$BEFORE_SHA}" + fi + if [ -z "$base" ] || [ "$base" = "$zero" ] \ + || ! git cat-file -e "$base" 2>/dev/null; then + base=$(git merge-base origin/dev "$HEAD_SHA" || echo "") + fi + if [ -z "$base" ]; then + echo "не нашёл базу диапазона — мутанты по диффу пропущены, это увидит следующий пуш" + exit 0 + fi + echo "диапазон: $base..$HEAD_SHA" + npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs + node scripts/mutation-gate.mjs --changed="$base..$HEAD_SHA" + frontend: name: "Фронтенд: типы, юниты, мутанты, синхрон бандла" needs: changes diff --git a/scripts/mutation-gate.mjs b/scripts/mutation-gate.mjs index ea27c57f..4e50092c 100644 --- a/scripts/mutation-gate.mjs +++ b/scripts/mutation-gate.mjs @@ -2862,6 +2862,30 @@ const MUTANT_DEFINITIONS = [ replace: " if (asEscaped) { escaped.add(asEscaped[1]); continue; }\n if (/^FAIL (\\S+)/.test(line)) { escaped.add(line.split(' ')[1].replace(/:$/, '')); continue; }", }], }, + { + id: 'changed-selection-ignores-guard-files', + guard: 'node --test --test-name-pattern="#475 AC2" test/mutation-gate.test.mjs', + because: 'a witness also rots when its guard changes and stops reaching the mutated branch; ' + + 'selecting by patch file alone lets that class survive until the weekly full run (#475)', + patches: [{ + file: 'scripts/mutation-gate.mjs', + // Реестр живёт в том же файле, что и код: якорь собирается из двух + // частей, иначе --check найдёт его дважды — в коде и здесь. + find: ' || guardFiles(m.guard, exists)' + '.some((file) => changed.has(file)));', + replace: ' || false);', + }], + }, + { + id: 'changed-selection-matches-any-token', + guard: 'node --test --test-name-pattern="#475 AC3" test/mutation-gate.test.mjs', + because: 'treating every token of the guard command as a file turns pattern fragments ' + + 'like `b.mjs"` into guard files and selects mutants for unrelated diffs (#475)', + patches: [{ + file: 'scripts/mutation-gate.mjs', + find: " if (!/^[\\w./-]+\\.(mjs|py)$/.test(bare) || bare.startsWith('-')) continue;", + replace: " if (!/\\.(mjs|py)/.test(bare)) continue;", + }], + }, { id: 'review-comment-source-ignores-issue-number', guard: 'node --test --test-name-pattern="по документу ЭТОЙ задачи|чужой номер задачи" ' @@ -6975,9 +6999,41 @@ function runCleanGuards(mutants) { * проверки и ревью-циклов; полный набор остаётся предрелизным контрактом, * поэтому пустая выборка — честный успех с явным сообщением, а не ошибка. */ -export function selectChangedMutants(mutants, changedFiles) { +/** + * Файлы, на которые ссылается команда гарда (#475). + * + * Без парсинга команды: берутся токены с суффиксом `.mjs`, `.test.mjs` или + * `.py`, которые существуют в репозитории. Флаги (`--test-name-pattern=…`), + * шаблоны и произвольные слова файлами не считаются. Фикстуры гардов + * (`test/fixtures/*`, `tests_backend/fixtures/*`) по команде вывести нельзя — + * это граница: их дрейф остаётся полному прогону. + */ +export function guardFiles(guard, exists = (file) => existsSync(join(repoRoot, file))) { + const files = new Set(); + for (const token of String(guard || '').split(/\s+/)) { + const bare = token.replace(/^["']|["']$/g, ''); + // Путь, а не обрывок шаблона: только [A-Za-z0-9_./-], без кавычек, + // «|» и флагов. `--test-name-pattern="a|b.mjs"` даёт токен `b.mjs"` — + // он не файл, даже если бы такой существовал. + if (!/^[\w./-]+\.(mjs|py)$/.test(bare) || bare.startsWith('-')) continue; + if (exists(bare)) files.add(bare); + } + return [...files]; +} + +/** + * Мутанты, затронутые диффом (#332, расширено в #475). + * + * Два способа свидетелю сгнить: изменился файл, который он патчит, — либо + * изменился его гард (тест, смок, pytest-модуль), и тот перестал ходить по + * мутированной ветке. Прежде отбор видел только первый; так после #302/#309 + * четыре мутанта пережили свои гарды и обнаружились лишь полным прогоном + * перед v1.72.0 (#466, #467). + */ +export function selectChangedMutants(mutants, changedFiles, exists) { const changed = new Set(changedFiles); - return mutants.filter((m) => m.patches.some((patch) => changed.has(patch.file))); + return mutants.filter((m) => m.patches.some((patch) => changed.has(patch.file)) + || guardFiles(m.guard, exists).some((file) => changed.has(file))); } /** diff --git a/test/mutation-gate.test.mjs b/test/mutation-gate.test.mjs index 2bd5069c..4da1643b 100644 --- a/test/mutation-gate.test.mjs +++ b/test/mutation-gate.test.mjs @@ -6,8 +6,7 @@ import { join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { - MUTANTS, applyPatches, guardNeedsBundle, guardNeedsTestBuild, - selectChangedMutants, shardMutants, + MUTANTS, applyPatches, guardNeedsBundle, guardNeedsTestBuild, selectChangedMutants, shardMutants, guardFiles, } from '../scripts/mutation-gate.mjs'; const repoRoot = fileURLToPath(new URL('..', import.meta.url)); @@ -274,3 +273,56 @@ test('#472 AC7: отсутствие Telegram-секретов не роняет test('#472 AC8: Validate сверяет mutation-gate.yml между main и dev наравне с process.yml', () => { assert.match(validateWorkflowText, /for file in process\.yml mutation-gate\.yml; do/); }); + +// #475. Свидетель гниёт двумя способами: изменился файл, который он патчит, +// либо изменился его гард — и тот перестал ходить по мутированной ветке. +// Прежний отбор по диффу видел только первый; четыре мутанта пережили свои +// гарды после #302/#309 и нашлись лишь полным прогоном перед v1.72.0. + +const always = () => true; + +test('#475 AC1: мутант отбирается по изменённому файлу патча', () => { + const m = { id: 'x', guard: 'node --test test/x.test.mjs', patches: [{ file: 'src/x.ts' }] }; + assert.equal(selectChangedMutants([m], ['src/x.ts'], always).length, 1); + assert.equal(selectChangedMutants([m], ['src/y.ts'], always).length, 0); +}); + +test('#475 AC2: мутант отбирается по изменённому файлу гарда — смок, юнит, pytest', () => { + const smoke = { id: 's', guard: 'node demo/smoke_x.mjs', patches: [{ file: 'src/a.ts' }] }; + const unit = { id: 'u', guard: 'node --test --test-name-pattern="p" test/u.test.mjs', patches: [{ file: 'src/a.ts' }] }; + const py = { id: 'p', guard: 'python3 -m pytest tests_backend/test_x.py -q -p no:cacheprovider', patches: [{ file: 'custom_components/houseplan/x.py' }] }; + assert.deepEqual(selectChangedMutants([smoke, unit, py], ['demo/smoke_x.mjs'], always).map((m) => m.id), ['s']); + assert.deepEqual(selectChangedMutants([smoke, unit, py], ['test/u.test.mjs'], always).map((m) => m.id), ['u']); + assert.deepEqual(selectChangedMutants([smoke, unit, py], ['tests_backend/test_x.py'], always).map((m) => m.id), ['p']); +}); + +test('#475 AC3: флаги и шаблоны гарда файлами не считаются', () => { + const files = guardFiles('node --test --test-name-pattern="magnet presses|x.mjs" test/furniture.test.mjs', always); + assert.deepEqual(files, ['test/furniture.test.mjs']); + // несуществующий путь — не файл гарда, даже если похож + assert.deepEqual(guardFiles('node demo/smoke_nope.mjs', () => false), []); + assert.deepEqual(guardFiles('', always), []); +}); + +test('#475 AC5: дифф, не задевающий ни патчей, ни гардов, ничего не отбирает', () => { + const m = { id: 'x', guard: 'node demo/smoke_x.mjs', patches: [{ file: 'src/x.ts' }] }; + assert.deepEqual(selectChangedMutants([m], ['docs/README.md', 'src/other.ts'], always), []); +}); + +test('#475 AC6: воспроизведение #467 — дифф по src/wall-thickness.ts отбирает multi-wall мутантов', () => { + const ids = selectChangedMutants(MUTANTS, ['src/wall-thickness.ts']).map((m) => m.id); + for (const id of ['multi-wall-orthogonal-strip-protection-disabled', 'multi-wall-exterior-corridor-disabled', 'junction-fan-limit-back-to-249']) { + assert.ok(ids.includes(id), `${id} не отобран`); + } +}); + +test('#475 AC7: воспроизведение находки ревью — бэкенд-мутанты отбираются по .py гарду и патчу', () => { + const byGuard = selectChangedMutants(MUTANTS, ['tests_backend/test_ha_frontend_registration.py']).map((m) => m.id); + const byPatch = selectChangedMutants(MUTANTS, ['custom_components/houseplan/frontend_registration.py']).map((m) => m.id); + const registration = MUTANTS.filter((m) => m.id.startsWith('frontend-registration-')).map((m) => m.id); + assert.ok(registration.length >= 3, 'в реестре есть бэкенд-мутанты регистрации'); + for (const id of registration) { + assert.ok(byGuard.includes(id), `${id} не отобран по гарду`); + assert.ok(byPatch.includes(id), `${id} не отобран по патчу`); + } +}); diff --git a/test/validate-workflow.test.mjs b/test/validate-workflow.test.mjs index 4f628c77..c88d1d6f 100644 --- a/test/validate-workflow.test.mjs +++ b/test/validate-workflow.test.mjs @@ -360,3 +360,21 @@ test('релизные гейты требуют трейлер Release: и св assert.match(read('release.yml'), trailer); assert.match(read('publish-prerelease.yml'), /check-docs\.mjs --screenshots=strict/); }); + +test('мутанты по диффу гоняются на каждом пуше с базы диапазона (#475 AC4)', () => { + const workflow = read('validate.yml'); + const start = workflow.indexOf('\n changed_mutants:\n'); + assert.ok(start > 0, 'нет job changed_mutants'); + const job = workflow.slice(start, workflow.indexOf('\n frontend:\n', start)); + // Триггер — и фронтенд, и бэкенд: бэкенд-мутанты патчат .py и охраняются + // pytest, а дифф только по ним даёт backend=true без frontend=true (ревью r1). + assert.match(job, /if: needs\.changes\.outputs\.frontend == 'true' \|\| needs\.changes\.outputs\.backend == 'true'/); + // База диапазона — та же, что у остальных гейтов ветки (#387/#388). + assert.match(job, /PROVEN_BASE: \$\{\{ needs\.changes\.outputs\.range_base \}\}/); + assert.match(job, /git merge-base origin\/dev "\$HEAD_SHA"/); + assert.match(job, /node scripts\/mutation-gate\.mjs --changed="\$base\.\.\$HEAD_SHA"/); + // pytest-гарды исполнимы: Python и зависимости ставятся, как в mutation-gate.yml. + assert.match(job, /pip install -r tests_backend\/requirements\.txt/); + // Блокирующая job: свидетель, разучившийся краснеть, — отказ, а не предупреждение. + assert.ok(!job.includes('continue-on-error'), 'job обязана красить прогон'); +});