ci: мутанты по диффу на каждом пуше, отбор и по файлам гарда

User-Visible: no
Issue: #475
This commit is contained in:
Codex
2026-09-06 19:31:48 +03:00
parent d12b1f20b0
commit a6077ac922
4 changed files with 192 additions and 4 deletions
+62
View File
@@ -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
+58 -2
View File
@@ -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)));
}
/**
+54 -2
View File
@@ -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} не отобран по патчу`);
}
});
+18
View File
@@ -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 обязана красить прогон');
});