ci(mutants): skip the nightly registry on a proven tree; install only the shard's environment

#620. Mutants were ~70 % of CI machine time.

1. mutation-gate.yml: a green full run (aggregator verified all six shards)
   leaves a cache marker keyed by material tree + workflow SHA. A scheduled
   night with the same tree and workflow, marker not older than 7 days, skips
   the shards and writes "reused from run N" to the run summary. Red runs
   leave no marker, so the next night runs again and still files the issue
   (#472). Manual dispatch always runs the full registry. Decision is a pure
   function in scripts/mutation-nightly-reuse.mjs.
2. changed_mutants: the shard plan (already computed before setup, #518) now
   names the environment of its guards (plan-browser=/plan-python=, from
   scripts/mutation-environment.mjs). Python + backend deps only for shards
   with pytest guards, Chromium only for shards whose guards reach
   Playwright; pip wheels cached. Every shard still runs and reports, so the
   six mutant jobs of the review proof are unchanged.

Item 3 of the issue (smoke guards -> node --test) is out of scope here.

Issue: #620
User-Visible: no
This commit is contained in:
Claude
2026-09-24 05:48:42 +00:00
committed by claude[bot]
parent 7bb55c2a41
commit 44ee23ee33
10 changed files with 739 additions and 11 deletions
+114
View File
@@ -0,0 +1,114 @@
// Окружение, которое нужно гардам плана (#620). Чистые функции: команда гарда
// и чтение файлов приходят извне, файловой системы модуль не трогает.
//
// Зачем. `changed_mutants` ставил Python с зависимостями бэкенда и Chromium на
// каждом шарде с непустым планом, хотя план уже знает своих гардов: дифф с
// одними юнит-гардами браузер не открывает, а установка стоит минуты на шард.
//
// Что считается кодом гарда. Не замыкание входов из mutation-selection — оно
// нарочно широкое (пути в строках, данные check-inputs) и отвечает на другой
// вопрос: «что может изменить исход». Здесь вопрос «что исполнится», поэтому
// граф такой: точки входа — файлы из строки гарда, объявленные входы обёрток и
// пути, названные в самих точках входа (тест, запускающий `node scripts/x.mjs`,
// порождает процесс); от каждой точки входа — транзитивные относительные
// импорты (тот же процесс). Файл графа, импортирующий Playwright, требует
// браузер; `.py` в графе или запуск `'python3'`/pytest — Python.
//
// Направление ошибки. Лишняя установка стоит минуты. Недоустановка ложной
// зелени не даёт: чистый прогон гарда без Chromium или pytest падает ДО
// мутантов (`runCleanGuards` → код 2), а гард, пропустивший себя без среды,
// остаётся зелёным и на мутанте — это `survived`, тоже красный. Цена ошибки
// здесь — лишний круг задачи, не пропущенная поломка.
import { posix } from 'node:path';
import { guardNeedsBundle } from './mutation-execution.mjs';
import { guardFiles, wrapperInputs } from './mutation-selection.mjs';
const GUARD_BROWSER_RE = /playwright|chromium/i;
const GUARD_PYTHON_RE = /\bpython3?\b|\bpytest\b/;
/** Импорт Playwright — статический или динамический. */
const SOURCE_BROWSER_RE = /(?:\bfrom\s*|\bimport\s*\(\s*)['"]playwright(?:-core)?['"]/;
/** Запуск Python литералом (`spawnSync('python3', …)`, `'pytest'`, `PYTHON`). */
const SOURCE_PYTHON_RE = /['"`](?:python3?|pytest)['"`]|process\.env\.PYTHON\b/;
/** Относительный импорт: тот же процесс. */
const IMPORT_RE = /(?:\bfrom\s*|\bimport\s*\(\s*|^\s*import\s+)['"](\.{1,2}\/[^'"]+)['"]/gm;
/** Путь-литерал в точке входа: относительный или от корня репозитория. */
const PATH_LITERAL_RE = /['"`]((?:\.{1,2}\/)+[\w./-]+\.(?:mjs|cjs|js|py)|(?:scripts|demo|test|tests_backend)\/[\w./-]+\.(?:mjs|cjs|js|py))['"`]/g;
const isScript = (file) => /\.(?:mjs|cjs|js)$/.test(file);
const isWrapper = (file) => /^scripts\/[\w-]+-guard\.mjs$/.test(file);
function resolveFrom(file, spec) {
const target = spec.startsWith('.') ? posix.join(posix.dirname(file), spec) : posix.normalize(spec);
return target.startsWith('..') ? null : target;
}
/**
* Файлы, чей код исполнится при запуске гарда (см. шапку модуля).
* @param {string} guard
* @param {{ read: (file: string) => string, exists: (file: string) => boolean }} io
* @returns {string[]}
*/
export function guardRuntimeFiles(guard, { read, exists }) {
const named = guardFiles(guard, exists);
const explicit = named.some((file) => !isWrapper(file));
const declared = explicit ? [] : named.filter(isWrapper).flatMap((file) => wrapperInputs(file, read));
const seen = new Set();
const queue = [...new Set([...named, ...declared])].filter(exists).map((file) => ({ file, entry: true }));
while (queue.length) {
const { file, entry } = queue.shift();
if (seen.has(file)) continue;
seen.add(file);
if (!isScript(file)) continue;
const text = String(read(file) || '');
const push = (spec, asEntry) => {
const target = resolveFrom(file, spec);
if (target && !seen.has(target) && exists(target)) queue.push({ file: target, entry: asEntry });
};
for (const match of text.matchAll(IMPORT_RE)) push(match[1], false);
if (entry) {
for (const match of text.matchAll(PATH_LITERAL_RE)) {
const spec = match[1];
// Путь от корня разрешается от корня, относительный — от файла.
push(spec.startsWith('.') ? spec : `./${posix.relative(posix.dirname(file), spec)}`, true);
}
}
}
return [...seen].sort();
}
/**
* @param {string} guard команда гарда
* @param {{ read: (file: string) => string, exists: (file: string) => boolean }} io
* @returns {{ browser: boolean, python: boolean }}
*/
export function guardEnvironment(guard, io) {
const text = String(guard || '');
const files = guardRuntimeFiles(text, io);
const sources = files.filter(isScript).map((file) => String(io.read(file) || ''));
return {
browser: guardNeedsBundle(text) || GUARD_BROWSER_RE.test(text)
|| sources.some((source) => SOURCE_BROWSER_RE.test(source)),
python: GUARD_PYTHON_RE.test(text) || files.some((file) => file.endsWith('.py'))
|| sources.some((source) => SOURCE_PYTHON_RE.test(source)),
};
}
/**
* Окружение шарда — объединение по его гардам. Пустой план не требует ничего.
* @param {{ guard: string }[]} mutants
* @param {(guard: string) => { browser: boolean, python: boolean }} environmentOf
*/
export function planEnvironment(mutants, environmentOf) {
const need = { browser: false, python: false };
for (const guard of new Set(mutants.map((mutant) => mutant.guard))) {
const env = environmentOf(guard);
need.browser ||= Boolean(env.browser);
need.python ||= Boolean(env.python);
}
return need;
}
/** Строки, которые читает шаг плана в validate.yml. Формат менять синхронно. */
export function planEnvironmentLines(need) {
return [`plan-browser=${need.browser ? 'true' : 'false'}`, `plan-python=${need.python ? 'true' : 'false'}`];
}
+6
View File
@@ -23,6 +23,7 @@ import {
LEDGER_SCHEMA, readLedger, recordCaught, splitByLedger, witnessFingerprint,
} from './mutation-evidence.mjs';
import { attributeSetupFailure } from './mutation-attribution.mjs';
import { guardEnvironment, planEnvironment, planEnvironmentLines } from './mutation-environment.mjs';
import { MUTATION_OUTCOME, isProofOutcome } from './mutation-guard-outcome.mjs';
const repoRoot = fileURLToPath(new URL('..', import.meta.url));
@@ -215,6 +216,11 @@ export async function main(argv) {
// ДО установки окружения (npm ci, python, Chromium ≈ 3 минуты на шард).
if (argv.includes('--plan-only')) {
console.log(`plan=${toRun.length}`);
// #620: и какое окружение нужно гардам плана — шаг ставит только его.
const exists = (file) => existsSync(join(repoRoot, file));
const read = (file) => (exists(file) ? readFileSync(join(repoRoot, file), 'utf8') : '');
const need = planEnvironment(toRun, (guard) => guardEnvironment(guard, { read, exists }));
for (const line of planEnvironmentLines(need)) console.log(line);
reportPlanMetrics();
return 0;
}
+132
View File
@@ -0,0 +1,132 @@
#!/usr/bin/env node
// #620: ночной полный реестр не гоняется повторно на том же дереве.
//
// Что было. `mutation-gate.yml` каждую ночь прогонял все 800+ мутантов —
// ≈ 212 job-минут, — даже когда `dev` не менялся: 16–20.09 четыре ночи подряд
// ушли на один SHA. Доказательство прогона content-адресно уже с #549
// (evidence несёт tree), но им никто не пользовался.
//
// Как теперь. Зелёный полный прогон (все шесть шардов доказаны агрегатором)
// оставляет маркер: tree материала, SHA workflow, номер прогона, время. Ночь
// по расписанию читает самый свежий маркер своего tree и workflow и, если он
// действителен, не гоняет шарды — в сводке прогона стоит «reused from run N».
//
// Чего маркер не делает. Он не переносит красный: маркер пишется только после
// зелёного агрегатора, так что ночь после отказа гонит реестр заново и снова
// заводит issue (#472). Ручной dispatch — отладка гейта — всегда гонит полный
// реестр. Маркер старше MAX_REUSE_AGE_DAYS не принимается: tree фиксирует код,
// но не раннер (образ ubuntu, патч Python, кэш Chromium), и раз в неделю реестр
// обязан пройти на свежем окружении даже на неизменном дереве. SHA workflow в
// ключе — по той же причине: другой workflow (делитель шардов, шаги) — другое
// доказательство.
import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { dirname } from 'node:path';
import { isMainModule } from './spawn-portable.mjs';
export const REUSE_MARKER_SCHEMA = 'houseplan-mutation-green/v1';
export const MAX_REUSE_AGE_DAYS = 7;
const DAY_MS = 24 * 60 * 60 * 1000;
// Часы раннеров расходятся на секунды; маркер «из будущего» дальше этого — подделка
// или сломанные часы, и принимать его нельзя.
const CLOCK_SKEW_MS = 10 * 60 * 1000;
const FULL_SHA = /^[0-9a-f]{40}$/;
const positiveInteger = (value) => /^[1-9]\d*$/.test(String(value ?? ''));
/** Маркер зелёного полного прогона. Бросает на неполной identity. */
export function reuseMarker(input) {
const marker = {
schema: REUSE_MARKER_SCHEMA,
tree: String(input.tree || ''),
sha: String(input.sha || ''),
workflowSha: String(input.workflowSha || ''),
runId: String(input.runId ?? ''),
runAttempt: String(input.runAttempt ?? ''),
event: String(input.event || ''),
provenAt: new Date(input.now ?? Date.now()).toISOString(),
};
if (!FULL_SHA.test(marker.tree) || !FULL_SHA.test(marker.sha) || !FULL_SHA.test(marker.workflowSha)
|| !positiveInteger(marker.runId) || !positiveInteger(marker.runAttempt) || !marker.event) {
throw new Error('invalid mutation reuse marker identity');
}
return marker;
}
/**
* Чистое решение: можно ли не гонять реестр этой ночью.
*
* @param {object} p
* @param {string} p.event github.event_name текущего прогона
* @param {string} p.tree tree зафиксированного материала
* @param {string} p.workflowSha github.workflow_sha текущего прогона
* @param {object|null} p.marker восстановленный маркер либо null
* @param {number} p.now мс эпохи
* @param {number} [p.maxAgeDays]
* @returns {{ reuse: boolean, reason: string, runId?: string }}
*/
export function decideNightlyReuse({ event, tree, workflowSha, marker, now, maxAgeDays = MAX_REUSE_AGE_DAYS }) {
const no = (reason) => ({ reuse: false, reason });
if (event !== 'schedule') return no('not a scheduled run — manual dispatch always runs the full registry');
if (!FULL_SHA.test(String(tree || ''))) return no('material tree is not a full SHA');
if (!marker || typeof marker !== 'object') return no('no green marker for this tree');
if (marker.schema !== REUSE_MARKER_SCHEMA) return no('marker schema is unknown');
if (marker.tree !== tree) return no('marker proves another tree');
if (!FULL_SHA.test(String(workflowSha || '')) || marker.workflowSha !== workflowSha) {
return no('marker was proved by another workflow revision');
}
if (!positiveInteger(marker.runId)) return no('marker names no run');
const provenAt = Date.parse(String(marker.provenAt || ''));
if (!Number.isFinite(provenAt)) return no('marker has no proof time');
if (provenAt - now > CLOCK_SKEW_MS) return no('marker is dated in the future');
if (now - provenAt > maxAgeDays * DAY_MS) return no(`marker is older than ${maxAgeDays} days`);
return { reuse: true, reason: `tree ${tree} already proved green`, runId: String(marker.runId) };
}
function readMarker(file) {
if (!file || !existsSync(file)) return null;
try {
return JSON.parse(readFileSync(file, 'utf8'));
} catch {
return null;
}
}
const arg = (argv, name) => argv.find((a) => a.startsWith(`--${name}=`))?.slice(name.length + 3);
export function main(argv, { now = Date.now(), log = (line) => console.log(line), warn = (line) => console.error(line) } = {}) {
const writeTo = arg(argv, 'write-marker');
if (writeTo) {
const marker = reuseMarker({
tree: arg(argv, 'tree'), sha: arg(argv, 'sha'), workflowSha: arg(argv, 'workflow-sha'),
runId: arg(argv, 'run-id'), runAttempt: arg(argv, 'run-attempt'), event: arg(argv, 'event'), now,
});
mkdirSync(dirname(writeTo), { recursive: true });
writeFileSync(writeTo, `${JSON.stringify(marker, null, 2)}\n`);
warn(`маркер зелёного прогона: tree ${marker.tree}, run ${marker.runId}`);
return 0;
}
if (!argv.includes('--decide')) {
warn('usage: mutation-nightly-reuse.mjs --decide --event= --tree= --workflow-sha= --marker= [--summary=] [--run-url-base=]\n'
+ ' mutation-nightly-reuse.mjs --write-marker=<file> --tree= --sha= --workflow-sha= --run-id= --run-attempt= --event=');
return 2;
}
const decision = decideNightlyReuse({
event: arg(argv, 'event'), tree: arg(argv, 'tree'), workflowSha: arg(argv, 'workflow-sha'),
marker: readMarker(arg(argv, 'marker')), now,
});
// stdout — только строки key=value для $GITHUB_OUTPUT; человеку — stderr и сводка.
log(`reuse=${decision.reuse ? 'true' : 'false'}`);
if (decision.reuse) log(`reused_run=${decision.runId}`);
warn(decision.reuse ? `reused from run ${decision.runId}: ${decision.reason}` : `full run: ${decision.reason}`);
const summary = arg(argv, 'summary');
if (summary) {
const base = arg(argv, 'run-url-base');
const link = decision.reuse && base ? ` (${base}/${decision.runId})` : '';
appendFileSync(summary, decision.reuse
? `### Мутационный реестр: reused from run ${decision.runId}${link}\n\n`
+ `Дерево \`${arg(argv, 'tree')}\` уже доказано зелёным полным прогоном; шарды не запускались (#620).\n`
: `### Мутационный реестр: полный прогон\n\nПовторное использование отклонено: ${decision.reason}.\n`);
}
return 0;
}
if (isMainModule(import.meta.url)) process.exit(main(process.argv.slice(2)));
+111
View File
@@ -4209,6 +4209,117 @@ const MUTANT_DEFINITIONS = [
replace: " if (false && INTERRUPTED_OUTCOMES.has(evidence.outcome)) {\n errors.push(`shard ${shard}: run was interrupted (step outcome ${evidence.outcome})`);\n }",
}],
},
// #620: ночь по неизменённому дереву и окружение шарда по гардам плана.
{
id: 'nightly-reuse-ignores-tree',
guard: 'node --test --test-name-pattern="маркер другого дерева" test/mutation-nightly-reuse.test.mjs',
because: 'a green marker proves exactly one tree; accepting it for another tree would skip the '
+ 'nightly registry on changed code and hide rotted witnesses until release (#620)',
patches: [{
file: 'scripts/mutation-nightly-reuse.mjs',
find: " if (marker.tree !== tree) return no('marker proves another tree');",
replace: " if (false && marker.tree !== tree) return no('marker proves another tree');",
}],
},
{
id: 'nightly-reuse-accepts-stale-marker',
guard: 'node --test --test-name-pattern="старше" test/mutation-nightly-reuse.test.mjs',
because: 'the tree pins code, not the runner; without the age limit an unchanged dev would never '
+ 'run the registry on a fresh environment again (#620)',
patches: [{
file: 'scripts/mutation-nightly-reuse.mjs',
find: ' if (now - provenAt > maxAgeDays * DAY_MS) return no(',
replace: ' if (now - provenAt > maxAgeDays * DAY_MS * 1000) return no(',
}],
},
{
id: 'nightly-reuse-on-manual-dispatch',
guard: 'node --test --test-name-pattern="ручной dispatch" test/mutation-nightly-reuse.test.mjs',
because: 'manual dispatch is how the gate itself is debugged; reusing a marker there would '
+ 'answer a debugging run with an old result (#620)',
patches: [{
file: 'scripts/mutation-nightly-reuse.mjs',
find: " if (event !== 'schedule') return no(",
replace: " if (event === 'mutant-never-an-event') return no(",
}],
},
{
id: 'green-marker-without-green-aggregator',
guard: 'node --test --test-name-pattern="#620: пропуск ночи" test/mutation-gate.test.mjs',
because: 'a marker written after a red or partial run would let the next night skip the registry '
+ 'and never file the failure issue again (#620, #472)',
patches: [{
file: '.github/workflows/mutation-gate.yml',
find: " if: needs.material.outputs.reuse != 'true' && needs.mutants.result == 'success' && needs.evidence.result == 'success'",
replace: " if: always() && needs.material.outputs.reuse != 'true'",
}],
},
{
id: 'nightly-reuse-decision-error-skips-registry',
guard: 'node --test --test-name-pattern="#620: пропуск ночи" test/mutation-gate.test.mjs',
because: 'a skipped night must be proved by a marker; a failed decision step has to mean '
+ 'a full run, not a silent skip (#620)',
patches: [{
file: '.github/workflows/mutation-gate.yml',
find: ' echo "reuse=false" >> "$GITHUB_OUTPUT"',
replace: ' echo "reuse=true" >> "$GITHUB_OUTPUT"',
}],
},
{
id: 'browser-shard-skips-chromium',
guard: 'node --test --test-name-pattern="#620 AC2" test/validate-workflow.test.mjs',
because: 'a shard whose guards open a browser must install Chromium, or its clean run goes red '
+ 'and the task loses a review round for nothing (#620)',
patches: [{
file: '.github/workflows/validate.yml',
find: " if: steps.plan.outputs.count != '0' && steps.plan.outputs.browser == 'true' && steps.pw.outputs.cache-hit != 'true'",
replace: " if: steps.plan.outputs.count != '0' && steps.plan.outputs.browser == 'yes' && steps.pw.outputs.cache-hit != 'true'",
}],
},
{
id: 'unread-plan-environment-skips-install',
guard: 'node --test --test-name-pattern="#620 AC2" test/validate-workflow.test.mjs',
because: 'a plan line that was not read must mean "install": skipping the environment on an '
+ 'unknown need trades minutes for a red shard (#620)',
patches: [{
file: '.github/workflows/validate.yml',
find: ' echo "browser=${browser:-true}" >> "$GITHUB_OUTPUT"',
replace: ' echo "browser=${browser:-false}" >> "$GITHUB_OUTPUT"',
}],
},
{
id: 'environment-misses-playwright-import',
guard: 'node --test --test-name-pattern="#620: браузер нужен" test/mutation-gate.test.mjs',
because: 'a unit guard that reaches Playwright through an import still needs Chromium; the '
+ 'command line alone does not show it (#620)',
patches: [{
file: 'scripts/mutation-environment.mjs',
find: ' || sources.some((source) => SOURCE_BROWSER_RE.test(source)),',
replace: ' || sources.length < 0,',
}],
},
{
id: 'environment-misses-backend-files',
guard: 'node --test --test-name-pattern="#620: Python нужен" test/mutation-gate.test.mjs',
because: 'a wrapper guard runs pytest on the declared .py files; missing them leaves the shard '
+ 'without backend dependencies (#620)',
patches: [{
file: 'scripts/mutation-environment.mjs',
find: " python: GUARD_PYTHON_RE.test(text) || files.some((file) => file.endsWith('.py'))",
replace: " python: GUARD_PYTHON_RE.test(text) || files.some((file) => file.endsWith('.pyc'))",
}],
},
{
id: 'environment-ignores-spawned-scripts',
guard: 'node --test --test-name-pattern="#620: Python нужен" test/mutation-gate.test.mjs',
because: 'a test that spawns a script runs that script as a process; its needs are the '
+ "guard's needs (#620)",
patches: [{
file: 'scripts/mutation-environment.mjs',
find: ' if (entry) {',
replace: ' if (entry && file.length < 0) {',
}],
},
// #481: журнал пойманных свидетелей — каждый защитный контракт под свидетелем.
{
id: 'ledger-records-escaped',