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
This commit is contained in:
Claude
2026-09-25 18:31:17 +03:00
parent a55f564498
commit 43459bcbe0
7 changed files with 321 additions and 2 deletions
+167
View File
@@ -113,10 +113,177 @@ const ORACLE_SETUP_FAILURE = new RegExp([
// 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);