diff --git a/.github/workflows/mutation-gate.yml b/.github/workflows/mutation-gate.yml index 79b434f9..b1a8825d 100644 --- a/.github/workflows/mutation-gate.yml +++ b/.github/workflows/mutation-gate.yml @@ -67,15 +67,21 @@ jobs: echo "ref=${{ github.event_name == 'workflow_dispatch' && inputs.ref || 'dev' }}" >> "$GITHUB_OUTPUT" mutants: - name: "Мутанты: каждый обязан красить тесты (шард ${{ matrix.shard }} из 4)" + name: "Мутанты: каждый обязан красить тесты (шард ${{ matrix.shard }} из 6)" needs: material runs-on: ubuntu-latest strategy: fail-fast: false matrix: - shard: [1, 2, 3, 4] - # Шард ~64 мутантов × свой guard; бандл собирают только браузерные гварды. - # Час — потолок против зависшего Chromium. + shard: [1, 2, 3, 4, 5, 6] + # Шесть чересполосных шардов (#604): при четырёх шард нёс ~203 мутанта из + # 810 и рос с реестром — 42 мин 10.09, 57 мин 20.09, 61 мин 21.09, и шард + # 2/4 был снят по потолку без единого FAIL. Делитель тот же, что у + # `changed_mutants` в Validate; число шардов повторяется в `--shard=i/6`, + # `--shards=6` и имени job — тест `mutation-gate.test.mjs` держит их + # равными. Бандл собирают только браузерные гварды. Час — потолок против + # зависшего Chromium, а не бюджет шарда: шард, упёршийся в него, — сигнал + # снова делить, и отчёт (#472) называет такой шард прерванным, не «ok». timeout-minutes: 60 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 @@ -124,10 +130,14 @@ jobs: # ЧТО сбежало. Без артефакта отказ безымянный. `PIPESTATUS` — чтобы # `tee` не съел код выхода раннера. - name: Каждый тест ловит свою поломку + id: gate run: | mkdir -p artifacts/mutation-shard-${{ matrix.shard }} set -o pipefail - node scripts/mutation-gate.mjs --shard=${{ matrix.shard }}/4 2>&1 | tee artifacts/mutation-shard-${{ matrix.shard }}/mutation-shard-${{ matrix.shard }}.log + node scripts/mutation-gate.mjs --shard=${{ matrix.shard }}/6 2>&1 | tee artifacts/mutation-shard-${{ matrix.shard }}/mutation-shard-${{ matrix.shard }}.log + # Исход шага едет в evidence (#604): снятый по timeout-minutes шаг даёт + # `cancelled`, и агрегатор отвергает такой шард как неполный — лог без + # строк FAIL сам по себе зелёным не считается. - name: Записать identity шарда if: always() run: | @@ -137,7 +147,8 @@ jobs: --tree=${{ needs.material.outputs.tree }} \ --workflow-sha=${{ github.workflow_sha }} \ --run-id=${{ github.run_id }} --run-attempt=${{ github.run_attempt }} \ - --shard=${{ matrix.shard }} --shards=4 + --shard=${{ matrix.shard }} --shards=6 \ + --outcome=${{ steps.gate.outcome }} - name: Сохранить лог и identity шарда if: always() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 @@ -147,7 +158,8 @@ jobs: if-no-files-found: warn retention-days: 30 - # Результат нельзя приписывать material, пока не доказаны все четыре шарда. + # Результат нельзя приписывать material, пока не доказаны все шесть шардов — + # каждый с identity и с дошедшим до конца прогоном (#549, #604). # always() нужен при красном мутанте: лог красного шарда всё равно evidence. evidence: name: "Доказать единый material всех шардов" @@ -171,7 +183,7 @@ jobs: - name: Проверить полноту и identity run: | node scripts/mutation-gate-report.mjs --verify-only \ - --logs=artifacts/mutation-logs --shards=4 \ + --logs=artifacts/mutation-logs --shards=6 \ --sha=${{ needs.material.outputs.sha }} \ --tree=${{ needs.material.outputs.tree }} \ --workflow-sha=${{ github.workflow_sha }} \ @@ -214,7 +226,7 @@ jobs: run: | mkdir -p artifacts node scripts/mutation-gate-report.mjs \ - --require-evidence --logs=artifacts/mutation-logs --shards=4 \ + --require-evidence --logs=artifacts/mutation-logs --shards=6 \ --sha=${{ needs.material.outputs.sha }} \ --tree=${{ needs.material.outputs.tree }} \ --workflow-sha=${{ github.workflow_sha }} \ diff --git a/docs/TESTING.md b/docs/TESTING.md index ec57c186..84e78e6b 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -29,11 +29,13 @@ но не при её неработоспособности. Проверка: `node scripts/mutation-gate.mjs --check` — якоря патчей живы; -полный прогон — workflow `mutation-gate.yml` (четыре чересполосных шарда -`--shard=i/4`, +полный прогон — workflow `mutation-gate.yml` (шесть чересполосных шардов +`--shard=i/6` — при четырёх шард упёрся в потолок 60 минут на 810 мутантах, #604; один зафиксированный commit/tree для всего прогона; каждый артефакт несёт -identity, а отдельный агрегатор fail-closed отвергает смешанные или неполные -evidence даже при частичном rerun) каждую ночь по расписанию (01:00 UTC); в +identity и исход шага прогона, а отдельный агрегатор fail-closed отвергает +смешанные, неполные или прерванные по таймауту evidence даже при частичном +rerun; лог шарда считается зелёным только с итоговой строкой `поймано N из M`) +каждую ночь по расписанию (01:00 UTC); в релизном гейте он не участвует — проверяет тесты, а не продукт; отказ сам заводит issue с отчётом (#472, #513). Дешёвая половина идёт с юнитами: `test/mutation-gate.test.mjs`. Локально для дельты задачи — diff --git a/scripts/mutation-gate-report.mjs b/scripts/mutation-gate-report.mjs index d6b6a1aa..3fab2674 100755 --- a/scripts/mutation-gate-report.mjs +++ b/scripts/mutation-gate-report.mjs @@ -38,6 +38,16 @@ const RED_GUARD_LINE = /^FAIL чистый прогон: (.+?) красный б 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; @@ -54,6 +64,14 @@ export function mutationShardEvidence(input) { 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) @@ -107,6 +125,12 @@ export function validateMutationShardEvidence(rows, expected) { || 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 }; } @@ -136,7 +160,11 @@ export function loadMutationShardArtifacts(root, expected) { 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 }); + 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; @@ -146,10 +174,18 @@ export function loadMutationShardArtifacts(root, expected) { /** * Разобрать логи шардов. * - * @param {Array<{ shard: number, text: string | null }>} logs — `text: null` - * означает, что артефакт шарда не пришёл. Это тоже отказ: «лога нет» не - * значит «сбежавших нет», это значит «мы не знаем». + * @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); @@ -158,11 +194,14 @@ export function parseShardLogs(logs, knownIds) { const unverifiable = []; const unparsed = []; const shards = []; - for (const { shard, text } of logs) { + 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); @@ -181,7 +220,12 @@ export function parseShardLogs(logs, knownIds) { } unparsed.push({ shard, line }); } - shards.push({ shard, status: failed ? 'failed' : 'ok' }); + 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(), @@ -217,7 +261,10 @@ export function mutationGateReport(input) { lines.push('| шард | результат |'); lines.push('|---|---|'); for (const s of parsed.shards) { - const label = s.status === 'ok' ? 'ok' : s.status === 'failed' ? '**красный**' : '**артефакт не пришёл**'; + const label = s.status === 'ok' ? 'ok' + : s.status === 'failed' ? '**красный**' + : s.status === 'interrupted' ? '**прерван — лог без итоговой строки (таймаут или отмена)**' + : '**артефакт не пришёл**'; lines.push(`| ${s.shard} | ${label} |`); } if (evidenceErrors.length) { @@ -270,6 +317,13 @@ export function mutationGateReport(input) { 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`, @@ -298,12 +352,12 @@ if (invokedDirectly) { return found ? found.slice(name.length + 3) : fallback; }; const writeEvidence = value('write-evidence'); - const shardCount = Number(value('shards', '4')); + 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, + shard: value('shard'), shardCount, outcome: value('outcome'), }); mkdirSync(dirname(writeEvidence), { recursive: true }); writeFileSync(writeEvidence, `${JSON.stringify(evidence, null, 2)}\n`, 'utf8'); diff --git a/scripts/mutation-registry.mjs b/scripts/mutation-registry.mjs index 7609e364..11657786 100644 --- a/scripts/mutation-registry.mjs +++ b/scripts/mutation-registry.mjs @@ -3988,6 +3988,30 @@ const MUTANT_DEFINITIONS = [ replace: ' if (false && evidence.materialSha !== expected.materialSha) errors.push(`shard ${shard}: foreign material SHA`);', }], }, + // #604: шард, снятый по таймауту, оставляет лог без строк FAIL и без итоговой + // строки; отчёт называл его «ok», а агрегатор — доказанным. + { + id: 'mutation-report-truncated-log-is-ok', + guard: 'node --test --test-name-pattern="#604: лог без итоговой строки" test/mutation-gate-report.test.mjs', + because: 'a log cut by timeout-minutes has no FAIL lines only because the run never reached ' + + 'them; calling it ok published "| 2 | ok |" for the cancelled shard of 21.09 (#604)', + patches: [{ + file: 'scripts/mutation-gate-report.mjs', + find: " else if (INTERRUPTED_OUTCOMES.has(outcome) || !summary) status = 'interrupted';", + replace: " else if (INTERRUPTED_OUTCOMES.has(outcome)) status = 'interrupted'; // mutant: no summary line still ok", + }], + }, + { + id: 'mutation-evidence-ignores-cancelled-step', + guard: 'node --test --test-name-pattern="#604: evidence несёт исход шага" test/mutation-gate-report.test.mjs', + because: 'the evidence job proves completeness, not just identity: a shard whose gate step ' + + 'was cancelled by the job timeout must not count towards a proven material (#604)', + patches: [{ + file: 'scripts/mutation-gate-report.mjs', + find: " if (INTERRUPTED_OUTCOMES.has(evidence.outcome)) {\n errors.push(`shard ${shard}: run was interrupted (step outcome ${evidence.outcome})`);\n }", + replace: " if (false && INTERRUPTED_OUTCOMES.has(evidence.outcome)) {\n errors.push(`shard ${shard}: run was interrupted (step outcome ${evidence.outcome})`);\n }", + }], + }, // #481: журнал пойманных свидетелей — каждый защитный контракт под свидетелем. { id: 'ledger-records-escaped', diff --git a/test/mutation-gate-report.test.mjs b/test/mutation-gate-report.test.mjs index 79abce00..db30c2e7 100644 --- a/test/mutation-gate-report.test.mjs +++ b/test/mutation-gate-report.test.mjs @@ -68,7 +68,7 @@ test('сбежавшие собираются из нескольких шард const report = mutationGateReport({ ...meta, guards, logs: [ { shard: 1, text: 'ok x\nFAIL beta-mutant: тест остался зелёным на сломанном коде\n guard: node demo/smoke_b.mjs\n' }, { shard: 2, text: 'FAIL alpha-mutant: тест остался зелёным на сломанном коде\nFAIL beta-mutant: тест остался зелёным на сломанном коде\n' }, - { shard: 3, text: 'ok gamma-mutant: тест покраснел, как обязан\n' }, + { shard: 3, text: 'ok gamma-mutant: тест покраснел, как обязан\n\nпоймано 1 из 1\n' }, ] }); assert.deepEqual(report.escaped, ['alpha-mutant', 'beta-mutant']); assert.deepEqual(report.shards.map((s) => s.status), ['failed', 'failed', 'ok']); @@ -139,13 +139,56 @@ test('заголовок несёт постоянный маркер, тело test('зелёный набор логов даёт failed=false (#472)', () => { const report = mutationGateReport({ ...meta, guards, logs: [ - { shard: 1, text: 'ok alpha-mutant: тест покраснел, как обязан\n' }, + { shard: 1, text: 'ok alpha-mutant: тест покраснел, как обязан\n\nпоймано 1 из 1\n', outcome: 'success' }, { shard: 2, text: 'поймано 3 из 3\n' }, ] }); assert.equal(report.failed, false); assert.deepEqual(report.shards.map((s) => s.status), ['ok', 'ok']); }); +// #604. Шард 2/4 ночного прогона 21.09 снят по timeout-minutes на 60-й минуте +// после ≥155 зелёных строк; строк FAIL в обрывке нет, и отчёт написал «ok» при +// красном прогоне. Зелёным считается только лог, дошедший до итоговой строки. +test('#604: лог без итоговой строки — прерван, а не «ok»', () => { + const truncated = 'ok alpha-mutant: тест покраснел, как обязан\nok beta-mutant: тест покраснел, как обязан\n'; + const report = mutationGateReport({ ...meta, guards, logs: [ + { shard: 1, text: 'поймано 2 из 2\n' }, + { shard: 2, text: truncated }, + ] }); + assert.deepEqual(report.shards.map((s) => s.status), ['ok', 'interrupted']); + assert.equal(report.failed, true, 'обрыв — отказ: до сбежавших могли не дойти'); + assert.deepEqual(report.escaped, [], 'сбежавших из обрывка не выдумывается'); + assert.match(report.body, /\| 2 \| \*\*прерван — лог без итоговой строки \(таймаут или отмена\)\*\* \|/); + assert.match(report.body, /Шарды 2 прерваны до итоговой строки/); + assert.match(telegramSummary(report, 'https://x/issues/9'), /2:interrupted/); +}); + +test('#604: исход шага cancelled прерывает шард даже при итоговой строке; failure без FAIL — красный', () => { + const finished = 'ok alpha-mutant: тест покраснел, как обязан\n\nпоймано 1 из 1\n'; + const parsed = parseShardLogs([ + { shard: 1, text: finished, outcome: 'cancelled' }, + { shard: 2, text: finished, outcome: 'skipped' }, + { shard: 3, text: finished, outcome: 'failure' }, + { shard: 4, text: 'поймано 2 из 3\n', outcome: 'success' }, + { shard: 5, text: finished, outcome: 'success' }, + { shard: 6, text: finished }, + ], KNOWN); + assert.deepEqual(parsed.shards.map((s) => s.status), ['interrupted', 'interrupted', 'failed', 'failed', 'ok', 'ok']); +}); + +test('#604: evidence несёт исход шага, агрегатор отвергает прерванный шард как неполный', () => { + const rows = [1, 2, 3, 4].map((shard) => evidenceRow(shard, { outcome: shard === 2 ? 'cancelled' : 'success' })); + assert.equal(rows[1].evidence.outcome, 'cancelled'); + assert.equal(evidenceRow(1).evidence.outcome, undefined, 'старые артефакты без исхода остаются валидными'); + const result = validateMutationShardEvidence(rows, expectedEvidence); + assert.equal(result.ok, false); + assert.ok(result.errors.some((error) => error.includes('shard 2: run was interrupted (step outcome cancelled)'))); + // красный шаг — не нарушение identity: красноту называет лог, не агрегатор + const red = validateMutationShardEvidence([1, 2, 3, 4].map((shard) => evidenceRow(shard, { outcome: 'failure' })), expectedEvidence); + assert.equal(red.ok, true); + assert.throws(() => mutationShardEvidence({ ...expectedEvidence, shard: 1, runAttempt: 1, outcome: 'timed-out' }), /unknown shard outcome/); +}); + test('сводка для Telegram коротка и ведёт на issue (#472)', () => { const report = mutationGateReport({ ...meta, guards, logs: [ { shard: 1, text: 'FAIL alpha-mutant: тест остался зелёным на сломанном коде\n' }, diff --git a/test/mutation-gate.test.mjs b/test/mutation-gate.test.mjs index e56c2cde..336664d6 100644 --- a/test/mutation-gate.test.mjs +++ b/test/mutation-gate.test.mjs @@ -305,6 +305,31 @@ test('#472 AC2 / #549: каждый шард сохраняет лог и identi assert.match(mutationWorkflow, /--write-evidence=.*evidence\.json/); }); +// #604. Шард 2/4 снят по timeout-minutes: реестр вырос до 810 мутантов, ~203 на +// шард, 55–61 мин при потолке 60. Шардов шесть, и делитель повторяется в +// четырёх местах workflow — тест не даёт им разойтись при следующем делении. +test('#604: делитель шардов ночного прогона один во всех местах workflow, шардов шесть', () => { + const matrix = /shard: \[([0-9, ]+)\]/.exec(mutationWorkflow); + assert.ok(matrix, 'matrix.shard объявлена списком'); + const shards = matrix[1].split(',').map((n) => Number(n.trim())); + assert.deepEqual(shards, [1, 2, 3, 4, 5, 6]); + const n = shards.length; + assert.match(mutationWorkflow, new RegExp(`\\(шард \\$\\{\\{ matrix\\.shard \\}\\} из ${n}\\)`), 'имя job'); + assert.match(mutationWorkflow, new RegExp(`--shard=\\$\\{\\{ matrix\\.shard \\}\\}/${n} `), 'делитель раннера'); + // Только строки команд: комментарий про `--shards=6` — не место вызова. + const shardsArgs = mutationWorkflow.split('\n').filter((line) => !/^\s*#/.test(line)) + .flatMap((line) => line.match(/--shards=\d+/g) || []); + assert.equal(shardsArgs.length, 3, 'evidence шарда, агрегатор, отчёт'); + assert.ok(shardsArgs.every((arg) => arg === `--shards=${n}`), shardsArgs.join(' ')); + assert.ok(!/--shard=\$\{\{ matrix\.shard \}\}\/4\b|--shards=4\b|из 4\)/.test(mutationWorkflow), 'старый делитель 4 не остался'); + assert.match(mutationWorkflow, /timeout-minutes: 60/, 'потолок остаётся стражем от зависшего Chromium'); +}); + +test('#604: исход шага прогона едет в evidence шарда', () => { + assert.match(mutationWorkflow, /- name: Каждый тест ловит свою поломку\n\s+id: gate\n/); + assert.match(mutationWorkflow, /--write-evidence=[^\n]*\n(?:[^\n]*\n){5}\s+--outcome=\$\{\{ steps\.gate\.outcome \}\}/); +}); + test('#472 AC5: job report — только по расписанию, только при не-успехе, с полными правами', () => { const report = mutationWorkflow.slice(mutationWorkflow.indexOf(' report:')); assert.match(report, /if: always\(\) && github\.event_name == 'schedule' && \(needs\.mutants\.result != 'success' \|\| needs\.evidence\.result != 'success'\)/);