#!/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 : тест остался зелёным на сломанном коде → escaped * FAIL чистый прогон: красный без мутанта → redGuards * FAIL : ошибка подготовки до заявленного теста → 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..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[]} 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|Record} 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}`); } } }