fix(gates): съёмка кадров в чужой среде отказывается заранее

Вопрос владельца: зачем агенты снимают PNG на Windows, если кадры мы всё
равно не принимаем, тем более что WSL есть на обеих машинах. Ответ по
коду: этому ничто не мешало. Ни один из шести скриптов съёмки и приёмки
не знал, на какой он ОС — ни `process.platform`, ни win32, ни WSL не
упоминались нигде. Съёмка отрабатывала штатно, а стена появлялась на
приёмке, и текст стены говорил «сцен-свидетелей 0 из 10», то есть
подсказывал неверный вывод «надо объявить больше сцен» — от которого до
`--expect-change` на всю матрицу одна команда.

Причина запрета не политика, а физика: Windows растеризует текст через
DirectWrite, с другим субпиксельным сглаживанием и DPI, поэтому
байтового совпадения с принятым эталоном не даёт никогда, свидетелей
среды быть не может, и приёмка откажет всё равно. Флаги детерминизма из
#410 убирают разброс внутри среды, а не между ОС.

Что сделано:

- `golden:capture` отказывается до запуска браузера, в тексте отказа
  готовая команда для WSL;
- `golden:verify` остаётся законным в любой среде: он ничего не
  принимает, а как грубая проверка полезен;
- обе приёмки (golden и документации) отказываются в чужой среде;
- к отказу свидетелей приписывается фраза про расхождение среды — та
  самая, которой не хватало, чтобы отказ не читался как «объяви больше
  сцен»;
- платформа уезжает в манифесты рядом с версией Chromium: у кадров
  появился провенанс среды;
- осознанный обход есть и требует причину: HP_ALLOW_FOREIGN_CAPTURE.

Главный урок задачи — про цену правки файла, у которого записан хеш.
Первая редакция встроила проверку в `demo/docs/capture.mjs`, и гейт
документации сразу покраснел: его sha записан в индексе скриншотов, и
`scripts/check-docs.mjs` их сверяет. То есть проверка, которая ничего не
рисует, стоила бы пересъёмки всех картинок документации и визуальной
приёмки владельца. Поэтому для документации отказ живёт шагом раньше —
`npm run docs:capture` вызывает `scripts/assert-capture-env.mjs` — и
шагом позже, на приёмке. По той же причине гейт golden стоит в
`demo/golden/policy.mjs`, а не в `run.mjs`: последний входит в корпус
sourceFingerprint. Тест закрепляет обе границы: гейт обязан быть в
policy.mjs и в npm-скрипте и обязан отсутствовать в двух
фингерпринтуемых файлах.

Платформа в юнитах — параметр, а не `process.platform`: иначе тест был
бы зелёным на Linux и красным на машине владельца, то есть тестом про
хост, а не про правило.

AGENTS.md приведён к состоянию после #401 (принимается любая среда,
доказавшая себя байтовым совпадением непринятых кадров) и разводит
проверку и съёмку — прежний текст сливал их в «advisory» и утверждал
«accepted only on a complete Linux CI artefact».

Свидетели, все проверены отрицательным прогоном: снятый гейт съёмки,
гейт, отказывающий и на verify, обход без причины, отказ без команды,
убранная приписка про среду, отцепленные гейты обеих приёмок,
переставшая бросать обёртка, npm-скрипт без проверки и возврат гейта в
каждый из двух фингерпринтуемых файлов. Проверка подключения сначала
смотрела только на импорт модуля и молча проходила, когда отказ
заменяли на `void` — теперь она проверяет вызов бросающей обёртки.

Гейты: npm test 1918 tests, 1917 pass, 0 fail; typecheck зелёный;
check-docs зелёный (индекс скриншотов не задет); pytest без HA 378
passed, 3 skipped.

