diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7d1bc8a0f..f4c8c0b81 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,6 +6,11 @@ on: pull_request: merge_group: workflow_dispatch: + inputs: + profile_baseline: + description: 'Снять зарегистрированный размерный baseline PROFILE-01' + type: boolean + default: false permissions: contents: read @@ -21,6 +26,12 @@ env: COREPACK_DEFAULT_TO_LATEST: "0" jobs: + # Только явный baseline-запрос: обычный кандидат может менять runtime и + # не обязан совпадать с зарегистрированным старым источником PROFILE-01. + profile-baseline: + if: ${{ github.event_name == 'workflow_dispatch' && inputs.profile_baseline }} + uses: ./.github/workflows/profile-01.yml + verify: name: typecheck · build · size · package runs-on: ubuntu-latest diff --git a/.github/workflows/profile-01.yml b/.github/workflows/profile-01.yml new file mode 100644 index 000000000..a5b4fe92f --- /dev/null +++ b/.github/workflows/profile-01.yml @@ -0,0 +1,121 @@ +name: PROFILE-01 — проверка размерного протокола + +# Размерный вектор вызывается вручную через ci.yml в той же среде, что обычный CI +# публичного репозитория. Время/GPU/энергия этой машины не являются device proof. +on: + workflow_call: + +permissions: + contents: read + +env: + COREPACK_ENABLE_DOWNLOAD_PROMPT: '0' + COREPACK_DEFAULT_TO_LATEST: '0' + PROFILE_SOURCE_SHA: ${{ github.event.pull_request.head.sha || github.sha }} + +jobs: + probe: + runs-on: ubuntu-latest + timeout-minutes: 45 + steps: + - name: Получить точный источник измерителя + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} + persist-credentials: false + fetch-depth: 0 + + - name: Настроить Node + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v6 + with: + node-version: '24' + + - name: Активировать закреплённый pnpm + run: | + corepack enable + corepack install --global pnpm@11.11.0 + + - name: Проверить точную версию протокола + shell: bash + run: | + set -euo pipefail + test "$(git rev-parse HEAD)" = "$PROFILE_SOURCE_SHA" + node --input-type=module <<'NODE' + import { PROFILE_01, preregistrationDigest, verifyPreregistration } from './bench/profile/profile-01-preregistration.mjs'; + verifyPreregistration(PROFILE_01); + if (PROFILE_01.candidateSamplesObservedAtRegistration !== false) { + throw new Error('preregistration обязана предшествовать samples'); + } + console.log(`preregistration digest: ${preregistrationDigest(PROFILE_01)}`); + NODE + + - name: Установить зависимости + run: pnpm install --frozen-lockfile + + - name: Измерить прежний размерный вектор + id: probe + shell: bash + run: | + set -euo pipefail + mkdir -p "$RUNNER_TEMP/profile-01-raw" + node bench/profile/probe-profile-01.mjs \ + --mode old-vector \ + --cells all \ + --out "$RUNNER_TEMP/profile-01-raw" + + - name: Независимо перепроверить raw-артефакт + if: ${{ !cancelled() && steps.probe.outcome != 'skipped' }} + shell: bash + run: | + set -euo pipefail + for raw in "$RUNNER_TEMP"/profile-01-raw/*.json; do + test -f "$raw" + node bench/profile/validate-profile-01.mjs --raw "$raw" + done + + - name: Проверить отказ внешнего validator на подменах настоящего результата + if: ${{ !cancelled() && steps.probe.outcome == 'success' }} + shell: bash + run: | + set -euo pipefail + node --input-type=module <<'NODE' + import assert from 'node:assert/strict'; + import { spawnSync } from 'node:child_process'; + import { mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs'; + import { tmpdir } from 'node:os'; + import { join } from 'node:path'; + const rawDirectory = join(process.env.RUNNER_TEMP, 'profile-01-raw'); + const files = readdirSync(rawDirectory).filter(name => name.endsWith('.json')); + assert.equal(files.length, 1); + const original = JSON.parse(readFileSync(join(rawDirectory, files[0]), 'utf8')); + const directory = mkdtempSync(join(tmpdir(), 'profile-replay-mutations-')); + try { + const cases = [ + ['число', value => { value.costVector.scenarios[0].gzBytes += 1; }, 'scenarios не воспроизводится'], + ['identity', value => { value.head = '0'.repeat(40); }, 'baseProof не покрывает'], + ['клетки', value => { value.cellsUnproven = []; }, 'неизмеренные клетки потеряны'], + ]; + for (const [name, mutate, rejection] of cases) { + const value = structuredClone(original); + mutate(value); + const path = join(directory, 'mutated.json'); + writeFileSync(path, JSON.stringify(value)); + const run = spawnSync(process.execPath, ['bench/profile/validate-profile-01.mjs', '--raw', path], + { encoding: 'utf8', timeout: 180_000, maxBuffer: 8 * 1024 * 1024 }); + assert.equal(run.status, 1, `${name}: ${run.error ?? run.stderr}`); + assert.ok(run.stderr.includes(rejection), `${name}: неверная причина отказа: ${run.stderr}`); + console.log(`${name}: ожидаемый предметный отказ подтверждён`); + } + } finally { + rmSync(directory, { recursive: true, force: true }); + } + NODE + + - name: Опубликовать raw-артефакт + # Отрицательный результат сохраняется даже после отказа измерителя. + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: profile-01-raw-${{ env.PROFILE_SOURCE_SHA }} + path: ${{ runner.temp }}/profile-01-raw/ + retention-days: 90 diff --git a/bench/profile/probe-profile-01.mjs b/bench/profile/probe-profile-01.mjs new file mode 100644 index 000000000..f5336d554 --- /dev/null +++ b/bench/profile/probe-profile-01.mjs @@ -0,0 +1,140 @@ +// PROFILE-01 probe: исполняемый измерительный стенд. +// Измерение размера использует обычную среду CI публичного репозитория. +// Оно не доказывает задержку, частоту кадров или свойства физического устройства. +// Недействительная калибровка или расхождение с протоколом запрещают допуск. +// Использование: node bench/profile/probe-profile-01.mjs --mode old-vector|aa|ab --cells desktop|all --out + +import { createHash } from 'node:crypto'; +import { mkdirSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { + PROFILE_01, + preregistrationDigest, + verifyPreregistration, + unmeasuredCells, +} from './profile-01-preregistration.mjs'; +import { PREREG_OWN_PATHS, makeGit } from './profile-git-proof.mjs'; +import { measureOldVector } from './profile-measurement.mjs'; + +function fail(message) { + throw new Error(`PROFILE-01 probe (fail-closed): ${message}`); +} + +function arg(name) { + const index = process.argv.indexOf(name); + return index === -1 ? undefined : process.argv[index + 1]; +} + +const git = makeGit(fail); + +function writeArtifact(outDir, artifact, head, digest) { + const rawPath = join(outDir, `profile-01-${artifact.mode}-${head.slice(0, 12)}.json`); + const rawBytes = `${JSON.stringify(artifact, null, 2)}\n`; + writeFileSync(rawPath, rawBytes); + const rawDigest = createHash('sha256').update(rawBytes).digest('hex'); + // eslint-disable-next-line no-console + console.log(JSON.stringify({ rawPath, rawDigest, preregistrationDigest: digest, admission: artifact.admission })); +} + +// Отказ после измерения сохраняет артефакт с причиной и хешем. +// Отказ до измерения ещё не создаёт данных для сохранения. +function persistAndFail(outDir, artifact, head, digest, reason) { + artifact.finishedAtUtc = new Date().toISOString(); + artifact.admission = 'NOT-GRANTED'; + artifact.rejection = reason; + writeArtifact(outDir, artifact, head, digest); + fail(reason); +} + +async function main() { + const repoRoot = fileURLToPath(new URL('../../', import.meta.url)); + const mode = arg('--mode') ?? fail('требуется --mode old-vector|aa|ab'); + if (!['old-vector', 'aa', 'ab'].includes(mode)) fail(`неизвестный --mode ${mode}`); + const cells = arg('--cells') ?? 'desktop'; + if (!['desktop', 'all'].includes(cells)) fail(`неизвестный --cells ${cells}`); + const outDir = resolve(arg('--out') ?? join(tmpdir(), 'profile-01-raw')); + mkdirSync(outDir, { recursive: true }); + + // Протокол проверяется до любых измерений. + verifyPreregistration(PROFILE_01); + const digest = preregistrationDigest(PROFILE_01); + + // 2. Точный base/provenance. + const head = git.head(repoRoot); + const artifact = { + node: 'PROFILE-01', + revision: 'r11', + mode, + cells, + preregistrationDigest: digest, + candidateSamplesObservedAtRegistration: false, + head, + sizeGateBlob: null, + baseProof: null, + seed: 20260929, + startedAtUtc: new Date().toISOString(), + cellsMeasured: [], + cellsUnproven: unmeasuredCells(cells), + rawControls: {}, + calibration: {}, + costVector: null, + admission: 'NOT-GRANTED', + rejection: null, + }; + + if (mode === 'old-vector') { + const base = PROFILE_01.productBase.sourceSha; + if (!git.ancestor(repoRoot, base)) fail(`old-vector требует HEAD, выросший из PRODUCT_BASE ${base}`); + const sizeGateBlob = git.blob(repoRoot, 'HEAD', 'scripts/size-gate.mjs'); + if (sizeGateBlob !== PROFILE_01.productBase.sizeGateBlob) { + fail(`size-gate provenance drifted: ${sizeGateBlob}`); + } + // Рабочая копия обязана совпадать с коммитом: иначе измеритель + // исполнит непроверенный файл, а baseProof этого не покажет. + const workingSizeGateBlob = git.workingBlob(repoRoot, 'scripts/size-gate.mjs'); + if (workingSizeGateBlob !== sizeGateBlob) { + fail(`рабочая копия size-gate.mjs отличается от коммита: working ${workingSizeGateBlob}, committed ${sizeGateBlob}`); + } + const diffPaths = head === base ? [] : git.diffNames(repoRoot, base, head); + const foreign = diffPaths.filter((path) => !PREREG_OWN_PATHS.includes(path)); + if (foreign.length > 0) fail(`измеряемое дерево отличается от PRODUCT_BASE вне prereg-пакета: ${foreign.join(', ')}`); + artifact.sizeGateBlob = sizeGateBlob; + artifact.baseProof = { productBase: base, head, diffPaths }; + try { + artifact.costVector = await measureOldVector(repoRoot); + } catch (error) { + persistAndFail(outDir, artifact, head, digest, `незавершённое измерение: ${error.message}`); + } + if (artifact.costVector.exitCode !== 0) { + persistAndFail(outDir, artifact, head, digest, `старый cost vector не зелёный на PRODUCT_BASE (exit ${artifact.costVector.exitCode})`); + } + for (const name of Object.keys(PROFILE_01.oldCostVectorGzipBytes.scenarios)) { + if (!artifact.costVector.scenarios.some((row) => row.name === name)) { + persistAndFail(outDir, artifact, head, digest, `size-gate не содержит сценарий ${name}`); + } + } + artifact.cellsMeasured.push('desktop-size-vector'); + } + + // 4. Калибровка: A/A и deliberate 2×work обязаны быть явными. + // Детализация timing-калибровки — в отдельной browser-фазе; + // без пройденной калибровки admission не выдаётся (см. ниже). + artifact.calibration = { aa: 'PENDING', positive2x: 'PENDING' }; + + // Без калибровки aa/ab сохраняют отказ до завершения процесса. + if (mode !== 'old-vector') { + persistAndFail(outDir, artifact, head, digest, 'aa/ab режимы требуют отдельной зелёной browser-калибровки'); + } + const ready = mode === 'old-vector' && artifact.costVector !== null; + artifact.finishedAtUtc = new Date().toISOString(); + artifact.admission = ready ? 'OLD-VECTOR-ONLY' : 'NOT-GRANTED'; + if (!ready) { + persistAndFail(outDir, artifact, head, digest, 'old-vector не готов: costVector отсутствует'); + } + + writeArtifact(outDir, artifact, head, digest); +} + +await main(); diff --git a/bench/profile/profile-01-preregistration.mjs b/bench/profile/profile-01-preregistration.mjs new file mode 100644 index 000000000..142144093 --- /dev/null +++ b/bench/profile/profile-01-preregistration.mjs @@ -0,0 +1,216 @@ +// Исполняемый контракт PROFILE-01. Версия закрепляется до измерений кандидата. +// Неизмеренные клетки сохраняют UNPROVEN и не дают допуска A/B. + +import { createHash } from 'node:crypto'; +import { isDeepStrictEqual } from 'node:util'; + +function deepFreeze(value) { + if (value !== null && typeof value === 'object') { + for (const child of Object.values(value)) deepFreeze(child); + Object.freeze(value); + } + return value; +} + +function invariant(condition, message) { + if (!condition) throw new Error(`PROFILE-01 preregistration: ${message}`); +} + +// Точный main после исправления зависимостей тестов. +// Runtime, размерный измеритель и потолки прежнего baseline сохранены. +export const PRODUCT_BASE = Object.freeze({ + repo: 'Labpics-Team/lab-motion', + sourceSha: '7d3ed42e5b054a06e4f6ca7f0c2efc02484e20fd', + upstreamMainSha: '7d3ed42e5b054a06e4f6ca7f0c2efc02484e20fd', + sizeGateBlob: '4b0f181212b65a881e750e84564778f5828448a3', + reason: 'исправление безопасности зависимостей; runtime и размерные потолки сохранены', +}); + +// Полный старый cost vector: потолки кода, а не новые оценки. +// Provenance: scripts/size-gate.mjs, PRODUCT_BASE.sizeGateBlob. +// Архивные фактические размеры новым измерением не являются: +// метод повторного снятия — node scripts/size-gate.mjs на exact base. +export const OLD_COST_VECTOR_GZIP_BYTES = Object.freeze({ + core: 2220, + subpath: 4608, + fullCoreConsumer: 2330, + nano: 1024, + compiledRuntime: 341, + compilerSurface: 1024, + inView: 1839, + inViewConsumer: 1908, + compositorCapability: 6600, + fullAnimate: 15600, + animateCompositorMixed: 17500, + bespoke: Object.freeze({ + './behaviors/reorder': 1518, + './utils': 1400, + './compiler/vite': 9163, + './compiler/runtime': 341, + './compositor': 6450, + './compositor/stagger': 6450, + './tokens': 1650, + './projection': 5750, + './smart': 7450, + './presets': 5600, + './animate': 15600, + './nano': 1024, + './compiler/surface': 1024, + './in-view': 1839, + './behaviors': 4600, + }), + scenarios: Object.freeze({ + 'reorder-controlled': 1522, + 'nano spring-to': 1024, + 'surface executor': 1024, + 'in-view one-liner': 1908, + 'only-spring': 920, + 'projection-core-only': 720, + 'projection-dom-one-liner': 5750, + 'only-MotionValue': 1660, + 'full-core': 2330, + 'compositor-stagger capability': 6600, + 'animate + compositor': 17500, + 'only-clamp (utils tree-shake)': 340, + 'animate-one-liner (фасад)': 15600, + 'animate component scope': 15700, + 'behaviors-sheet-one-liner': 3700, + }), + brotliAndTotals: 'initial+reachable total, CSS/data/lazy chunks и Brotli снимаются тем же прогоном size-gate на exact base; знаменатели не смешиваются', +}); + +// Классы устройств по r11 §PROFILE-01. Конкретные модели и версии выбираются +// до измерений кандидата; флагман не заменяет устройство среднего класса. +export const ROSTER = Object.freeze({ + classes: Object.freeze([ + Object.freeze({ id: 'android-mid-60', device: 'физический Android среднего класса', refreshHz: 60, browsers: ['chromium-webview-stable'], status: 'UNPROVEN' }), + Object.freeze({ id: 'android-mid-120', device: 'физический Android среднего класса с экраном 120 Гц', refreshHz: 120, browsers: ['chromium-webview-stable'], status: 'UNPROVEN' }), + Object.freeze({ id: 'ios-60', device: 'физическое устройство iOS', refreshHz: 60, browsers: ['webkit-stable'], status: 'UNPROVEN' }), + Object.freeze({ id: 'ios-120', device: 'физическое устройство iOS с ProMotion', refreshHz: 120, browsers: ['webkit-stable'], status: 'UNPROVEN' }), + Object.freeze({ id: 'desktop-chromium', device: 'настольный Linux на собственном runner', refreshHz: null, browsers: ['chromium-stable'], status: 'UNPROVEN' }), + Object.freeze({ id: 'desktop-firefox', device: 'настольный Linux на собственном runner', refreshHz: null, browsers: ['firefox-stable'], status: 'UNPROVEN' }), + Object.freeze({ id: 'desktop-webkit', device: 'настольный Linux на собственном runner', refreshHz: null, browsers: ['webkit-stable'], status: 'UNPROVEN' }), + ]), + selectionRule: 'модели и версии ОС — первые доступные репрезентативные устройства собственного парка; выбор фиксируется до измерений кандидата', + no120HzRule: 'когда device не умеет 120 Hz, 120 Hz target ему не приписывается, но 60 Hz задача остаётся', + cpuThrottleNote: 'CPU-throttle desktop — диагностический контроль, не mobile proof; порог M-04 не выводится из быстрого сервера', + fixedConditions: Object.freeze([ + 'температура, питание и состояние экрана', 'нагрузка', 'фоновые процессы', 'viewport/DPR/content', + 'источники времени', 'warm/cold режимы', 'единицы выборки', 'N/iterations/seed', 'все знаменатели', + ]), +}); + +// Отсутствие оборудования затрагивает отдельные клетки. Клетка без устройства остаётся +// UNPROVEN и блокирует A/B admission своей клетки; unit/browser подготовка +// при этом не останавливается, платная закупка без допуска запрещена. +export const AFFECTED_CELLS_MISSING_HW = Object.freeze([ + Object.freeze({ cell: 'android-mid-120', blocks: 'A/B admission 120 Hz Android-клетки; 60 Hz задача независима' }), + Object.freeze({ cell: 'ios-120', blocks: 'A/B admission 120 Hz iOS-клетки; 60 Hz задача независима' }), + Object.freeze({ cell: 'whole-page energy/GPU', blocks: 'выводы M-04/M-05 о полной стоимости устройства; frame CPU-time клетки независимы' }), +]); + +// Замороженные сцены измерения (выбор ДО samples, смена выбора = новая регистрация). +// M-04: 100 активных скалярных каналов, p99 собственного CPU-time Lab Motion ≤0,5 ms/frame; +// полный 120 Hz сценарий: верхняя 95% граница доли пропущенных кадров ≤0,1%. +// M-05: две тяжёлые сцены разных семейств, верхняя 95% граница candidate/best ≤0,50. +export const SCENES = Object.freeze({ + m04channels: Object.freeze({ + id: 'm04-100-scalar-channels', + channels: 100, + fixture: 'behaviors-sheet heavy scene (createBottomSheet, snapPoints [0, 300, 600])', + acceptance: 'p99 собственного CPU-time Lab Motion ≤0,5 ms/frame; OS/browser/render отдельно; deliberate extra work различается стендом', + }), + m04full120: Object.freeze({ + id: 'm04-full-120hz', + acceptance: 'верхняя 95% граница доли пропущенных кадров ≤0,1% выбранного 120 Hz сценария', + }), + m05a: Object.freeze({ + id: 'm05-sheet', + family: 'непосредственное управление (sheet)', + fixture: 'behaviors-sheet-one-liner consumer (gate 3700 B gzip)', + acceptance: 'верхняя 95% граница candidate/best ≤0,50 по доминирующей устранимой стоимости; protected клетки не хуже', + }), + m05b: Object.freeze({ + id: 'm05-list', + family: 'изменяемая коллекция (фильтруемый/переставляемый список)', + fixture: 'reorder-controlled consumer (gate 1522 B gzip)', + acceptance: 'верхняя 95% граница candidate/best ≤0,50 по доминирующей устранимой стоимости; protected клетки не хуже', + }), + rawControlRule: 'для raw control с нулевой стоимостью отношение не вычисляется: требуется совпадение границы и выигрыш другой содержательной метрики', +}); + +// Каждый A/B сопровождается полным набором контрольных измерений. +export const RAW_CONTROLS = Object.freeze({ + noMotion: 'статический кадр без движения обязан совпадать попиксельно с точностью оракула', + reducedMotion: 'при reduced-motion частые действия имеют путь без движения', + waapi: 'платформенный контроль bench/compare/waapi-control.entry', + oldLab: 'old-Lab profile на том же стенде (PRODUCT_BASE без кандидата)', + bestComparator: 'best-comparator: Motion, Anime Animatable/Layout и raw platform; Rive только в совпадающих authoring/handoff задачах', + aa: 'A/A: тот же build дважды; обязан различить отсутствие изменения в non-inferiority полосе', + positive: 'deliberate 2×work положительный контроль обязан детектироваться', +}); + +// Статистика и MDE. Единица независимости — прогон (run); кадры/каналы внутри +// прогона зависимы и не создают фиктивное число участников. +export const STATS_MDE = Object.freeze({ + independenceUnit: 'run (device × build × scene × mode блок)', + design: 'парный/блочный дизайн, warm+cold режимы', + sampleSize: 'N из заранее выбранных MDE/power и null-pilot оценки дисперсии; N замораживается до A/B', + seed: 'фиксированный seed публикуется в артефакте', + stoppingRule: 'правило остановки фиксировано заранее; добор samples после просмотра запрещён', + coverage: 'family-wise 95% coverage либо заранее утверждённая multiplicity correction', + noRepeatToGreen: 'повтор того же опыта до green не допускается; failed A/A = UNPROVEN + baseline RCA, не NO-GO идеи', +}); + +// Неудачные измерения сохраняются вместе с успешными. +export const OBSERVATION_POLICY = Object.freeze({ + preserve: Object.freeze(['все samples', 'отказы', 'зависания', 'GC/JIT/context switches', 'повреждённые квитанции']), + rawAndDigest: 'сырые данные + внешний digest (sha256) в артефакте; команды и метод позволяют независимый replay', + retention: 'истечение Actions artifact не удаляет единственную копию proof: компактный witness у research owner, raw по durable artifact policy', + forcedGc: 'forced GC допустим только в выделенном retention proof, не в timing', + denominators: 'initial, reachable total, cold/warm-compile, startup, interruption, frame, teardown, import/build, peak/retained — свои знаменатели', +}); + +// Недействительная калибровка запрещает допуск кандидата. +export const CALIBRATION = Object.freeze({ + aaBand: 'A/A обязан показать отсутствие изменения в исходной non-inferiority полосе; старый p95 admission upper ≤1,05 и остальные условия не меняются', + positiveDetection: 'deliberate 2×work обязан детектироваться стендом', + failClosed: 'invalid calibration → candidate admission не запускается; baseline-only RCA меняет метод лишь с конкретным дефектом/новой предпосылкой', + staleProfile: 'профиль, снятый не на зарегистрированном roster/base, к admission не допускается', +}); + +export const PROFILE_01 = deepFreeze({ + node: 'PROFILE-01', + revision: 'r11', + productBase: PRODUCT_BASE, + oldCostVectorGzipBytes: OLD_COST_VECTOR_GZIP_BYTES, + roster: ROSTER, + affectedCellsMissingHw: AFFECTED_CELLS_MISSING_HW, + scenes: SCENES, + rawControls: RAW_CONTROLS, + statsMde: STATS_MDE, + observationPolicy: OBSERVATION_POLICY, + calibration: CALIBRATION, + candidateSamplesObservedAtRegistration: false, +}); + +export function preregistrationDigest(value = PROFILE_01) { + return createHash('sha256').update(JSON.stringify(value)).digest('hex'); +} + +// Размер пакета не измеряет временные клетки roster. Имя runner и переменная +// среды не превращают отсутствие измерения в доказательство. +export function unmeasuredCells(cells) { + invariant(cells === 'desktop' || cells === 'all', 'неизвестный набор клеток'); + return PROFILE_01.roster.classes + .filter((cell) => cells === 'all' || cell.id.startsWith('desktop-')) + .map((cell) => ({ cell: cell.id, reason: 'временные метрики и калибровка устройства не измерены', blocks: 'допуск A/B для этой клетки' })) + .concat([{ cell: 'whole-page energy/GPU', reason: 'энергия и GPU устройства не измерены', blocks: 'выводы M-04/M-05 о полной стоимости устройства' }]); +} + +// Вход сравнивается целиком с одним неизменяемым протоколом. Его версию +// закрепляют commit, независимое ревью и CI; копия констант рядом не нужна. +export function verifyPreregistration(value = PROFILE_01) { + invariant(isDeepStrictEqual(value, PROFILE_01), 'контракт отличается от зарегистрированного протокола'); + return true; +} diff --git a/bench/profile/profile-git-proof.mjs b/bench/profile/profile-git-proof.mjs new file mode 100644 index 000000000..c356645ca --- /dev/null +++ b/bench/profile/profile-git-proof.mjs @@ -0,0 +1,64 @@ +// PROFILE-01 git-proof: общие git-доказательства provenance для probe и validator. +// Один источник PREREG_OWN_PATHS исключает дрейф allowlist между файлами. +// Недоступность Git запрещает допуск через переданный обработчик отказа. + +import { execFileSync } from 'node:child_process'; +import { readCheckoutState } from '../compare/provenance.mjs'; + +// Измеряемое дерево обязано совпадать с PRODUCT_BASE везде, кроме самих +// файлов preregistration-пакета. Это позволяет снимать old-vector на +// PR-ветке, выросшей из PRODUCT_BASE, с доказанной эквивалентностью. +export const PREREG_OWN_PATHS = Object.freeze([ + 'bench/profile/profile-01-preregistration.mjs', + 'bench/profile/probe-profile-01.mjs', + 'bench/profile/validate-profile-01.mjs', + 'bench/profile/profile-git-proof.mjs', + 'bench/profile/profile-measurement.mjs', + 'test/profile-measurement.test.ts', + '.github/workflows/profile-01.yml', + '.github/workflows/ci.yml', + 'test/ci-workflow-contract.test.ts', +]); + +export function makeGit(fail) { + const head = (cwd) => { + try { + const state = readCheckoutState(cwd); + if (state.dirty) fail('измерение требует clean checkout'); + return state.revision; + } catch { + fail('git недоступен для доказательства provenance'); + } + }; + const blob = (cwd, rev, path) => { + try { + return execFileSync('git', ['--no-replace-objects', 'rev-parse', `${rev}:${path}`], { cwd, encoding: 'utf8' }).trim(); + } catch { + fail(`git не смог доказать blob ${path}@${rev}`); + } + }; + const workingBlob = (cwd, path) => { + try { + return execFileSync('git', ['hash-object', '--', path], { cwd, encoding: 'utf8' }).trim(); + } catch { + fail(`git не смог доказать рабочий blob ${path}`); + } + }; + const diffNames = (cwd, base, headRef) => { + try { + const output = execFileSync('git', ['--no-replace-objects', 'diff', '--name-only', `${base}`, `${headRef}`], { cwd, encoding: 'utf8' }); + return output.split('\n').map((line) => line.trim()).filter(Boolean); + } catch { + fail(`git не смог доказать эквивалентность дерева ${base}..${headRef}`); + } + }; + const ancestor = (cwd, base) => { + try { + execFileSync('git', ['--no-replace-objects', 'merge-base', '--is-ancestor', base, 'HEAD'], { cwd, stdio: 'ignore' }); + return true; + } catch { + return false; + } + }; + return { head, blob, workingBlob, diffNames, ancestor }; +} diff --git a/bench/profile/profile-measurement.mjs b/bench/profile/profile-measurement.mjs new file mode 100644 index 000000000..d2dcba38f --- /dev/null +++ b/bench/profile/profile-measurement.mjs @@ -0,0 +1,51 @@ +import { isDeepStrictEqual } from 'node:util'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { CANONICAL_GZIP_PACKAGE } from '../../scripts/compression-policy.mjs'; +import { + prepareBenchmarkCheckout, + assertCheckoutUnchanged, + assertInstalledPackageTreesUnchanged, +} from '../compare/provenance.mjs'; + +// Сборка и identity принадлежат общему стенду; числа и потолки — size-gate. +// Адаптер сохраняет структурированный результат тех же измерений, что pnpm size. +export async function measureOldVector(root) { + const provenance = prepareBenchmarkCheckout({ + root, + benchDirectory: root, + requiredRootPackages: ['esbuild', CANONICAL_GZIP_PACKAGE], + }); + const gate = await import('../../scripts/size-gate.mjs'); + const pkg = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')); + const entries = gate.measureEntries(gate.deriveEntriesFromExports(pkg), root); + const scenarios = []; + for (const scenario of gate.IMPORT_COST_SCENARIOS) { + scenarios.push(await gate.measureScenario(scenario, join(root, 'dist/index.js'))); + } + const failed = entries.hasWarnings || scenarios.some((row) => row.error || gate.evaluateScenarioBudget(row).exceeded); + assertCheckoutUnchanged(root, provenance); + assertInstalledPackageTreesUnchanged(root, provenance.environment.rootPackages); + return { exitCode: failed ? 1 : 0, entries, scenarios, provenance }; +} + +export function validateReplayedVector(recorded, replayed) { + if (recorded?.exitCode !== 0 || replayed?.exitCode !== 0) { + throw new Error('PROFILE-01: старый cost vector не прошёл независимое измерение'); + } + if (!recorded.entries || !Array.isArray(recorded.scenarios) || recorded.scenarios.length === 0) { + throw new Error('PROFILE-01: отсутствуют структурированные измерения'); + } + for (const field of ['entries', 'scenarios']) { + if (!isDeepStrictEqual(recorded[field], replayed[field])) { + throw new Error(`PROFILE-01: ${field} не воспроизводится независимым измерением`); + } + } + // Время новой сборки отличается; исходники, inputs, dist и инструменты должны совпасть. + for (const field of ['revision', 'trackedRevisionSha256', 'inputs', 'distRuntime', 'environment']) { + if (recorded.provenance?.[field] === undefined || + !isDeepStrictEqual(recorded.provenance[field], replayed.provenance?.[field])) { + throw new Error(`PROFILE-01: provenance.${field} не совпадает при воспроизведении`); + } + } +} diff --git a/bench/profile/validate-profile-01.mjs b/bench/profile/validate-profile-01.mjs new file mode 100644 index 000000000..51f3c23a8 --- /dev/null +++ b/bench/profile/validate-profile-01.mjs @@ -0,0 +1,114 @@ +// PROFILE-01 validate: независимая перепроверка raw-артефакта пробы. +// Повторяет размерные измерения; не удостоверяет время исторического запуска +// и не выдаёт допуска производительности или человеческой оценки. +// Использование: node bench/profile/validate-profile-01.mjs --raw + +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { isDeepStrictEqual } from 'node:util'; +import { + PROFILE_01, + preregistrationDigest, + verifyPreregistration, + unmeasuredCells, +} from './profile-01-preregistration.mjs'; +import { PREREG_OWN_PATHS, makeGit } from './profile-git-proof.mjs'; +import { measureOldVector, validateReplayedVector } from './profile-measurement.mjs'; + +function fail(message) { + throw new Error(`PROFILE-01 validate (fail-closed): ${message}`); +} + +function arg(name) { + const index = process.argv.indexOf(name); + return index === -1 ? undefined : process.argv[index + 1]; +} + +async function main() { + const rawPath = resolve(arg('--raw') ?? fail('требуется --raw ')); + const artifact = JSON.parse(readFileSync(rawPath, 'utf8')); + if (artifact.node !== 'PROFILE-01' || artifact.revision !== 'r11' || + !['old-vector', 'aa', 'ab'].includes(artifact.mode)) fail('неверный вид артефакта'); + if (!isDeepStrictEqual(artifact.cellsUnproven, unmeasuredCells(artifact.cells))) { + fail('неизмеренные клетки потеряны или подменены'); + } + const expectedMeasured = artifact.admission === 'OLD-VECTOR-ONLY' ? ['desktop-size-vector'] : []; + if (!isDeepStrictEqual(artifact.cellsMeasured, expectedMeasured)) fail('неверный набор измеренных клеток'); + + // 1. Замороженный контракт не менялся. + verifyPreregistration(PROFILE_01); + const frozen = preregistrationDigest(PROFILE_01); + if (artifact.preregistrationDigest !== frozen) { + fail(`preregistration не совпадает: artifact ${artifact.preregistrationDigest}, frozen ${frozen}`); + } + if (artifact.candidateSamplesObservedAtRegistration !== false) { + fail('preregistration обязана предшествовать samples'); + } + + // Происхождение исходников пересчитывается независимо через Git. + // Валидатор не доверяет полям raw JSON: HEAD, ancestry, diff и blob + // пересчитываются из checkout, в котором запущен валидатор. + // Записанный exitCode не удостоверяет измерение. После проверки Git повторяем + // сборку и существующий size-gate; сравниваем данные, а не формат console.log. + if (artifact.mode === 'old-vector') { + const base = PROFILE_01.productBase.sourceSha; + const proof = artifact.baseProof ?? {}; + if (proof.productBase !== base || proof.head !== artifact.head) fail('baseProof не покрывает artifact head'); + if (!Array.isArray(proof.diffPaths)) fail('baseProof.diffPaths отсутствует'); + const repoRoot = fileURLToPath(new URL('../../', import.meta.url)); + const git = makeGit(fail); + const currentHead = git.head(repoRoot); + if (currentHead !== artifact.head) { + fail(`валидатор запущен не на artifact head: checkout ${currentHead}, artifact ${artifact.head}`); + } + if (!git.ancestor(repoRoot, base)) fail(`artifact head не вырос из PRODUCT_BASE ${base}`); + const recomputed = currentHead === base ? [] : git.diffNames(repoRoot, base, currentHead); + const asSet = (paths) => JSON.stringify([...paths].sort()); + if (asSet(recomputed) !== asSet(proof.diffPaths)) { + fail(`baseProof.diffPaths не совпадает с git: artifact [${proof.diffPaths.join(', ')}], git [${recomputed.join(', ')}]`); + } + const foreign = recomputed.filter((path) => !PREREG_OWN_PATHS.includes(path)); + if (foreign.length > 0) fail(`дерево отличается от PRODUCT_BASE вне prereg-пакета: ${foreign.join(', ')}`); + const committed = git.blob(repoRoot, artifact.head, 'scripts/size-gate.mjs'); + if (committed !== PROFILE_01.productBase.sizeGateBlob || committed !== artifact.sizeGateBlob) { + fail(`size-gate provenance drifted: artifact ${artifact.sizeGateBlob}, git ${committed}`); + } + if (artifact.admission === 'OLD-VECTOR-ONLY') { + validateReplayedVector(artifact.costVector, await measureOldVector(repoRoot)); + } + } + + // 3. A/B без калибровки не существует. + if (artifact.mode !== 'old-vector' && artifact.admission !== 'NOT-GRANTED') { + fail('некалиброванный A/B не может нести admission'); + } + if (artifact.admission !== 'NOT-GRANTED' && artifact.admission !== 'OLD-VECTOR-ONLY') { + fail(`неизвестный admission ${artifact.admission}`); + } + // Отклонённый артефакт обязан нести причину: NOT-GRANTED без rejection + // означает потерянный failure (OBSERVATION_POLICY нарушена). + if (artifact.admission === 'NOT-GRANTED') { + if (typeof artifact.rejection !== 'string' || artifact.rejection.length === 0) { + fail('NOT-GRANTED артефакт обязан содержать непустой rejection'); + } + } else if (artifact.rejection !== null && artifact.rejection !== undefined) { + fail('успешный артефакт не должен нести rejection'); + } + + // 4. Affected cells обязаны быть перечислены, а не молча пропущены. + if (!Array.isArray(artifact.cellsUnproven)) fail('cellsUnproven обязан быть списком'); + if (!Array.isArray(artifact.cellsMeasured)) fail('cellsMeasured обязан быть списком'); + + // 5. Времена и seed фиксированы. + if (!Number.isFinite(Date.parse(artifact.startedAtUtc ?? ''))) fail('startedAtUtc отсутствует'); + if (!Number.isFinite(Date.parse(artifact.finishedAtUtc ?? ''))) fail('finishedAtUtc отсутствует'); + if (Date.parse(artifact.finishedAtUtc) < Date.parse(artifact.startedAtUtc)) fail('время окончания предшествует началу'); + if (artifact.seed !== 20260929) fail('seed drifted'); + + // eslint-disable-next-line no-console + console.log(JSON.stringify({ valid: true, mode: artifact.mode, admission: artifact.admission, + verification: artifact.admission === 'OLD-VECTOR-ONLY' ? 'independent-size-remeasurement' : 'recorded-refusal-only' })); +} + +await main(); diff --git a/test/ci-workflow-contract.test.ts b/test/ci-workflow-contract.test.ts index 8a3467ede..58058b6f7 100644 --- a/test/ci-workflow-contract.test.ts +++ b/test/ci-workflow-contract.test.ts @@ -283,9 +283,20 @@ function assertNativeGraph(files: Map) { expect(files.has('ci-gate.yml')).toBe(false); const ci = workflows.get('ci.yml')!; const browser = workflows.get('browser.yml')!; - expect(ci.on).toEqual({ push: { branches: ['main'] }, pull_request: null, merge_group: null, workflow_dispatch: null }); + expect(ci.on).toEqual({ + push: { branches: ['main'] }, pull_request: null, merge_group: null, + workflow_dispatch: { inputs: { profile_baseline: { + description: 'Снять зарегистрированный размерный baseline PROFILE-01', + type: 'boolean', default: false, + } } }, + }); expect(browser.on).toEqual({ workflow_call: null }); - expect(Object.keys(ci.jobs).sort()).toEqual(['CI', 'browser-static', 'mutation', 'node-floor', 'tests', 'verify']); + expect(Object.keys(ci.jobs).sort()).toEqual(['CI', 'browser-static', 'mutation', 'node-floor', 'profile-baseline', 'tests', 'verify']); + expect(ci.jobs['profile-baseline']).toEqual({ + if: "${{ github.event_name == 'workflow_dispatch' && inputs.profile_baseline }}", + uses: './.github/workflows/profile-01.yml', + }); + expect(workflows.get('profile-01.yml')!.on).toEqual({ workflow_call: null }); expect(Object.keys(browser.jobs)).toEqual(['conformance']); for (const document of [ci, browser]) { expect(document.permissions).toEqual({ contents: 'read' }); @@ -394,6 +405,11 @@ describe('нативный граф CI', () => { ['пустой успех', 'ci.yml', (w: Workflow) => { w.jobs.CI!.steps![0]!.run = 'true\n'; }], ['игнорирование итога', 'ci.yml', (w: Workflow) => { w.jobs.CI!['continue-on-error'] = true; }], ['добавлен trigger browser', 'browser.yml', (w: Workflow) => { w.on.pull_request = null; }], + ['добавлен PR trigger PROFILE', 'profile-01.yml', (w: Workflow) => { w.on.pull_request = null; }], + ['PROFILE запущен для обычного кандидата', 'ci.yml', (w: Workflow) => { delete w.jobs['profile-baseline']!.if; }], + ['PROFILE без явного запроса', 'ci.yml', (w: Workflow) => { + w.jobs['profile-baseline']!.if = "${{ github.event_name == 'workflow_dispatch' }}"; + }], ['нет workflow_call', 'browser.yml', (w: Workflow) => { w.on = { workflow_dispatch: null }; }], ...['chromium', 'firefox', 'webkit'].map((engine) => [`нет ${engine}`, 'browser.yml', (w: Workflow) => { const matrix = w.jobs.conformance!.strategy!.matrix; @@ -494,24 +510,22 @@ describe('нативный граф CI', () => { } }); - it('shell итога требует success каждого dependency', () => { + it.each(['VERIFY_RESULT', 'TESTS_RESULT', 'MUTATION_RESULT', 'NODE_FLOOR_RESULT', 'BROWSER_RESULT'] + .flatMap((key) => ['success', 'failure', 'cancelled', 'skipped', 'neutral', 'pending', '', undefined] + .map((result) => ({ key, result }))))('shell итога проверяет $key=$result', ({ key, result }) => { const ci = parse(sources().get('ci.yml')!) as Workflow; const program = ci.jobs.CI!.steps![0]!.run!; - for (const key of ['VERIFY_RESULT', 'TESTS_RESULT', 'MUTATION_RESULT', 'NODE_FLOOR_RESULT', 'BROWSER_RESULT']) { - for (const result of ['success', 'failure', 'cancelled', 'skipped', 'neutral', 'pending', '', undefined]) { - const env: NodeJS.ProcessEnv = { - ...process.env, VERIFY_RESULT: 'success', TESTS_RESULT: 'success', - MUTATION_RESULT: 'success', NODE_FLOOR_RESULT: 'success', BROWSER_RESULT: 'success', - }; - delete env[key]; - if (result !== undefined) env[key] = result; - const actual = spawnSync(bash, ['--noprofile', '--norc', '-e', '-o', 'pipefail', '-c', program], { - env, encoding: 'utf8', timeout: 5000, - }); - expect(actual.error).toBeUndefined(); - expect(actual.status === 0, `${key}=${result}: ${actual.stderr}`).toBe(result === 'success'); - } - } + const env: NodeJS.ProcessEnv = { + ...process.env, VERIFY_RESULT: 'success', TESTS_RESULT: 'success', + MUTATION_RESULT: 'success', NODE_FLOOR_RESULT: 'success', BROWSER_RESULT: 'success', + }; + delete env[key]; + if (result !== undefined) env[key] = result; + const actual = spawnSync(bash, ['--noprofile', '--norc', '-e', '-o', 'pipefail', '-c', program], { + env, encoding: 'utf8', timeout: 5000, + }); + expect(actual.error).toBeUndefined(); + expect(actual.status === 0, `${key}=${result}: ${actual.stderr}`).toBe(result === 'success'); }); it('shell Vitest сохраняет код отказа после tee для diagnostics', () => { @@ -532,4 +546,4 @@ describe('нативный граф CI', () => { rmSync(cwd, { recursive: true, force: true }); } }); -}); \ No newline at end of file +}); diff --git a/test/profile-measurement.test.ts b/test/profile-measurement.test.ts new file mode 100644 index 000000000..902f4ec08 --- /dev/null +++ b/test/profile-measurement.test.ts @@ -0,0 +1,242 @@ +import { describe, expect, it } from 'vitest'; +import { execFileSync, spawnSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { validateReplayedVector } from '../bench/profile/profile-measurement.mjs'; +import { PROFILE_01, unmeasuredCells, verifyPreregistration } from '../bench/profile/profile-01-preregistration.mjs'; +import { makeGit } from '../bench/profile/profile-git-proof.mjs'; + +describe('PROFILE: происхождение подтверждает настоящий Git', () => { + it('различает clean, tracked/untracked drift, ancestry и недоступное доказательство', () => { + const directory = mkdtempSync(join(tmpdir(), 'motion-profile-git-')); + const git = (...args: string[]) => execFileSync('git', args, { + cwd: directory, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], timeout: 10_000, + }).trim(); + const proof = makeGit((message: string) => { throw new Error(message); }); + try { + git('init', '--quiet'); + git('config', 'core.autocrlf', 'false'); + git('config', 'core.hooksPath', join(directory, 'no-hooks')); + const commit = () => { + git('add', '.'); + git('-c', 'user.name=PROFILE test', '-c', 'user.email=profile@example.invalid', '-c', 'commit.gpgsign=false', + 'commit', '--quiet', '-m', 'fixture'); + return git('rev-parse', 'HEAD'); + }; + writeFileSync(join(directory, 'source.txt'), 'first\n'); + const base = commit(); + expect(proof.head(directory)).toBe(base); + const originalBlob = createHash('sha1').update('blob 6\0first\n').digest('hex'); + expect(proof.blob(directory, base, 'source.txt')).toBe(originalBlob); + expect(proof.workingBlob(directory, 'source.txt')).toBe(originalBlob); + writeFileSync(join(directory, 'source.txt'), 'second\n'); + expect(() => proof.head(directory)).toThrow(); + expect(proof.workingBlob(directory, 'source.txt')).not.toBe(originalBlob); + const head = commit(); + expect(proof.head(directory)).toBe(head); + expect(proof.diffNames(directory, base, head)).toEqual(['source.txt']); + expect(proof.ancestor(directory, base)).toBe(true); + git('checkout', '--quiet', '--detach', base); + expect(proof.ancestor(directory, head)).toBe(false); + writeFileSync(join(directory, 'untracked.txt'), 'drift\n'); + expect(() => proof.head(directory)).toThrow(); + expect(() => proof.blob(directory, base, 'missing.txt')).toThrow(); + expect(() => proof.workingBlob(directory, 'missing.txt')).toThrow(); + expect(() => proof.diffNames(directory, 'missing-revision', head)).toThrow(); + expect(() => proof.head(join(directory, 'missing-directory'))).toThrow(); + } finally { + rmSync(directory, { recursive: true, force: true }); + } + }, 30_000); + + it('old-vector сохраняет отказ измерения, когда в clean clone нет инструментов', () => { + const directory = mkdtempSync(join(tmpdir(), 'motion-profile-missing-tools-')); + try { + const root = fileURLToPath(new URL('../', import.meta.url)); + const clone = join(directory, 'checkout'); + const sourceHead = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: root, encoding: 'utf8' }).trim(); + execFileSync('git', ['clone', '--quiet', '--no-hardlinks', '--no-checkout', root, clone], + { encoding: 'utf8', timeout: 30_000 }); + execFileSync('git', ['checkout', '--quiet', '--detach', sourceHead], { cwd: clone, timeout: 30_000 }); + const run = spawnSync(process.execPath, [ + 'bench/profile/probe-profile-01.mjs', '--mode', 'old-vector', '--cells', 'all', + '--out', join(directory, 'raw'), + ], { cwd: clone, encoding: 'utf8', timeout: 30_000 }); + expect(run.status, run.stderr).toBe(1); + const receipt = JSON.parse(run.stdout.trim()); + const raw = readFileSync(receipt.rawPath); + const artifact = JSON.parse(raw.toString('utf8')); + expect(artifact.head).toBe(sourceHead); + expect(artifact.admission).toBe('NOT-GRANTED'); + expect(artifact.rejection).toContain('незавершённое измерение'); + expect(artifact.rejection).toMatch(/не установлен|не создала обязательный|сборка/); + expect(artifact.costVector).toBeNull(); + expect(artifact.cellsMeasured).toEqual([]); + expect(artifact.cellsUnproven).toEqual(unmeasuredCells('all')); + expect(receipt.rawDigest).toBe(createHash('sha256').update(raw).digest('hex')); + const refusal = spawnSync(process.execPath, ['bench/profile/validate-profile-01.mjs', '--raw', receipt.rawPath], + { cwd: clone, encoding: 'utf8', timeout: 30_000 }); + expect(refusal.status, refusal.stderr).toBe(0); + expect(JSON.parse(refusal.stdout.trim()).verification).toBe('recorded-refusal-only'); + } finally { + rmSync(directory, { recursive: true, force: true }); + } + }, 90_000); +}); + +describe('PROFILE: замороженный протокол проверяется целиком', () => { + it('зарегистрированные потолки совпадают с единственным исполняемым size-gate', async () => { + const gate = await import('../scripts/size-gate.mjs'); + const registered = PROFILE_01.oldCostVectorGzipBytes; + expect(registered.bespoke).toEqual(gate.BESPOKE_SUBPATH_GATES); + expect(registered.scenarios).toEqual(Object.fromEntries( + gate.IMPORT_COST_SCENARIOS.map(({ name, gate: ceiling }) => [name, ceiling]), + )); + expect(registered.core).toBe(gate.CORE_GATE_BYTES); + expect(registered.subpath).toBe(gate.SUBPATH_GATE_BYTES); + expect(registered.fullCoreConsumer).toBe(gate.FULL_CORE_CONSUMER_GATE_BYTES); + expect(registered.nano).toBe(gate.NANO_GATE_BYTES); + expect(registered.compiledRuntime).toBe(gate.BESPOKE_SUBPATH_GATES['./compiler/runtime']); + expect(registered.compilerSurface).toBe(gate.BESPOKE_SUBPATH_GATES['./compiler/surface']); + expect(registered.inView).toBe(gate.IN_VIEW_GATE_BYTES); + expect(registered.inViewConsumer).toBe(gate.IN_VIEW_CONSUMER_GATE_BYTES); + expect(registered.compositorCapability).toBe(gate.COMPOSITOR_CAPABILITY_GATE_BYTES); + expect(registered.fullAnimate).toBe(gate.FULL_ANIMATE_GATE_BYTES); + expect(registered.animateCompositorMixed).toBe(gate.ANIMATE_COMPOSITOR_MIXED_GATE_BYTES); + }); + + it('принимает независимую JSON-копию полного протокола', () => { + expect(verifyPreregistration()).toBe(true); + expect(verifyPreregistration(JSON.parse(JSON.stringify(PROFILE_01)))).toBe(true); + }); + + it.each([ + ['productBase', 'reason'], ['scenes', 'm05a', 'acceptance'], + ['rawControls', 'aa'], ['statsMde', 'sampleSize'], ['calibration', 'failClosed'], + ])('отвергает подмену условия %s.%s', (...path) => { + const changed = JSON.parse(JSON.stringify(PROFILE_01)); + const parent = path.slice(0, -1).reduce((object, key) => object[key], changed); + parent[path.at(-1)!] = 'условие удалено'; + expect(() => verifyPreregistration(changed)).toThrow(); + }); + + it('отвергает потерю, подмену и лишние поля на каждой глубине', () => { + function visit(value: unknown, path: string[] = []) { + if (value === null || typeof value !== 'object') return; + for (const [key, child] of Object.entries(value)) { + const childPath = [...path, key]; + for (const operation of ['delete', 'replace']) { + const changed = JSON.parse(JSON.stringify(PROFILE_01)); + const parent = path.reduce((object, segment) => object[segment], changed); + if (operation === 'delete') delete parent[key]; + else parent[key] = typeof child === 'string' ? `${child}!` : 'другой тип'; + expect(() => verifyPreregistration(changed), `${operation} ${childPath.join('.')}`).toThrow(); + } + visit(child, childPath); + } + const extra = JSON.parse(JSON.stringify(PROFILE_01)); + const parent = path.reduce((object, key) => object[key], extra); + parent.unregistered = true; + expect(() => verifyPreregistration(extra), `extra ${path.join('.')}`).toThrow(); + } + visit(PROFILE_01); + for (const invalid of [null, [], '', 1, true]) { + expect(() => verifyPreregistration(invalid)).toThrow(); + } + }); + + it('не позволяет мутировать вложенные массивы исходного протокола', () => { + expect(() => PROFILE_01.roster.classes[0].browsers.push('подмена')).toThrow(); + expect(PROFILE_01.roster.classes[0].browsers).toEqual(['chromium-webview-stable']); + }); +}); + +function measured() { + return { + exitCode: 0, + entries: { rows: [{ label: '.', gzBytes: 42, gate: 100 }], hasWarnings: false }, + scenarios: [{ name: 'only-spring', gzBytes: 7, totalGzBytes: 7, gate: 10 }], + provenance: { + revision: 'a'.repeat(40), trackedRevisionSha256: 'b'.repeat(64), + inputs: { 'root/pnpm-lock.yaml': 'c'.repeat(64) }, + distRuntime: { sha256: 'd'.repeat(64), files: 2 }, + environment: { node: 'v24.15.0', pnpm: '11.11.0' }, + builtAt: '2026-09-30T00:00:00Z', + }, + }; +} + +describe('PROFILE: записанный успех не заменяет независимый результат', () => { + it('принимает воспроизведённые данные с новым временем сборки', () => { + const replayed = measured(); + replayed.provenance.builtAt = '2026-09-30T00:01:00Z'; + expect(() => validateReplayedVector(measured(), replayed)).not.toThrow(); + }); + + it('отвергает старую подделку из имён сценариев и exit 0', () => { + const forged = { exitCode: 0, transcript: 'only-spring size-gate: PASS' }; + expect(() => validateReplayedVector(forged, measured())).toThrow('структурированные измерения'); + }); + + it('отвергает отказ повторного измерения при записанном успехе', () => { + expect(() => validateReplayedVector(measured(), { ...measured(), exitCode: 1 })).toThrow('независимое измерение'); + }); + + it('не повышает сохранённый отказ до успеха', () => { + expect(() => validateReplayedVector({ ...measured(), exitCode: 1 }, measured())).toThrow('независимое измерение'); + }); + + it('ловит подмену числа при сохранённом PASS и неизменных названиях', () => { + const forged = measured(); + forged.scenarios[0].gzBytes = 1; + expect(() => validateReplayedVector(forged, measured())).toThrow('scenarios'); + }); + + it('ловит удалённый consumer и лишний неподтверждённый entry', () => { + const missing = measured(); + missing.scenarios = []; + expect(() => validateReplayedVector(missing, measured())).toThrow(); + const extra = measured(); + extra.entries.rows.push({ label: './extra', gzBytes: 1, gate: 100 }); + expect(() => validateReplayedVector(extra, measured())).toThrow('entries'); + }); + + it.each(['revision', 'trackedRevisionSha256', 'inputs', 'distRuntime', 'environment'])('отвергает другой %s', (field) => { + const forged = measured(); + Reflect.set(forged.provenance, field, 'forged'); + expect(() => validateReplayedVector(forged, measured())).toThrow(`provenance.${field}`); + }); + + it('размерный прогон оставляет все device/timing клетки непроверенными', () => { + expect(unmeasuredCells('desktop').map((row: { cell: string }) => row.cell)).toEqual([ + 'desktop-chromium', 'desktop-firefox', 'desktop-webkit', 'whole-page energy/GPU', + ]); + expect(unmeasuredCells('all').map((row: { cell: string }) => row.cell)).toEqual([ + 'android-mid-60', 'android-mid-120', 'ios-60', 'ios-120', + 'desktop-chromium', 'desktop-firefox', 'desktop-webkit', 'whole-page energy/GPU', + ]); + expect(() => unmeasuredCells('unknown')).toThrow(); + }); + + it('хеш реального failure-артефакта покрывает отступы, Unicode и конечный LF', () => { + const directory = mkdtempSync(join(tmpdir(), 'motion-profile-digest-')); + try { + const root = fileURLToPath(new URL('../', import.meta.url)); + const run = spawnSync(process.execPath, [ + 'bench/profile/probe-profile-01.mjs', '--mode', 'aa', '--out', directory, + ], { cwd: root, encoding: 'utf8', timeout: 15_000 }); + expect(run.status, run.stderr).toBe(1); + const receipt = JSON.parse(run.stdout.trim()); + const raw = readFileSync(receipt.rawPath); + expect(JSON.parse(raw.toString('utf8')).rejection).toContain('browser-калибровки'); + expect(raw.toString('utf8')).toContain('\n "node"'); + expect(raw.at(-1)).toBe(10); + expect(receipt.rawDigest).toBe(createHash('sha256').update(raw).digest('hex')); + } finally { + rmSync(directory, { recursive: true, force: true }); + } + }, 20_000); +});