ci: mutant selection sees wrapper defaults, guard imports, fixtures and registry edits

guardInputs() replaces guardFiles() in selection and fingerprints: the
files named in the guard, the GUARD_INPUTS a wrapper declares (read
statically — the wrappers run on import), and the closure of imports and
path literals of every guard file, stopping at src/** which stays the
patch side. A diff that touches the registry itself selects every added or
changed definition against the base registry read from git. Five mutants
guard the manifest and this selection.

Issue: #492
User-Visible: no
This commit is contained in:
Codex
2026-09-08 21:55:07 +00:00
committed by claude[bot]
parent 658e395360
commit 51beeeb2c4
5 changed files with 294 additions and 13 deletions
+8 -1
View File
@@ -6,7 +6,14 @@ if (!pattern) {
console.error('usage: node scripts/backend-test-guard.mjs <pytest-k-pattern>');
process.exit(2);
}
const testFile = process.argv[3] || 'tests_backend/test_ha_import_export.py';
/**
* Входы обёртки для отбора мутантов (#492 §6.1): без третьего аргумента гард
* бежит по этому файлу, и правка файла обязана отбирать таких свидетелей.
* Явно переданный файл попадает в отбор из самой строки гарда.
*/
export const GUARD_INPUTS = ['tests_backend/test_ha_import_export.py'];
const testFile = process.argv[3] || GUARD_INPUTS[0];
const python = process.env.PYTHON || (process.platform === 'win32' ? 'python' : 'python3');
const result = spawnSync(python, [
+21 -1
View File
@@ -100,6 +100,7 @@ export function trackedFiles(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;
@@ -107,6 +108,7 @@ 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;
@@ -166,6 +168,8 @@ export function referencesOf(file, rawText) {
}
}
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)) {
@@ -181,6 +185,15 @@ export function referencesOf(file, rawText) {
}
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] };
}
@@ -196,6 +209,13 @@ export function referencesOf(file, rawText) {
* данные. `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) => {
@@ -212,7 +232,7 @@ export function closure(root, entries, { tracked = trackedFiles(root), stopAt =
const file = queue.shift();
if (seen.has(file)) continue;
seen.add(file);
if (stopAt(file)) continue;
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; }
+174 -9
View File
@@ -35,10 +35,11 @@ import {
} from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { createHash } from 'node:crypto';
import { mkdirSync } from 'node:fs';
import { withoutProductVersion } from './source-fingerprint.mjs';
import { closure, trackedFiles } from './check-inputs.mjs';
const repoRoot = fileURLToPath(new URL('..', import.meta.url));
@@ -3559,6 +3560,61 @@ 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: 'changed-selection-ignores-guard-files',
guard: 'node --test --test-name-pattern="#475 AC2" test/mutation-gate.test.mjs',
@@ -3568,7 +3624,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 +7915,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 +7984,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 +8049,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 +8113,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 +8139,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 +8246,8 @@ function main(argv) {
}
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
process.exit(main(process.argv.slice(2)));
main(process.argv.slice(2)).then((code) => process.exit(code), (err) => {
console.error(err);
process.exit(2);
});
}
+4 -2
View File
@@ -8,10 +8,12 @@ const localPython = process.platform === 'win32'
: join(process.cwd(), '.venv', 'bin', 'python');
const python = process.env.PYTHON
|| (existsSync(localPython) ? localPython : (process.platform === 'win32' ? 'python' : 'python3'));
/** Входы обёртки для отбора мутантов (#492 §6.1): запускаемые pytest-модули. */
export const GUARD_INPUTS = ['tests_backend/test_trails.py', 'tests_backend/test_trail_recorder.py'];
const result = spawnSync(python, [
'-m', 'pytest',
'tests_backend/test_trails.py',
'tests_backend/test_trail_recorder.py',
...GUARD_INPUTS,
'-q', '-k', 'resume or short_available',
], { stdio: 'inherit' });