diff --git a/scripts/backend-test-guard.mjs b/scripts/backend-test-guard.mjs index eed2c6e5..b1a67552 100644 --- a/scripts/backend-test-guard.mjs +++ b/scripts/backend-test-guard.mjs @@ -6,7 +6,14 @@ if (!pattern) { console.error('usage: node scripts/backend-test-guard.mjs '); 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, [ diff --git a/scripts/check-inputs.mjs b/scripts/check-inputs.mjs index d3ef8e29..d7616420 100755 --- a/scripts/check-inputs.mjs +++ b/scripts/check-inputs.mjs @@ -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; } diff --git a/scripts/mutation-gate.mjs b/scripts/mutation-gate.mjs index ca71632e..d10704b3 100644 --- a/scripts/mutation-gate.mjs +++ b/scripts/mutation-gate.mjs @@ -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); + }); } diff --git a/scripts/trail-resume-test-guard.mjs b/scripts/trail-resume-test-guard.mjs index 74433cf6..2db6653f 100644 --- a/scripts/trail-resume-test-guard.mjs +++ b/scripts/trail-resume-test-guard.mjs @@ -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' }); diff --git a/test/mutation-gate.test.mjs b/test/mutation-gate.test.mjs index 5c638358..57889347 100644 --- a/test/mutation-gate.test.mjs +++ b/test/mutation-gate.test.mjs @@ -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, [], 'временный модуль удалён'); +});