Files
houseplan-card/scripts/docs-acceptance.mjs
T
Matysh 5c8cb58e1f ci: prove the screenshot environment instead of trusting the place
Правило приёмки скриншотов было про место: снимать только в CI. Обоснование
измерено — съёмка в другом окружении переписывает файлы без содержательных
изменений, в #231 два из девяти на 7–8 байт, набор с беты все девять. Но
держалось правило на комментарии, а не на механизме: кандидат проверялся на
самосогласованность и принимался целиком, ни разу не сравниваясь с тем, что
лежит в репозитории.

Цена видна на #390: правка типов, которая физически не может сдвинуть
пиксель, потребовала прогона workflow, а затем правки одиннадцати полей
манифеста руками.

Теперь правило про доказательство, и оно то же, что у golden с #334: среда
доказана, если каждый кадр, который менять не собирались, совпал с
закоммиченным байт-в-байт. Расхождение растеризации спрятать нельзя — оно
задевает все кадры с текстом сразу. Снимать можно где угодно, включая WSL;
принять получится только оттуда, где кадры воспроизводятся, и перестанет
получаться в тот день, когда обновятся шрифты.

Остальное следует из того же правила: намерение объявляется
--expect-change, необъявленное расхождение останавливает приёмку,
объявленное без расхождения — тоже (ложная декларация обесценивает список),
заменяются ровно объявленные файлы, а тотальная перерисовка требует
--no-witnesses --reason, и причина уезжает в манифест.

Частый случай закрылся сам: ничего не объявлено, все кадры совпали —
принимается один манифест, руками ничего писать не надо.

Проверено шестью сквозными прогонами на подделанном артефакте, не только
юнитами: идентичный кандидат, необъявленное расхождение, объявленное,
молчаливая декларация, тотальная перерисовка без причины и с ней.

Issue: #401
User-Visible: no
2026-08-31 09:40:52 +03:00

