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
+47 -20
View File
@@ -491,13 +491,13 @@ jobs:
# диффе без входов (отбор пустой, минута на checkout): гейт ревью читает
# её исход по job, и skipped был бы неотличим от «не запрашивали».
changed_mutants:
name: "Мутанты по диффу (${{ matrix.shard }}/3): затронутые свидетели краснеют"
name: "Мутанты по диффу (${{ matrix.shard }}/6): затронутые свидетели краснеют"
needs: changes
if: needs.changes.outputs.mutants_requested == 'true'
strategy:
fail-fast: false
matrix:
shard: [1, 2, 3]
shard: [1, 2, 3, 4, 5, 6]
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
@@ -508,26 +508,13 @@ jobs:
with:
node-version: 22
cache: npm
- run: npm ci
- uses: actions/setup-python@v7
with:
python-version: '3.14'
- name: Backend test dependencies
run: pip install -r tests_backend/requirements.txt
- name: Кэш браузеров Playwright
id: pw
uses: actions/cache@v6
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
- name: Установить Chromium
if: steps.pw.outputs.cache-hit != 'true'
run: npx playwright install --with-deps chromium
# Журнал пойманных свидетелей (#481). Отменённый или упавший по таймауту
# прогон не пропадает: журнал пишется после каждого пойманного мутанта и
# сохраняется при любом исходе шага, а следующая попытка или пуш начинает
# с того места, где предыдущий остановился. Мутант с тем же отпечатком входов
# (файлы патча и гарда, без строки версии) повторно не гоняется.
# (область якоря и файлы гарда, без строки версии) повторно не гоняется.
# Восстанавливается ДО плана (#518): план обязан видеть журнал, иначе
# пропуск станет известен только после установки окружения.
- name: Журнал свидетелей (последний по шарду)
uses: actions/cache/restore@v6
with:
@@ -538,7 +525,13 @@ jobs:
restore-keys: |
mutation-ledger-${{ matrix.shard }}-${{ github.run_id }}-
mutation-ledger-${{ matrix.shard }}-
- name: Затронутые мутанты ловятся
# #518: план шарда считается до установки окружения. npm ci, Python с
# зависимостями бэкенда и Chromium — около трёх минут на шард, и раньше
# их платили даже тогда, когда гонять было нечего: правка задела чужой
# конец хост-файла или журнал уже держит всех отобранных. Плану хватает
# git и node из образа.
- name: План шарда
id: plan
env:
EVENT_NAME: ${{ github.event_name }}
BEFORE_SHA: ${{ github.event.before }}
@@ -560,11 +553,45 @@ jobs:
fi
if [ -z "$base" ]; then
echo "не нашёл базу диапазона — мутанты по диффу пропущены, это увидит следующий пуш"
echo "count=0" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "диапазон: $base..$HEAD_SHA"
echo "base=$base" >> "$GITHUB_OUTPUT"
count=$(node scripts/mutation-gate.mjs --changed="$base..$HEAD_SHA" --shard="$SHARD/6" \
--ledger="artifacts/mutation-ledger/shard-$SHARD.json" --plan-only \
| tee /dev/stderr | sed -n 's/^plan=//p' | tail -1)
echo "к прогону в этом шарде: ${count:-0}"
echo "count=${count:-0}" >> "$GITHUB_OUTPUT"
- if: steps.plan.outputs.count != '0'
run: npm ci
- if: steps.plan.outputs.count != '0'
uses: actions/setup-python@v7
with:
python-version: '3.14'
- name: Backend test dependencies
if: steps.plan.outputs.count != '0'
run: pip install -r tests_backend/requirements.txt
- name: Кэш браузеров Playwright
id: pw
if: steps.plan.outputs.count != '0'
uses: actions/cache@v6
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
- name: Установить Chromium
if: steps.plan.outputs.count != '0' && steps.pw.outputs.cache-hit != 'true'
run: npx playwright install --with-deps chromium
- name: Затронутые мутанты ловятся
if: steps.plan.outputs.count != '0'
env:
BASE: ${{ steps.plan.outputs.base }}
HEAD_SHA: ${{ github.sha }}
SHARD: ${{ matrix.shard }}
run: |
set -euo pipefail
npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs
node scripts/mutation-gate.mjs --changed="$base..$HEAD_SHA" --shard="$SHARD/3" \
node scripts/mutation-gate.mjs --changed="$BASE..$HEAD_SHA" --shard="$SHARD/6" \
--ledger="artifacts/mutation-ledger/shard-$SHARD.json"
# Сохраняется всегда: и после красного шага (пойманные до отказа уже
# записаны), и при отмене прогона — post-шаг cache/save исполняется,
+164 -12
View File
@@ -3611,6 +3611,53 @@ const MUTANT_DEFINITIONS = [
replace: " if (ledger.caught[mutant.id] === fingerprint || mutant.id in ledger.caught) skipped.push(mutant);",
}],
},
{
id: 'anchor-region-narrows-ambiguous-anchor',
guard: 'node --test --test-name-pattern="#518 AC4" test/mutation-gate.test.mjs',
because: '#518: якорь, найденный не ровно один раз, значит «реестр отстал от кода» — '
+ 'судить такого свидетеля по окрестности первого попавшегося вхождения значит '
+ 'молча сузить проверку там, где она и так под вопросом',
patches: [{
file: 'scripts/mutation-gate.mjs',
// Реестр живёт в том же файле, что и код: якорь собирается из частей,
// иначе --check найдёт его дважды — в коде и здесь.
find: ' if (!find || text.split(find)' + '.length - 1 !== 1) return null;',
replace: ' if (!find) return null;',
}],
},
{
id: 'anchor-radius-collapses',
guard: 'node --test --test-name-pattern="#518 AC2" test/mutation-gate.test.mjs',
because: '#518: нулевой радиус оставляет в отпечатке одни строки патча — правка соседней '
+ 'строки перестаёт перегонять свидетеля, и «поймано» начинает значить «не проверяли»',
patches: [{
file: 'scripts/mutation-gate.mjs',
find: 'export const ANCHOR_RADIUS' + '_LINES = 40;',
replace: 'export const ANCHOR_RADIUS' + '_LINES = 0;',
}],
},
{
id: 'anchor-select-drops-guard-inputs',
guard: 'node --test --test-name-pattern="#518 AC1/AC2 \\(отбор\\)" test/mutation-gate.test.mjs',
because: '#518: сужение касается ТОЛЬКО стороны патча; у гарда якоря нет, и его правка '
+ 'обязана отбирать свидетеля при любых известных областях (#475 AC2 иначе отменяется)',
patches: [{
file: 'scripts/mutation-gate.mjs',
find: ' || inputsOf(m.guard).some((file) =>' + ' changed.has(file)));',
replace: ' || (!ranges && inputsOf(m.guard).some((file) => changed.has(file))));',
}],
},
{
id: 'anchor-touched-defaults-to-skip',
guard: 'node --test --test-name-pattern="#518 AC1/AC2 \\(отбор\\)" test/mutation-gate.test.mjs',
because: '#518: файл в диффе без прочитанных ханков — незнание, а не доказательство; '
+ 'ответ по умолчанию обязан быть «гнать», иначе непрочитанный дифф тихо пропускает свидетелей',
patches: [{
file: 'scripts/mutation-gate.mjs',
find: ' if (!hunks || !hunks.length)' + ' return true;',
replace: ' if (!hunks || !hunks.length) return false;',
}],
},
{
id: 'ledger-version-sensitive',
guard: 'node --test --test-name-pattern="#481 AC1" test/mutation-gate.test.mjs',
@@ -3618,8 +3665,8 @@ const MUTANT_DEFINITIONS = [
+ 'version normalisation every witness patching them re-runs on every candidate — the #480 timeout (#481)',
patches: [{
file: 'scripts/mutation-gate.mjs',
find: " hash.update(normalize(String(read(file))" + ".replace(/\\r\\n?/g, '\\n')));",
replace: " hash.update(String(read(file)).replace(/\\r\\n?/g, '\\n'));",
find: ' hash.update(normalize(' + 'valueOf()));',
replace: ' hash.update(valueOf());',
}],
},
{
@@ -8717,6 +8764,81 @@ export function guardInputs(guard, {
.sort();
}
/**
* Радиус области якоря (#518): сколько строк вокруг патча считается «его
* кодом». Хост-файлы карты — тринадцать тысяч строк, и правка в одном их
* конце перегоняла свидетелей из другого: на #500 двенадцать изменённых строк
* `houseplan-editor-runtime.ts` тянули 53 мутанта из 75. Сорок строк — то
* расстояние, на котором правка ещё почти всегда трогает тот же код; дальше
* начинается чужой, и его перебирает ночной полный гейт (#513).
*/
export const ANCHOR_RADIUS_LINES = 40;
/**
* Строки области якоря, 1-based включительно, или `null` — когда `find`
* встречается в файле не ровно один раз. `null` значит «судить по файлу
* целиком»: реестр, отставший от кода, обязан отвечать консервативно, а не
* сужать проверку (эту же однократность требуют `--check` и `applyPatches`).
*/
export function anchorSpan(source, find, radius = ANCHOR_RADIUS_LINES) {
const text = String(source ?? '');
if (!find || text.split(find).length - 1 !== 1) return null;
const start = text.indexOf(find);
const before = text.slice(0, start).split('\n').length; // 1-based строка начала
const inside = String(find).split('\n').length - 1;
const lines = text.split('\n').length;
return {
from: Math.max(1, before - radius),
to: Math.min(lines, before + inside + radius),
};
}
/** Текст области якоря (или весь файл, если якорь не однозначен). */
export function anchorRegion(source, find, radius = ANCHOR_RADIUS_LINES) {
const text = String(source ?? '');
const span = anchorSpan(text, find, radius);
if (!span) return text;
return text.split('\n').slice(span.from - 1, span.to).join('\n');
}
/**
* Изменённые области по файлам из `git diff --unified=0` — стороны ГОЛОВЫ
* (`+`), потому что якоря ищутся в рабочем дереве. Чистое удаление даёт
* нулевую длину `+c,0`: считаем задетыми строки вокруг стыка, иначе вырезанный
* кусок кода не задел бы никого.
*/
export function parseDiffRanges(diffText) {
const ranges = new Map();
let file = null;
for (const line of String(diffText ?? '').split('\n')) {
const head = /^\+\+\+ (?:b\/)?(.+)$/.exec(line);
if (head) { file = head[1] === '/dev/null' ? null : head[1]; continue; }
const hunk = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/.exec(line);
if (!hunk || !file) continue;
const from = Number(hunk[1]);
const count = hunk[2] === undefined ? 1 : Number(hunk[2]);
const list = ranges.get(file) ?? [];
list.push(count === 0 ? [from, from + 1] : [from, from + count - 1]);
ranges.set(file, list);
}
return ranges;
}
const spansOverlap = (span, [from, to]) => from <= span.to && to >= span.from;
/**
* Задел ли дифф область якоря патча (#518). `ranges` — карта из
* `parseDiffRanges`; её отсутствие означает прежний ответ по файлу целиком.
*/
export function patchTouched(patch, ranges, read) {
if (!ranges) return true;
const hunks = ranges.get(patch.file);
if (!hunks || !hunks.length) return true;
const span = anchorSpan(read(patch.file), patch.find);
if (!span) return true; // якорь не однозначен — судим по файлу
return hunks.some((hunk) => spansOverlap(span, hunk));
}
/**
* Отпечаток свидетеля (#481): содержимое файлов патча и гарда плюс само
* объявление мутанта. Строка версии продукта нормализуется, как в
@@ -8735,14 +8857,20 @@ export function witnessFingerprint(mutant, {
const hash = createHash('sha256');
hash.update(JSON.stringify({ id: mutant.id, guard: mutant.guard, patches: mutant.patches }));
hash.update('\0');
const files = new Set([
...mutant.patches.map((patch) => patch.file),
...guardInputs(mutant.guard, { exists, read }),
]);
for (const file of [...files].sort()) {
hash.update(file);
const text = (file) => String(read(file)).replace(/\r\n?/g, '\n');
// Сторона патча — только область якоря (#518); сторона гарда — файл целиком:
// у гарда якоря нет, он судит поведение и меняется весь.
const entries = [
...mutant.patches.map((patch, index) => [
`${patch.file}#якорь-${index}`,
() => anchorRegion(text(patch.file), patch.find),
]),
...guardInputs(mutant.guard, { exists, read }).map((file) => [file, () => text(file)]),
].sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0));
for (const [key, valueOf] of entries) {
hash.update(key);
hash.update('\0');
hash.update(normalize(String(read(file)).replace(/\r\n?/g, '\n')));
hash.update(normalize(valueOf()));
hash.update('\0');
}
return hash.digest('hex');
@@ -8805,7 +8933,12 @@ export function splitByLedger(mutants, ledger, fingerprintOf = (m) => witnessFin
export function selectChangedMutants(mutants, changedFiles, exists, options = {}) {
const changed = new Set(changedFiles);
const inputsOf = options.guardInputs || ((guard) => guardInputs(guard, { exists, ...options }));
return mutants.filter((m) => m.patches.some((patch) => changed.has(patch.file))
// #518: когда известны области диффа, файл патча отбирает свидетеля лишь
// тем, что задел его якорь. Без областей — прежний ответ по файлу.
const ranges = options.ranges || null;
const read = options.read || ((file) => (existsSync(join(repoRoot, file)) ? readFileSync(join(repoRoot, file), 'utf8') : ''));
return mutants.filter((m) => m.patches.some((patch) => changed.has(patch.file)
&& patchTouched(patch, ranges, read))
|| inputsOf(m.guard).some((file) => changed.has(file)));
}
@@ -8933,14 +9066,26 @@ async function main(argv) {
base = await baseRegistry(baseRef);
if (!base) console.log(`реестр базы ${baseRef} не прочитан — отбор по определениям пропущен`);
}
const picked = selectForDiff(selected, files, base);
// #518: области диффа сужают сторону патча до окрестности якоря. Не
// прочитались — отбор остаётся файловым, то есть прежним и более широким.
const hunks = spawnSync('git', ['-C', repoRoot, 'diff', '--unified=0', '--no-color', range],
{ encoding: 'utf8', maxBuffer: 256 * 1024 * 1024 });
const ranges = hunks.status === 0 && !hunks.error ? parseDiffRanges(hunks.stdout) : null;
if (!ranges) console.log('области диффа не прочитаны — отбор по файлам целиком');
const wide = selectForDiff(selected, files, base);
const picked = selectForDiff(selected, files, base, { ranges });
if (picked.removed.length) console.log(`удалены из реестра: ${picked.removed.join(', ')}`);
selected = picked.selected;
console.log(`дифф-режим ${range}: файлов в диффе ${files.length}, `
+ `мутантов затронуто ${selected.length} из ${before} (по файлам ${picked.byFiles.length}, по определениям ${picked.byRegistry.length})`);
if (ranges && wide.selected.length !== selected.length) {
console.log(`области якорей (радиус ${ANCHOR_RADIUS_LINES} строк, #518): `
+ `${wide.selected.length} → ${selected.length}`);
}
if (!selected.length) {
console.log('дифф не задевает ни одного patch.file — гонять нечего; '
console.log('дифф не задевает ни одной области якоря — гонять нечего; '
+ 'полный реестр идёт ночным расписанием (#513)');
if (argv.includes('--plan-only')) console.log('plan=0');
return 0;
}
}
@@ -9016,10 +9161,17 @@ async function main(argv) {
plan = split.run;
if (!plan.length) {
console.log('все отобранные свидетели уже пойманы на этих же входах — гонять нечего');
if (argv.includes('--plan-only')) console.log('plan=0');
return 0;
}
}
const toRun = plan.map((entry) => entry.mutant);
// #518: `--plan-only` считает план и выходит — job мутантов спрашивает его
// ДО установки окружения (npm ci, python, Chromium ≈ 3 минуты на шард).
if (argv.includes('--plan-only')) {
console.log(`plan=${toRun.length}`);
return 0;
}
if (!runCleanGuards(toRun)) return 2;
let caught = 0;
for (const entry of plan) {
+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/);