Files
houseplan-card/scripts/golden-acceptance.mjs
T
Codex 7c285d25b7 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
2026-08-28 18:18:24 +03:00

206 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Правило допустимости съёмки golden-кандидатов (#334).
*
* Прежде эталон принимался только из артефакта CI: растеризация шрифтов на
* другой машине может отличаться, а доказать обратное было нечем. Цена —
* двойной roundtrip на каждый визуальный фикс: пуш, семь минут CI, скачивание
* артефакта, приёмка, второй пуш, второй полный прогон. При версии матрицы 48
* это платится регулярно.
*
* Доказательство есть, и оно эмпирическое: среда съёмки равна раннеру, если
* КАЖДАЯ сцена, которую менять не собирались, совпала со своим принятым
* эталоном. Расхождение растеризации нельзя спрятать — оно задевает все сцены
* с текстом, а не только правленные. Поэтому ревьюер объявляет намерение
* списком `--expect-change`, и всё, что разошлось помимо списка, приёмку
* запрещает: либо это регрессия рендера, либо среда не та.
*
* То же правило ловит другое, независимо от среды: «принять всё, чтобы CI
* позеленел». Именно так эталон перестаёт быть эталоном — молча, одной
* командой, без единого названного намерения.
*
* Новая сцена объявляется отдельно, флагом `--expect-new` (#350). Прежде она
* проходила молча, и три кадра каталога устройств стали контрактом без единого
* взгляда. Половина прежнего обоснования верна и остаётся: расхождение
* растеризации новая сцена выявить не может, для доказательства параллельности
* среды годятся только сцены с эталонами. Но правило отвечало лишь на вопрос
* «та ли это среда» и молчало про второй — «правильный ли это кадр». Пустой,
* обрезанный или снятый в неверном состоянии кадр новая сцена закрепляет ровно
* так же надёжно, как испорченный старый.
*
* Поэтому два флага утверждают разное и не заменяют друг друга:
* `--expect-change` — «я знаю, почему старый кадр изменился», `--expect-new` —
* «я посмотрел на новый кадр». Путаница между ними тоже запрещена: имя в чужом
* флаге останавливает приёмку.
* она могла бы противоречить.
*
* Почему это лежит в `scripts/`, а не рядом с `accept.mjs`. Отпечаток
* `sourceFingerprint` включает ВСЕ `.mjs` из `demo/golden`, включая
* `accept.mjs` и `policy.mjs`, которые исполняются после того, как картинка уже
* снята, и ни одного пикселя изменить не могут. Правка любого из них объявляет
* устаревшими и закоммиченный бандл, и манифест скриншотов документации, и
* манифест эталонов — то есть требует ровно того двойного цикла, который эта
* задача убирает. Сужение корпуса отпечатка — отдельная задача: оно неизбежно
* требует пересборки бандла, потому что сам `source-fingerprint.mjs` в корпусе.
*/
export const goldenAcceptanceRefusal = (results, declared = [], declaredNew = []) => {
if (!Array.isArray(results)) return 'отчёт кандидатов не содержит результатов сцен';
const expected = new Set(declared.filter(Boolean));
const expectedNew = new Set(declaredNew.filter(Boolean));
const statusById = new Map(results.map((result) => [result.id, result.status]));
const unknown = [...expected, ...expectedNew].filter((id) => !statusById.has(id));
if (unknown.length) {
return 'названы сцены, которых нет в отчёте:'
+ ` ${[...new Set(unknown)].sort().join(', ')}`;
}
// Путаница между флагами — не мелочь: она означает, что ревьюер думал об
// одной сцене, а утверждал про другую.
const newInWrongFlag = [...expected]
.filter((id) => statusById.get(id) === 'missing-baseline').sort();
if (newInWrongFlag.length) {
return `у этих сцен эталона ещё нет, их место в --expect-new: ${newInWrongFlag.join(', ')}`;
}
const oldInWrongFlag = [...expectedNew]
.filter((id) => statusById.get(id) !== 'missing-baseline').sort();
if (oldInWrongFlag.length) {
return 'у этих сцен эталон уже есть, их место в --expect-change:'
+ ` ${oldInWrongFlag.join(', ')}`;
}
const undeclared = results
.filter((result) => result.status === 'different' && !expected.has(result.id))
.map((result) => result.id)
.sort();
if (undeclared.length) {
return 'съёмка разошлась с принятыми эталонами в сценах, которые менять не собирались:'
+ ` ${undeclared.join(', ')}.`
+ ' Либо это регрессия рендера, либо среда съёмки не совпадает с раннером —'
+ ' в обоих случаях приёмка запрещена. Намеренные сцены перечисляются в'
+ ' --expect-change=<id,id>';
}
const undeclaredNew = results
.filter((result) => result.status === 'missing-baseline' && !expectedNew.has(result.id))
.map((result) => result.id)
.sort();
if (undeclaredNew.length) {
return 'у этих сцен эталона ещё нет, и они станут контрактом:'
+ ` ${undeclaredNew.join(', ')}.`
+ ' Посмотрите кадры в artifacts/golden/actual — пустой или обрезанный кадр'
+ ' закрепляется так же надёжно, как испорченный старый, — и перечислите их'
+ ' в --expect-new=<id,id>';
}
return null;
};
/** Сцены, объявленные изменёнными, но совпавшие с эталоном: не ошибка, но и не молчание. */
export const goldenSilentDeclarations = (results, declared = []) => declared
.filter((id) => results.find((result) => result.id === id)?.status === 'passed')
.sort();
/**
* План замены: что переписать, что оставить как было (#351).
*
* Чистая функция, потому что решение здесь одно и ошибка в нём дорога:
* `passed` означает «в пределах порога», а не «байт в байт». Прежняя приёмка
* копировала кандидата поверх КАЖДОГО эталона, поэтому подпороговый дрейф уезжал
* в контракт молча и накапливался: каждая приёмка подтягивала эталон к последней
* среде, порог не пересекался никогда, а эталон уходил. Так `1e341c60` заменил
* 22 картинки, объявив четыре.
*
* Необъявленная сцена сохраняет и файл, и свой хеш из прежнего индекса. Хеша нет
* только у сцены без эталона, а такая обязана быть названа в `--expect-new` —
* поэтому его отсутствие здесь ошибка, а не повод взять кандидата.
*/
export const goldenAcceptancePlan = ({
scenarioIds, results, previousHashes = {}, declared = [], declaredNew = [],
}) => {
const accepted = new Set([...declared, ...declaredNew].filter(Boolean));
const byId = new Map((results || []).map((result) => [result.id, result]));
const replace = [];
const keep = [];
const hashes = {};
for (const id of scenarioIds) {
if (accepted.has(id)) {
const digest = byId.get(id)?.actualSha256;
if (typeof digest !== 'string' || !digest) {
throw new Error(`объявленная сцена без хеша кандидата: ${id}`);
}
replace.push(id);
hashes[id] = digest;
continue;
}
const existing = previousHashes[id];
if (typeof existing !== 'string' || !existing) {
throw new Error(`необъявленная сцена без прежнего эталона: ${id};`
+ ' назовите её в --expect-new');
}
hashes[id] = existing;
keep.push(id);
}
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 };
};