From 2783ceff944b1cd234d5e32c46d603dc4eb0aeb0 Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 10 Sep 2026 14:17:23 +0300 Subject: [PATCH] perf(ci): judge a witness by its anchor's neighbourhood, not the whole file MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .github/workflows/validate.yml | 67 ++++++++---- scripts/mutation-gate.mjs | 176 +++++++++++++++++++++++++++++--- test/mutation-gate.test.mjs | 110 ++++++++++++++++++++ test/validate-workflow.test.mjs | 39 ++++++- 4 files changed, 356 insertions(+), 36 deletions(-) diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 8896cd3f..be7e3fee 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -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 исполняется, diff --git a/scripts/mutation-gate.mjs b/scripts/mutation-gate.mjs index 04af2598..72624587 100644 --- a/scripts/mutation-gate.mjs +++ b/scripts/mutation-gate.mjs @@ -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) { diff --git a/test/mutation-gate.test.mjs b/test/mutation-gate.test.mjs index 81ca5680..4db6e060 100644 --- a/test/mutation-gate.test.mjs +++ b/test/mutation-gate.test.mjs @@ -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: [] }; diff --git a/test/validate-workflow.test.mjs b/test/validate-workflow.test.mjs index d2891ab1..8d17fbbf 100644 --- a/test/validate-workflow.test.mjs +++ b/test/validate-workflow.test.mjs @@ -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/);