Files
houseplan-card/scripts/docs-acceptance.mjs
T
Matysh 8119c523fa fix: the screenshot witness floor comes from the set, not from survivors
Тот же дефект, что #408 у golden, и в моём же коде из #401. Порог свидетелей
считался от числа кадров, уцелевших на диске: rm docs/images/*.png плюс
объявить все десять через --expect-change — уцелевших ноль, порог ноль,
причины никто не спрашивает, а в манифесте остаётся {"witnesses":0,"floor":0}
без единого слова о произошедшем. Щель была описана в комментарии над самой
функцией и оставлена открытой.

Порог теперь от набора сценариев, свидетели — из тех, с кем есть что
сравнить. Разделение принципиальное: удаление кадров лишает доказательств, но
не должно снижать планку. Первичная съёмка идёт через --no-witnesses
--reason, как теперь и в golden.

Отдельного параметра размера, как в golden, здесь не нужно, и это не
небрежность: `ids` и есть набор — docs-accept.mjs передаёт DOC_SCREENSHOTS, а
verifyDocsCandidate до того отказывает, если набор сцен в кандидате не совпал
с ожидаемым. Пустой ids — отказ, а не ноль.

Попутно Low из #405: повторная приёмка неизменённого набора затирала
acceptance.declared пустым списком. След приёмки отвечает на вопрос «когда
эти пиксели приняли и что тогда объявляли», а обновление отпечатка пикселей
не меняет — значит и стирать ответ не должно. Прежний след сохраняется и
помечается lastWriteWasFingerprintOnly.

Заодно отказ по свидетелям теперь возвращает сами числа: вызывающий печатает
свой вердикт, и сочинять их заново ему не из чего.

Issue: #409
User-Visible: no
2026-09-01 17:54:48 +03:00

156 lines
11 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`), и это намеренно: два набора картинок в одном
* репозитории не должны требовать от человека помнить два разных правила.
*
* Для десяти сцен порог равен одной. Этого достаточно, потому что основную
* работу делает не порог, а требование «все необъявленные совпали»: расхождение
* среды не бывает точечным. Порог закрывает единственную оставшуюся щель —
* попытку объявить изменёнными все кадры разом, когда свидетелей не остаётся
* вовсе и подтвердить среду становится нечем.
*
* Первая редакция (#401) считала порог от числа кадров, УЦЕЛЕВШИХ на диске, и
* той самой щелью и обходилась: `rm docs/images/*.png` + объявить все десять
* через `--expect-change` — уцелевших ноль, порог ноль, причины никто не
* спрашивает, а в манифесте остаётся `{"witnesses":0,"floor":0}` без единого
* слова о том, что произошло. Щель была описана в этом самом комментарии
* строкой выше и оставлена открытой (#409, близнец golden-дефекта #408).
*
* Отсутствие закоммиченных кадров — не смягчающее обстоятельство. Свидетелем
* такая сцена быть не может, это правда; но невозможность доказать среду не
* отменяет требования, а требует сказать это вслух — `--no-witnesses
* --reason="…"`, и причина уезжает в манифест.
*/
export const docsWitnessFloor = (sceneCount) => (sceneCount > 0
? Math.min(10, Math.ceil(sceneCount * 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; либо съёмка велась в другой среде, и тогда её кадры'
+ ' принимать нельзя: растеризация задевает все кадры с текстом сразу' };
}
// Порог — от набора сценариев, свидетели — из тех, с кем есть что сравнить
// (#409). Разделение принципиальное: удаление кадров лишает доказательств, но
// не должно снижать планку.
//
// `ids` здесь и есть набор: `docs-accept.mjs` передаёт DOC_SCREENSHOTS, а
// `verifyDocsCandidate` до этого отказывает, если набор сцен в кандидате не
// совпал с ожидаемым. Отдельный параметр размера (как в golden, где отчёт
// бывает частичным) поэтому не нужен — но пустой `ids` означает, что считать
// не от чего, и это отказ, а не ноль.
if (!ids.length) {
return { ...empty, refusal: 'набор сценариев пуст: порог свидетелей считать не от чего' };
}
const withCommitted = ids.filter((id) => committed[id]);
const floor = docsWitnessFloor(ids.length);
const witnesses = withCommitted.filter((id) => !announced.has(id)
&& committed[id] === candidate[id]);
if (!skipWitnesses && witnesses.length < floor) {
// Числа возвращаются и при отказе: вызывающий печатает свой вердикт, и
// сочинять их заново ему не из чего.
return { ...empty, witnesses, floor, refusal: 'кадров-свидетелей недостаточно:'
+ ` ${witnesses.length} из необходимых ${floor}`
+ ` (сцен в наборе ${ids.length}, с закоммиченным кадром ${withCommitted.length}).`
+ ' Свидетель — необъявленный кадр, совпавший с закоммиченным байт-в-байт;'
+ ' только он доказывает, что среда съёмки та же. Если перерисовка'
+ ' действительно тотальная и осознанная — --no-witnesses --reason="…"'
+ ' оставит причину в манифесте' };
}
const replace = ids.filter((id) => announced.has(id));
return {
refusal: null,
replace,
keep: ids.filter((id) => !announced.has(id)),
witnesses,
floor,
};
}