perf(ci): judge a witness by its anchor's neighbourhood, not the whole file

The review gate re-ran almost every selected witness on every round even
when the executor's fix was twelve lines: the ledger fingerprint and the
diff selection both worked on whole files, and the card hosts are
thirteen thousand lines each. On #500 those twelve lines in
houseplan-editor-runtime.ts pulled 53 of the 75 witnesses the third
round ran, and the gate cost 140 job-minutes and an hour of the
reviewer's wall clock across three rounds.

The patch side is now judged by the anchor's neighbourhood — the anchor
lines plus ANCHOR_RADIUS_LINES on each side — in both the ledger
fingerprint and the diff selection, which now reads hunk ranges from
git diff --unified=0. The guard side keeps whole-file granularity: a
guard has no anchor and changes as a whole. An anchor that is not found
exactly once falls back to the whole file, and so does a file whose
hunks were not read: not knowing is not proof. Same class of
approximation as the existing diff selection, with the nightly full
gate (#513) as the floor.

Two more cuts to the wall clock of a review round. The shard plan is now
computed before the environment is installed — restore the ledger,
select, split, and only then pay for npm ci, Python and Chromium; the
job still runs, so the review gate's proof (#510) is unaffected. And the
matrix goes from three shards to six: the same job-minutes, half the
wall time.

On the #500 round the selection drops 60 → 7. Four witnesses guard the
new logic, including the two unsafe defaults (ambiguous anchor, missing
hunks).

Issue: #518
User-Visible: no
This commit is contained in:
Codex
2026-09-10 14:17:36 +03:00
parent 0c74828240
commit 2783ceff94
4 changed files with 356 additions and 36 deletions
+110
View File
@@ -8,6 +8,7 @@ import { fileURLToPath } from 'node:url';
import {
MUTANTS, applyPatches, guardNeedsBundle, guardNeedsTestBuild, selectChangedMutants, shardMutants, guardFiles, packageJsonRelevance,
anchorSpan, anchorRegion, parseDiffRanges, ANCHOR_RADIUS_LINES,
witnessFingerprint, readLedger, recordCaught, splitByLedger, LEDGER_SCHEMA,
} from '../scripts/mutation-gate.mjs';
@@ -399,6 +400,115 @@ test('#481 AC1: отпечаток свидетеля не меняется от
assert.notEqual(witnessFingerprint({ ...LEDGER_MUTANT, patches: [{ file: 'src/x.ts', find: 'a', replace: 'c' }] }, base), fp, 'объявление патча');
});
// #518. Хост-файлы карты — тринадцать тысяч строк. Отпечаток и отбор по файлу
// целиком означали, что правка в одном их конце перегоняет свидетелей из
// другого: на #500 двенадцать изменённых строк тянули 53 мутанта из 75.
// Сторона патча судится по области якоря; сторона гарда — по-прежнему целиком.
/** Файл из `lines` строк, где на `at` (1-based) стоит якорь. */
const withAnchor = (lines, at, anchor = 'const ЯКОРЬ = 1;') => Array.from(
{ length: lines }, (_, i) => (i + 1 === at ? anchor : `let x${i} = ${i};`),
).join('\n');
const ANCHORED = { id: 'x', guard: 'node --test test/x.test.mjs', patches: [{ file: 'src/big.ts', find: 'const ЯКОРЬ = 1;', replace: 'const ЯКОРЬ = 2;' }] };
const anchoredFs = (source, guard = 'assert(a)') => ({
root: '/repo',
read: (file) => (file === 'src/big.ts' ? source : guard),
exists: (file) => ['src/big.ts', 'test/x.test.mjs'].includes(file),
normalize: (text) => text,
});
test('#518 AC1: правка дальше радиуса от якоря не меняет отпечаток', () => {
const source = withAnchor(400, 200);
const fp = witnessFingerprint(ANCHORED, anchoredFs(source));
const far = source.split('\n');
far[10] = 'let x10 = 999;'; // 190 строк от якоря — чужой код
assert.equal(witnessFingerprint(ANCHORED, anchoredFs(far.join('\n'))), fp, 'дальняя правка — не изменение свидетеля');
});
test('#518 AC2: правка ВНУТРИ области якоря возвращает свидетеля в прогон', () => {
const source = withAnchor(400, 200);
const fp = witnessFingerprint(ANCHORED, anchoredFs(source));
for (const line of [200 - ANCHOR_RADIUS_LINES, 199, 201, 200 + ANCHOR_RADIUS_LINES]) {
const near = source.split('\n');
near[line - 1] = 'let touched = 1;';
assert.notEqual(witnessFingerprint(ANCHORED, anchoredFs(near.join('\n'))), fp,
`правка строки ${line} обязана перегнать свидетеля (якорь на 200, радиус ${ANCHOR_RADIUS_LINES})`);
}
});
test('#518 AC3: изменение входа гарда перегоняет свидетеля независимо от расстояния', () => {
const source = withAnchor(400, 200);
const fp = witnessFingerprint(ANCHORED, anchoredFs(source));
assert.notEqual(witnessFingerprint(ANCHORED, anchoredFs(source, 'assert(b)')), fp);
});
test('#518 AC4: якорь не однозначен — область равна файлу целиком', () => {
const twice = `${withAnchor(400, 200)}\nconst ЯКОРЬ = 1;`;
assert.equal(anchorSpan(twice, 'const ЯКОРЬ = 1;'), null, 'два вхождения — области нет');
assert.equal(anchorSpan('нет якоря', 'const ЯКОРЬ = 1;'), null);
assert.equal(anchorRegion(twice, 'const ЯКОРЬ = 1;'), twice, 'фолбэк — весь файл');
const fp = witnessFingerprint(ANCHORED, anchoredFs(twice));
const far = twice.split('\n');
far[10] = 'let x10 = 999;';
assert.notEqual(witnessFingerprint(ANCHORED, anchoredFs(far.join('\n'))), fp,
'при неоднозначном якоре ответ обязан остаться консервативным — как до #518');
});
test('#518 AC1/AC2 (отбор): ханк вне области якоря не отбирает, внутри — отбирает', () => {
const source = withAnchor(400, 200);
const read = () => source;
const pick = (ranges) => selectChangedMutants([ANCHORED], ['src/big.ts'], always, { ranges, read }).length;
assert.equal(pick(new Map([['src/big.ts', [[10, 12]]]])), 0, 'дальний ханк');
assert.equal(pick(new Map([['src/big.ts', [[199, 201]]]])), 1, 'ханк по якорю');
assert.equal(pick(new Map([['src/big.ts', [[200 + ANCHOR_RADIUS_LINES, 260]]]])), 1, 'ханк по краю области');
assert.equal(pick(new Map([['src/big.ts', [[10, 12], [199, 199]]]])), 1, 'хотя бы один ханк в области');
assert.equal(pick(null), 1, 'без областей — прежний ответ по файлу');
assert.equal(pick(new Map()), 1, 'файл в диффе, а ханков нет — консервативно');
// Гард судится по файлу целиком и при известных областях (AC3).
const guardOnly = selectChangedMutants([ANCHORED], ['test/x.test.mjs'], always,
{ ranges: new Map([['test/x.test.mjs', [[1, 1]]]]), read });
assert.equal(guardOnly.length, 1);
});
test('#518: области диффа читаются из --unified=0, удаление считается задевшим стык', () => {
const ranges = parseDiffRanges([
'diff --git a/src/big.ts b/src/big.ts',
'--- a/src/big.ts',
'+++ b/src/big.ts',
'@@ -10,2 +10,3 @@',
'@@ -50 +51,0 @@',
'--- a/gone.ts',
'+++ /dev/null',
'@@ -1,5 +0,0 @@',
].join('\n'));
assert.deepEqual(ranges.get('src/big.ts'), [[10, 12], [51, 52]]);
assert.equal(ranges.has('gone.ts'), false, 'удалённый файл ханков головы не даёт — судится по имени');
});
test('#518 AC5: у релоцированных свидетелей область ищется в файле назначения', () => {
const relocated = MUTANTS.filter((m) => m.patches.some((patch) => patch.file === 'src/houseplan-editor-runtime.ts'));
assert.ok(relocated.length > 10, 'в реестре есть свидетели, переехавшие в редакторский рантайм');
const source = readFileSync(join(repoRoot, 'src/houseplan-editor-runtime.ts'), 'utf8');
for (const mutant of relocated) {
for (const patch of mutant.patches.filter((x) => x.file === 'src/houseplan-editor-runtime.ts')) {
assert.notEqual(anchorSpan(source, patch.find), null,
`${mutant.id}: якорь не найден в файле назначения — область стала бы файлом целиком`);
}
}
});
test('#518: сужение реально режет отбор на реестре — правка одной функции хоста', () => {
const host = 'src/houseplan-editor-runtime.ts';
const source = readFileSync(join(repoRoot, host), 'utf8');
const byFile = selectChangedMutants(MUTANTS, [host], always).length;
assert.ok(byFile > 20, `по файлу целиком отбирается ${byFile} свидетелей`);
const lines = source.split('\n').length;
const tail = new Map([[host, [[lines, lines]]]]);
const byRange = selectChangedMutants(MUTANTS, [host], always, { ranges: tail, read: () => source }).length;
assert.ok(byRange < byFile / 2, `правка последней строки не должна отбирать ${byRange} из ${byFile}`);
});
test('#481 AC2: по журналу пропускается только совпавший отпечаток; чужой или отсутствующий — к прогону', () => {
const a = { id: 'a', guard: 'g', patches: [] };
const b = { id: 'b', guard: 'g', patches: [] };
+35 -4
View File
@@ -393,15 +393,42 @@ test('мутанты по диффу гоняются по запросу с б
// 30-минутный timeout. Тот же набор теперь делится существующим
// детерминированным shardMutants без пропусков и пересечений.
assert.match(job, /fail-fast: false/);
assert.match(job, /shard: \[1, 2, 3\]/);
// #518: шесть шардов вместо трёх — job-минуты те же, стена раунда ревью вдвое короче.
assert.match(job, /shard: \[1, 2, 3, 4, 5, 6\]/);
assert.match(job, /SHARD: \$\{\{ matrix\.shard \}\}/);
assert.match(job, /node scripts\/mutation-gate\.mjs --changed="\$base\.\.\$HEAD_SHA" --shard="\$SHARD\/3"/);
assert.match(job, /node scripts\/mutation-gate\.mjs --changed="\$base\.\.\$HEAD_SHA" --shard="\$SHARD\/6"/);
assert.match(job, /node scripts\/mutation-gate\.mjs --changed="\$BASE\.\.\$HEAD_SHA" --shard="\$SHARD\/6"/);
// pytest-гарды исполнимы: Python и зависимости ставятся, как в mutation-gate.yml.
assert.match(job, /pip install -r tests_backend\/requirements\.txt/);
// Блокирующая job: свидетель, разучившийся краснеть, — отказ, а не предупреждение.
assert.ok(!job.includes('continue-on-error'), 'job обязана красить прогон');
});
test('#518: пустой план шарда не ставит окружение, job остаётся исполненной', () => {
const workflow = read('validate.yml');
const start = workflow.indexOf('\n changed_mutants:\n');
const job = workflow.slice(start, workflow.indexOf('\n frontend:\n', start));
// План считается на голом образе: git и node уже есть, npm ci/python/Chromium — нет.
const plan = job.slice(job.indexOf('name: План шарда'), job.indexOf("- if: steps.plan.outputs.count != '0'"));
assert.ok(!/npm ci|playwright install|pip install/.test(plan), 'план обязан обходиться без установки окружения');
assert.match(plan, /--plan-only/);
assert.match(plan, /count=\$\{count:-0\}/, 'непрочитанный план считается пустым, а не срывает шаг');
// Дорогие шаги — под условием, но сама job исполняется: доказательство
// гейта ревью (#510 provesMutants) требует УСПЕШНОЙ job, а не пропущенной.
for (const step of ['run: npm ci', 'actions/setup-python@v7', 'pip install -r tests_backend/requirements.txt',
'name: Затронутые мутанты ловятся']) {
const at = job.indexOf(step);
assert.ok(at > 0, `нет шага ${step}`);
const from = job.lastIndexOf('\n - ', at);
const to = job.indexOf('\n - ', at + step.length);
const block = job.slice(from, to < 0 ? job.length : to);
assert.match(block, /if: steps\.plan\.outputs\.count != '0'/, `шаг ${step} обязан быть под условием плана`);
}
assert.ok(!/\n if: [^\n]*steps\.plan/.test(job), 'условие плана — на шагах, не на job');
const save = job.slice(job.indexOf('name: Сохранить журнал свидетелей'));
assert.match(save, /if: always\(\)/);
});
test('ручной/ночной полный прогон не делит concurrency с push (#479)', () => {
const text = read('validate.yml');
assert.match(text, /group: validate-\$\{\{ github\.event_name == 'workflow_dispatch' && 'dispatch-' \|\| '' \}\}/);
@@ -411,11 +438,15 @@ test('журнал свидетелей changed_mutants: rerun продолжа
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 restore = job.slice(job.indexOf('actions/cache/restore@v6'), job.indexOf('name: Затронутые мутанты ловятся'));
const restore = job.slice(job.indexOf('actions/cache/restore@v6'), job.indexOf('name: План шарда'));
assert.match(restore, /key: mutation-ledger-\$\{\{ matrix\.shard \}\}-\$\{\{ github\.run_id \}\}-\$\{\{ github\.run_attempt \}\}/);
assert.match(restore, /mutation-ledger-\$\{\{ matrix\.shard \}\}-\$\{\{ github\.run_id \}\}-/, 'rerun обязан восстановить предыдущую попытку того же run');
assert.match(restore, /^\s+mutation-ledger-\$\{\{ matrix\.shard \}\}-\s*$/m, 'новый run обязан найти последний журнал шарда');
assert.match(job, /--changed="\$base\.\.\$HEAD_SHA" --shard="\$SHARD\/3" \\\n\s+--ledger="artifacts\/mutation-ledger\/shard-\$SHARD\.json"/);
assert.match(job, /--changed="\$base\.\.\$HEAD_SHA" --shard="\$SHARD\/6" \\\n\s+--ledger="artifacts\/mutation-ledger\/shard-\$SHARD\.json" --plan-only/);
assert.match(job, /--changed="\$BASE\.\.\$HEAD_SHA" --shard="\$SHARD\/6" \\\n\s+--ledger="artifacts\/mutation-ledger\/shard-\$SHARD\.json"/);
// #518: журнал обязан восстанавливаться ДО плана, иначе план не увидит
// уже пойманных и шард заплатит за окружение впустую.
assert.ok(job.indexOf('actions/cache/restore@v6') < job.indexOf('name: План шарда'), 'restore журнала идёт до плана');
const save = job.slice(job.indexOf('name: Сохранить журнал свидетелей'));
assert.match(save, /if: always\(\)/, 'красный или отменённый шард обязан сохранить уже пойманное');
assert.match(save, /actions\/cache\/save@v6/);