Issue: #455
User-Visible: no
This commit is contained in:
Claude
2026-09-04 19:26:35 +03:00
parent 9e56842bc1
commit 28a4cb5eec
10 changed files with 397 additions and 6 deletions
+31
View File
@@ -0,0 +1,31 @@
#!/usr/bin/env node
/**
* Отказ до съёмки — для тех точек, где гейт нельзя встроить в сам скрипт (#455).
*
* `demo/docs/capture.mjs` править нельзя дёшево: его sha записан в индексе
* скриншотов документации, и `scripts/check-docs.mjs` сверяет их. Любая правка
* объявляет закоммиченный индекс устаревшим — то есть стоит пересъёмки всех
* картинок и визуальной приёмки владельца за проверку, которая ничего не
* рисует. Поэтому проверка живёт шагом раньше, в npm-скрипте:
*
* "docs:capture": "node scripts/assert-capture-env.mjs docs && npm run build && node demo/docs/capture.mjs"
*
* У golden такой проблемы нет: там гейт стоит в `demo/golden/policy.mjs`,
* который исключён из корпуса отпечатка и уже вызывается из `run.mjs`.
*
* node scripts/assert-capture-env.mjs <golden|docs> [--stage=capture|accept]
*/
import { assertCaptureEnvironment } from './capture-environment.mjs';
const [kindArg] = process.argv.slice(2).filter((arg) => !arg.startsWith('--'));
const stageArg = process.argv.find((arg) => arg.startsWith('--stage='));
const kind = kindArg === 'docs' ? 'docs' : 'golden';
const stage = stageArg?.slice('--stage='.length) === 'accept' ? 'accept' : 'capture';
try {
const allowance = assertCaptureEnvironment({ kind, stage });
if (allowance) console.log(`Чужая среда разрешена осознанно: ${allowance}`);
} catch (error) {
console.error(error.message);
process.exit(1);
}
+120
View File
@@ -0,0 +1,120 @@
/**
* Среда съёмки как проверяемое условие, а не как знание в голове (#455).
*
* Правило после #401: принимается любая среда, доказавшая себя байтовым
* совпадением непринятых кадров. Windows такого совпадения не даёт и не может:
* другая растеризация шрифтов (DirectWrite против FreeType), субпиксельное
* сглаживание, DPI и другая сборка Chromium. Флаги детерминизма из #410
* (`--force-color-profile=srgb`, `--font-render-hinting=none`,
* `--disable-lcd-text`, целочисленный клип) убирают разброс ВНУТРИ среды, а не
* между операционными системами.
*
* До этой задачи знание «на Windows снимать бесполезно» жило только в головах и
* в документах ревью. Съёмка отрабатывала штатно, а стена появлялась на
* приёмке — и текст той стены говорил про число сцен-свидетелей, то есть
* подсказывал неверный вывод «надо объявить больше сцен». Отсюда две вещи:
* отказ переносится на самое начало, а текст называет причину.
*
* Проверка съёмки — не то же, что проверка приёмки: `golden:verify` на Windows
* законен и полезен как грубая проверка «не сломал ли рендер вообще». Запрещена
* только съёмка с расчётом на приёмку.
*
* Где гейт НЕ стоит и почему. В `demo/docs/capture.mjs` — ни строки: его sha
* записан в манифест скриншотов документации (`captureScriptSha256`), и
* `scripts/check-docs.mjs` сверяет его с файлом. Любая правка объявляет
* закоммиченный индекс устаревшим, то есть стоит пересъёмки десяти картинок и
* визуальной приёмки владельца — за проверку, которая ничего не рисует.
* Проверено на себе: первая редакция этой задачи правку сделала, и гейт
* документации сразу покраснел. Поэтому для документации отказ живёт на шаг
* раньше (`scripts/assert-capture-env.mjs` в npm-скрипте `docs:capture`) и на
* шаг позже (приёмка). То же и для golden: гейт в `policy.mjs`, а не в
* `run.mjs`, который входит в корпус `sourceFingerprint`.
*/
/** Канон среды съёмки: Linux CI и WSL. */
export const CAPTURE_CANON_PLATFORM = 'linux';
/** Переменная осознанного обхода. Пустая причина обходом не считается. */
export const ALLOW_FOREIGN_ENV = 'HP_ALLOW_FOREIGN_CAPTURE';
const WSL_COMMAND = {
golden: 'wsl -d Ubuntu → cd ~/houseplan-card && npm run build && npm run golden:capture',
docs: 'wsl -d Ubuntu → cd ~/houseplan-card && npm run build && node demo/docs/capture.mjs',
};
/** Провенанс среды: то, что уезжает в манифест рядом с версией Chromium. */
export const captureEnvironment = (source = process) => ({
platform: String(source.platform || ''),
arch: String(source.arch || ''),
});
/**
* Разрешён ли осознанный обход. Возвращает причину или `null`.
* Пустая строка — не причина: обход без записанной причины неотличим от
* забытой переменной в окружении.
*/
export const foreignCaptureAllowance = (env = process.env) => {
const reason = String(env?.[ALLOW_FOREIGN_ENV] ?? '').trim();
return reason || null;
};
/**
* Отказ съёмки или приёмки в чужой среде.
*
* `kind`: `golden` | `docs` — от него зависит только команда в подсказке.
* `stage`: `capture` | `accept` — от него зависит формулировка.
* Возвращает `{ refusal, allowance }`: `refusal` — текст или `null`.
*/
export const foreignCaptureRefusal = ({
platform,
kind = 'golden',
stage = 'capture',
canon = CAPTURE_CANON_PLATFORM,
allowance = null,
} = {}) => {
if (platform === canon) return { refusal: null, allowance: null };
if (allowance) return { refusal: null, allowance };
const what = kind === 'docs' ? 'скриншоты документации' : 'эталоны golden';
const action = stage === 'accept'
? `приёмка отказана: ${what} сняты на платформе «${platform}»`
: `съёмка отказана: ${what} на платформе «${platform}» принять будет нечем`;
return {
refusal: `${action}. Байтового совпадения с принятыми кадрами Windows не даёт`
+ ' (растеризация шрифтов, субпиксельное сглаживание, DPI, другая сборка Chromium),'
+ ` поэтому сцен-свидетелей среды будет ноль и приёмка откажет. Снимайте в WSL:\n`
+ ` ${WSL_COMMAND[kind] || WSL_COMMAND.golden}\n`
+ `Диагностика на Windows законна: golden:verify показывает расхождения и ничего не принимает.`
+ ` Если чужая среда осознанна — ${ALLOW_FOREIGN_ENV}="причина" оставит её в выводе и в манифесте.`,
allowance: null,
};
};
/**
* Приписка к отказу приёмки, когда среда кадров и эталонов разошлась.
*
* Именно этой фразы не хватало: без неё отказ «свидетелей 0 из 10» читается
* как «объяви больше сцен», и обход в одну команду выглядит решением.
*/
export const environmentNote = ({ capturedOn, acceptedOn } = {}) => {
if (!capturedOn || !acceptedOn || capturedOn === acceptedOn) return null;
return `среда съёмки (${capturedOn}) не совпадает со средой принятых эталонов`
+ ` (${acceptedOn}): свидетелей и не могло быть — дело не в числе объявленных сцен`;
};
/**
* Бросающая обёртка для точек, где отказ обязан остановить работу.
*
* Возвращает разрешённую причину обхода (или `null`) — вызывающий печатает её
* сам, чтобы след остался в выводе прогона.
*/
export const assertCaptureEnvironment = ({
kind = 'golden', stage = 'capture', platform, allowance,
} = {}) => {
const resolvedPlatform = platform ?? captureEnvironment().platform;
const resolvedAllowance = allowance ?? foreignCaptureAllowance();
const { refusal, allowance: accepted } = foreignCaptureRefusal({
platform: resolvedPlatform, kind, stage, allowance: resolvedAllowance,
});
if (refusal) throw new Error(refusal);
return accepted;
};
+13
View File
@@ -25,6 +25,7 @@
*/
import { createHash } from 'node:crypto';
import { copyFileSync, existsSync, readFileSync, writeFileSync } from 'node:fs';
import { assertCaptureEnvironment, captureEnvironment } from './capture-environment.mjs';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
@@ -94,9 +95,13 @@ export function verifyDocsCandidate({
/** Build the manifest written by the CLI without erasing an earlier review trace. */
export function acceptedDocsManifest({
manifest, previousAcceptance, decision, skipWitnesses = false, skipReason = '',
platform = null,
}) {
return {
...manifest,
// Провенанс среды приёмки (#455): рядом с версией браузера, чтобы у
// следующего разбора «почему кадры разошлись» была не только догадка.
...(platform ? { acceptedOn: platform } : {}),
acceptance: decision.replace.length
? {
declared: [...decision.replace],
@@ -114,6 +119,13 @@ const list = (argv, name) => argv
.filter(Boolean);
function main(argv) {
// Среда приёмки (#455). Платформу съёмки манифест не несёт и не может:
// добавить поле — значит править `demo/docs/capture.mjs`, чей sha записан в
// индексе скриншотов, то есть платить пересъёмкой десяти картинок за
// проверку. Приёмка идёт там, где лежат артефакты, поэтому её платформа —
// достаточный признак среды кадров.
const allowed = assertCaptureEnvironment({ kind: 'docs', stage: 'accept' });
if (allowed) console.log(`Чужая среда приёмки разрешена осознанно: ${allowed}`);
if (!argv.includes('--reviewed')) {
console.error('отказ: замена скриншотов без явного --reviewed');
return 2;
@@ -166,6 +178,7 @@ function main(argv) {
manifest,
previousAcceptance: previous,
decision,
platform: captureEnvironment().platform,
skipWitnesses,
skipReason,
});