test: golden acceptance keeps environment witnesses (#355)

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
This commit is contained in:
Codex
2026-08-28 18:18:24 +03:00
parent 4808426369
commit 7c285d25b7
4 changed files with 166 additions and 0 deletions
+24
View File
@@ -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}).`);
}
+7
View File
@@ -4,6 +4,13 @@
*
* node scripts/golden-accept.mjs --reviewed --expect-change=<id,id>
* node scripts/golden-accept.mjs --reviewed --expect-new=<id,id>
* node scripts/golden-accept.mjs --reviewed --expect-change=… --no-witnesses --reason="…"
*
* Floor свидетелей (#355): после вычета объявленных сцен должно остаться
* достаточно необъявленных, совпавших с эталоном байт-в-байт, — они доказывают,
* что среда съёмки та же, что у принятого эталона. Тотальная перерисовка
* обходится только явным `--no-witnesses --reason="…"`; причина уезжает в
* манифест эталонов.
*
* Обёртка появилась потому, что до #344 любой `.mjs` из `demo/golden` входил в
* корпус отпечатка, и правка инструмента приёмки объявляла устаревшими бандл и
+62
View File
@@ -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 };
};
+73
View File
@@ -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');
});