Files
houseplan-card/scripts/mutation-guard-outcome.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

344 lines
14 KiB
JavaScript
Raw 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.
/**
* Honest outcome taxonomy for one mutation witness (#550).
*
* A shell command such as `tsc && fix-test-build && node --test` has two
* different meanings: the prefix prepares the oracle, while only the last
* command can prove the behavioural assertion. Treating every non-zero exit
* as "caught" turns a missing dependency or a mutant that no longer compiles
* into false test evidence. This module keeps that distinction pure and
* fixture-testable; the worktree/build orchestration lives in
* mutation-execution.mjs.
*/
export const MUTATION_OUTCOME = Object.freeze({
INVALID: 'invalid-mutation',
SETUP: 'setup-failure',
ASSERTION_KILLED: 'assertion-killed',
COMPILE_KILLED: 'compile-killed',
SURVIVED: 'survived',
INTERRUPTED: 'infrastructure-interruption',
});
export const MUTATION_PROOF = Object.freeze({
ASSERTION: 'assertion',
COMPILE: 'compile',
});
/**
* Кому принадлежит отказ подготовки (#568).
*
* Свидетель, который не готовится к прогону, краснит гейт той задачи, чей дифф
* его выбрал, — но причина может лежать в чужом коммите. Единственное честное
* доказательство здесь — прогон ТОГО ЖЕ мутанта на дереве базы диапазона:
* ложных срабатываний быть не может, потому что сравниваются два прогона одного
* мутанта, а не код с ожиданием.
*
* `null` означает «сказать нечего»: базы нет либо прогон на ней сорвался. Тогда
* поведение остаётся прежним — отказ считается отказом этой задачи, потому что
* недоказанная невиновность не оправдание.
*/
export function setupFailureOwner(headOutcome, baseOutcome) {
if (headOutcome?.kind !== MUTATION_OUTCOME.SETUP) return null;
if (!baseOutcome) return null;
if (baseOutcome.kind === MUTATION_OUTCOME.SETUP) return 'pre-existing';
if (baseOutcome.kind === MUTATION_OUTCOME.INTERRUPTED) return null;
return 'introduced';
}
const outputOf = (result = {}) => `${result.stdout || ''}\n${result.stderr || ''}`.trim();
/** Split `&&` only when it is a shell operator, not text inside quotes. */
export function splitAndChain(command) {
const parts = [];
let start = 0;
let quote = '';
let escaped = false;
const text = String(command || '');
for (let index = 0; index < text.length - 1; index++) {
const char = text[index];
if (escaped) { escaped = false; continue; }
if (char === '\\' && quote !== "'") { escaped = true; continue; }
if (quote) {
if (char === quote) quote = '';
continue;
}
if (char === "'" || char === '"') { quote = char; continue; }
if (char === '&' && text[index + 1] === '&') {
const part = text.slice(start, index).trim();
if (part) parts.push(part);
start = index + 2;
index++;
}
}
const tail = text.slice(start).trim();
if (tail) parts.push(tail);
return parts;
}
export function guardPhases(guard, proof = MUTATION_PROOF.ASSERTION) {
if (!Object.values(MUTATION_PROOF).includes(proof)) {
throw new Error(`unknown mutation oracle: ${proof}`);
}
const commands = splitAndChain(guard);
if (!commands.length) throw new Error('empty mutation guard');
// A compile-time witness is deliberately explicit. Its command is the
// oracle itself; silently treating a prefix compile as evidence is forbidden.
if (proof === MUTATION_PROOF.COMPILE) {
if (commands.length !== 1) throw new Error('compile witness guard must be one command');
return { setup: [], oracle: commands[0], proof };
}
return { setup: commands.slice(0, -1), oracle: commands.at(-1), proof };
}
function interruption(result = {}) {
if (result.error || result.signal || result.status == null) {
const code = result.error?.code || result.signal || 'no-exit-status';
return { kind: MUTATION_OUTCOME.INTERRUPTED, detail: String(code), command: '' };
}
return null;
}
// These mean the declared oracle could not load/collect/start. Ordinary
// exceptions from product code are intentionally absent: a test that executes
// the mutated path and crashes has still exposed the regression.
const ORACLE_SETUP_FAILURE = new RegExp([
'command not found', 'is not recognized as an internal or external command',
'ENOENT', 'ERR_MODULE_NOT_FOUND', 'Cannot find (?:module|package)',
'No module named', 'ImportError while (?:loading conftest|importing test module)',
'ERROR collecting', 'collected 0 items', 'no tests ran',
].join('|'), 'i');
// Test frameworks include user/fixture text in failure diagnostics. A fixture
// may literally mention ERR_MODULE_NOT_FOUND, so a proven assertion failure
// must win over a setup-looking substring quoted inside that assertion.
const ASSERTION_EVIDENCE = /(?:ERR_ASSERTION|AssertionError|^FAILED\s+\S+|^# Subtest:|^✖\s+)/im;
/**
* A `--test-name-pattern` that matches no test (#650).
*
* `node --test --test-name-pattern=X file` with no test named like X exits 0:
* TAP reports only the file itself (`ok 1 - test/file.test.mjs`). On the clean
* run that reads as a healthy witness and on the mutant as `survived`, although
* no assertion executed either time — typically a task renamed the test. It is
* a setup failure of the oracle, not a verdict about the code.
*/
const TEST_FILE = /\.(?:m|c)?[jt]s$/;
const looksLikePath = (name) => /^[\w@.~/\\:-]+$/.test(name) && TEST_FILE.test(name);
/** Shell-like words; quotes group and are removed, `\` escapes outside '...'. */
export function shellWords(command) {
const words = [];
let word = '';
let quote = '';
let active = false;
const text = String(command || '');
for (let index = 0; index < text.length; index++) {
const char = text[index];
if (quote) {
if (char === quote) { quote = ''; continue; }
// POSIX: inside "…" a backslash escapes only $ ` " \ and newline.
if (char === '\\' && quote === '"' && /[$`"\\\n]/.test(text[index + 1] || '')) {
word += text[++index];
continue;
}
word += char;
continue;
}
if (char === "'" || char === '"') { quote = char; active = true; continue; }
if (char === '\\' && index + 1 < text.length) { word += text[++index]; active = true; continue; }
if (/\s/.test(char)) {
if (active) words.push(word);
word = ''; active = false;
continue;
}
word += char; active = true;
}
if (active) words.push(word);
return words;
}
/**
* `{ patterns, files }` of one `node --test` command that filters by name, or
* `null` when the command runs no name filter (nothing to prove empty).
*/
export function nodeTestSelection(command) {
const words = shellWords(command);
const node = words.findIndex((word, index) => /(?:^|\/)node(?:\.exe)?$/.test(word)
&& words[index + 1] === '--test');
if (node < 0) return null;
const patterns = [];
const files = [];
for (let index = node + 2; index < words.length; index++) {
const word = words[index];
if (word.startsWith('--test-name-pattern=')) { patterns.push(word.slice('--test-name-pattern='.length)); continue; }
if (word === '--test-name-pattern' && index + 1 < words.length) { patterns.push(words[++index]); continue; }
if (word.startsWith('-')) continue;
if (TEST_FILE.test(word)) files.push(word);
}
return patterns.length ? { patterns, files } : null;
}
/** Node's pattern grammar: `/source/flags` or a plain RegExp source. */
export function testNamePatternRegExp(pattern) {
const literal = /^\/(.*)\/([a-z]*)$/s.exec(pattern);
return literal ? new RegExp(literal[1], literal[2]) : new RegExp(pattern);
}
/**
* Names of tests the reporter says were executed (TAP and spec), excluding
* the per-file wrapper and tests skipped by the pattern. `null` when the
* output carries no reporter summary at all — then nothing can be judged.
*/
export function executedTestNames(output) {
const text = String(output || '');
if (!/^\s*(?:#|ℹ) tests \d+/m.test(text)) return null;
const names = [];
for (const match of text.matchAll(/^\s*(?:not )?ok \d+ - (.+?)\s*$/gm)) {
const [name, directive = ''] = match[1].split(/\s+#\s+/);
if (/^SKIP\b/i.test(directive)) continue;
const plain = name.replace(/\\#/g, '#').trim();
if (!looksLikePath(plain)) names.push(plain);
}
for (const match of text.matchAll(/^\s*[✔✖] (.+?) \(\d[\d.]*m?s\)\s*$/gm)) {
const plain = match[1].trim();
if (!looksLikePath(plain) && !names.includes(plain)) names.push(plain);
}
return names;
}
/** Diagnostic text when a name-filtered oracle executed no test; else `null`. */
export function emptyTestSelection(command, output) {
const selection = nodeTestSelection(command);
if (!selection) return null;
const names = executedTestNames(output);
if (names === null || names.length) return null;
return `--test-name-pattern ${selection.patterns.map((p) => JSON.stringify(p)).join(', ')}`
+ ` не совпал ни с одним тестом в ${selection.files.join(' ') || 'выбранных файлах'}`
+ ' — свидетель не исполнил ни одного ассерта (#650)';
}
/**
* Static test names of a test source: `test(`, `it(`, `describe(`, `suite(`
* and `t.test(` with a literal first argument. A template literal with `${…}`
* is dynamic: it cannot be proven absent.
*/
export function staticTestNames(source) {
const names = [];
let dynamic = false;
const call = /(?:^|[^\w.$]|\bt\.)(?:test|it|describe|suite)(?:\.(?:only|skip|todo))?\(\s*(?:(['"])((?:\\.|(?!\1)[^\\\n])*)\1|`((?:\\.|[^\\`])*)`)/g;
for (const match of String(source || '').matchAll(call)) {
if (match[3] !== undefined) {
if (/\$\{/.test(match[3])) { dynamic = true; continue; }
names.push(match[3]);
} else {
names.push(match[2].replace(/\\(.)/g, '$1'));
}
}
return { names, dynamic };
}
/**
* Registry-time check of one guard: every name-filtered `node --test` must
* match at least one static test name in its files. `read(file)` returns the
* source or `null` for a missing file.
*/
export function staticTestSelectionProblems(guard, read) {
const problems = [];
for (const command of splitAndChain(guard)) {
const selection = nodeTestSelection(command);
if (!selection) continue;
if (!selection.files.length || selection.files.some((file) => /[*?[]/.test(file))) continue;
let regexps;
try {
regexps = selection.patterns.map(testNamePatternRegExp);
} catch (error) {
problems.push({ level: 'error', text: `--test-name-pattern не RegExp: ${error.message}` });
continue;
}
const names = [];
let dynamic = false;
const missing = selection.files.filter((file) => read(file) == null);
if (missing.length) {
problems.push({ level: 'error', text: `нет файла ${missing.join(' ')}` });
continue;
}
for (const file of selection.files) {
const found = staticTestNames(read(file));
names.push(...found.names);
dynamic ||= found.dynamic;
}
if (names.some((name) => regexps.some((regexp) => regexp.test(name)))) continue;
const text = `--test-name-pattern ${selection.patterns.map((p) => JSON.stringify(p)).join(', ')}`
+ ` не совпадает ни с одним тестом в ${selection.files.join(' ')} (#650)`;
problems.push(dynamic
? { level: 'warn', text: `${text}; в файле есть имена с \${…} — проверить нельзя` }
: { level: 'error', text });
}
return problems;
}
export function classifyCommandResult(result, { phase = 'oracle', proof = MUTATION_PROOF.ASSERTION } = {}) {
const stopped = interruption(result);
if (stopped) return { ...stopped, command: result?.command || '' };
if (Number(result.status) === 0) {
// #650: a green oracle that executed no named test proves nothing either way.
const empty = phase === 'oracle' ? emptyTestSelection(result?.command, outputOf(result)) : null;
if (empty) return { kind: MUTATION_OUTCOME.SETUP, detail: empty, command: result?.command || '' };
return { kind: MUTATION_OUTCOME.SURVIVED, detail: '', command: result?.command || '' };
}
const detail = outputOf(result).slice(-2000);
if (phase === 'setup') {
return { kind: MUTATION_OUTCOME.SETUP, detail, command: result?.command || '' };
}
if (proof !== MUTATION_PROOF.COMPILE && !ASSERTION_EVIDENCE.test(detail)
&& ORACLE_SETUP_FAILURE.test(detail)) {
return { kind: MUTATION_OUTCOME.SETUP, detail, command: result?.command || '' };
}
return {
kind: proof === MUTATION_PROOF.COMPILE
? MUTATION_OUTCOME.COMPILE_KILLED : MUTATION_OUTCOME.ASSERTION_KILLED,
proof,
detail,
command: result?.command || '',
};
}
/** Execute setup commands in order, then the one declared oracle. */
export function runGuardPhases(guard, {
proof = MUTATION_PROOF.ASSERTION,
execute,
} = {}) {
if (typeof execute !== 'function') throw new Error('mutation guard executor is required');
let phases;
try {
phases = guardPhases(guard, proof);
} catch (error) {
return { kind: MUTATION_OUTCOME.INVALID, detail: error.message, command: String(guard || '') };
}
for (const command of phases.setup) {
const result = execute(command, 'setup') || {};
const outcome = classifyCommandResult({ ...result, command }, { phase: 'setup', proof });
if (outcome.kind !== MUTATION_OUTCOME.SURVIVED) return outcome;
}
const result = execute(phases.oracle, 'oracle') || {};
return classifyCommandResult({ ...result, command: phases.oracle }, { phase: 'oracle', proof });
}
/** Keep patch/preparation exceptions distinct from a test verdict. */
export function runMutationLifecycle({ apply, prepare, guard, proof, execute }) {
try { apply(); } catch (error) {
return { kind: MUTATION_OUTCOME.INVALID, detail: error.message, command: '' };
}
try { prepare(); } catch (error) {
return { kind: MUTATION_OUTCOME.SETUP, detail: error.message, command: '' };
}
return runGuardPhases(guard, { proof, execute });
}
export function isProofOutcome(outcome) {
return (outcome?.kind === MUTATION_OUTCOME.ASSERTION_KILLED
&& outcome.proof === MUTATION_PROOF.ASSERTION)
|| (outcome?.kind === MUTATION_OUTCOME.COMPILE_KILLED
&& outcome.proof === MUTATION_PROOF.COMPILE);
}