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
+120 -1
View File
@@ -332,7 +332,8 @@ test('#604: исход шага прогона едет в evidence шарда',
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'\)/);
// #620: принятое доказательство прошлого зелёного прогона отказом не считается.
assert.match(report, /if: always\(\) && github\.event_name == 'schedule' && needs\.material\.outputs\.reuse != 'true' && \(needs\.mutants\.result != 'success' \|\| needs\.evidence\.result != 'success'\)/);
const permissions = report.slice(report.indexOf('permissions:'), report.indexOf('steps:'));
for (const grant of ['contents: read', 'actions: read', 'issues: write']) {
assert.ok(permissions.includes(grant), `нет права ${grant} у job report`);
@@ -915,3 +916,121 @@ test('#496: добавленный script в package.json гварды не за
assert.equal(packageJsonRelevance(base, deps).relevant, true);
assert.equal(packageJsonRelevance('{not json', added).relevant, true, 'неразобранное — задевает: сторона ошибки — лишний прогон');
});
// #620. План шарда называет окружение своих гардов, и `changed_mutants` ставит
// Python и Chromium только шарду, которому они нужны. Ложной зелени отсюда не
// бывает (гард без среды краснеет чистым прогоном), но недоустановка стоит
// лишнего круга задачи — поэтому признаки проверяются на обе стороны.
import {
guardEnvironment, guardRuntimeFiles, planEnvironment, planEnvironmentLines,
} from '../scripts/mutation-environment.mjs';
function fakeRepo(files) {
return { read: (file) => files[file] ?? '', exists: (file) => Object.hasOwn(files, file) };
}
test('#620: юнит-гард без браузера и Python не требует окружения', () => {
const io = fakeRepo({
'test/pure.test.mjs': "import { f } from '../scripts/pure.mjs';\n// python3 в комментарии без кавычек — не запуск",
'scripts/pure.mjs': 'export const f = () => 1;',
});
assert.deepEqual(guardEnvironment('node --test test/pure.test.mjs', io), { browser: false, python: false });
assert.deepEqual(guardRuntimeFiles('node --test test/pure.test.mjs', io), ['scripts/pure.mjs', 'test/pure.test.mjs']);
});
test('#620: браузер нужен смоку и юниту, чей граф импортов дотягивается до Playwright', () => {
const io = fakeRepo({
'demo/smoke_x.mjs': "import { serve } from './serve.mjs';",
'demo/serve.mjs': "import { chromium } from 'playwright';",
'test/uses-browser.test.mjs': "import { page } from './helpers/page.mjs';",
'test/helpers/page.mjs': "export const page = async () => (await import('playwright')).chromium;",
});
assert.equal(guardEnvironment('node demo/smoke_x.mjs', io).browser, true, 'смок');
assert.equal(guardEnvironment('node --test test/uses-browser.test.mjs', io).browser, true,
'юнит через динамический импорт помощника');
});
test('#620: Python нужен pytest-гарду, обёртке и тесту, запускающему скрипт с python3', () => {
const io = fakeRepo({
'scripts/backend-test-guard.mjs': "export const GUARD_INPUTS = ['tests_backend/test_a.py'];\nspawnSync(python, ['-m', mod]);",
'tests_backend/test_a.py': 'def test_a(): pass',
'test/spawns.test.mjs': "spawnSync(process.execPath, ['scripts/runner.mjs']);",
'scripts/runner.mjs': "spawnSync('python3', ['x.py']);",
});
assert.equal(guardEnvironment('python3 -m pytest tests_backend/test_a.py -q', io).python, true, 'строка гарда');
assert.equal(guardEnvironment('node scripts/backend-test-guard.mjs pattern', io).python, true, 'обёртка → .py');
assert.equal(guardEnvironment('node --test test/spawns.test.mjs', io).python, true,
'путь-литерал точки входа — порождённый процесс');
assert.equal(guardEnvironment('node --test test/spawns.test.mjs', io).browser, false);
});
test('#620: окружение шарда — объединение гардов; пустой план не требует ничего', () => {
const env = { a: { browser: true, python: false }, b: { browser: false, python: true }, c: { browser: false, python: false } };
const of = (guard) => env[guard];
assert.deepEqual(planEnvironment([], of), { browser: false, python: false });
assert.deepEqual(planEnvironment([{ guard: 'c' }, { guard: 'c' }], of), { browser: false, python: false });
assert.deepEqual(planEnvironment([{ guard: 'a' }, { guard: 'c' }], of), { browser: true, python: false });
assert.deepEqual(planEnvironment([{ guard: 'a' }, { guard: 'b' }], of), { browser: true, python: true });
assert.deepEqual(planEnvironmentLines({ browser: true, python: false }), ['plan-browser=true', 'plan-python=false']);
});
test('#620 (реестр): каждый смок-гард получает браузер, каждый pytest-гард — Python, юниты — не все', () => {
const io = {
read: (file) => (existsSync(join(repoRoot, file)) ? readFileSync(join(repoRoot, file), 'utf8') : ''),
exists: (file) => existsSync(join(repoRoot, file)),
};
const guards = [...new Set(MUTANTS.map((mutant) => mutant.guard))];
let bare = 0;
for (const guard of guards) {
const env = guardEnvironment(guard, io);
if (/\bnode demo\//.test(guard)) assert.equal(env.browser, true, `смок без браузера: ${guard}`);
if (/python3? -m pytest|backend-test-guard\.mjs/.test(guard)) assert.equal(env.python, true, `pytest без Python: ${guard}`);
if (!env.browser && !env.python) bare++;
}
// Без этого признак выродился бы в «ставить всё» — и правило перестало бы экономить.
assert.ok(bare > guards.length / 3, `гардов без окружения ${bare} из ${guards.length}`);
});
test('#620: --plan-only печатает окружение плана', () => {
const script = join(repoRoot, 'scripts/mutation-gate.mjs');
const plan = (id) => spawnSync(process.execPath, [script, `--id=${id}`, '--plan-only'], { encoding: 'utf8' });
const smoke = plan('discard-confirm-action-icon-falls-back-to-lock');
assert.equal(smoke.status, 0, smoke.stderr);
assert.match(smoke.stdout, /^plan=1$/m);
assert.match(smoke.stdout, /^plan-browser=true$/m);
const pytest = plan('frontend-registration-skips-retry');
assert.match(pytest.stdout, /^plan-python=true$/m);
assert.match(pytest.stdout, /^plan-browser=false$/m);
const unit = plan('view-conflict-requires-editor-runtime');
assert.match(unit.stdout, /^plan-browser=false$/m);
assert.match(unit.stdout, /^plan-python=false$/m);
});
// #620. Ночь по расписанию не гоняет реестр на дереве, уже доказанном зелёным
// полным прогоном. Решение — чистая функция (test/mutation-nightly-reuse.test.mjs);
// здесь — что workflow пропускает ровно шарды и не глушит адресата отказа.
test('#620: пропуск ночи — только по маркеру, маркер — только после зелёного агрегатора', () => {
const job = (name, next) => mutationWorkflow.slice(mutationWorkflow.indexOf(`\n ${name}:\n`),
next ? mutationWorkflow.indexOf(`\n ${next}:\n`) : undefined);
const material = job('material', 'mutants');
const mutants = job('mutants', 'evidence');
const evidence = job('evidence', 'green_marker');
const marker = job('green_marker', 'report');
const report = job('report');
assert.match(material, /reuse: \$\{\{ steps\.reuse\.outputs\.reuse \}\}/);
// Маркер читается только по расписанию и только для своего tree и workflow.
const restore = material.slice(material.indexOf('actions/cache/restore@'), material.indexOf('- name: Нужен ли прогон'));
assert.match(material.slice(material.lastIndexOf('- name:', material.indexOf('actions/cache/restore@'))),
/^- name: [^\n]*\n\s+if: github\.event_name == 'schedule'\n/);
assert.match(restore, /restore-keys: \|\n\s+mutation-green-v1-\$\{\{ steps\.identity\.outputs\.tree \}\}-\$\{\{ github\.workflow_sha \}\}-\n/);
// Сбой решения — полный прогон.
assert.match(material, /else\n\s+echo "::warning::[^\n]*"\n\s+echo "reuse=false" >> "\$GITHUB_OUTPUT"/);
assert.match(material, /--decide/);
assert.match(mutants, /\n if: needs\.material\.outputs\.reuse != 'true'\n/);
assert.match(evidence, /\n if: always\(\) && needs\.material\.outputs\.reuse != 'true'\n/);
assert.match(marker, /\n if: needs\.material\.outputs\.reuse != 'true' && needs\.mutants\.result == 'success' && needs\.evidence\.result == 'success'\n/);
assert.match(marker, /--write-marker=artifacts\/mutation-green\/marker\.json/);
assert.match(marker, /key: mutation-green-v1-\$\{\{ needs\.material\.outputs\.tree \}\}-\$\{\{ github\.workflow_sha \}\}-\$\{\{ github\.run_id \}\}/);
// Отказ по-прежнему заводит issue; не заводит только принятое доказательство.
assert.match(report, /\n if: always\(\) && github\.event_name == 'schedule' && needs\.material\.outputs\.reuse != 'true' && \(needs\.mutants\.result != 'success' \|\| needs\.evidence\.result != 'success'\)\n/);
});
+98
View File
@@ -0,0 +1,98 @@
// #620: ночной полный реестр не гоняется повторно на уже доказанном дереве.
// Решение — чистая функция; workflow-проводку держит test/mutation-gate.test.mjs.
import assert from 'node:assert/strict';
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import test from 'node:test';
import {
MAX_REUSE_AGE_DAYS, REUSE_MARKER_SCHEMA, decideNightlyReuse, main, reuseMarker,
} from '../scripts/mutation-nightly-reuse.mjs';
const TREE = 'a'.repeat(40);
const OTHER_TREE = 'b'.repeat(40);
const SHA = 'c'.repeat(40);
const WORKFLOW = 'd'.repeat(40);
const NOW = Date.parse('2026-09-24T01:05:00Z');
const DAY = 24 * 60 * 60 * 1000;
const marker = (overrides = {}) => ({
...reuseMarker({ tree: TREE, sha: SHA, workflowSha: WORKFLOW, runId: 1234, runAttempt: 1, event: 'schedule', now: NOW - DAY }),
...overrides,
});
const decide = (overrides = {}) => decideNightlyReuse({
event: 'schedule', tree: TREE, workflowSha: WORKFLOW, marker: marker(), now: NOW, ...overrides,
});
test('#620 AC1: неизменённое дерево с зелёным маркером — прогон переиспользуется с номером', () => {
assert.deepEqual(decide(), { reuse: true, reason: `tree ${TREE} already proved green`, runId: '1234' });
});
test('#620: маркер другого дерева не переиспользуется', () => {
const result = decide({ marker: marker({ tree: OTHER_TREE }) });
assert.equal(result.reuse, false);
assert.match(result.reason, /another tree/);
assert.equal(decide({ tree: OTHER_TREE }).reuse, false, 'дерево текущей ночи другое');
assert.equal(decide({ tree: 'HEAD' }).reuse, false, 'tree не полный SHA');
});
test('#620: маркер другого workflow не переиспользуется', () => {
assert.equal(decide({ workflowSha: 'e'.repeat(40) }).reuse, false);
assert.equal(decide({ workflowSha: '' }).reuse, false);
});
test('#620: ручной dispatch гоняет реестр всегда', () => {
const result = decide({ event: 'workflow_dispatch' });
assert.equal(result.reuse, false);
assert.match(result.reason, /manual dispatch/);
});
test('#620: без маркера, с чужой схемой или без номера прогона — полный прогон', () => {
assert.equal(decide({ marker: null }).reuse, false);
assert.equal(decide({ marker: marker({ schema: 'houseplan-mutation-green/v0' }) }).reuse, false);
assert.equal(decide({ marker: marker({ runId: '' }) }).reuse, false);
assert.equal(decide({ marker: marker({ runId: '0' }) }).reuse, false);
});
test(`#620: маркер старше ${MAX_REUSE_AGE_DAYS} суток или из будущего не принимается`, () => {
const at = (ms) => marker({ provenAt: new Date(ms).toISOString() });
assert.equal(decide({ marker: at(NOW - (MAX_REUSE_AGE_DAYS * DAY - 60_000)) }).reuse, true, 'в пределах недели');
const stale = decide({ marker: at(NOW - (MAX_REUSE_AGE_DAYS * DAY + 60_000)) });
assert.equal(stale.reuse, false);
assert.match(stale.reason, /older than/);
assert.equal(decide({ marker: at(NOW + DAY) }).reuse, false, 'из будущего');
assert.equal(decide({ marker: marker({ provenAt: 'вчера' }) }).reuse, false, 'время не читается');
});
test('#620: маркер не пишется с неполной identity', () => {
assert.equal(marker().schema, REUSE_MARKER_SCHEMA);
assert.throws(() => reuseMarker({ tree: TREE, sha: SHA, workflowSha: WORKFLOW, runId: 1, runAttempt: 1 }), /identity/);
assert.throws(() => reuseMarker({ tree: 'short', sha: SHA, workflowSha: WORKFLOW, runId: 1, runAttempt: 1, event: 'schedule' }), /identity/);
});
test('#620 AC1: CLI пишет маркер, решает по нему и оставляет «reused from run N» в сводке', () => {
const dir = mkdtempSync(join(tmpdir(), 'hp-620-'));
try {
const file = join(dir, 'green', 'marker.json');
const summary = join(dir, 'summary.md');
const quiet = () => {};
assert.equal(main([`--write-marker=${file}`, `--tree=${TREE}`, `--sha=${SHA}`, `--workflow-sha=${WORKFLOW}`,
'--run-id=777', '--run-attempt=2', '--event=schedule'], { now: NOW - DAY, warn: quiet }), 0);
assert.equal(JSON.parse(readFileSync(file, 'utf8')).runId, '777');
const out = [];
assert.equal(main(['--decide', '--event=schedule', `--tree=${TREE}`, `--workflow-sha=${WORKFLOW}`,
`--marker=${file}`, `--summary=${summary}`, '--run-url-base=https://example.test/runs'],
{ now: NOW, log: (line) => out.push(line), warn: quiet }), 0);
assert.deepEqual(out, ['reuse=true', 'reused_run=777']);
assert.match(readFileSync(summary, 'utf8'), /reused from run 777 \(https:\/\/example\.test\/runs\/777\)/);
// Битый маркер — не доказательство.
writeFileSync(file, '{');
const broken = [];
main(['--decide', '--event=schedule', `--tree=${TREE}`, `--workflow-sha=${WORKFLOW}`, `--marker=${file}`],
{ now: NOW, log: (line) => broken.push(line), warn: quiet });
assert.deepEqual(broken, ['reuse=false']);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
+30
View File
@@ -482,6 +482,36 @@ test('#518: пустой план шарда не ставит окружени
assert.match(save, /if: always\(\)/);
});
test('#620 AC2: Python и Chromium ставятся только шарду, чьим гардам они нужны', () => {
const workflow = read('validate.yml');
const start = workflow.indexOf('\n changed_mutants:\n');
const job = workflow.slice(start, workflow.indexOf('\n frontend:\n', start));
const stepOf = (needle) => {
const at = job.indexOf(needle);
assert.ok(at > 0, `нет шага ${needle}`);
const from = job.lastIndexOf('\n - ', at);
const to = job.indexOf('\n - ', at + needle.length);
return job.slice(from, to < 0 ? job.length : to);
};
// План называет окружение; при непустом плане непрочитанная строка — «ставить».
const plan = stepOf('name: План шарда');
assert.match(plan, /echo "browser=\$\{browser:-true\}" >> "\$GITHUB_OUTPUT"/);
assert.match(plan, /echo "python=\$\{python:-true\}" >> "\$GITHUB_OUTPUT"/);
assert.match(plan, /sed -n 's\/\^plan-browser=\/\/p'/);
assert.match(plan, /sed -n 's\/\^plan-python=\/\/p'/);
for (const step of ['actions/setup-python@', 'pip install -r tests_backend/requirements.txt']) {
assert.match(stepOf(step), /if: steps\.plan\.outputs\.count != '0' && steps\.plan\.outputs\.python == 'true'\n/, step);
}
for (const step of ['name: Кэш браузеров Playwright', 'npx playwright install --with-deps chromium']) {
assert.match(stepOf(step), /if: steps\.plan\.outputs\.count != '0' && steps\.plan\.outputs\.browser == 'true'/, step);
}
// npm ci и сам прогон нужны любому непустому плану — без условия по окружению.
for (const step of ['run: npm ci', 'name: Затронутые мутанты ловятся']) {
assert.doesNotMatch(stepOf(step), /outputs\.(browser|python)/, step);
}
assert.match(stepOf('actions/setup-python@'), /cache: pip\n\s+cache-dependency-path: tests_backend\/requirements\.txt/);
});
test('ручной/ночной полный прогон не делит concurrency с push (#479)', () => {
const text = read('validate.yml');
assert.match(text, /group: validate-\$\{\{ github\.event_name == 'workflow_dispatch' && 'dispatch-' \|\| '' \}\}/);