Files
houseplan-card/scripts/golden-acceptance.mjs
Matysh cd0ecf4db2 fix: the witness floor comes from the matrix, not from surviving baselines
Порог свидетелей считался от числа сцен со статусом не missing-baseline, то
есть от эталонов, уцелевших на диске. Обход в одну команду: git rm
demo/golden/baselines/*.png — все сцены становятся missing-baseline, порог
обращается в ноль, свидетелей никто не требует, и чужая съёмка всей матрицы
принимается без единого следа причины в манифесте. Отказ
goldenAcceptanceRefusal этого не ловит: он требует объявить каждую новую сцену
в --expect-new, а объявить их все ничто не мешает.

Прежняя редакция объясняла ноль тем, что первичная съёмка свидетелей иметь не
может. Верно по факту и неверно по выводу: невозможность доказать среду не
отменяет требования, она требует сказать это вслух. Теперь и первичная съёмка
идёт через --no-witnesses --reason, а причина уезжает в манифест эталонов.

Размер матрицы стал обязательным параметром, а не выводится из отчёта: у
частичного прогона (run.mjs --only=…) results короче матрицы, и порог просел
бы молча — тот же дефект в другой одежде. Отсутствие параметра — отказ.

Формула не менялась: она общая с docsWitnessFloor и обязана такой остаться.
Менялся источник счётчика. На обычной приёмке ничего не меняется: при 143
эталонах порог был и остался 10.

Issue: #408
User-Visible: no
2026-09-01 17:25:54 +03:00

237 lines
16 KiB
JavaScript
Raw Permalink 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% сцен
* МАТРИЦЫ, что меньше.
*
* Именно матрицы, а не уцелевших на диске эталонов (#408). Прежняя редакция
* считала floor от числа сцен со статусом не `missing-baseline`, и у неё был
* обход в одну команду: `git rm demo/golden/baselines/*.png` — все сцены
* становятся `missing-baseline`, floor обращается в ноль, свидетелей никто не
* требует, и чужая съёмка всей матрицы принимается без единого следа причины.
* Отказ `goldenAcceptanceRefusal` этого не ловит: он требует, чтобы каждая
* новая сцена была объявлена в `--expect-new`, а объявить их все ничто не
* мешает. Прежняя редакция объясняла ноль тем, что первичная съёмка свидетелей
* иметь не может — верно по факту и неверно по выводу: невозможность
* доказательства не отменяет требования, она требует признать это вслух.
*
* Осознанный обход для действительно тотальных перерисовок и для первичной
* съёмки — `--no-witnesses` с обязательной причиной: она уезжает в манифест
* эталонов, то есть в артефакт и его git-историю, а не только в историю shell.
*/
/**
* Порог по размеру МАТРИЦЫ сцен. Формула та же, что у `docsWitnessFloor`
* (комментарий `docs-acceptance.mjs` требует, чтобы они совпадали); менялся
* в #408 только источник счётчика.
*/
export const goldenWitnessFloor = (sceneCount) => (sceneCount > 0
? Math.min(10, Math.ceil(sceneCount * 0.1))
: 0);
export const goldenWitnessRefusal = ({
results, declared = [], declaredNew = [], previousHashes = {},
skipWitnesses = false, skipReason = '', sceneCount,
}) => {
if (skipWitnesses) {
if (typeof skipReason !== 'string' || !skipReason.trim()) {
return { refusal: '--no-witnesses требует --reason="…": причина обхода'
+ ' обязана остаться в отчёте приёмки', witnesses: [], floor: 0 };
}
return { refusal: null, witnesses: [], floor: 0 };
}
// Размер матрицы обязателен и не выводится из отчёта: `results.length` у
// частичного прогона (`run.mjs --only=…`) меньше матрицы, и floor молча
// просел бы — тот же дефект #408 в другой одежде. Отсутствие параметра —
// отказ, а не догадка.
if (!Number.isInteger(sceneCount) || sceneCount < 0) {
return {
refusal: 'приёмка не знает размера матрицы сцен: floor свидетелей считать не от чего.'
+ ' Передайте sceneCount (GOLDEN_SCENARIOS.length) — от числа уцелевших на диске'
+ ' эталонов его считать нельзя, это обходится удалением эталонов (#408)',
witnesses: [],
floor: 0,
};
}
const accepted = new Set([...declared, ...declaredNew].filter(Boolean));
// Свидетелем может быть только сцена с эталоном: сравнивать не с чем. Но
// ПОРОГ от этого не зависит — иначе удаление эталонов снижало бы планку.
const withBaseline = results.filter((result) => result.status !== 'missing-baseline');
const floor = goldenWitnessFloor(sceneCount);
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}`
+ ` (сцен в матрице ${sceneCount}, с эталонами ${withBaseline.length},`
+ ` объявлено ${accepted.size}).`
+ ' Свидетель — необъявленная сцена, совпавшая с эталоном байт-в-байт;'
+ ' именно они доказывают, что среда съёмки та же, что у принятого'
+ ' эталона. Если перерисовка действительно тотальная и осознанная —'
+ ' --no-witnesses --reason="…" оставит причину в манифесте эталонов',
witnesses,
floor,
};
}
return { refusal: null, witnesses, floor };
};