From 7c285d25b706fa3f9287620ae0b76cf21909c922 Mon Sep 17 00:00:00 2001 From: Codex Date: Fri, 28 Aug 2026 18:18:24 +0300 Subject: [PATCH] test: golden acceptance keeps environment witnesses (#355) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Declaring the whole matrix in --expect-change could accept a completely foreign capture (different font stack, different machine): no undeclared passed scenes would remain, and undeclared passed scenes are exactly what proves the capture environment equals the accepted baseline's. The realistic failure is fatigue, not malice — a mass framing change where the author lists "everything that went red", accidentally sweeping in scenes that diverged because of the environment. Acceptance now requires a witness floor: after subtracting --expect-change/--expect-new, at least min(10, 10% of baseline scenes) undeclared scenes must match their accepted baselines BYTE-FOR-BYTE (a sub-threshold 'passed' proves nothing about the environment — #351). A truly total repaint passes only with an explicit --no-witnesses --reason="…", and the reason is written into the baseline manifest — a trace in the artifact and its git history, not just in the shell history. A first-ever capture with no baselines requires no witnesses: every frame there is declared in --expect-new anyway. Issue: #355 User-Visible: no --- demo/golden/accept.mjs | 24 ++++++++++++ scripts/golden-accept.mjs | 7 ++++ scripts/golden-acceptance.mjs | 62 +++++++++++++++++++++++++++++ test/golden-policy.test.mjs | 73 +++++++++++++++++++++++++++++++++++ 4 files changed, 166 insertions(+) diff --git a/demo/golden/accept.mjs b/demo/golden/accept.mjs index ac6ff651..1b8a5f65 100644 --- a/demo/golden/accept.mjs +++ b/demo/golden/accept.mjs @@ -8,6 +8,7 @@ import { GOLDEN_MATRIX_VERSION, GOLDEN_SCENARIOS } from './matrix.mjs'; import { GOLDEN_BASELINE_MANIFEST } from './policy.mjs'; import { goldenAcceptancePlan, goldenAcceptanceRefusal, goldenSilentDeclarations, + goldenWitnessRefusal, } from '../../scripts/golden-acceptance.mjs'; const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../..'); @@ -21,6 +22,9 @@ const list = (name) => { }; const declared = list('expect-change'); const declaredNew = list('expect-new'); +const skipWitnesses = process.argv.includes('--no-witnesses'); +const reasonArg = process.argv.find((arg) => arg.startsWith('--reason=')); +const skipReason = reasonArg ? reasonArg.slice('--reason='.length) : ''; if (!reviewed) throw new Error('refusing to replace baselines without explicit --reviewed'); const reportPath = resolve(from, 'golden-report.json'); @@ -55,6 +59,17 @@ const manifestPath = resolve(baselineRoot, GOLDEN_BASELINE_MANIFEST); const previous = existsSync(manifestPath) ? JSON.parse(readFileSync(manifestPath, 'utf8')).scenarios || {} : {}; +// #355: floor свидетелей — необъявленные сцены, совпавшие с эталоном +// байт-в-байт, доказывают, что среда съёмки та же, что у принятого эталона. +const witnessCheck = goldenWitnessRefusal({ + results: report.results, + declared, + declaredNew, + previousHashes: previous, + skipWitnesses, + skipReason, +}); +if (witnessCheck.refusal) throw new Error(witnessCheck.refusal); // Кандидат проверяется целиком, до всякого решения о замене: сломанный отчёт // не имеет права оставить каталог эталонов половинным. for (const scenario of GOLDEN_SCENARIOS) { @@ -84,6 +99,10 @@ writeFileSync(resolve(baselineRoot, GOLDEN_BASELINE_MANIFEST), `${JSON.stringify acceptedAt: new Date().toISOString(), sourceFingerprint: report.buildFingerprint, chromium: report.chromium, + // #355: след приёмки в артефакте, не только в истории shell. + witnesses: skipWitnesses + ? { skipped: true, reason: skipReason } + : { count: witnessCheck.witnesses.length, floor: witnessCheck.floor }, scenarios: hashes, }, null, 2)}\n`, 'utf8'); const silent = goldenSilentDeclarations(report.results, declared); @@ -94,3 +113,8 @@ console.log(`Заменено эталонов: ${plan.replace.length}` + `${plan.replace.length ? ` (${[...plan.replace].sort().join(', ')})` : ''}.`); console.log(`Сохранено без изменений: ${plan.keep.length}.`); console.log(`Индекс перезаписан на ${GOLDEN_SCENARIOS.length} сцен.`); +if (skipWitnesses) { + console.log(`Свидетели пропущены осознанно (--no-witnesses): ${skipReason}`); +} else { + console.log(`Свидетелей среды: ${witnessCheck.witnesses.length} (floor ${witnessCheck.floor}).`); +} diff --git a/scripts/golden-accept.mjs b/scripts/golden-accept.mjs index f774dd39..68fcb48d 100644 --- a/scripts/golden-accept.mjs +++ b/scripts/golden-accept.mjs @@ -4,6 +4,13 @@ * * node scripts/golden-accept.mjs --reviewed --expect-change= * node scripts/golden-accept.mjs --reviewed --expect-new= + * node scripts/golden-accept.mjs --reviewed --expect-change=… --no-witnesses --reason="…" + * + * Floor свидетелей (#355): после вычета объявленных сцен должно остаться + * достаточно необъявленных, совпавших с эталоном байт-в-байт, — они доказывают, + * что среда съёмки та же, что у принятого эталона. Тотальная перерисовка + * обходится только явным `--no-witnesses --reason="…"`; причина уезжает в + * манифест эталонов. * * Обёртка появилась потому, что до #344 любой `.mjs` из `demo/golden` входил в * корпус отпечатка, и правка инструмента приёмки объявляла устаревшими бандл и diff --git a/scripts/golden-acceptance.mjs b/scripts/golden-acceptance.mjs index e41c5107..69c86565 100644 --- a/scripts/golden-acceptance.mjs +++ b/scripts/golden-acceptance.mjs @@ -141,3 +141,65 @@ export const goldenAcceptancePlan = ({ } return { replace, keep, hashes }; }; + +/** + * Floor сцен-свидетелей (#355). + * + * Смысл непереименованных прошедших сцен — быть СВИДЕТЕЛЯМИ среды: если бы + * растеризация или окружение съёмки отличались от эталонных, они бы разошлись. + * Перечислив ВСЮ матрицу в `--expect-change`, можно принять полностью чужую + * съёмку — свидетелей не останется, а след останется только в истории команды. + * Практический сценарий — не злой умысел, а усталость: массовая framing-правка, + * автор перечисляет «всё, что покраснело», захватывая и сцены, разошедшиеся по + * причине среды. + * + * Свидетель — необъявленная сцена, чей кандидат совпал с принятым эталоном + * БАЙТ-В-БАЙТ: `passed` означает лишь «в пределах порога» (#351), а среду + * доказывает только точное совпадение. Floor — 10 свидетелей или 10% сцен с + * эталонами, что меньше; матрица без эталонов (первичная съёмка) свидетелей + * требовать не может — там каждый кадр и так объявляется в `--expect-new`. + * + * Осознанный обход для действительно тотальных перерисовок — `--no-witnesses` + * с обязательной причиной: она уезжает в манифест эталонов, то есть в артефакт + * и его git-историю, а не только в историю shell. + */ +export const goldenWitnessFloor = (baselineCount) => (baselineCount > 0 + ? Math.min(10, Math.ceil(baselineCount * 0.1)) + : 0); + +export const goldenWitnessRefusal = ({ + results, declared = [], declaredNew = [], previousHashes = {}, + skipWitnesses = false, skipReason = '', +}) => { + if (skipWitnesses) { + if (typeof skipReason !== 'string' || !skipReason.trim()) { + return { refusal: '--no-witnesses требует --reason="…": причина обхода' + + ' обязана остаться в отчёте приёмки', witnesses: [], floor: 0 }; + } + return { refusal: null, witnesses: [], floor: 0 }; + } + const accepted = new Set([...declared, ...declaredNew].filter(Boolean)); + const withBaseline = results.filter((result) => result.status !== 'missing-baseline'); + const floor = goldenWitnessFloor(withBaseline.length); + const witnesses = withBaseline + .filter((result) => !accepted.has(result.id) + && result.status === 'passed' + && typeof result.actualSha256 === 'string' + && result.actualSha256 === previousHashes[result.id]) + .map((result) => result.id) + .sort(); + if (witnesses.length < floor) { + return { + refusal: 'сцен-свидетелей среды недостаточно:' + + ` ${witnesses.length} из необходимых ${floor}` + + ` (эталонных сцен ${withBaseline.length}, объявлено ${accepted.size}).` + + ' Свидетель — необъявленная сцена, совпавшая с эталоном байт-в-байт;' + + ' именно они доказывают, что среда съёмки та же, что у принятого' + + ' эталона. Если перерисовка действительно тотальная и осознанная —' + + ' --no-witnesses --reason="…" оставит причину в манифесте эталонов', + witnesses, + floor, + }; + } + return { refusal: null, witnesses, floor }; +}; diff --git a/test/golden-policy.test.mjs b/test/golden-policy.test.mjs index bb96bc54..a5c53e3e 100644 --- a/test/golden-policy.test.mjs +++ b/test/golden-policy.test.mjs @@ -176,3 +176,76 @@ test('объявленная сцена без хеша кандидата — declared: ['a'], }), /без хеша кандидата: a/); }); + +// #355. Floor свидетелей: перечислив всю матрицу в --expect-change, принять +// чужую съёмку больше нельзя — среду доказывают только необъявленные сцены, +// совпавшие с эталоном байт-в-байт. +test('приёмка без свидетелей отказывает и называет дефицит (#355)', async () => { + const { goldenWitnessRefusal } = await import('../scripts/golden-acceptance.mjs'); + const all = Array.from({ length: 20 }, (_, index) => ({ + id: `scene-${index}`, status: 'different', actualSha256: `new-${index}`, + })); + const { refusal, floor } = goldenWitnessRefusal({ + results: all, + declared: all.map((result) => result.id), + previousHashes: Object.fromEntries(all.map((result) => [result.id, `old-${result.id}`])), + }); + assert.equal(floor, 2, '10% от 20 эталонных сцен'); + assert.match(refusal, /0 из необходимых 2/); + assert.match(refusal, /--no-witnesses/); +}); + +test('обычная приёмка с достаточным числом свидетелей проходит (#355)', async () => { + const { goldenWitnessRefusal } = await import('../scripts/golden-acceptance.mjs'); + const scenes = [ + { id: 'edited', status: 'different', actualSha256: 'changed' }, + ...Array.from({ length: 30 }, (_, index) => ({ + id: `same-${index}`, status: 'passed', actualSha256: `sha-${index}`, + })), + ]; + const previousHashes = Object.fromEntries( + scenes.filter((scene) => scene.id !== 'edited') + .map((scene) => [scene.id, scene.actualSha256]), + ); + const { refusal, witnesses, floor } = goldenWitnessRefusal({ + results: scenes, declared: ['edited'], previousHashes, + }); + assert.equal(refusal, null); + assert.equal(floor, 4, '10% от 31 эталонной сцены'); + assert.equal(witnesses.length, 30); +}); + +test('passed в пределах порога — не свидетель: среду доказывает байт-в-байт (#355)', async () => { + const { goldenWitnessRefusal } = await import('../scripts/golden-acceptance.mjs'); + const scenes = Array.from({ length: 10 }, (_, index) => ({ + id: `drifted-${index}`, status: 'passed', actualSha256: `candidate-${index}`, + })); + const previousHashes = Object.fromEntries( + scenes.map((scene) => [scene.id, `baseline-${scene.id}`]), + ); + const { refusal, witnesses } = goldenWitnessRefusal({ + results: scenes, declared: [], previousHashes, + }); + assert.equal(witnesses.length, 0, 'подпороговый дрейф не доказывает среду'); + assert.match(refusal, /0 из необходимых 1/); +}); + +test('--no-witnesses требует причину и с ней пропускает floor (#355)', async () => { + const { goldenWitnessRefusal } = await import('../scripts/golden-acceptance.mjs'); + const bare = goldenWitnessRefusal({ results: [], skipWitnesses: true }); + assert.match(bare.refusal, /--reason/); + const reasoned = goldenWitnessRefusal({ + results: [], skipWitnesses: true, skipReason: 'полная перерисовка матрицы v49', + }); + assert.equal(reasoned.refusal, null); +}); + +test('первичная съёмка без единого эталона свидетелей не требует (#355)', async () => { + const { goldenWitnessRefusal, goldenWitnessFloor } = await import('../scripts/golden-acceptance.mjs'); + assert.equal(goldenWitnessFloor(0), 0); + const { refusal } = goldenWitnessRefusal({ + results: [{ id: 'first', status: 'missing-baseline', actualSha256: 'x' }], + declaredNew: ['first'], + }); + assert.equal(refusal, null, 'каждый кадр и так объявлен в --expect-new'); +});