Files
houseplan-card/scripts/capture-environment.mjs
T
Claude 28a4cb5eec 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
2026-09-04 19:26:35 +03:00

121 lines
7.6 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.
/**
* Среда съёмки как проверяемое условие, а не как знание в голове (#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;
};