mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-28 19:01:34 +00:00
Аудит 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
186 lines
11 KiB
JavaScript
186 lines
11 KiB
JavaScript
/**
|
||
* Среда съёмки как проверяемое условие, а не как знание в голове (#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;
|
||
};
|