mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-02 04:38:55 +00:00
feat(gates): measure capture drift between runs and anchor reachability (#422)
User-Visible: no Issue: #422
This commit is contained in:
@@ -0,0 +1,87 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Гейт воспроизводимости съёмки документации между прогонами (#422).
|
||||
*
|
||||
* Зачем отдельный гейт, если есть `--stability`. Тот делает N снимков в одном
|
||||
* процессе и одном состоянии страницы — то есть отвечает на вопрос «плавает ли
|
||||
* кадр от времени внутри страницы». Дефект #410 был по другой оси: обрезка
|
||||
* плавала МЕЖДУ прогонами, и три снимка внутри одного процесса совпали бы
|
||||
* всегда. Проверка, объявленная гарантией воспроизводимости, на собственном
|
||||
* инциденте промолчала бы; этот гейт закрывает вторую ось.
|
||||
*
|
||||
* Обе проверки остаются: одна другую не заменяет.
|
||||
*
|
||||
* Приём простой до скуки: снять набор дважды в разных процессах и сравнить
|
||||
* хеши. Ничего не сохраняется между прогонами, кроме самих хешей, поэтому гейт
|
||||
* не зависит от того, что лежало в `docs/images` до него.
|
||||
*/
|
||||
|
||||
import { createHash } from 'node:crypto';
|
||||
import { readdirSync, readFileSync } from 'node:fs';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
|
||||
const IMAGES = resolve(ROOT, 'docs/images');
|
||||
const CAPTURE = resolve(ROOT, 'demo/docs/capture.mjs');
|
||||
|
||||
/** Хеши всех кадров набора: имя → sha256. */
|
||||
export function frameHashes(directory, read = readFileSync, list = readdirSync) {
|
||||
const hashes = {};
|
||||
for (const name of list(directory).sort()) {
|
||||
if (!name.endsWith('.png')) continue;
|
||||
hashes[name] = createHash('sha256').update(read(resolve(directory, name))).digest('hex');
|
||||
}
|
||||
return hashes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Расхождения между двумя прогонами.
|
||||
*
|
||||
* Пропавший и появившийся кадр — тоже расхождение: набор обязан быть одним и
|
||||
* тем же, иначе сравнивать нечего и «совпало» ничего не значит.
|
||||
*/
|
||||
export function driftBetweenRuns(first, second) {
|
||||
const names = [...new Set([...Object.keys(first), ...Object.keys(second)])].sort();
|
||||
return names
|
||||
.filter((name) => first[name] !== second[name])
|
||||
.map((name) => ({
|
||||
name,
|
||||
first: first[name] || '(нет кадра)',
|
||||
second: second[name] || '(нет кадра)',
|
||||
}));
|
||||
}
|
||||
|
||||
function capture(label) {
|
||||
const run = spawnSync(process.execPath, [CAPTURE], { cwd: ROOT, stdio: 'inherit' });
|
||||
if (run.status !== 0) {
|
||||
console.error(`::error::съёмка (${label}) завершилась с кодом ${run.status}`);
|
||||
process.exit(run.status || 1);
|
||||
}
|
||||
}
|
||||
|
||||
function main() {
|
||||
capture('прогон 1');
|
||||
const first = frameHashes(IMAGES);
|
||||
capture('прогон 2');
|
||||
const second = frameHashes(IMAGES);
|
||||
|
||||
const drift = driftBetweenRuns(first, second);
|
||||
if (!drift.length) {
|
||||
console.log(`съёмка воспроизводима: ${Object.keys(first).length} кадров совпали между прогонами`);
|
||||
return 0;
|
||||
}
|
||||
console.error('::error::съёмка недетерминирована между прогонами —'
|
||||
+ ` разошлись кадры: ${drift.map((item) => item.name).join(', ')}`);
|
||||
for (const item of drift) {
|
||||
console.error(` ${item.name}: ${item.first.slice(0, 16)} ≠ ${item.second.slice(0, 16)}`);
|
||||
}
|
||||
console.error('Кадр, который зависит от прогона, делает приёмку скриншотов бессмысленной:'
|
||||
+ ' «изменилось десять кадров» перестаёт что-либо означать.');
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
||||
process.exit(main());
|
||||
}
|
||||
@@ -954,6 +954,28 @@ const MUTANT_DEFINITIONS = [
|
||||
file: 'demo/docs/browser-args.mjs',
|
||||
find: " '--run-all-compositor-stages-before-draw',\n",
|
||||
replace: '',
|
||||
id: 'capture-drifts-between-runs',
|
||||
guard: 'node --test test/capture-clip.test.mjs',
|
||||
because: 'a crop that rounds to the nearest pixel shaves half a pixel off the '
|
||||
+ 'target, and the frame then depends on sub-pixel layout — the very drift '
|
||||
+ 'that made #410 undiagnosable; the in-process stability probe stays green '
|
||||
+ 'on it, so the whole-pixel rule needs a check of its own (#422)',
|
||||
patches: [{
|
||||
file: 'demo/docs/clip.mjs',
|
||||
find: ' const x = Math.floor(rect.x);',
|
||||
replace: ' const x = Math.round(rect.x);',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'anchor-liveness-ignores-reachability',
|
||||
guard: 'node --test test/review-doc-guard.test.mjs',
|
||||
because: 'asking whether the object merely exists locally lets an anchor born on '
|
||||
+ 'the author machine soften the #413 refusal exactly where the mistake was '
|
||||
+ 'made — liveness has to mean reachable from origin (#422)',
|
||||
patches: [{
|
||||
file: 'scripts/review-doc-guard.mjs',
|
||||
find: " const probe = run(['log', '--remotes=origin', '--tags', '--format=%T']);",
|
||||
replace: " const probe = { status: 0, stdout: object };",
|
||||
}],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -205,6 +205,53 @@ export const ANCHOR_MARKER = '<!-- material-anchors: сгенерировано
|
||||
* ручного переписывания SHA здесь уже подвела, и заменять её другой ручной
|
||||
* дисциплиной смысла нет.
|
||||
*/
|
||||
/**
|
||||
* Живость якоря материала — это ДОСТИЖИМОСТЬ, а не наличие объекта (#422).
|
||||
*
|
||||
* Первая версия спрашивала `git cat-file -e`, то есть «лежит ли объект в
|
||||
* локальной базе». Объект, созданный на машине автора и никуда не привязанный,
|
||||
* этой проверке удовлетворяет — и смягчает жёсткий отказ #413 именно там, где
|
||||
* ошибку и совершили. Проверено исполнением: `git hash-object -w` даёт объект,
|
||||
* который `cat-file -e` признаёт, а `git log --all --find-object` не находит.
|
||||
*
|
||||
* Здесь спрашивается то же, что документ печатает читателю в машинном блоке
|
||||
* (`materialAnchorBlock`), но в области `refs/remotes/origin` и тегов: у
|
||||
* ревьюера локальных веток автора нет, и якорь, живой только благодаря им, —
|
||||
* это обещание, которое не выполнится.
|
||||
*
|
||||
* `run` вынесен параметром ради тестов: подставная проба позволяет проверить и
|
||||
* выбор команды по типу объекта, и поведение на неизвестном типе, не заводя
|
||||
* настоящий репозиторий.
|
||||
*/
|
||||
export function anchorLiveness(object, run) {
|
||||
const type = run(['cat-file', '-t', object]);
|
||||
if (type.status !== 0) return false;
|
||||
const kind = String(type.stdout || '').trim();
|
||||
if (kind === 'tree') {
|
||||
// Дерево адресуется содержимым и не находится через --find-object:
|
||||
// его ищут перебором %T по достижимым коммитам.
|
||||
const probe = run(['log', '--remotes=origin', '--tags', '--format=%T']);
|
||||
if (probe.status !== 0) return false;
|
||||
return String(probe.stdout || '').split('\n').some((line) => line.trim() === object);
|
||||
}
|
||||
if (kind === 'blob') {
|
||||
const probe = run([
|
||||
'log', '--remotes=origin', '--tags', `--find-object=${object}`, '--max-count=1', '--format=%H',
|
||||
]);
|
||||
return probe.status === 0 && String(probe.stdout || '').trim() !== '';
|
||||
}
|
||||
if (kind === 'commit') {
|
||||
const probe = run([
|
||||
'for-each-ref', '--contains', object, '--count=1', '--format=%(refname)',
|
||||
'refs/remotes/origin', 'refs/tags',
|
||||
]);
|
||||
return probe.status === 0 && String(probe.stdout || '').trim() !== '';
|
||||
}
|
||||
// Тег-объект и всё неизвестное живым не считается: якорь должен быть тем, что
|
||||
// печатает конвейер, а не чем угодно похожим на хеш.
|
||||
return false;
|
||||
}
|
||||
|
||||
export function materialAnchorBlock({ sha, tree, branch, specs = [] } = {}) {
|
||||
const short = (value) => (typeof value === 'string' ? value.slice(0, 12) : '');
|
||||
const lines = [
|
||||
@@ -307,9 +354,9 @@ if (invokedDirectly) {
|
||||
}
|
||||
return map;
|
||||
};
|
||||
const resolveObjects = (object) => spawnSync('git', ['cat-file', '-e', object], {
|
||||
encoding: 'utf8',
|
||||
}).status === 0;
|
||||
const resolveObjects = (object) => anchorLiveness(object, (args) => spawnSync('git', args, {
|
||||
encoding: 'utf8', maxBuffer: 64 * 1024 * 1024,
|
||||
}));
|
||||
const verdict = danglingMaterialRefusal(
|
||||
text, resolveReachable, REVIEW_HEADER_LINES, resolveObjects,
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user