feat(gates): measure capture drift between runs and anchor reachability (#422)

User-Visible: no
Issue: #422
This commit is contained in:
Codex
2026-09-02 22:10:16 +03:00
parent 8ed8ecc3fd
commit d59ceee905
12 changed files with 365 additions and 62 deletions
+87
View File
@@ -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());
}
+22
View File
@@ -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 };",
}],
},
{
+50 -3
View File
@@ -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,
);