129 lines
8.5 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.
/**
* Правило допустимости съёмки скриншотов документации (issue #401).
*
* До этого правило было про МЕСТО: снимать только в CI, потому что растеризация
* шрифтов на другой машине отличается. Обоснование измерено — пересъёмка в #231
* изменила два файла из девяти на 7–8 байт, а набор с беты все девять. Но
* держалось оно на комментарии в шапке `docs-accept.mjs`, а не на механизме:
* кандидат проверялся на самосогласованность и принимался целиком, без единого
* сравнения с тем, что лежит в репозитории.
*
* Цена такого правила видна на #390. Правка типов, которая физически не может
* сдвинуть пиксель, потребовала прогона workflow, а затем правки одиннадцати
* полей манифеста руками. Гейт, заставляющий писать хеши руками, работает
* против себя.
*
* Здесь правило заменено на проверяемое, и оно ровно то же, что у golden с
* #334: среда съёмки доказана, если КАЖДЫЙ кадр, который менять не собирались,
* совпал с закоммиченным БАЙТ-В-БАЙТ. Расхождение растеризации спрятать нельзя
* — оно задевает все кадры с текстом, а не только правленные. Поэтому автор
* объявляет намерение списком `--expect-change`, и всё, что разошлось помимо
* списка, приёмку запрещает: либо это незамеченная регрессия рендера, либо
* среда не та.
*
* То же правило ловит второе, независимо от среды: «принять всё, чтобы гейт
* позеленел». Так скриншот документации перестаёт показывать продукт — молча,
* одной командой, без единого названного намерения.
*
* Отличие от golden только в строгости, и оно в пользу скриншотов: сцен десять,
* а не сто сорок три, и сравнение точное, без порога. Поэтому «совпал» здесь
* значит буквально совпал, а не «в пределах допуска».
*/
/**
* Порог свидетелей. Формула та же, что у golden (`goldenWitnessFloor`), и это
* намеренно: два набора картинок в одном репозитории не должны требовать от
* человека помнить два разных правила.
*
* Для десяти сцен порог равен одной. Этого достаточно, потому что основную
* работу делает не порог, а требование «все необъявленные совпали»: расхождение
* среды не бывает точечным. Порог закрывает единственную оставшуюся щель —
* попытку объявить изменёнными все кадры разом, когда свидетелей не остаётся
* вовсе и подтвердить среду становится нечем.
*/
export const docsWitnessFloor = (committedCount) => (committedCount > 0
? Math.min(10, Math.ceil(committedCount * 0.1))
: 0);
/** Объявленные имена, которых нет в наборе сценариев. */
export const docsUnknownDeclarations = (ids, declared = []) => declared
.filter((id) => id && !ids.includes(id))
.sort();
/**
* Объявленные кадры, которые в действительности не изменились.
*
* Это не придирка к аккуратности. Декларация — утверждение «я знаю, почему этот
* кадр другой»; если он не другой, утверждение ложное, и вместе с ним теряет
* смысл весь список. На практике так выглядит усталость: автор перечисляет
* «всё, что покраснело», захватывая заодно то, что не краснело.
*/
export const docsSilentDeclarations = ({ committed = {}, candidate = {}, declared = [] }) =>
declared
.filter((id) => committed[id] && candidate[id] && committed[id] === candidate[id])
.sort();
/**
* План приёмки и причина отказа.
*
* @param ids все сценарии набора, в стабильном порядке
* @param committed id → sha256 закоммиченного файла (отсутствует — значит нет файла)
* @param candidate id → sha256 файла в артефакте
* @param declared что автор объявил изменённым
* @returns {{ refusal: string|null, replace: string[], keep: string[],
* witnesses: string[], floor: number }}
*/
export function docsAcceptancePlan({
ids = [], committed = {}, candidate = {}, declared = [],
skipWitnesses = false, skipReason = '',
}) {
const empty = { replace: [], keep: [...ids], witnesses: [], floor: 0 };
if (skipWitnesses && (typeof skipReason !== 'string' || !skipReason.trim())) {
return { ...empty, refusal: '--no-witnesses требует --reason="…": причина обхода обязана'
+ ' остаться в манифесте, а не только в истории shell' };
}
const unknown = docsUnknownDeclarations(ids, declared);
if (unknown.length) {
return { ...empty, refusal: `объявлены кадры, которых нет в наборе: ${unknown.join(', ')}` };
}
const silent = docsSilentDeclarations({ committed, candidate, declared });
if (silent.length) {
return { ...empty, refusal: `объявлены изменёнными, но не изменились: ${silent.join(', ')}.`
+ ' Декларация — утверждение «я знаю, почему этот кадр другой»; на неизменившемся'
+ ' кадре оно ложное и обесценивает весь список' };
}
const announced = new Set(declared.filter(Boolean));
const strayed = ids.filter((id) => !announced.has(id) && committed[id] !== candidate[id]);
if (strayed.length) {
return { ...empty, refusal: `разошлись, но не объявлены: ${strayed.join(', ')}.`
+ ' Либо это незамеченное изменение продукта — тогда объявите его'
+ ' --expect-change; либо съёмка велась в другой среде, и тогда её кадры'
+ ' принимать нельзя: растеризация задевает все кадры с текстом сразу' };
}
const withCommitted = ids.filter((id) => committed[id]);
const floor = docsWitnessFloor(withCommitted.length);
const witnesses = withCommitted.filter((id) => !announced.has(id)
&& committed[id] === candidate[id]);
if (!skipWitnesses && witnesses.length < floor) {
return { ...empty, refusal: 'кадров-свидетелей недостаточно:'
+ ` ${witnesses.length} из необходимых ${floor}.`
+ ' Свидетель — необъявленный кадр, совпавший с закоммиченным байт-в-байт;'
+ ' только он доказывает, что среда съёмки та же. Если перерисовка'
+ ' действительно тотальная и осознанная — --no-witnesses --reason="…"'
+ ' оставит причину в манифесте' };
}
const replace = ids.filter((id) => announced.has(id));
return {
refusal: null,
replace,
keep: ids.filter((id) => !announced.has(id)),
witnesses,
floor,
};
}