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' });
+87
View File
@@ -464,3 +464,90 @@ test('#499: ни один гвард реестра не собирает бан
const source = readFileSync(new URL('../scripts/mutation-gate.mjs', import.meta.url), 'utf8');
assert.match(source, /гвард сам собирает бандл — сборку делает раннер \(#499\)/);
});
// --- #492 §6: замыкание входов гарда, обёртки, определения реестра -----------
import {
baseRegistry, guardInputs, registryDelta, selectForDiff, wrapperInputs,
} from '../scripts/mutation-gate.mjs';
test('#492 §6.1: обёртки объявляют GUARD_INPUTS, и объявление читается статически', () => {
assert.deepEqual(wrapperInputs('scripts/backend-test-guard.mjs'), ['tests_backend/test_ha_import_export.py']);
assert.deepEqual(wrapperInputs('scripts/trail-resume-test-guard.mjs'),
['tests_backend/test_trails.py', 'tests_backend/test_trail_recorder.py']);
assert.deepEqual(wrapperInputs('test/x.test.mjs'), [], 'не обёртка — нет объявления');
// умолчание в коде обёртки совпадает с объявлением
const wrapper = readFileSync(join(repoRoot, 'scripts/backend-test-guard.mjs'), 'utf8');
assert.match(wrapper, /process\.argv\[3\] \|\| GUARD_INPUTS\[0\]/);
// каждая обёртка, которую использует реестр, объявляет входы
const wrappers = new Set(MUTANTS.flatMap((m) => guardFiles(m.guard)).filter((f) => /^scripts\/[\w-]+-guard\.mjs$/.test(f)));
for (const file of wrappers) assert.ok(wrapperInputs(file).length > 0, `${file}: нет GUARD_INPUTS`);
});
test('#492 §6.1: умолчание обёртки действует без третьего аргумента и уступает явному файлу', () => {
const byDefault = guardInputs('node scripts/backend-test-guard.mjs some_pattern');
assert.ok(byDefault.includes('tests_backend/test_ha_import_export.py'));
assert.ok(byDefault.includes('tests_backend/conftest.py'), 'pytest подхватывает conftest');
assert.ok(byDefault.includes('scripts/backend-test-guard.mjs'), 'текст обёртки — в отпечатке');
const explicit = guardInputs('node scripts/backend-test-guard.mjs some_pattern tests_backend/test_ha_websocket.py');
assert.ok(explicit.includes('tests_backend/test_ha_websocket.py'));
assert.ok(!explicit.includes('tests_backend/test_ha_import_export.py'), 'явный файл отменяет умолчание');
const trail = guardInputs('node scripts/trail-resume-test-guard.mjs');
assert.ok(trail.includes('tests_backend/test_trails.py') && trail.includes('tests_backend/test_trail_recorder.py'));
});
test('#492 §6.2: смок-гард тянет serve.mjs, compat-хелперы и фикстуры; src/** остаётся стороной патча', () => {
const inputs = guardInputs('node demo/smoke_zigbee_topology_hover.mjs');
for (const file of ['demo/serve.mjs', 'demo/editor-runtime-compat.mjs', 'demo/bundle-freshness.mjs']) {
assert.ok(inputs.includes(file), `${file} вне входов смок-гарда`);
}
assert.ok(!inputs.some((f) => f.startsWith('src/')), 'src/** — не вход гарда (§6.4)');
assert.ok(!inputs.some((f) => f.includes('/frontend/')), 'копия бандла — не вход');
});
test('#492 §8.2: правка запускаемого теста отбирает свидетелей обёрток и меняет их отпечаток', () => {
const defaults = MUTANTS.filter((m) => /^node scripts\/backend-test-guard\.mjs \S+$/.test(m.guard));
assert.ok(defaults.length >= 10, `обёрток без третьего аргумента: ${defaults.length}`);
const selected = new Set(selectChangedMutants(MUTANTS, ['tests_backend/test_ha_import_export.py']).map((m) => m.id));
for (const m of defaults) assert.ok(selected.has(m.id), `${m.id} не отобран правкой import-export теста`);
const explicitOther = MUTANTS.find((m) => m.guard.includes('tests_backend/test_ha_websocket.py'));
assert.ok(!selected.has(explicitOther.id), 'явный websocket-гард не отбирается правкой import-export');
const trail = MUTANTS.find((m) => m.id === 'vacuum-trail-resume-disabled');
assert.ok(selectChangedMutants(MUTANTS, ['tests_backend/test_trails.py']).some((m) => m.id === trail.id));
const before = witnessFingerprint(trail);
const after = witnessFingerprint(trail, {
read: (file) => (file === 'tests_backend/test_trails.py' ? '# changed\n' : (existsSync(join(repoRoot, file)) ? readFileSync(join(repoRoot, file), 'utf8') : '')),
});
assert.notEqual(after, before, 'правка test_trails.py обязана менять отпечаток trail-свидетеля');
const smokes = MUTANTS.filter((m) => /^node demo\/smoke_/.test(m.guard));
const byServe = new Set(selectChangedMutants(MUTANTS, ['demo/serve.mjs']).map((m) => m.id));
for (const m of smokes) assert.ok(byServe.has(m.id), `${m.id}: смок-свидетель не отобран правкой serve.mjs`);
});
test('#492 §6.4: дифф только по реестру отбирает добавленные и изменённые определения', () => {
const a = { id: 'a', guard: 'node --test test/a.test.mjs', patches: [{ file: 'src/a.ts', find: '1', replace: '2' }], because: 'a' };
const b = { id: 'b', guard: 'node --test test/b.test.mjs', patches: [{ file: 'src/b.ts', find: '1', replace: '2' }], because: 'b' };
const bChanged = { ...b, patches: [{ file: 'src/b.ts', find: '1', replace: '3' }] };
const c = { id: 'c', guard: 'node --test test/c.test.mjs', patches: [{ file: 'src/c.ts', find: '1', replace: '2' }], because: 'c' };
assert.deepEqual(registryDelta([a, bChanged, c], [a, b, { ...c, id: 'gone' }]), { changed: ['b', 'c'], removed: ['gone'] });
const picked = selectForDiff([a, bChanged, c], ['scripts/mutation-gate.mjs'], [a, b], { guardInputs: () => [] });
assert.deepEqual(picked.selected.map((m) => m.id), ['b', 'c']);
assert.deepEqual(picked.byFiles, []);
// без реестра базы отбор по определениям невозможен — и это не «ничего не изменилось»
const blind = selectForDiff([a, bChanged, c], ['scripts/mutation-gate.mjs'], null, { guardInputs: () => [] });
assert.deepEqual(blind.selected, []);
// дифф без реестра — определения не смотрятся
const plain = selectForDiff([a, bChanged, c], ['src/a.ts'], [a, b], { guardInputs: () => [] });
assert.deepEqual(plain.selected.map((m) => m.id), ['a']);
});
test('#492 §6.4: реестр базы читается из git без побочных эффектов', async () => {
const base = await baseRegistry('HEAD');
assert.ok(Array.isArray(base) && base.length > 500, 'реестр HEAD прочитан');
assert.ok(!existsSync(join(repoRoot, 'scripts')) || !readFileSync(join(repoRoot, 'scripts/mutation-gate.mjs'), 'utf8').includes('\0'));
assert.equal(await baseRegistry('0000000000000000000000000000000000000000'), null, 'нет такой базы — null, не бросок');
const leftovers = (await import('node:fs')).readdirSync(join(repoRoot, 'scripts')).filter((f) => f.startsWith('.mutation-gate.base-'));
assert.deepEqual(leftovers, [], 'временный модуль удалён');
});