Files
houseplan-card/scripts/mutation-gate.mjs
T
Claude 43459bcbe0 ci(mutants): an unmatched --test-name-pattern is setup-failure, not survived (#650)
A name-filtered `node --test` that matches no test exits 0 and TAP reports
only the file itself. The clean run took that for a healthy witness and the
mutant run for `survived`, although no assertion ran either time — as in
Validate 36151140394 on #649, where a renamed test left a guard pattern empty.

- mutation-guard-outcome: nodeTestSelection/executedTestNames/
  emptyTestSelection; a green oracle with a name filter and zero executed
  named tests is `setup-failure` (clean run: «FAIL чистая подготовка»,
  diff mode: attributed like any setup failure, #568).
- mutation-gate --check: staticTestSelectionProblems — every pattern must
  match a literal test(/it(/describe( name in the guard's files; files with
  ${…} names only warn. POSIX quoting keeps \( inside "…".
- Registry today: 349 unique name-filtered commands run on dev, all execute
  ≥ 1 test; --check: 0 errors, 3 warnings (dynamic names).
- Tests: 6 new in test/mutation-guard-outcome.test.mjs (incl. the installed
  node's real TAP and a registry-wide static check); 3 mutants.
- Docs: TESTING.md pointer, testing-notes/infrastructure.md section.

Issue: #650
User-Visible: no
2026-09-25 18:31:17 +03:00

296 lines
16 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
// Mutation gate CLI. Declarations, selection/fingerprints and execution live
// behind separate module boundaries so registry growth does not make every
// runner change conflict with hundreds of mutation definitions (#558).
import { spawnSync } from 'node:child_process';
import { existsSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { MUTANTS } from './mutation-registry.mjs';
import {
applyPatches, buildBundle, dropWorktree, guardNeedsBundle, guardNeedsTestBuild,
makeWorktree, runCleanGuards, runMutant,
} from './mutation-execution.mjs';
import {
ANCHOR_RADIUS_LINES, MUTATION_REGISTRY_FILES, anchorRegion, anchorSpan,
baseRegistry, createGuardInputResolver, guardFiles, guardInputs,
packageJsonRelevance, parseDiffRanges, patchTouched, registryDelta,
selectChangedMutants, selectForDiff, shardMutants, wrapperInputs,
} from './mutation-selection.mjs';
import {
LEDGER_SCHEMA, readLedger, recordCaught, splitByLedger, witnessFingerprint,
} from './mutation-evidence.mjs';
import { attributeSetupFailure } from './mutation-attribution.mjs';
import { guardEnvironment, planEnvironment, planEnvironmentLines } from './mutation-environment.mjs';
import {
MUTATION_OUTCOME, isProofOutcome, staticTestSelectionProblems,
} from './mutation-guard-outcome.mjs';
const repoRoot = fileURLToPath(new URL('..', import.meta.url));
// Preserve the public module surface used by tests and reporting scripts while
// callers migrate to the narrower modules at their own pace.
export {
ANCHOR_RADIUS_LINES, LEDGER_SCHEMA, MUTANTS, MUTATION_REGISTRY_FILES,
anchorRegion, anchorSpan, applyPatches, baseRegistry, createGuardInputResolver,
guardFiles, guardInputs, guardNeedsBundle, guardNeedsTestBuild,
packageJsonRelevance, parseDiffRanges, patchTouched, readLedger, recordCaught,
registryDelta, selectChangedMutants, selectForDiff, shardMutants,
splitByLedger, witnessFingerprint, wrapperInputs,
};
export async function main(argv) {
const planStarted = performance.now();
let guardInputReads = 0;
const inputsOf = createGuardInputResolver({
read: (file) => {
guardInputReads += 1;
const path = join(repoRoot, file);
return existsSync(path) ? readFileSync(path, 'utf8') : '';
},
});
let planMetricsReported = false;
const reportPlanMetrics = () => {
if (planMetricsReported) return;
planMetricsReported = true;
const stats = inputsOf.stats();
console.log(`plan-metrics: guard-input requests=${stats.requests}, computations=${stats.computations}, `
+ `cache-hits=${stats.hits}, unique-guards=${stats.uniqueGuards}, source-reads=${guardInputReads}, `
+ `duration-ms=${(performance.now() - planStarted).toFixed(1)}`);
};
const idArg = argv.find((a) => a.startsWith('--id='))?.slice(5);
let selected = idArg ? MUTANTS.filter((m) => m.id === idArg) : MUTANTS;
if (idArg && !selected.length) {
console.error(`мутант «${idArg}» не объявлен; --list покажет реестр`);
return 2;
}
const changedArg = argv.find((a) => a === '--changed' || a.startsWith('--changed='));
let rangeBase = null;
const ledgerArg = argv.find((a) => a.startsWith('--ledger='))?.slice(9);
if (ledgerArg && !changedArg) {
// Полный прогон журнал не читает — он его пишет по расписанию целиком;
// читать журнал в полном прогоне значило бы никогда не перепроверять.
console.error('--ledger работает только вместе с --changed: полный прогон журнал не читает');
return 2;
}
if (changedArg) {
const range = changedArg.includes('=') ? changedArg.split('=')[1] : 'origin/dev..HEAD';
const diff = spawnSync('git', ['-C', repoRoot, 'diff', '--name-only', range],
{ encoding: 'utf8' });
if (diff.status !== 0) {
console.error(`git diff ${range} не удался:\n${diff.stderr}`);
return 2;
}
let files = diff.stdout.split('\n').filter(Boolean);
const before = selected.length;
// #496: package.json отбирает ~все гварды; добавленный script — не вход.
if (files.includes('package.json') && range.includes('..')) {
const [baseRef, headRef] = range.split('..');
const show = (ref) => spawnSync('git', ['-C', repoRoot, 'show', `${ref}:package.json`], { encoding: 'utf8' });
const baseShown = show(baseRef); const headShown = show(headRef || 'HEAD');
const relevance = (baseShown.status === 0 && headShown.status === 0)
? packageJsonRelevance(baseShown.stdout, headShown.stdout)
: { relevant: true, reason: 'package.json базы или головы не прочитан' };
console.log(`package.json в диффе: ${relevance.relevant ? 'задевает гварды' : 'гварды не задевает'} — ${relevance.reason}`);
if (!relevance.relevant) files = files.filter((f) => f !== 'package.json');
}
// База диапазона нужна не только отбору: по ней атрибутируется отказ
// подготовки (#568).
rangeBase = range.includes('..') ? range.split('..')[0] : range;
// #492 §6.4: правка реестра отбирает добавленные и изменённые определения
// явно — новый свидетель не обязан трогать чужие patch/guard-файлы.
let base = null;
if (files.some((file) => MUTATION_REGISTRY_FILES.includes(file))) {
const baseRef = range.includes('..') ? range.split('..')[0] : range;
base = await baseRegistry(baseRef);
if (!base) console.log(`реестр базы ${baseRef} не прочитан — отбор по определениям пропущен`);
}
// #518: области диффа сужают сторону патча до окрестности якоря. Не
// прочитались — отбор остаётся файловым, то есть прежним и более широким.
const hunks = spawnSync('git', ['-C', repoRoot, 'diff', '--unified=0', '--no-color', range],
{ encoding: 'utf8', maxBuffer: 256 * 1024 * 1024 });
const ranges = hunks.status === 0 && !hunks.error ? parseDiffRanges(hunks.stdout) : null;
if (!ranges) console.log('области диффа не прочитаны — отбор по файлам целиком');
const wide = selectForDiff(selected, files, base, { guardInputs: inputsOf });
const picked = selectForDiff(selected, files, base, { ranges, guardInputs: inputsOf });
if (picked.removed.length) console.log(`удалены из реестра: ${picked.removed.join(', ')}`);
selected = picked.selected;
console.log(`дифф-режим ${range}: файлов в диффе ${files.length}, `
+ `мутантов затронуто ${selected.length} из ${before} (по файлам ${picked.byFiles.length}, по определениям ${picked.byRegistry.length})`);
if (ranges && wide.selected.length !== selected.length) {
console.log(`области якорей (радиус ${ANCHOR_RADIUS_LINES} строк, #518): `
+ `${wide.selected.length} → ${selected.length}`);
}
if (!selected.length) {
console.log('дифф не задевает ни одной области якоря — гонять нечего; '
+ 'полный реестр идёт ночным расписанием (#513)');
if (argv.includes('--plan-only')) console.log('plan=0');
reportPlanMetrics();
return 0;
}
}
const shardArg = argv.find((a) => a.startsWith('--shard='))?.slice(8);
if (shardArg) {
const match = /^([1-9]\d*)\/([1-9]\d*)$/.exec(shardArg);
if (!match || Number(match[1]) > Number(match[2])) {
console.error(`--shard ожидает i/n с 1 <= i <= n, получено «${shardArg}»`);
return 2;
}
const before = selected.length;
selected = shardMutants(selected, Number(match[1]), Number(match[2]));
console.log(`шард ${shardArg}: ${selected.length} из ${before} мутантов`);
}
if (argv.includes('--list')) {
for (const m of MUTANTS) console.log(`${m.id}\n guard: ${m.guard}\n ${m.because}\n`);
reportPlanMetrics();
return 0;
}
if (argv.includes('--check')) {
let stale = 0;
let warned = 0;
const readTest = (file) => {
const path = join(repoRoot, file);
return existsSync(path) ? readFileSync(path, 'utf8') : null;
};
for (const m of selected) {
try {
// #499: бандл мутанта собирает раннер (`buildBundle`, только rollup +
// sync). Гвард с собственным `npm run bundle:sync` собирал бы его второй
// раз — плюс `tsc --noEmit`, который на нестрогом мутанте падает сам и
// красит гвард ещё до теста: «мутант пойман» без единого запуска смока.
if (/bundle:sync|bundle-sync\.mjs|rollup -c/.test(m.guard)) {
throw new Error('гвард сам собирает бандл — сборку делает раннер (#499)');
}
for (const patch of m.patches) {
const source = readFileSync(join(repoRoot, patch.file), 'utf8');
const hits = source.split(patch.find).length - 1;
if (hits !== 1) throw new Error(`якорь найден ${hits} раз(а)`);
}
// #650: a name filter that matches nothing makes the witness vacuous.
const selection = staticTestSelectionProblems(m.guard, readTest);
const error = selection.find((problem) => problem.level === 'error');
if (error) throw new Error(error.text);
for (const problem of selection) {
console.log(`WARN ${m.id}: ${problem.text}`);
warned++;
}
console.log(`ok ${m.id}`);
} catch (error) {
console.log(`FAIL ${m.id}: ${error.message}`);
stale++;
}
}
if (warned) console.log(`предупреждений о шаблонах имён: ${warned}`);
reportPlanMetrics();
return stale ? 2 : 0;
}
if (argv.includes('--build-only')) {
for (const m of selected) {
const dir = makeWorktree();
try {
applyPatches(dir, m.patches);
buildBundle(dir);
console.log(`ok ${m.id}: патч лёг, бандл собрался`);
} catch (error) {
console.log(`FAIL ${m.id}: ${error.message}`);
return 2;
} finally {
dropWorktree(dir);
}
}
reportPlanMetrics();
return 0;
}
// Журнал (#481): отобранные по диффу мутанты, чьи входы не менялись с
// последнего пойманного прогона, не гоняются повторно.
let plan = selected.map((mutant) => ({ mutant, fingerprint: null }));
let ledger = null;
if (ledgerArg) {
ledger = readLedger(ledgerArg);
const split = splitByLedger(selected, ledger,
(mutant) => witnessFingerprint(mutant, { inputsOf }));
console.log(`журнал ${ledgerArg}: по журналу пропущено ${split.skipped.length} `
+ `(отпечатки совпали), к прогону ${split.run.length}`);
plan = split.run;
if (!plan.length) {
console.log('все отобранные свидетели уже пойманы на этих же входах — гонять нечего');
if (argv.includes('--plan-only')) console.log('plan=0');
reportPlanMetrics();
return 0;
}
}
const toRun = plan.map((entry) => entry.mutant);
// #518: `--plan-only` считает план и выходит — job мутантов спрашивает его
// ДО установки окружения (npm ci, python, Chromium ≈ 3 минуты на шард).
if (argv.includes('--plan-only')) {
console.log(`plan=${toRun.length}`);
// #620: и какое окружение нужно гардам плана — шаг ставит только его.
const exists = (file) => existsSync(join(repoRoot, file));
const read = (file) => (exists(file) ? readFileSync(join(repoRoot, file), 'utf8') : '');
const need = planEnvironment(toRun, (guard) => guardEnvironment(guard, { read, exists }));
for (const line of planEnvironmentLines(need)) console.log(line);
reportPlanMetrics();
return 0;
}
reportPlanMetrics();
if (!runCleanGuards(toRun)) return 2;
let caught = 0;
let unverifiable = false;
// #568: свидетель, который не готовится к прогону, ломает гейт той задачи,
// чей дифф его выбрал, — а причина может лежать в чужом коммите. Тогда автор
// либо чинит чужое, либо стоит; я сам потерял на этом два круга. Поэтому
// отказ подготовки атрибутируется: тот же мутант прогоняется на дереве базы
// диапазона. Ложных срабатываний тут быть не может — сравниваются два
// прогона одного и того же мутанта, а не код с ожиданием.
const preExisting = [];
for (const entry of plan) {
const outcome = runMutant(entry.mutant);
if (!isProofOutcome(outcome)) {
if (outcome.kind === MUTATION_OUTCOME.SETUP) {
const verdict = await attributeSetupFailure(entry.mutant, outcome, rangeBase);
if (verdict === 'pre-existing') {
// Не красим гейт этой задачи (решение владельца 14.09): поломка не из
// этого диффа. Но и не теряем её — она названа здесь и обязана
// покраснеть в ночном полном прогоне, у которого есть адресат (#472).
console.log(` ПРЕДСУЩЕСТВУЮЩИЙ: не готовится и на базе — отказ не из этого диффа`);
preExisting.push(entry.mutant.id);
continue;
}
if (verdict === 'introduced') {
console.log(' отказ внесён этим диффом: на базе тот же мутант готовится');
}
}
if (outcome.kind !== MUTATION_OUTCOME.SURVIVED) unverifiable = true;
continue;
}
caught++;
if (ledger) recordCaught(ledgerArg, ledger, entry.mutant, entry.fingerprint, outcome.proof);
}
console.log(`\nпоймано ${caught} из ${toRun.length - preExisting.length}`);
if (preExisting.length) {
// Строку читает человек и (при желании) CI. Формат менять синхронно с теми,
// кто её разбирает.
console.log(`предсуществующих отказов подготовки: ${preExisting.length}`);
console.log(`pre-existing-setup-failures=${preExisting.join(',')}`);
console.log('Эти свидетели мертвы до текущего диффа: гейт задачи они не красят, '
+ 'а ночной полный прогон обязан их назвать (#472, #568).');
}
if (unverifiable) return 2;
return caught === toRun.length - preExisting.length ? 0 : 1;
}
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
main(process.argv.slice(2)).then((code) => process.exit(code), (err) => {
console.error(err);
process.exit(2);
});
}