From 97aabddce76029a90a331c035a3bc6115bfaa6c3 Mon Sep 17 00:00:00 2001 From: Codex Date: Sun, 6 Sep 2026 23:40:21 +0300 Subject: [PATCH] =?UTF-8?q?ci:=20=D0=B6=D1=83=D1=80=D0=BD=D0=B0=D0=BB=20?= =?UTF-8?q?=D0=BF=D0=BE=D0=B9=D0=BC=D0=B0=D0=BD=D0=BD=D1=8B=D1=85=20=D1=81?= =?UTF-8?q?=D0=B2=D0=B8=D0=B4=D0=B5=D1=82=D0=B5=D0=BB=D0=B5=D0=B9=20=D0=B4?= =?UTF-8?q?=D0=BB=D1=8F=20changed=5Fmutants?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit witnessFingerprint (файлы патча и гарда + объявление, без строки версии), readLedger/recordCaught/splitByLedger, флаг --ledger только с --changed; журнал пишется после каждого пойманного мутанта, в CI — cache restore по префиксу шарда и save при любом исходе. Три свидетеля. Issue: #481 User-Visible: no --- .github/workflows/validate.yml | 24 ++++- scripts/mutation-gate.mjs | 156 +++++++++++++++++++++++++++++++- scripts/source-fingerprint.mjs | 15 ++- test/mutation-gate.test.mjs | 76 ++++++++++++++++ test/validate-workflow.test.mjs | 15 +++ 5 files changed, 277 insertions(+), 9 deletions(-) diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 38919154..b5d51414 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -494,6 +494,17 @@ jobs: - name: Установить Chromium if: steps.pw.outputs.cache-hit != 'true' run: npx playwright install --with-deps chromium + # Журнал пойманных свидетелей (#481). Отменённый или упавший по таймауту + # прогон не пропадает: журнал пишется после каждого пойманного мутанта и + # сохраняется при любом исходе шага, а следующий пуш начинает с того + # места, где предыдущий остановился. Мутант с тем же отпечатком входов + # (файлы патча и гарда, без строки версии) повторно не гоняется. + - name: Журнал свидетелей (последний по шарду) + uses: actions/cache/restore@v6 + with: + path: artifacts/mutation-ledger + key: mutation-ledger-${{ matrix.shard }}-${{ github.run_id }} + restore-keys: mutation-ledger-${{ matrix.shard }}- - name: Затронутые мутанты ловятся env: EVENT_NAME: ${{ github.event_name }} @@ -520,7 +531,18 @@ jobs: fi echo "диапазон: $base..$HEAD_SHA" 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/3" \ + --ledger="artifacts/mutation-ledger/shard-$SHARD.json" + # Сохраняется всегда: и после красного шага (пойманные до отказа уже + # записаны), и при отмене прогона — post-шаг cache/save исполняется, + # пока job не убита целиком. Ключ уникален на прогон: cache не + # перезаписывает существующий ключ, а restore-keys берёт самый новый. + - name: Сохранить журнал свидетелей + if: always() + uses: actions/cache/save@v6 + with: + path: artifacts/mutation-ledger + key: mutation-ledger-${{ matrix.shard }}-${{ github.run_id }} frontend: name: "Фронтенд: типы, юниты, мутанты, синхрон бандла" diff --git a/scripts/mutation-gate.mjs b/scripts/mutation-gate.mjs index 09292c5a..f3e8f29f 100644 --- a/scripts/mutation-gate.mjs +++ b/scripts/mutation-gate.mjs @@ -34,8 +34,11 @@ import { writeFileSync, } from 'node:fs'; import { tmpdir } from 'node:os'; -import { join } from 'node:path'; +import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; +import { createHash } from 'node:crypto'; +import { mkdirSync } from 'node:fs'; +import { withoutProductVersion } from './source-fingerprint.mjs'; const repoRoot = fileURLToPath(new URL('..', import.meta.url)); @@ -2873,6 +2876,46 @@ const MUTANT_DEFINITIONS = [ replace: " if (asEscaped) { escaped.add(asEscaped[1]); continue; }\n if (/^FAIL (\\S+)/.test(line)) { escaped.add(line.split(' ')[1].replace(/:$/, '')); continue; }", }], }, + // #481: журнал пойманных свидетелей — каждый защитный контракт под свидетелем. + { + id: 'ledger-records-escaped', + guard: 'node --test --test-name-pattern="#481 AC3|#481 AC2" test/mutation-gate.test.mjs', + because: 'the ledger may only remember a CAUGHT witness; recording an escaped one would ' + + 'skip it on every later push and hide the exact rot the gate exists for (#481)', + patches: [{ + file: 'scripts/mutation-gate.mjs', + find: ' if (!runMutant(entry.mutant)) ' + 'continue;\n caught++;', + replace: ' if (!runMutant(entry.mutant)) { if (ledger) recordCaught(ledgerArg, ledger, entry.mutant, entry.fingerprint); continue; }\n caught++;', + }, { + // Unit-наблюдаемая половина того же контракта: пустой отпечаток в + // записи считался бы «совпавшим» с любым — журнал перестаёт сравнивать. + file: 'scripts/mutation-gate.mjs', + find: " if (ledger.caught[mutant.id] === fingerprint) " + "skipped.push(mutant);", + replace: " if (ledger.caught[mutant.id] === fingerprint || mutant.id in ledger.caught) skipped.push(mutant);", + }], + }, + { + id: 'ledger-version-sensitive', + guard: 'node --test --test-name-pattern="#481 AC1" test/mutation-gate.test.mjs', + because: 'a release bump touches houseplan-card.ts and houseplan-editor-runtime.ts; without ' + + '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'));", + }], + }, + { + id: 'ledger-written-at-end-only', + guard: 'node --test --test-name-pattern="#481 AC3" test/mutation-gate.test.mjs', + because: 'the ledger must be written after EACH caught witness: a shard cancelled by the next push ' + + 'or killed by the timeout must keep what it proved, or the snowball returns (#481)', + patches: [{ + file: 'scripts/mutation-gate.mjs', + find: " ledger.caught[mutant.id] = fingerprint;\n mkdirSync(dirname(file), " + "{ recursive: true });\n writeFileSync(file,", + replace: " ledger.caught[mutant.id] = fingerprint;\n mkdirSync(dirname(file), { recursive: true });\n if (Object.keys(ledger.caught).length > 1) writeFileSync(file,", + }], + }, { id: 'changed-selection-ignores-guard-files', guard: 'node --test --test-name-pattern="#475 AC2" test/mutation-gate.test.mjs', @@ -7170,6 +7213,82 @@ export function guardFiles(guard, exists = (file) => existsSync(join(repoRoot, f return [...files]; } +/** + * Отпечаток свидетеля (#481): содержимое файлов патча и гарда плюс само + * объявление мутанта. Строка версии продукта нормализуется, как в + * `visualFingerprint` (#245): релизный бамп трогает `houseplan-card.ts` и + * `houseplan-editor-runtime.ts`, по которым отбираются десятки мутантов, но + * ни одного свидетеля не меняет. Приближение то же, что у отбора по диффу: + * непрямые зависимости гарда не учитываются — полный прогон остаётся + * контрактом, журнал его не заменяет. + */ +export function witnessFingerprint(mutant, { + root = repoRoot, + read = (file) => (existsSync(join(root, file)) ? readFileSync(join(root, file), 'utf8') : ''), + exists = (file) => existsSync(join(root, file)), + normalize = withoutProductVersion(root), +} = {}) { + 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), + ...guardFiles(mutant.guard, exists), + ]); + for (const file of [...files].sort()) { + hash.update(file); + hash.update('\0'); + hash.update(normalize(String(read(file)).replace(/\r\n?/g, '\n'))); + hash.update('\0'); + } + return hash.digest('hex'); +} + +export const LEDGER_SCHEMA = 1; + +/** Журнал пойманных свидетелей: `{ schema, caught: { [id]: fingerprint } }`. */ +export function readLedger(file) { + if (!file || !existsSync(file)) return { schema: LEDGER_SCHEMA, caught: {} }; + try { + const parsed = JSON.parse(readFileSync(file, 'utf8')); + if (parsed?.schema !== LEDGER_SCHEMA || typeof parsed.caught !== 'object' || !parsed.caught) { + return { schema: LEDGER_SCHEMA, caught: {} }; + } + return { schema: LEDGER_SCHEMA, caught: { ...parsed.caught } }; + } catch { + // Битый журнал — не отказ гейта: он лишь сужает работу, и пустой журнал + // означает «гонять всё отобранное», то есть прежнее поведение. + return { schema: LEDGER_SCHEMA, caught: {} }; + } +} + +/** + * Записать пойманного свидетеля — сразу, а не в конце прогона: отменённый + * или упавший по таймауту шард обязан сохранить уже сделанное, иначе + * следующий пуш начинает с нуля (снежный ком #481). + */ +export function recordCaught(file, ledger, mutant, fingerprint) { + ledger.caught[mutant.id] = fingerprint; + mkdirSync(dirname(file), { recursive: true }); + writeFileSync(file, `${JSON.stringify({ schema: LEDGER_SCHEMA, caught: ledger.caught }, null, 2)}\n`); +} + +/** + * Мутанты, чей отпечаток совпадает с журналом, уже доказали, что ловятся на + * этих же входах — их пропуск ничего не ослабляет. Сравнивается ОТПЕЧАТОК, + * не факт присутствия id: запись от другого содержимого файлов не считается. + */ +export function splitByLedger(mutants, ledger, fingerprintOf = (m) => witnessFingerprint(m)) { + const run = []; + const skipped = []; + for (const mutant of mutants) { + const fingerprint = fingerprintOf(mutant); + if (ledger.caught[mutant.id] === fingerprint) skipped.push(mutant); + else run.push({ mutant, fingerprint }); + } + return { run, skipped }; +} + /** * Мутанты, затронутые диффом (#332, расширено в #475). * @@ -7205,6 +7324,13 @@ function main(argv) { } const changedArg = argv.find((a) => a === '--changed' || a.startsWith('--changed=')); + const ledgerArg = argv.find((a) => a.startsWith('--ledger='))?.slice(9); + if (ledgerArg && !changedArg) { + // Полный прогон журнал не читает — он его пишет по расписанию целиком; + // читать журнал в полном прогоне значило бы никогда не перепроверять. + console.error('--ledger работает только вместе с --changed: полный прогон журнал не читает'); + return 2; + } if (changedArg) { const range = changedArg.includes('=') ? changedArg.split('=')[1] : 'origin/dev..HEAD'; const diff = spawnSync('git', ['-C', repoRoot, 'diff', '--name-only', range], @@ -7277,11 +7403,31 @@ function main(argv) { return 0; } - if (!runCleanGuards(selected)) return 2; + // Журнал (#481): отобранные по диффу мутанты, чьи входы не менялись с + // последнего пойманного прогона, не гоняются повторно. + let plan = selected.map((mutant) => ({ mutant, fingerprint: null })); + let ledger = null; + if (ledgerArg) { + ledger = readLedger(ledgerArg); + const split = splitByLedger(selected, ledger); + console.log(`журнал ${ledgerArg}: по журналу пропущено ${split.skipped.length} ` + + `(отпечатки совпали), к прогону ${split.run.length}`); + plan = split.run; + if (!plan.length) { + console.log('все отобранные свидетели уже пойманы на этих же входах — гонять нечего'); + return 0; + } + } + const toRun = plan.map((entry) => entry.mutant); + if (!runCleanGuards(toRun)) return 2; let caught = 0; - for (const m of selected) if (runMutant(m)) caught++; - console.log(`\nпоймано ${caught} из ${selected.length}`); - return caught === selected.length ? 0 : 1; + for (const entry of plan) { + if (!runMutant(entry.mutant)) continue; + caught++; + if (ledger) recordCaught(ledgerArg, ledger, entry.mutant, entry.fingerprint); + } + console.log(`\nпоймано ${caught} из ${toRun.length}`); + return caught === toRun.length ? 0 : 1; } if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) { diff --git a/scripts/source-fingerprint.mjs b/scripts/source-fingerprint.mjs index 2380afa4..5acaf6ba 100644 --- a/scripts/source-fingerprint.mjs +++ b/scripts/source-fingerprint.mjs @@ -125,7 +125,7 @@ const visualPackageProjection = (text) => { }; /** Номер версии продукта, как его знает package.json. */ -const productVersion = (root) => { +export const productVersion = (root) => { const path = resolve(root, 'package.json'); if (!existsSync(path)) return ''; try { @@ -153,11 +153,20 @@ const productVersion = (root) => { * версия зависимости в `package-lock.json` остаётся частью отпечатка, иначе * обновление зависимости перестало бы требовать пересъёмки. */ -export const visualFingerprint = (root = process.cwd()) => { +/** + * Та же нормализация версии для чужих корпусов (#481): журнал пойманных + * свидетелей считает отпечаток по файлам патча и гарда, и бамп версии + * не должен делать ни один свидетель «изменённым». + */ +export const withoutProductVersion = (root = process.cwd()) => { const version = productVersion(root); - const withoutVersion = version + return version ? (text) => text.split(version).join('0.0.0-product-version') : (text) => text; +}; + +export const visualFingerprint = (root = process.cwd()) => { + const withoutVersion = withoutProductVersion(root); return digest(root, fingerprintFiles(root), (text, name) => ( name === 'package.json' ? visualPackageProjection(withoutVersion(text)) diff --git a/test/mutation-gate.test.mjs b/test/mutation-gate.test.mjs index 76f5c3ca..aa428591 100644 --- a/test/mutation-gate.test.mjs +++ b/test/mutation-gate.test.mjs @@ -1,4 +1,5 @@ import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; import test from 'node:test'; import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; @@ -7,6 +8,7 @@ import { fileURLToPath } from 'node:url'; import { MUTANTS, applyPatches, guardNeedsBundle, guardNeedsTestBuild, selectChangedMutants, shardMutants, guardFiles, + witnessFingerprint, readLedger, recordCaught, splitByLedger, LEDGER_SCHEMA, } from '../scripts/mutation-gate.mjs'; const repoRoot = fileURLToPath(new URL('..', import.meta.url)); @@ -330,3 +332,77 @@ test('#475 AC7: воспроизведение находки ревью — б assert.ok(byPatch.includes(id), `${id} не отобран по патчу`); } }); + +// #481. Журнал пойманных свидетелей: отобранный по диффу мутант, чьи входы +// не менялись с последнего пойманного прогона, не гоняется повторно. Так +// отменённый прогон не пропадает даром, а релизный бамп версии не +// превращает 131 мутанта в 20 минут на шард. + +const LEDGER_MUTANT = { id: 'x', guard: 'node --test test/x.test.mjs', patches: [{ file: 'src/x.ts', find: 'a', replace: 'b' }] }; +const fakeFs = (files) => ({ + root: '/repo', + read: (file) => files[file] ?? '', + exists: (file) => file in files, + normalize: (text) => text.split('1.2.3').join('0.0.0-product-version'), +}); + +test('#481 AC1: отпечаток свидетеля не меняется от бампа версии, но меняется от правки патч-файла, гарда и объявления', () => { + const base = fakeFs({ 'src/x.ts': "const CARD_VERSION = '1.2.3';\nlet a = 1;", 'test/x.test.mjs': 'assert(a)' }); + const fp = witnessFingerprint(LEDGER_MUTANT, base); + const bumped = fakeFs({ ...{ 'src/x.ts': "const CARD_VERSION = '1.2.3';\nlet a = 1;", 'test/x.test.mjs': 'assert(a)' } }); + bumped.read = (file) => (file === 'src/x.ts' ? "const CARD_VERSION = '1.2.4';\nlet a = 1;" : 'assert(a)'); + bumped.normalize = (text) => text.split('1.2.4').join('0.0.0-product-version'); + assert.equal(witnessFingerprint(LEDGER_MUTANT, bumped), fp, 'бамп версии — не изменение свидетеля'); + const crlf = { ...base, read: (file) => base.read(file).replace(/\n/g, '\r\n') }; + assert.equal(witnessFingerprint(LEDGER_MUTANT, crlf), fp, 'CRLF канонизируется'); + const patchChanged = { ...base, read: (file) => (file === 'src/x.ts' ? 'let a = 2;' : base.read(file)) }; + assert.notEqual(witnessFingerprint(LEDGER_MUTANT, patchChanged), fp, 'правка патч-файла меняет отпечаток'); + const guardChanged = { ...base, read: (file) => (file === 'test/x.test.mjs' ? 'assert(b)' : base.read(file)) }; + assert.notEqual(witnessFingerprint(LEDGER_MUTANT, guardChanged), fp, 'правка файла гарда меняет отпечаток'); + assert.notEqual(witnessFingerprint({ ...LEDGER_MUTANT, guard: 'node --test test/y.test.mjs' }, base), fp, 'объявление гарда'); + assert.notEqual(witnessFingerprint({ ...LEDGER_MUTANT, patches: [{ file: 'src/x.ts', find: 'a', replace: 'c' }] }, base), fp, 'объявление патча'); +}); + +test('#481 AC2: по журналу пропускается только совпавший отпечаток; чужой или отсутствующий — к прогону', () => { + const a = { id: 'a', guard: 'g', patches: [] }; + const b = { id: 'b', guard: 'g', patches: [] }; + const c = { id: 'c', guard: 'g', patches: [] }; + const ledger = { schema: LEDGER_SCHEMA, caught: { a: 'fp-a', b: 'fp-old' } }; + const split = splitByLedger([a, b, c], ledger, (m) => `fp-${m.id}`); + assert.deepEqual(split.skipped.map((m) => m.id), ['a']); + assert.deepEqual(split.run.map((entry) => `${entry.mutant.id}:${entry.fingerprint}`), ['b:fp-b', 'c:fp-c']); +}); + +test('#481 AC3: журнал пишется сразу при поимке и переживает битый/чужой файл', () => { + const dir = mkdtempSync(join(tmpdir(), 'houseplan-ledger-')); + try { + const file = join(dir, 'nested', 'ledger.json'); + assert.deepEqual(readLedger(file), { schema: LEDGER_SCHEMA, caught: {} }, 'нет файла — пустой журнал'); + const ledger = readLedger(file); + recordCaught(file, ledger, { id: 'a' }, 'fp-a'); + assert.deepEqual(JSON.parse(readFileSync(file, 'utf8')), { schema: LEDGER_SCHEMA, caught: { a: 'fp-a' } }, + 'запись появляется в файле немедленно, не в конце прогона'); + recordCaught(file, ledger, { id: 'b' }, 'fp-b'); + assert.deepEqual(readLedger(file).caught, { a: 'fp-a', b: 'fp-b' }); + writeFileSync(file, '{ not json'); + assert.deepEqual(readLedger(file).caught, {}, 'битый журнал — пустой, не отказ'); + writeFileSync(file, JSON.stringify({ schema: 99, caught: { a: 'x' } })); + assert.deepEqual(readLedger(file).caught, {}, 'чужая схема — пустой'); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test('#481 AC4: --ledger без --changed — отказ с кодом 2', () => { + const script = join(repoRoot, 'scripts/mutation-gate.mjs'); + const run = spawnSync(process.execPath, [script, '--ledger=/tmp/none.json', '--id=budget-warning-never-fires'], { encoding: 'utf8' }); + assert.equal(run.status, 2); + assert.match(run.stderr, /--ledger работает только вместе с --changed/); +}); + +test('#481: отпечатки реестра детерминированы и различают мутантов', () => { + const first = witnessFingerprint(MUTANTS[0]); + assert.equal(witnessFingerprint(MUTANTS[0]), first); + assert.notEqual(witnessFingerprint(MUTANTS[1]), first); + assert.match(first, /^[0-9a-f]{64}$/); +}); diff --git a/test/validate-workflow.test.mjs b/test/validate-workflow.test.mjs index 982eed74..ed684f52 100644 --- a/test/validate-workflow.test.mjs +++ b/test/validate-workflow.test.mjs @@ -394,3 +394,18 @@ test('ручной/ночной полный прогон не делит concur const text = read('validate.yml'); assert.match(text, /group: validate-\$\{\{ github\.event_name == 'workflow_dispatch' && 'dispatch-' \|\| '' \}\}/); }); + +test('журнал свидетелей changed_mutants: restore по шарду, ledger в команде, save при любом исходе (#481 AC5)', () => { + 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: Затронутые мутанты ловятся')); + assert.match(restore, /key: mutation-ledger-\$\{\{ matrix\.shard \}\}-\$\{\{ github\.run_id \}\}/); + assert.match(restore, /restore-keys: mutation-ledger-\$\{\{ matrix\.shard \}\}-/, 'без префикса журнал прошлого прогона не найдётся'); + assert.match(job, /--changed="\$base\.\.\$HEAD_SHA" --shard="\$SHARD\/3" \\\n\s+--ledger="artifacts\/mutation-ledger\/shard-\$SHARD\.json"/); + const save = job.slice(job.indexOf('name: Сохранить журнал свидетелей')); + assert.match(save, /if: always\(\)/, 'красный или отменённый шард обязан сохранить уже пойманное'); + assert.match(save, /actions\/cache\/save@v6/); + assert.match(save, /key: mutation-ledger-\$\{\{ matrix\.shard \}\}-\$\{\{ github\.run_id \}\}/); + assert.ok(job.indexOf('name: Сохранить журнал свидетелей') > job.indexOf('--ledger='), 'save идёт после шага прогона'); +});