Files
houseplan-card/demo/golden/accept.mjs
T

238 lines
14 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.
#!/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';
import { verifyWslAttestation } from '../../scripts/golden-wsl-artifact.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}`);
}
// #641: локальная Linux-съёмка больше не выдаёт себя за «почти CI». У неё
// отдельный fail-closed паспорт WSL: опубликованный clean SHA, toolchain,
// полный набор PNG и намерение приёмки. GitHub-артефакт продолжает
// определяться собственным capture.ci; осознанный foreign override остаётся
// аварийной веткой старого контракта #455.
const acceptanceIntent = {
expectChange: [...new Set(declared)].sort(),
expectNew: [...new Set(declaredNew)].sort(),
noWitnesses: skipWitnesses,
reason: skipReason,
};
const localAttestation = await verifyWslAttestation({
root: ROOT, artifactRoot: from, intent: acceptanceIntent,
});
if (!provenance?.ci && capturedOn === CAPTURE_CANON_PLATFORM
&& !localAttestation && !foreignAllowed) {
throw new Error('локальная Linux-съёмка не аттестована: используйте npm run golden:wsl:capture внутри WSL; обычный golden:capture остаётся диагностикой');
}
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,
// Локальный источник не маскируется под actions run. Хеш паспорта связывает
// индекс, терминальный `Baseline-Reviewed-Local` и CI proof финального SHA.
localAttestation: localAttestation ? {
schema: localAttestation.schema,
sha256: localAttestation.sha256,
artifactSha256: localAttestation.artifactSha256,
createdAt: localAttestation.createdAt,
source: localAttestation.source,
environment: localAttestation.environment,
toolchain: {
node: localAttestation.toolchain.node,
npm: localAttestation.toolchain.npm,
playwright: localAttestation.toolchain.playwright,
chromiumExecutableSha256: localAttestation.toolchain.chromiumExecutableSha256,
},
} : null,
// Причина осознанного обхода живёт в индексе, а не только в 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}).`);
}