Files
houseplan-card/test/gate-reuse.test.mjs
Claude 47f36e571c ci: proof различает продуктовое дерево и overlay эталонов (#573)
Приёмка эталонов на beta.3 (`ad4000f9`) стоила второго полного Validate —
22 минуты, из них 17–22 на шард мутантов. Причина одна: корпус отпечатка
(`source-fingerprint.mjs`) называет `demo/golden` строкой-каталогом, а
замыкание входов раскрывало каталог во все текстовые файлы под ним, включая
`baselines-index.json`. Индекс становился входом smoke, performance_smoke и
каждого гарда через `serve.mjs`: на реальной паре C→B ключи smoke/perf были
DIFFERENT, отпечатки 181 из 183 браузерных свидетелей менялись, журнал их не
пропускал.

- `check-inputs.mjs`: `BASELINE_OVERLAY` — раскрытие каталога не выдаёт
  overlay; явный корень golden и явная ссылка на файл — как были. На паре
  C→B: ключи smoke/perf/parity/backend same, golden DIFFERENT; отпечатки
  743 из 744 равны; план мутантов B с журналом C — 0–1 на шард вместо 38–44
- `ci-proof.mjs`: составное evidence — product tree без overlay, overlay
  (tree, sha256 индекса, run из `Baseline-Reviewed`), content-ключи всех
  реюзных job (исполненных тоже); `evaluateCiProof({expected, reviewedRun})`
  сверяет с локальным расчётом, fail-closed на ключ, tree, индекс, reviewed
  run, маркер с чужим ключом; proof без evidence при ожиданиях — stale
- `release-gate.mjs` / `release-prerelease.mjs`: ожидания считаются на
  checkout кандидата (`candidateExpectations`), чужой checkout — notice
- мутанты: `baseline-overlay-leaks-into-every-key`,
  `proof-trusts-evidence-it-could-verify`,
  `reused-marker-key-unchecked-against-candidate`,
  `product-tree-identity-counts-baselines`; перенацелен
  `ci-proof-ignores-run-attempt`
- docs: TESTING (правило overlay), DEVELOPMENT (evidence в release proof),
  STATUS

Issue: #573
User-Visible: no
2026-09-17 22:00:15 +03:00

426 lines
22 KiB
JavaScript

import assert from 'node:assert/strict';
import test from 'node:test';
import { spawnSync } from 'node:child_process';
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import {
JOBS, harnessFiles, inheritedFailureNote, parseFailureMarker, reuseKey,
} from '../scripts/gate-reuse.mjs';
/**
* Дерево, минимально достаточное для manifest каждой тяжёлой job (#492):
* корни проверок, точки входа и то, что они импортируют. Реальные каталоги,
* а не подмены: ключ обязан отражать файловую систему так же, как в CI.
* Без `.git` manifest обходит дерево сам.
*/
const makeTree = () => {
const dir = mkdtempSync(join(tmpdir(), 'hp-reuse-'));
const put = (rel, text) => {
const full = join(dir, rel);
mkdirSync(join(full, '..'), { recursive: true });
writeFileSync(full, text);
};
put('package.json', '{"name":"x","version":"1.0.0"}\n');
put('package-lock.json', '{"lockfileVersion":3}\n');
put('rollup.config.mjs', 'export default {};\n');
put('tsconfig.json', '{}\n');
put('tsconfig.junction-parity.json', '{}\n');
put('.nvmrc', '22\n');
put('.python-version', '3.14\n');
put('.github/workflows/validate.yml', 'name: Validate\n');
put('scripts/source-fingerprint.mjs', '// pinned by the real repo copy\n');
put('scripts/gate-reuse.mjs', '// reuse protocol\n');
put('scripts/check-inputs.mjs', '// manifest\n');
put('scripts/fix-test-build.mjs', '// esm import fixer\n');
put('src/card.ts', "export const CARD_VERSION = '1.0.0';\n");
put('src/plan-optimizer.ts', 'export const PLAN_MODEL_VERSION = 1;\n');
put('src/logic.ts', 'export const logic = 1;\n');
put('src/junction-limits.ts', "import './space-geometry';\nexport const MIN = 5;\n");
put('src/space-geometry.ts', 'export const GRID_STEP_N = 1 / 240;\n');
put('demo/serve.mjs', "import './bundle-freshness.mjs';\n");
put('demo/bundle-freshness.mjs', 'export const fresh = 1;\n');
put('demo/srv/demo.html', '<div id="host"></div>\n');
put('demo/fixtures/one.mjs', 'export const fixture = 1;\n');
put('demo/fixtures/large-house.mjs', 'export const makeLargeHouseFixture = () => ({});\n');
put('demo/fixtures/visual-matrix.mjs', 'export const makeVisualMatrixFixture = () => ({});\n');
put('demo/smoke_alpha.mjs', "import { launch } from './serve.mjs';\nimport '../scripts/model-invariants.mjs';\nconsole.log(1);\n");
put('demo/smoke_beta.mjs', "import { launch } from './serve.mjs';\nconsole.log(2);\n");
put('scripts/model-invariants.mjs', 'export const invariants = 1;\n');
put('demo/guard/verify-guard.mjs', '// probes\n');
put('demo/benchmark_glow.mjs', "import './serve.mjs';\nexport const glow = 1;\n");
put('demo/benchmark_large_house.mjs', "import './serve.mjs';\nimport './fixtures/one.mjs';\n");
put('demo/golden/run.mjs', "import '../serve.mjs';\nexport const run = 1;\n");
put('demo/golden/baselines/one.png', Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x00, 0x01]));
put('demo/performance/compare.mjs', 'export const cmp = 1;\n');
put('demo/performance/budgets-glow-smoke.json', '{"hardMaxMs":1}\n');
put('tests_backend/test_pure.py', 'from custom_components.houseplan.store import VERSION\n\ndef test_x():\n assert True\n');
put('tests_backend/junction_parity.py', 'from pure_imports import load_pure\n');
put('tests_backend/pure_imports.py', 'def load_pure():\n pass\n');
put('tests_backend/requirements.txt', 'pytest\n');
put('test/fixtures/junction-limits-parity.json', '{"schema_version":1}\n');
put('custom_components/houseplan/junction_limits.py', 'from .wall_segment_model import VERSION\n');
put('custom_components/houseplan/wall_segment_model.py', 'VERSION = 1\n');
put('custom_components/houseplan/store.py', 'VERSION = 1\n');
put('custom_components/houseplan/manifest.json', '{"domain":"houseplan","version":"1.0.0"}\n');
put('custom_components/houseplan/frontend/houseplan-card.js', 'built bundle\n');
put('scripts/support-relay/relay.py', 'from hp_relay.app import main\n');
put('scripts/support-relay/hp_relay/app.py', 'def main():\n pass\n');
put('scripts/support-relay/tests/test_relay.py', 'from hp_relay.app import main\n');
put('scripts/config-schema.json', '{}\n');
put('pytest.ini', '[pytest]\n');
put('pyproject.toml', '[tool.ruff]\n');
put('scripts/backend-coverage-baseline.txt', '80.0\n');
put('docs/STATUS.md', 'status\n');
return { dir, put };
};
const keys = (dir) => Object.fromEntries(JOBS.map((job) => [job, reuseKey(dir, job)]));
test('every heavy job has non-empty inputs and its own key', () => {
const { dir } = makeTree();
try {
assert.deepEqual(JOBS, ['smoke', 'golden', 'performance_smoke', 'geometry_parity', 'backend']);
const k = keys(dir);
for (const job of JOBS) assert.ok(harnessFiles(dir, job).length > 0, job);
assert.equal(new Set(Object.values(k)).size, JOBS.length, 'ключи job обязаны различаться');
assert.throws(() => reuseKey(dir, 'frontend'), /неизвестная job/);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test('documentation, the process canon and tooling nobody executes leave every key untouched (#208)', () => {
// Именно этот случай и оплачивал полный прогон: коммит, не меняющий ни одного
// входа ни одной тяжёлой job.
const { dir, put } = makeTree();
try {
const before = keys(dir);
put('docs/STATUS.md', 'status changed\n');
put('test/some.test.mjs', 'import test from "node:test";\n');
put('scripts/process-gate.mjs', '// unrelated tooling\n');
put('PROCESS.md', 'canon\n');
assert.deepEqual(keys(dir), before);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test('the workflow itself is a toolchain input of every job (#492 §5.4)', () => {
// Правка шага job меняет, ЧТО проверяется; пропустить такую job как
// переиспользованную — тот же дефект, что #430, этажом выше.
const { dir, put } = makeTree();
try {
const before = keys(dir);
put('.github/workflows/validate.yml', 'name: Validate\n# step added\n');
for (const job of JOBS) assert.notEqual(reuseKey(dir, job), before[job], job);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test('a behaviour input changes every browser key and a version bump changes all of them (#208, #492 AC6)', () => {
const { dir, put } = makeTree();
try {
const before = keys(dir);
put('src/card.ts', "export const CARD_VERSION = '1.0.0';\n// behaviour\n");
for (const job of ['smoke', 'golden', 'performance_smoke']) assert.notEqual(reuseKey(dir, job), before[job], job);
// Обратная проба AC6: бэкенд UI не исполняет — его ключ на месте.
assert.equal(reuseKey(dir, 'backend'), before.backend, 'backend не зависит от src/**');
// Релизный кандидат бампает версию в package.json (браузерные job) и в
// manifest.json интеграции (backend): ключи кандидата заведомо новые, и
// полный набор гейтов прогоняется всегда.
const bumped = keys(dir);
put('src/card.ts', "export const CARD_VERSION = '1.1.0';\n// behaviour\n");
put('package.json', '{"name":"x","version":"1.1.0"}\n');
put('custom_components/houseplan/manifest.json', '{"domain":"houseplan","version":"1.1.0"}\n');
for (const job of JOBS) assert.notEqual(reuseKey(dir, job), bumped[job], job);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
// #573 — воспроизведение beta.3 на синтетическом дереве: кандидат C и
// baseline-only коммит B отличаются только overlay эталонов (PNG + индекс).
// Корпус отпечатка называет `demo/golden` каталогом ровно как настоящий
// source-fingerprint.mjs; до #573 это делало индекс входом smoke и perf.
test('#573: приёмка эталонов меняет только ключ golden — smoke, perf, parity и backend переиспользуются', () => {
const { dir, put } = makeTree();
try {
put('scripts/source-fingerprint.mjs', "const corpus = ['demo/fixtures', 'demo/golden'];\nexport const fp = 1;\n");
put('demo/bundle-freshness.mjs', "import '../scripts/source-fingerprint.mjs';\nexport const fresh = 1;\n");
put('demo/golden/baselines/baselines-index.json', '{"scenarios":{"one":"a"}}\n');
const candidate = keys(dir);
// B: 13 кадров и индекс переписаны, продукт не тронут
put('demo/golden/baselines/one.png', Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x00, 0x02]));
put('demo/golden/baselines/baselines-index.json', '{"scenarios":{"one":"b"},"acceptedAt":"later"}\n');
const accepted = keys(dir);
for (const job of JOBS) {
if (job === 'golden') assert.notEqual(accepted[job], candidate[job], 'golden сравнивает с эталонами и обязана перегоняться');
else assert.equal(accepted[job], candidate[job], `${job}: входы побайтово те же, что у кандидата`);
}
// но правка продукта вместе с эталонами перегоняет всё, что собирает бандл
put('src/card.ts', "export const CARD_VERSION = '1.0.0'; export const changed = true;\n");
const both = keys(dir);
for (const job of ['smoke', 'golden', 'performance_smoke']) assert.notEqual(both[job], accepted[job], `${job} видит правку исходника`);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test('harness edits are isolated to their own job (#208)', () => {
const { dir, put } = makeTree();
const only = (changed) => {
const before = keys(dir);
return (apply) => {
apply();
const after = keys(dir);
for (const job of JOBS) {
if (changed.includes(job)) assert.notEqual(after[job], before[job], `${job} должен меняться`);
else assert.equal(after[job], before[job], `${job} меняться не должен`);
}
};
};
try {
only(['smoke'])(() => put('demo/smoke_alpha.mjs', "import { launch } from './serve.mjs';\nconsole.log(3);\n"));
// Эталон — вход сравнения, его подмена обязана менять ключ golden.
only(['golden'])(() => put('demo/golden/baselines/one.png',
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x00, 0x02])));
only(['performance_smoke'])(() => put('demo/performance/compare.mjs', 'export const cmp = 2;\n'));
only(['geometry_parity'])(() => put('test/fixtures/junction-limits-parity.json', '{"schema_version":1,"changed":true}\n'));
only(['backend'])(() => put('custom_components/houseplan/store.py', 'VERSION = 2\n'));
// Протокол браузерного харнеса общий для трёх job (#492 §5.1 protocol).
only(['smoke', 'golden', 'performance_smoke'])(() => put('demo/serve.mjs', "import './bundle-freshness.mjs';\n// harness\n"));
only(['smoke', 'golden', 'performance_smoke'])(() => put('demo/srv/demo.html', '<div id="host"></div>\n<!-- page -->\n'));
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test('#492 backend inputs the old HARNESS did not know: relay, schema, converter, pyproject', () => {
const { dir, put } = makeTree();
const bumps = (rel, text) => {
const before = reuseKey(dir, 'backend');
put(rel, text);
assert.notEqual(reuseKey(dir, 'backend'), before, `${rel}: правка не меняет ключ backend`);
};
try {
bumps('scripts/support-relay/relay.py', 'from hp_relay.app import main\n# changed\n');
bumps('scripts/support-relay/hp_relay/app.py', 'def main():\n return 1\n');
bumps('scripts/config-schema.json', '{"v":2}\n');
bumps('pyproject.toml', '[tool.ruff]\nline-length = 100\n');
bumps('scripts/backend-coverage-baseline.txt', '81.0\n');
bumps('tests_backend/requirements.txt', 'pytest==9\n');
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test('#542 dynamic backend inputs invalidate its reuse key and unrelated fixtures do not', () => {
const { dir, put } = makeTree();
const bumpsBackend = (rel, text) => {
const before = reuseKey(dir, 'backend');
put(rel, text);
assert.notEqual(reuseKey(dir, 'backend'), before, `${rel}: backend reuse key не изменился`);
};
try {
bumpsBackend('src/plan-optimizer.ts', 'export const PLAN_MODEL_VERSION = 2;\n');
bumpsBackend('src/logic.ts', 'export const logic = 2;\n');
bumpsBackend('demo/fixtures/large-house.mjs', 'export const makeLargeHouseFixture = () => ({ changed: true });\n');
bumpsBackend('demo/fixtures/visual-matrix.mjs', 'export const makeVisualMatrixFixture = () => ({ changed: true });\n');
const before = reuseKey(dir, 'backend');
put('demo/fixtures/one.mjs', 'export const fixture = 2;\n');
assert.equal(reuseKey(dir, 'backend'), before,
'нерелевантная fixture не должна превращать backend в широкий UI-гейт');
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test('the built bundle is outside every key — each job builds it itself (#208)', () => {
const { dir, put } = makeTree();
try {
const before = keys(dir);
put('custom_components/houseplan/frontend/houseplan-card.js', 'rebuilt bundle\n');
put('dist/houseplan-card.js', 'rebuilt bundle\n');
put('demo/srv/assets/houseplan-card.js', 'rebuilt bundle\n');
assert.deepEqual(keys(dir), before);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test('the key is canonical across line endings', () => {
const { dir, put } = makeTree();
try {
const before = keys(dir);
put('demo/smoke_alpha.mjs', "import { launch } from './serve.mjs';\r\nimport '../scripts/model-invariants.mjs';\r\nconsole.log(1);\r\n");
assert.deepEqual(keys(dir), before, 'CRLF не должен рождать другой ключ');
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test('the CLI prints the key and writes it to GITHUB_OUTPUT', (t) => {
const { dir } = makeTree();
const cli = fileURLToPath(new URL('../scripts/gate-reuse.mjs', import.meta.url));
const out = join(dir, 'gh-output');
writeFileSync(out, '');
try {
const run = (args, env = {}) => spawnSync(process.execPath, [cli, ...args],
{ encoding: 'utf8', env: { ...process.env, ...env } });
const ok = run([`--repo=${dir}`, '--job=golden'], { GITHUB_OUTPUT: out });
assert.equal(ok.status, 0, ok.stdout + ok.stderr);
const printed = ok.stdout.trim();
assert.match(printed, /^[0-9a-f]{64}$/);
assert.equal(printed, reuseKey(dir, 'golden'));
// Значение уезжает в GITHUB_OUTPUT ровно в том виде, который читает шаг.
// Раньше здесь стоял динамический import внутри синхронной функции — CLI
// падал бы именно в CI, где GITHUB_OUTPUT задан.
assert.equal(readFileSync(out, 'utf8'), `key=${printed}\n`);
// Без --job работать нельзя: пустой ключ совпал бы со всем подряд.
assert.equal(run([`--repo=${dir}`]).status, 2);
const unknown = run([`--repo=${dir}`, '--job=frontend']);
assert.equal(unknown.status, 1);
assert.match(unknown.stderr, /неизвестная job/);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test('#430 ключ смоков покрывает всё, что эта job исполняет', () => {
// Прогон #2371 (ee678352) добавил в verify-guard.mjs пробу гарда benchmark,
// и job со смоками была пропущена как переиспользованная: файл исполняется
// только там, а в её ключ не входил. Проба уехала в dev, не запустившись ни
// разу. Здесь закреплено, что так больше не выйдет.
const { dir, put } = makeTree();
try {
put('demo/guard/guard_tail_exception.mjs', '// probe\n');
put('demo/benchmark_backdrop_decode.mjs', '// benchmark\n');
const files = harnessFiles(dir, 'smoke');
for (const rel of [
'demo/serve.mjs',
'demo/bundle-freshness.mjs',
'demo/srv/demo.html',
'demo/guard/verify-guard.mjs',
'demo/guard/guard_tail_exception.mjs',
'demo/benchmark_backdrop_decode.mjs',
'scripts/model-invariants.mjs',
]) {
assert.ok(files.includes(rel), `${rel} вне ключа смоков — его правка будет реюзнута`);
}
// И ключ обязан меняться от правки каждого из них: список файлов сам по
// себе ничего не гарантирует, если хэш их не читает.
for (const rel of files) {
if (rel === 'demo/golden/baselines/one.png') continue;
const before = reuseKey(dir, 'smoke');
put(rel, readFileSync(join(dir, rel), 'utf8') + '// changed\n');
assert.notEqual(reuseKey(dir, 'smoke'), before, `${rel}: правка не меняет ключ`);
}
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test('scripts/** enter a key only when the job actually reaches them (#208, #492)', () => {
// Инфраструктурная работа правит scripts/** постоянно. В ключ попадает не
// каталог, а то, что job импортирует или запускает: process-gate.mjs — нет,
// model-invariants.mjs (импорт смока) — да.
const { dir, put } = makeTree();
try {
put('scripts/process-gate.mjs', '// tooling\n');
for (const job of JOBS) {
assert.ok(!harnessFiles(dir, job).includes('scripts/process-gate.mjs'), `${job}: process-gate в оснастке`);
}
assert.ok(harnessFiles(dir, 'smoke').includes('scripts/model-invariants.mjs'));
assert.ok(!harnessFiles(dir, 'backend').includes('scripts/model-invariants.mjs'));
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
// --- маркер падения (#386) -------------------------------------------------
const GATE_REUSE = fileURLToPath(new URL('../scripts/gate-reuse.mjs', import.meta.url));
test('первое падение на этих входах называет виновником текущий коммит (#386)', () => {
const note = inheritedFailureNote({ job: 'golden', sha: 'dbbe94aeff00', runUrl: 'https://run/1', prior: null });
assert.equal(note.first, true);
assert.match(note.notice, /впервые/);
assert.match(note.notice, /dbbe94ae/);
assert.match(note.summary.join('\n'), /этого коммита/);
});
test('унаследованное падение снимает вину с текущего коммита (#386)', () => {
const note = inheritedFailureNote({
job: 'golden',
sha: '0f7b6f5aaaaa',
runUrl: 'https://run/2',
prior: { sha: 'dbbe94aeff00', runUrl: 'https://run/1' },
});
assert.equal(note.first, false);
// Оба SHA обязаны быть различимы: письмо называет второй, а причина в первом.
assert.match(note.notice, /dbbe94ae/);
assert.equal(note.notice.includes('0f7b6f5'), false,
'свидетель не должен фигурировать как причина');
assert.match(note.summary.join('\n'), /не ронял/);
assert.match(note.summary.join('\n'), /https:\/\/run\/1/);
});
test('маркер без SHA не выдумывает коммит (#386)', () => {
const note = inheritedFailureNote({ job: 'smoke', sha: 'abc1234567', runUrl: '', prior: { sha: '', runUrl: '' } });
assert.equal(note.first, false);
assert.match(note.summary.join('\n'), /не записан/);
assert.equal(/`[0-9a-f]{8}`/.test(note.summary.join('\n')), false);
});
test('разбор маркера переживает пустой и мусорный вход (#386)', () => {
assert.equal(parseFailureMarker(''), null);
assert.equal(parseFailureMarker(undefined), null);
assert.deepEqual(parseFailureMarker('golden упала\nSHA: abc1234\nпрогон: https://run/9\n'),
{ sha: 'abc1234', runUrl: 'https://run/9' });
assert.deepEqual(parseFailureMarker('мусор\n'), { sha: '', runUrl: '' });
});
test('CLI пишет маркер на первом падении и не трогает его на втором (#386)', () => {
const dir = mkdtempSync(join(tmpdir(), 'hp-failnote-'));
const marker = join(dir, '.fail-marker');
const summary = join(dir, 'summary.md');
const run = (sha) => spawnSync(process.execPath, [
GATE_REUSE, '--job=golden', '--note', `--marker=${marker}`,
], {
encoding: 'utf8',
env: {
...process.env,
GITHUB_SHA: sha,
RUN_URL: `https://run/${sha}`,
GITHUB_STEP_SUMMARY: summary,
GITHUB_OUTPUT: join(dir, 'out.txt'),
},
});
try {
const first = run('dbbe94aeff00');
assert.equal(first.status, 0, first.stderr);
assert.match(first.stdout, /::notice::/);
assert.match(readFileSync(marker, 'utf8'), /SHA: dbbe94aeff00/);
const second = run('0f7b6f5aaaaa');
assert.equal(second.status, 0, second.stderr);
// Главное свойство: свидетель не переписывает первопричину.
assert.match(readFileSync(marker, 'utf8'), /SHA: dbbe94aeff00/);
assert.match(second.stdout, /не ронял/);
assert.match(readFileSync(join(dir, 'out.txt'), 'utf8'), /first=false/);
assert.match(readFileSync(summary, 'utf8'), /унаследованное падение/);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});