mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-30 11:49:16 +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
202 lines
12 KiB
JavaScript
202 lines
12 KiB
JavaScript
#!/usr/bin/env node
|
||
import { createHash } from 'node:crypto';
|
||
import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||
import { dirname, resolve } from 'node:path';
|
||
import { fileURLToPath } from 'node:url';
|
||
import { sourceFingerprint } from '../../scripts/source-fingerprint.mjs';
|
||
import { GOLDEN_MATRIX_VERSION, GOLDEN_SCENARIOS } from './matrix.mjs';
|
||
import { GOLDEN_BASELINE_MANIFEST, GOLDEN_INDEX_SCHEMA, indexCapturedOn } from './policy.mjs';
|
||
import {
|
||
goldenAcceptancePlan, goldenAcceptanceRefusal, goldenSilentDeclarations,
|
||
goldenWitnessRefusal,
|
||
} from '../../scripts/golden-acceptance.mjs';
|
||
import {
|
||
CAPTURE_CANON_PLATFORM, captureEnvironment, environmentNote,
|
||
foreignCaptureAllowance, foreignCaptureRefusal, reportCaptureProvenance,
|
||
} from '../../scripts/capture-environment.mjs';
|
||
|
||
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../..');
|
||
const reviewed = process.argv.includes('--reviewed');
|
||
const fromArg = process.argv.find((arg) => arg.startsWith('--from='));
|
||
const from = resolve(fromArg ? fromArg.slice('--from='.length) : resolve(ROOT, 'artifacts/golden'));
|
||
const list = (name) => {
|
||
const found = process.argv.find((arg) => arg.startsWith(`--${name}=`));
|
||
return (found ? found.slice(name.length + 3) : '')
|
||
.split(',').map((id) => id.trim()).filter(Boolean);
|
||
};
|
||
const declared = list('expect-change');
|
||
const declaredNew = list('expect-new');
|
||
const skipWitnesses = process.argv.includes('--no-witnesses');
|
||
const reasonArg = process.argv.find((arg) => arg.startsWith('--reason='));
|
||
const skipReason = reasonArg ? reasonArg.slice('--reason='.length) : '';
|
||
if (!reviewed) throw new Error('refusing to replace baselines without explicit --reviewed');
|
||
|
||
const reportPath = resolve(from, 'golden-report.json');
|
||
if (!existsSync(reportPath)) throw new Error(`candidate report not found: ${reportPath}`);
|
||
const report = JSON.parse(readFileSync(reportPath, 'utf8'));
|
||
if (report.matrixVersion !== GOLDEN_MATRIX_VERSION)
|
||
throw new Error(`candidate matrix ${report.matrixVersion} != current ${GOLDEN_MATRIX_VERSION}`);
|
||
if (report.buildFingerprint !== sourceFingerprint(ROOT))
|
||
throw new Error('candidate screenshots were not captured from the current frontend source');
|
||
if (typeof report.chromium !== 'string' || !report.chromium)
|
||
throw new Error('candidate report does not identify its Chromium build');
|
||
if (!Array.isArray(report.results)) throw new Error('candidate report has no scenario results');
|
||
|
||
// Провенанс съёмки и приёмки — две разные вещи, и до #571 их путали.
|
||
//
|
||
// Прежняя редакция знала только свою платформу и записывала её в индекс как
|
||
// платформу КАДРОВ: артефакт Linux-прогона 34853080375, принятый на Windows,
|
||
// дал на `ad4000f9` запись `"platform": "win32"` у кадров, которых Windows не
|
||
// снимала. Причина осознанного обхода при этом осталась в stdout и в индекс не
|
||
// попала, хотя `AGENTS.md` обещает след в обоих местах.
|
||
//
|
||
// Теперь среда съёмки читается из отчёта, среда приёмки остаётся своей, и обе
|
||
// уезжают в индекс под разными именами. Гейт чужой среды стоит на обеих:
|
||
// съёмка вне канона — то, ради чего правило вообще есть; приёмка вне канона
|
||
// остаётся осознанным решением с записанной причиной.
|
||
const acceptance = captureEnvironment();
|
||
const { provenance, legacy } = reportCaptureProvenance(report);
|
||
const capturedOn = provenance?.platform || null;
|
||
const allowance = foreignCaptureAllowance();
|
||
for (const [platform, stageNote] of [
|
||
// Схема 1 платформы съёмки не несёт физически: судить нечего, и подставлять
|
||
// свою — ровно та ошибка, которую чинит задача. Такой отчёт принимается, но
|
||
// в индекс уедет `capturedOn: null`.
|
||
[capturedOn, 'съёмки'],
|
||
[acceptance.platform, 'приёмки'],
|
||
]) {
|
||
if (!platform) continue;
|
||
const { refusal } = foreignCaptureRefusal({
|
||
platform, kind: 'golden', stage: 'accept', allowance,
|
||
});
|
||
if (refusal) throw new Error(`${refusal}\n(среда ${stageNote}: ${platform})`);
|
||
}
|
||
const foreignAllowed = (capturedOn && capturedOn !== CAPTURE_CANON_PLATFORM)
|
||
|| acceptance.platform !== CAPTURE_CANON_PLATFORM
|
||
? allowance
|
||
: null;
|
||
if (legacy) {
|
||
console.log('Отчёт старой схемы: провенанса съёмки нет, в индекс уедет capturedOn=null (#571).');
|
||
}
|
||
if (foreignAllowed) {
|
||
console.log(`Чужая среда разрешена осознанно: ${foreignAllowed}`);
|
||
}
|
||
|
||
const refusal = goldenAcceptanceRefusal(report.results, declared, declaredNew);
|
||
if (refusal) throw new Error(refusal);
|
||
|
||
const byId = new Map(report.results.map((result) => [result.id, result]));
|
||
/**
|
||
* Куда писать эталоны. По умолчанию — каталог репозитория; `--baselines=<dir>`
|
||
* нужен свидетелям (#571): иначе проверить «отказ произошёл ДО записи» можно
|
||
* было бы только порчей рабочего дерева, а значит никак. Тот же довод вынес
|
||
* разбор вердикта ревью в отдельный скрипт (#556): враждебные случаи должны
|
||
* быть исполнимы.
|
||
*/
|
||
const baselinesArg = process.argv.find((arg) => arg.startsWith('--baselines='));
|
||
const baselineRoot = baselinesArg
|
||
? resolve(baselinesArg.slice('--baselines='.length))
|
||
: resolve(ROOT, 'demo/golden/baselines');
|
||
mkdirSync(baselineRoot, { recursive: true });
|
||
/**
|
||
* Прежний индекс: источник хешей для сцен, которые остаются как были (#351).
|
||
*
|
||
* `passed` не значит «байт в байт» — он значит «в пределах порога». Прежняя
|
||
* версия копировала кандидата поверх КАЖДОГО эталона, поэтому подпороговый
|
||
* дрейф уезжал в контракт молча, и накапливался: каждая приёмка подтягивала
|
||
* эталон к последней среде, порог не пересекался никогда, а эталон уходил.
|
||
* Так `1e341c60` заменил 22 картинки, объявив четыре. Владелец делал эту работу
|
||
* руками (`ad3f9981`: «nine unrelated baselines … were restored to their
|
||
* reviewed versions»); теперь её делает инструмент.
|
||
*/
|
||
const manifestPath = resolve(baselineRoot, GOLDEN_BASELINE_MANIFEST);
|
||
const previous = existsSync(manifestPath)
|
||
? JSON.parse(readFileSync(manifestPath, 'utf8')).scenarios || {}
|
||
: {};
|
||
// #355: floor свидетелей — необъявленные сцены, совпавшие с эталоном
|
||
// байт-в-байт, доказывают, что среда съёмки та же, что у принятого эталона.
|
||
const witnessCheck = goldenWitnessRefusal({
|
||
results: report.results,
|
||
// #408: от размера матрицы, а не от числа уцелевших эталонов — иначе порог
|
||
// обходится удалением каталога эталонов.
|
||
sceneCount: GOLDEN_SCENARIOS.length,
|
||
declared,
|
||
declaredNew,
|
||
previousHashes: previous,
|
||
skipWitnesses,
|
||
skipReason,
|
||
});
|
||
if (witnessCheck.refusal) {
|
||
// Приписка про среду — то, чего не хватало отказу: «свидетелей 0 из 10» без
|
||
// неё читается как «объяви больше сцен», и обход в одну команду выглядит
|
||
// решением (#455).
|
||
const previousIndex = existsSync(manifestPath)
|
||
? JSON.parse(readFileSync(manifestPath, 'utf8'))
|
||
: null;
|
||
const note = environmentNote({
|
||
capturedOn,
|
||
// Индекс схемы 1 хранил платформу приёмщика под именем `platform`; читаем
|
||
// обе, но новую — первой.
|
||
acceptedOn: indexCapturedOn(previousIndex),
|
||
});
|
||
throw new Error(note ? `${witnessCheck.refusal}\n${note}` : witnessCheck.refusal);
|
||
}
|
||
// Кандидат проверяется целиком, до всякого решения о замене: сломанный отчёт
|
||
// не имеет права оставить каталог эталонов половинным.
|
||
for (const scenario of GOLDEN_SCENARIOS) {
|
||
const result = byId.get(scenario.id);
|
||
const candidate = resolve(from, 'actual', `${scenario.id}.png`);
|
||
if (result?.error || !['missing-baseline', 'passed', 'different'].includes(result?.status))
|
||
throw new Error(`review candidate has an invalid run status: ${scenario.id} (${result?.status || 'missing'})`);
|
||
if (!result?.actualSha256 || !existsSync(candidate))
|
||
throw new Error(`review candidate missing: ${scenario.id}`);
|
||
const digest = createHash('sha256').update(readFileSync(candidate)).digest('hex');
|
||
if (digest !== result.actualSha256) throw new Error(`candidate changed after capture: ${scenario.id}`);
|
||
}
|
||
const plan = goldenAcceptancePlan({
|
||
scenarioIds: GOLDEN_SCENARIOS.map((scenario) => scenario.id),
|
||
results: report.results,
|
||
previousHashes: previous,
|
||
declared,
|
||
declaredNew,
|
||
});
|
||
const hashes = plan.hashes;
|
||
for (const id of plan.replace) {
|
||
copyFileSync(resolve(from, 'actual', `${id}.png`), resolve(baselineRoot, `${id}.png`));
|
||
}
|
||
writeFileSync(resolve(baselineRoot, GOLDEN_BASELINE_MANIFEST), `${JSON.stringify({
|
||
schema: GOLDEN_INDEX_SCHEMA,
|
||
matrixVersion: GOLDEN_MATRIX_VERSION,
|
||
acceptedAt: new Date().toISOString(),
|
||
sourceFingerprint: report.buildFingerprint,
|
||
chromium: report.chromium,
|
||
// #571: две стороны провенанса, а не одна. `capturedOn` — где сняты кадры (из
|
||
// отчёта), `acceptedOn` — где их приняли. Раньше поле было одно, называлось
|
||
// `platform` и заполнялось платформой приёмщика, то есть отвечало на вопрос,
|
||
// которого никто не задавал.
|
||
capturedOn: capturedOn,
|
||
acceptedOn: acceptance.platform,
|
||
capture: provenance,
|
||
// Причина осознанного обхода живёт в индексе, а не только в stdout: через
|
||
// неделю stdout нет ни у кого, а индекс лежит в репозитории.
|
||
foreignCapture: foreignAllowed ? { reason: foreignAllowed } : null,
|
||
// #355: след приёмки в артефакте, не только в истории shell.
|
||
witnesses: skipWitnesses
|
||
? { skipped: true, reason: skipReason }
|
||
: { count: witnessCheck.witnesses.length, floor: witnessCheck.floor },
|
||
scenarios: hashes,
|
||
}, null, 2)}\n`, 'utf8');
|
||
const silent = goldenSilentDeclarations(report.results, declared);
|
||
if (silent.length) {
|
||
console.log(`Объявлены как изменённые, но совпали с эталоном: ${silent.join(', ')}.`);
|
||
}
|
||
console.log(`Заменено эталонов: ${plan.replace.length}`
|
||
+ `${plan.replace.length ? ` (${[...plan.replace].sort().join(', ')})` : ''}.`);
|
||
console.log(`Сохранено без изменений: ${plan.keep.length}.`);
|
||
console.log(`Индекс перезаписан на ${GOLDEN_SCENARIOS.length} сцен.`);
|
||
if (skipWitnesses) {
|
||
console.log(`Свидетели пропущены осознанно (--no-witnesses): ${skipReason}`);
|
||
} else {
|
||
console.log(`Свидетелей среды: ${witnessCheck.witnesses.length} (floor ${witnessCheck.floor}).`);
|
||
}
|