Files
houseplan-card/scripts/capture-environment.mjs
T
Codex 3f95dac451 fix: индекс эталонов хранит платформу съёмки, а не платформу приёмки (#571)
Аудит 14.09, §4 и §9. `golden-report.json` не нёс платформу вовсе, поэтому
приёмщик вызывал `captureEnvironment()` у себя и записывал СВОЮ платформу как
платформу кадров. На `ad4000f9` это дало `"platform": "win32"` у кадров,
снятых Linux-прогоном 34853080375, а причина осознанного обхода осталась в
stdout и в индекс не попала — хотя `AGENTS.md` обещает след в обоих местах.
Сами PNG были и остаются целы: 101 свидетель совпал байт-в-байт. Врал
провенанс.

Что сделано:

- отчёт съёмки получил раздел `capture`: платформа, архитектура, сборка
  Chromium, отпечаток материала и — в CI — прогон с попыткой и SHA. Схема
  отчёта поднята до 2;
- приёмка читает среду съёмки из отчёта. Гейт чужой среды теперь судит обе
  стороны: съёмку (из отчёта) и приёмку (свою). Отказ — до единой записи;
- индекс эталонов поднят до схемы 2 и различает `capturedOn` и `acceptedOn`,
  несёт раздел `capture` и причину осознанного обхода в `foreignCapture`;
- отчёт схемы 1 платформы съёмки не несёт физически: это отдельная явная
  ветка, `capturedOn` уезжает `null`. Выдумывать платформу нельзя — ровно этим
  задача и вызвана;
- `--baselines=<dir>` у приёмки: без него проверить «отказ произошёл ДО
  записи» можно было бы только порчей рабочего дерева, то есть никак (#556).

Осознанно отменено решение #455 «не трогать run.mjs»: ради ГЕЙТА цена
фингерпринта не окупалась, ради ПРОВЕНАНСА окупилась — платформу кадров знает
только тот, кто их снял. Плата разовая: пересобран бандл, индекс скриншотов
документации переснят отдельным коммитом. Тест #455 переписан под новый
инвариант, а не удалён.

Свидетели: `test/golden-capture-provenance.test.mjs` — семь проверок, все пять
сценариев приёмки из issue, включая «отказ до записи» и «подмена PNG и неполный
артефакт по-прежнему fail-closed». Мутанты `golden-index-invents-capture-platform`
(возвращает платформу приёмщика) и `golden-report-provenance-optional`
(разрешает отчёт схемы 2 без провенанса) прогнаны лично: оба краснеют.

npm test 2762/2761/0 fail, typecheck чистый.

Issue: #571
User-Visible: no
2026-09-17 23:19:56 +03:00

186 lines
11 KiB
JavaScript
Raw Permalink 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 || ''),
});
/** Версия схемы отчёта съёмки, в которой провенанс обязателен (#571). */
export const CAPTURE_PROVENANCE_SCHEMA = 2;
/**
* Провенанс СЪЁМКИ для отчёта (#571).
*
* До этой задачи отчёт платформу не нёс вовсе, и приёмщик записывал в индекс
* эталонов СВОЮ платформу как платформу кадров. На `ad4000f9` это дало
* `"platform": "win32"` у кадров, снятых Linux-прогоном 34853080375: индекс
* утверждал неправду, а причина осознанного обхода осталась только в stdout.
*
* Поэтому провенанс собирается там, где кадры снимаются, и уезжает в отчёт:
* платформа, архитектура, сборка Chromium, отпечаток материала и — если съёмка
* шла в CI — прогон с попыткой. Последнее не косметика: артефакт можно скачать
* и принять спустя сутки, и ссылка на прогон единственная, что связывает
* картинки с их происхождением.
*/
export const captureProvenance = ({
chromium = null, buildFingerprint = null, source = process, env = process.env,
} = {}) => {
const { platform, arch } = captureEnvironment(source);
const run = String(env?.GITHUB_RUN_ID ?? '').trim();
const attempt = String(env?.GITHUB_RUN_ATTEMPT ?? '').trim();
const repository = String(env?.GITHUB_REPOSITORY ?? '').trim();
const sha = String(env?.GITHUB_SHA ?? '').trim();
return {
platform,
arch,
chromium: chromium || null,
buildFingerprint: buildFingerprint || null,
// Пустой объект вместо `null` был бы ложью «CI известен, полей нет».
ci: run ? {
repository: repository || null,
run: Number(run) || null,
attempt: Number(attempt) || 1,
sha: sha || null,
} : null,
};
};
/**
* Провенанс отчёта в пригодном для решения виде: `{ provenance, legacy }`.
*
* `legacy: true` — отчёт старой схемы, платформы съёмки в нём нет физически.
* Такой отчёт не отвергается (артефакты живут дольше схемы), но и не выдаёт
* себя за проверенный: платформа съёмки остаётся `null`, и вызывающий обязан
* решить это явной веткой, а не молча подставить свою.
*/
export const reportCaptureProvenance = (report = {}) => {
const schema = Number(report?.schema) || 1;
const provenance = report?.capture;
if (schema >= CAPTURE_PROVENANCE_SCHEMA) {
// Fail-closed: схема обещает провенанс, значит его отсутствие — поломка
// инструмента съёмки, а не повод угадывать.
if (!provenance || typeof provenance !== 'object') {
throw new Error(`отчёт схемы ${schema} обязан нести раздел capture с провенансом съёмки (#571)`);
}
if (!provenance.platform) {
throw new Error(`отчёт схемы ${schema} не называет платформу съёмки (#571)`);
}
return { provenance, legacy: false };
}
return { provenance: null, legacy: true };
};
/**
* Разрешён ли осознанный обход. Возвращает причину или `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;
};