Files
houseplan-card/scripts/mutation-gate-report.mjs
T
Claude cc2300bf86 ci(mutation-gate): шесть шардов ночного прогона, прерванный шард — отказ, а не «ok» (#604)
Шард 2/4 прогона 35565222849 снят по timeout-minutes: реестр вырос до 810
мутантов (~203 на шард), длительность за 11 дней 42 → 61 мин при потолке 60.
Отчёт назвал его «ok»: лог без строк FAIL считался зелёным, а обрыв по
таймауту строк FAIL не содержит. Агрегатор проверял identity, но не
завершённость — evidence шага `if: always()` было на месте.

- mutation-gate.yml: matrix из шести шардов, `--shard=i/6`, `--shards=6`;
  шаг прогона получил id, его `outcome` пишется в evidence.
- mutation-gate-report.mjs: шард `ok` только с итоговой строкой
  `поймано N из M`, N = M, без FAIL и с исходом шага `success`; лог без
  итога или исход `cancelled`/`skipped` — `interrupted`, отказ; агрегатор
  отвергает прерванный шард как неполный. `outcome` в evidence необязателен
  ради старых артефактов, но, если назван, обязан быть из известного набора.
- тесты: обрыв → interrupted, исходы шага, evidence с outcome; делитель
  шардов один во всех местах workflow; фикстуры зелёных логов получили итог.
- мутанты: mutation-report-truncated-log-is-ok,
  mutation-evidence-ignores-cancelled-step.
- docs/TESTING.md: шесть шардов, итоговая строка.

Потолок 60 минут остаётся стражем от зависшего Chromium. Ledger в ночном
прогоне не включён: ночь гоняет всё.

Issue: #604
User-Visible: no
2026-09-21 14:12:17 +03:00

411 lines
21 KiB
JavaScript
Executable File
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
/**
* Отчёт об отказе полного мутационного прогона по расписанию (#472).
*
* Что случилось. Еженедельный прогон `mutation-gate.yml` дважды подряд не дал
* зелёного результата (24.08 отменён, 31.08 красный во всех шардах), и никто
* этого не открыл: у отказа не было адресата. Пять дней спустя ручной прогон
* перед стабильной v1.72.0 остановил релиз теми же сбежавшими мутантами
* (#465–#467). Механизм есть — его выход никто не читает.
*
* Этот модуль — чистая функция «логи шардов → отчёт» и тонкий CLI. Обвязка
* (`gh issue`, `curl` в Telegram) остаётся в workflow, потому что у shell там
* нет тестов, а у разбора логов — есть (урок #454).
*
* Две формы `FAIL` в логе раннера различаются явно:
*
* FAIL <mutant-id>: тест остался зелёным на сломанном коде → escaped
* FAIL чистый прогон: <guard> красный без мутанта → redGuards
* FAIL <mutant-id>: ошибка подготовки до заявленного теста → unverifiable
*
* Наивный парсер «id — это слово после FAIL» сделал бы из второй формы
* мутанта по имени «чистый», которого в реестре нет, и команда `--id=чистый`
* в письме не сработала бы. Поэтому id обязан существовать в реестре; всё
* остальное уходит в `unparsed` с текстом как есть — потерять строку нельзя,
* но и выдумывать из неё сущность тоже.
*/
import {
existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync,
} from 'node:fs';
import { dirname, join } from 'node:path';
import { isMainModule } from './spawn-portable.mjs';
export const REPORT_TITLE_MARKER = '[mutation-gate] отказ прогона по расписанию';
export const MUTATION_EVIDENCE_SCHEMA = 'houseplan-mutation-shard-evidence/v1';
const ESCAPED_LINE = /^FAIL (\S+): тест остался зелёным на сломанном коде\s*$/;
const RED_GUARD_LINE = /^FAIL чистый прогон: (.+?) красный без мутанта\s*$/;
const UNVERIFIABLE_LINE = /^FAIL (\S+): (неприменимый мутант|ошибка подготовки до заявленного теста|прерывание инфраструктуры)\s*$/;
const CLEAN_UNVERIFIABLE_LINE = /^FAIL чистая (подготовка|инфраструктура): (.+?)\s*$/;
const ANY_FAIL_LINE = /^FAIL /;
// Итоговая строка раннера (`scripts/mutation-gate.mjs`, конец main). Лог без
// неё — обрыв: шард снят по timeout-minutes или отменён, и «FAIL не встретился»
// значит лишь «до FAIL не дошли». 21.09 (#604) шард 2/4 умер на 60-й минуте
// после ≥155 зелёных строк, и отчёт назвал его «ok».
const SUMMARY_LINE = /^поймано (\d+) из (\d+)\s*$/;
// Исходы шага GitHub Actions (`steps.<id>.outcome`), которые шард пишет в
// evidence (#604). `cancelled` — timeout job или отмена прогона; такой шард
// не завершён независимо от того, что успел попасть в лог.
export const SHARD_OUTCOMES = Object.freeze(['success', 'failure', 'cancelled', 'skipped']);
const INTERRUPTED_OUTCOMES = new Set(['cancelled', 'skipped']);
const FULL_SHA = /^[0-9a-f]{40}$/;
const positiveInteger = (value) => Number.isInteger(Number(value)) && Number(value) > 0;
/** Machine-readable identity written beside every nightly shard log (#549). */
export function mutationShardEvidence(input) {
const evidence = {
schema: MUTATION_EVIDENCE_SCHEMA,
materialSha: String(input.materialSha || ''),
materialTree: String(input.materialTree || ''),
workflowSha: String(input.workflowSha || ''),
runId: Number(input.runId),
runAttempt: Number(input.runAttempt),
shard: Number(input.shard),
shardCount: Number(input.shardCount),
};
// Исход шага прогона — необязателен ради артефактов, записанных до #604;
// если назван, обязан быть одним из известных: опечатка в workflow не должна
// превращаться в «неизвестно, значит ок».
const outcome = input.outcome == null || input.outcome === '' ? undefined : String(input.outcome);
if (outcome !== undefined) {
if (!SHARD_OUTCOMES.includes(outcome)) throw new Error(`unknown shard outcome: ${outcome}`);
evidence.outcome = outcome;
}
if (!FULL_SHA.test(evidence.materialSha) || !FULL_SHA.test(evidence.materialTree)
|| !FULL_SHA.test(evidence.workflowSha) || !positiveInteger(evidence.runId)
|| !positiveInteger(evidence.runAttempt) || !positiveInteger(evidence.shard)
|| !positiveInteger(evidence.shardCount) || evidence.shard > evidence.shardCount) {
throw new Error('invalid mutation shard evidence identity');
}
return evidence;
}
/**
* Select the newest artifact attempt for each shard, then prove that the whole
* set belongs to one immutable material and workflow run. Older artifacts are
* deliberately ignored so a full rerun may pin a fresh material, while a
* partial rerun can reuse successful shards from its earlier attempt.
*/
export function validateMutationShardEvidence(rows, expected) {
const errors = [];
const byShard = new Map();
for (const row of rows || []) {
if (row?.error) { errors.push(`${row.file || 'evidence'}: ${row.error}`); continue; }
const evidence = row?.evidence;
try {
mutationShardEvidence(evidence || {});
} catch {
errors.push(`${row?.file || 'evidence'}: invalid evidence identity`);
continue;
}
const shard = Number(evidence.shard);
const current = byShard.get(shard);
if (!current || Number(evidence.runAttempt) > Number(current.evidence.runAttempt)) {
byShard.set(shard, row);
} else if (Number(evidence.runAttempt) === Number(current.evidence.runAttempt)) {
errors.push(`shard ${shard}: duplicate evidence for attempt ${evidence.runAttempt}`);
}
}
const selected = [];
const shardCount = Number(expected.shardCount);
for (let shard = 1; shard <= shardCount; shard++) {
const row = byShard.get(shard);
if (!row) { errors.push(`shard ${shard}: evidence is missing`); continue; }
const evidence = row.evidence;
selected.push(row);
if (evidence.schema !== MUTATION_EVIDENCE_SCHEMA) errors.push(`shard ${shard}: wrong schema`);
if (Number(evidence.shardCount) !== shardCount) errors.push(`shard ${shard}: wrong shard count`);
if (evidence.materialSha !== expected.materialSha) errors.push(`shard ${shard}: foreign material SHA`);
if (evidence.materialTree !== expected.materialTree) errors.push(`shard ${shard}: foreign material tree`);
if (evidence.workflowSha !== expected.workflowSha) errors.push(`shard ${shard}: foreign workflow SHA`);
if (Number(evidence.runId) !== Number(expected.runId)) errors.push(`shard ${shard}: foreign run id`);
if (!positiveInteger(evidence.runAttempt)
|| Number(evidence.runAttempt) > Number(expected.runAttempt)) {
errors.push(`shard ${shard}: impossible run attempt`);
}
// Полнота — это не только «артефакт пришёл», но и «шард дошёл до конца»
// (#604): снятый по таймауту шаг оставляет evidence через `if: always()`,
// и без этой проверки агрегатор приписывал material результат, которого нет.
if (INTERRUPTED_OUTCOMES.has(evidence.outcome)) {
errors.push(`shard ${shard}: run was interrupted (step outcome ${evidence.outcome})`);
}
}
return { ok: errors.length === 0, errors, selected };
}
function evidenceFiles(root) {
if (!existsSync(root)) return [];
const out = [];
const walk = (dir) => {
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const path = join(dir, entry.name);
if (entry.isDirectory()) walk(path);
else if (entry.name === 'evidence.json') out.push(path);
}
};
walk(root);
return out.sort();
}
export function loadMutationShardArtifacts(root, expected) {
const rows = evidenceFiles(root).map((file) => {
try { return { file, evidence: JSON.parse(readFileSync(file, 'utf8')) }; }
catch (error) { return { file, error: `invalid JSON: ${error.message}` }; }
});
const validation = validateMutationShardEvidence(rows, expected);
const selectedByShard = new Map(validation.selected.map((row) => [Number(row.evidence.shard), row]));
const logs = [];
for (let shard = 1; shard <= Number(expected.shardCount); shard++) {
const row = selectedByShard.get(shard);
const path = row ? join(dirname(row.file), `mutation-shard-${shard}.log`) : '';
logs.push({
shard,
text: path && existsSync(path) ? readFileSync(path, 'utf8') : null,
outcome: row?.evidence?.outcome,
});
if (row && (!path || !existsSync(path))) validation.errors.push(`shard ${shard}: log is missing`);
}
validation.ok = validation.errors.length === 0;
return { ...validation, logs };
}
/**
* Разобрать логи шардов.
*
* @param {Array<{ shard: number, text: string | null, outcome?: string }>} logs —
* `text: null` означает, что артефакт шарда не пришёл. Это тоже отказ: «лога
* нет» не значит «сбежавших нет», это значит «мы не знаем». `outcome` —
* исход шага прогона из evidence, если записан (#604).
* @param {Set<string>|string[]} knownIds — id реестра.
*
* Статусы шарда: `ok` — лог дошёл до итоговой строки `поймано N из M`, N = M,
* ни одной строки FAIL, исход шага не назван или `success`; `failed` — есть
* FAIL либо итог не сходится либо шаг красный без FAIL; `interrupted` — лог
* обрывается до итоговой строки или исход шага `cancelled`/`skipped` (таймаут,
* отмена) — тоже отказ, потому что до сбежавших могли не дойти; `missing` —
* артефакта нет.
*/
export function parseShardLogs(logs, knownIds) {
const known = new Set(knownIds);
const escaped = new Set();
const redGuards = new Set();
const unverifiable = [];
const unparsed = [];
const shards = [];
for (const { shard, text, outcome } of logs) {
if (text == null) { shards.push({ shard, status: 'missing' }); continue; }
let failed = false;
let summary = null;
for (const raw of String(text).split('\n')) {
const line = raw.replace(/\r$/, '');
const asSummary = SUMMARY_LINE.exec(line);
if (asSummary) { summary = { caught: Number(asSummary[1]), total: Number(asSummary[2]) }; continue; }
if (!ANY_FAIL_LINE.test(line)) continue;
failed = true;
const asEscaped = ESCAPED_LINE.exec(line);
if (asEscaped && known.has(asEscaped[1])) { escaped.add(asEscaped[1]); continue; }
const asRed = RED_GUARD_LINE.exec(line);
if (asRed) { redGuards.add(asRed[1]); continue; }
const asUnverifiable = UNVERIFIABLE_LINE.exec(line);
if (asUnverifiable && known.has(asUnverifiable[1])) {
unverifiable.push({ shard, id: asUnverifiable[1], reason: asUnverifiable[2] });
continue;
}
const asCleanUnverifiable = CLEAN_UNVERIFIABLE_LINE.exec(line);
if (asCleanUnverifiable) {
unverifiable.push({ shard, id: '', reason: `чистая ${asCleanUnverifiable[1]}: ${asCleanUnverifiable[2]}` });
continue;
}
unparsed.push({ shard, line });
}
let status;
if (failed) status = 'failed';
else if (INTERRUPTED_OUTCOMES.has(outcome) || !summary) status = 'interrupted';
else if (summary.caught !== summary.total || (outcome !== undefined && outcome !== 'success')) status = 'failed';
else status = 'ok';
shards.push({ shard, status });
}
return {
escaped: [...escaped].sort(),
redGuards: [...redGuards].sort(),
unverifiable,
unparsed,
shards: shards.sort((a, b) => a.shard - b.shard),
};
}
/**
* Собрать заголовок и тело issue.
*
* @param {object} input
* @param {Array<{ shard: number, text: string | null }>} input.logs
* @param {Map<string, string>|Record<string, string>} input.guards — id → guard
* @param {string} input.runUrl
* @param {string} input.ref
* @param {string} input.sha
* @param {string} input.date — ISO
*/
export function mutationGateReport(input) {
const guards = input.guards instanceof Map ? input.guards : new Map(Object.entries(input.guards || {}));
const parsed = parseShardLogs(input.logs || [], [...guards.keys()]);
const evidenceErrors = [...(input.evidenceErrors || [])];
const failed = evidenceErrors.length > 0 || parsed.shards.some((s) => s.status !== 'ok')
|| parsed.escaped.length > 0 || parsed.redGuards.length > 0
|| parsed.unverifiable.length > 0 || parsed.unparsed.length > 0;
const lines = [];
lines.push(`Полный мутационный прогон по расписанию не прошёл: ${input.date}, \`${input.ref}\` @ \`${String(input.sha || '').slice(0, 12)}\`.`);
lines.push(`Прогон: ${input.runUrl}`);
lines.push('');
lines.push('| шард | результат |');
lines.push('|---|---|');
for (const s of parsed.shards) {
const label = s.status === 'ok' ? 'ok'
: s.status === 'failed' ? '**красный**'
: s.status === 'interrupted' ? '**прерван — лог без итоговой строки (таймаут или отмена)**'
: '**артефакт не пришёл**';
lines.push(`| ${s.shard} | ${label} |`);
}
if (evidenceErrors.length) {
lines.push('');
lines.push('## Материал шардов не доказан');
lines.push('');
lines.push('Агрегатор отверг смешанные или неполные evidence; общий результат этому SHA не приписывается.');
lines.push('');
for (const error of evidenceErrors) lines.push(`- ${error}`);
}
if (parsed.escaped.length) {
lines.push('');
lines.push(`## Сбежавшие мутанты (${parsed.escaped.length})`);
lines.push('');
lines.push('Тест остался зелёным на сломанном коде — свидетель разучился краснеть. Воспроизведение:');
lines.push('');
for (const id of parsed.escaped) {
lines.push(`- \`${id}\` — \`node scripts/mutation-gate.mjs --id=${id}\``);
const guard = guards.get(id);
if (guard) lines.push(` guard: \`${guard}\``);
}
}
if (parsed.redGuards.length) {
lines.push('');
lines.push(`## Гарды, красные без мутанта (${parsed.redGuards.length})`);
lines.push('');
lines.push('Это не сбежавший мутант: тест падает и на исправном коде, доказать им ничего нельзя. Команда как есть:');
lines.push('');
for (const guard of parsed.redGuards) lines.push(`- \`${guard}\``);
}
if (parsed.unverifiable.length) {
lines.push('');
lines.push(`## Свидетели без доказательства (${parsed.unverifiable.length})`);
lines.push('');
lines.push('Заявленный тест не дал вердикт: такой исход не записывается в ledger и не переиспользуется.');
lines.push('');
for (const item of parsed.unverifiable) {
const target = item.id ? `\`${item.id}\`` : `шард ${item.shard}`;
lines.push(`- ${target} — ${item.reason}`);
}
}
if (parsed.unparsed.length) {
lines.push('');
lines.push(`## Неразобранные строки FAIL (${parsed.unparsed.length})`);
lines.push('');
for (const { shard, line } of parsed.unparsed) lines.push(`- шард ${shard}: \`${line}\``);
}
const missing = parsed.shards.filter((s) => s.status === 'missing');
if (missing.length) {
lines.push('');
lines.push(`Артефакты шардов ${missing.map((s) => s.shard).join(', ')} не пришли — это отказ, а не отсутствие сбежавших.`);
}
const interrupted = parsed.shards.filter((s) => s.status === 'interrupted');
if (interrupted.length) {
lines.push('');
lines.push(`Шарды ${interrupted.map((s) => s.shard).join(', ')} прерваны до итоговой строки \`поймано N из M\` — `
+ 'timeout-minutes job или отмена прогона (#604). Сбежавших там не разобрано: до них могли не дойти. '
+ 'Если это таймаут — реестр вырос, шардов не хватает.');
}
return {
title: `${REPORT_TITLE_MARKER}: ${input.date}`,
body: `${lines.join('\n')}\n`,
failed,
...parsed,
};
}
/** Короткий текст для Telegram: заголовок, сбежавшие, ссылка. */
export function telegramSummary(report, issueUrl) {
const head = `⛔ houseplan-card: ${REPORT_TITLE_MARKER}`;
const escaped = report.escaped.length
? `сбежали: ${report.escaped.slice(0, 8).join(', ')}${report.escaped.length > 8 ? ` +${report.escaped.length - 8}` : ''}`
: 'сбежавших не разобрано';
const shards = report.shards.filter((s) => s.status !== 'ok').map((s) => `${s.shard}:${s.status}`).join(' ');
return `${head}\n${escaped}\nшарды: ${shards || '—'}\n${issueUrl}`;
}
// #496: pathToFileURL, не `file://${argv}` — на Windows последнее давало
// `file:///C:/C:/...`, CLI считал себя импортированным и молчал.
const invokedDirectly = isMainModule(import.meta.url);
if (invokedDirectly) {
const argv = process.argv.slice(2);
const value = (name, fallback = '') => {
const found = argv.find((item) => item.startsWith(`--${name}=`));
return found ? found.slice(name.length + 3) : fallback;
};
const writeEvidence = value('write-evidence');
const shardCount = Number(value('shards', '6'));
if (writeEvidence) {
const evidence = mutationShardEvidence({
materialSha: value('sha'), materialTree: value('tree'), workflowSha: value('workflow-sha'),
runId: value('run-id'), runAttempt: value('run-attempt'),
shard: value('shard'), shardCount, outcome: value('outcome'),
});
mkdirSync(dirname(writeEvidence), { recursive: true });
writeFileSync(writeEvidence, `${JSON.stringify(evidence, null, 2)}\n`, 'utf8');
console.log(`evidence=${writeEvidence}`);
} else {
const dir = value('logs', 'artifacts/mutation-logs');
const expected = {
materialSha: value('sha'), materialTree: value('tree'), workflowSha: value('workflow-sha'),
runId: Number(value('run-id')), runAttempt: Number(value('run-attempt')), shardCount,
};
const requireEvidence = argv.includes('--require-evidence') || argv.includes('--verify-only');
let logs = [];
let evidenceErrors = [];
if (requireEvidence) {
const loaded = loadMutationShardArtifacts(dir, expected);
logs = loaded.logs;
evidenceErrors = loaded.errors;
for (const error of evidenceErrors) console.error(`evidence: ${error}`);
if (argv.includes('--verify-only')) {
console.log(`verified=${loaded.ok}`);
if (!loaded.ok) process.exitCode = 1;
}
} else {
for (let shard = 1; shard <= shardCount; shard++) {
const path = `${dir}/mutation-shard-${shard}/mutation-shard-${shard}.log`;
logs.push({ shard, text: existsSync(path) ? readFileSync(path, 'utf8') : null });
}
}
if (!argv.includes('--verify-only')) {
const { MUTANTS } = await import('./mutation-gate.mjs');
const guards = new Map(MUTANTS.map((m) => [m.id, m.guard]));
const report = mutationGateReport({
logs, guards, evidenceErrors,
runUrl: value('run-url'), ref: value('ref', 'dev'), sha: value('sha'),
date: value('date', new Date().toISOString().slice(0, 10)),
});
const bodyPath = value('body-out', 'artifacts/mutation-report.md');
mkdirSync(dirname(bodyPath), { recursive: true });
writeFileSync(bodyPath, report.body, 'utf8');
const summaryPath = value('telegram-out', 'artifacts/mutation-telegram.txt');
mkdirSync(dirname(summaryPath), { recursive: true });
writeFileSync(summaryPath, telegramSummary(report, value('issue-url', '(issue)')), 'utf8');
console.log(`title=${report.title}`);
console.log(`marker=${REPORT_TITLE_MARKER}`);
console.log(`body=${bodyPath}`);
console.log(`escaped=${report.escaped.join(',')}`);
console.log(`failed=${report.failed}`);
}
}
}