Files
houseplan-card/scripts/mutation-gate-report.mjs
T
Matysh aef02229c6 fix(ci): give nightly mutation shards headroom and report cancellation accurately
Split the full registry into ten shards without increasing the one-hour
limit. Preserve summary and step outcome separately so cancellation after
194/194 remains red without claiming the summary was missing.

Issue: #795
User-Visible: no
2026-10-05 11:04:29 +03:00

421 lines
22 KiB
JavaScript
Executable File
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.
#!/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';
// #795: отмена может прийти ПОСЛЕ итоговой строки. Сохраняем оба факта:
// итог не превращает cancelled в успех, отмена не стирает итог из отчёта.
shards.push({ shard, status, outcome, summary });
}
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' ? interruptedLabel(s)
: '**артефакт не пришёл**';
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('Прерванные шарды не дают полного доказательства: итоговая строка не заменяет успешный исход шага. '
+ 'Причину cancelled (таймаут или ручная отмена) уточняют аннотации job; один лог её не доказывает.');
}
return {
title: `${REPORT_TITLE_MARKER}: ${input.date}`,
body: `${lines.join('\n')}\n`,
failed,
...parsed,
};
}
function interruptedLabel(shard) {
const outcome = shard.outcome === 'cancelled' ? 'шаг cancelled (таймаут или отмена); '
: shard.outcome === 'skipped' ? 'шаг skipped; ' : '';
const detail = shard.summary
? `итог в логе ${shard.summary.caught}/${shard.summary.total} не подтверждает успешное завершение`
: 'лог без итоговой строки; успешное завершение не доказано';
return `**прерван — ${outcome}${detail}**`;
}
/** Короткий текст для 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', '10'));
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}`);
}
}
}