Files
houseplan-card/test/check-inputs.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

308 lines
19 KiB
JavaScript
Executable File
Raw Permalink 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.
import test from 'node:test';
import assert from 'node:assert/strict';
import { execFileSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import {
BASELINE_OVERLAY, CHECKS, CHECK_NAMES, NOT_AN_INPUT, REUSE_JOBS, checksAffectedBy, closure, coverage,
globToRegExp, inputsOf, isBaselineOverlay, isDeclaredNotAnInput, isExecutableInput, manifest, referencesOf,
stripComments,
} from '../scripts/check-inputs.mjs';
// Единый manifest входов (#492 §5). Две группы доказательств: чистая механика
// (glob, ссылки, замыкание) на виртуальном дереве и представительные входы
// §8.1 на РЕАЛЬНОМ репозитории — для каждой тяжёлой job и каждой категории
// названный файл обязан быть её входом, а UI — не входом backend (AC6).
const ROOT = process.cwd();
const MANIFEST = manifest(ROOT);
// Пути, которых в manifest быть не должно, собираются из кусков — литерал в
// этом файле сделал бы их входом frontend (тесты читают то, что называют).
const p = (...parts) => parts.join('/');
test('glob: ** — любой путь, * — сегмент, точка буквальна', () => {
assert.ok(globToRegExp('src/**').test('src/a/b.ts'));
assert.ok(globToRegExp('src/**').test('src/a.ts'));
assert.ok(!globToRegExp('src/**').test('srcx/a.ts'));
assert.ok(globToRegExp('demo/smoke_*.mjs').test('demo/smoke_alpha.mjs'));
assert.ok(!globToRegExp('demo/smoke_*.mjs').test('demo/guard/smoke_alpha.mjs'));
assert.ok(globToRegExp('custom_components/**/*.py').test('custom_components/houseplan/store.py'));
assert.ok(!globToRegExp('custom_components/**/*.py').test('custom_components/houseplan/frontend/x.js'));
assert.ok(!globToRegExp('package.json').test('packageXjson'));
});
test('ссылки: импорты JS — код, строковые пути — данные, комментарии — ничего', () => {
const text = `
import a from './a.mjs';
import { b } from '../lib/b.mjs';
export * from './c.mjs';
const d = await import('./d.mjs');
const e = require('./e.mjs');
// import x from './comment.mjs';
/* readFileSync('demo/fixtures/comment.json') */
const f = readFileSync('demo/fixtures/f.json');
const g = 'docs/g.md';
const h = spawnSync('node', ['demo/benchmark_h.mjs', '--guard-probe']);
const hint = 'run node demo/benchmark_hint.mjs by hand';
`;
const refs = referencesOf('test/x.test.mjs', text);
assert.deepEqual(refs.code.sort(), [
'demo/benchmark_h.mjs', 'lib/b.mjs', 'test/a.mjs', 'test/c.mjs', 'test/d.mjs', 'test/e.mjs',
]);
// путь внутри фразы-подсказки — не ссылка: строка обязана быть путём целиком
assert.deepEqual(refs.data.sort(), ['demo/fixtures/f.json', 'docs/g.md']);
assert.ok(!stripComments('x.mjs', text).includes('comment.mjs'));
});
test('ссылки: test-build/*.js — это src/*.ts, компилируемый tsconfig.test.json', () => {
const refs = referencesOf('test/x.test.mjs', "import { f } from '../test-build/space-geometry.js';\n");
assert.deepEqual(refs.code, ['src/space-geometry.ts']);
});
test('ссылки: Python — пакеты репозитория, относительные модули relay, Path-цепочки', () => {
const text = `
from custom_components.houseplan.validation import CONFIG_SCHEMA
import tests_backend.pure_imports
from hp_relay.app import main
""" from custom_components.houseplan.ghost import x """
GOLDEN = REPO / "scripts" / "sh3d-convert" / "golden"
schema = (REPO / "scripts" / "config-schema.json").read_text()
`;
const refs = referencesOf('scripts/support-relay/tests/test_relay.py', text);
assert.ok(refs.code.includes('custom_components/houseplan/validation.py'));
assert.ok(refs.code.includes('tests_backend/pure_imports.py'));
assert.ok(refs.code.includes('scripts/support-relay/hp_relay/app.py'));
assert.ok(!refs.code.some((f) => f.includes('ghost')), 'docstring — не импорт');
assert.ok(refs.data.includes('scripts/sh3d-convert/golden'));
assert.ok(refs.data.includes('scripts/config-schema.json'));
});
test('замыкание: код транзитивно, данные — листья, каталог — все текстовые файлы под ним', () => {
const files = {
'demo/smoke_a.mjs': "import './serve.mjs';\nconst x = 'demo/fixtures';\n",
'demo/serve.mjs': "import './compat.mjs';\n",
'demo/compat.mjs': "import '../scripts/helper.mjs';\n",
'scripts/helper.mjs': "export const h = 1; // import './never.mjs'\n",
'scripts/never.mjs': '',
'demo/fixtures/one.mjs': "import '../deep.mjs';\n",
'demo/fixtures/two.json': '{}',
'demo/fixtures/pic.png': 'binary',
'demo/deep.mjs': '',
};
const tracked = Object.keys(files).sort();
const parents = new Map();
const reached = closure('/virtual', ['demo/smoke_a.mjs'], { tracked, read: (f) => files[f], parents });
assert.deepEqual(reached, [
'demo/compat.mjs', 'demo/fixtures/one.mjs', 'demo/fixtures/two.json', 'demo/serve.mjs',
'demo/smoke_a.mjs', 'scripts/helper.mjs',
]);
// фикстура — данные: её собственный импорт (deep.mjs) не читается,
// картинка под каталогом не берётся, комментарий не ссылка
assert.ok(!reached.includes('demo/deep.mjs'));
assert.ok(!reached.includes('demo/fixtures/pic.png'));
assert.ok(!reached.includes('scripts/never.mjs'));
assert.equal(parents.get('scripts/helper.mjs'), 'demo/compat.mjs');
});
// #573: overlay принятых эталонов принадлежит только golden. Строка-каталог
// `demo/golden` в корпусе отпечатка раскрывалась во ВСЕ текстовые файлы под
// ним, и индекс эталонов становился входом smoke, perf и каждого гарда через
// serve.mjs — приёмка 13 кадров на beta.3 перегнала 22 минуты чужой работы.
test('#573: раскрытие каталога не выдаёт overlay эталонов; явный корень и явная ссылка — выдают', () => {
const files = {
'demo/smoke_a.mjs': "import './serve.mjs';\n",
'demo/serve.mjs': "import '../scripts/source-fingerprint.mjs';\n",
'scripts/source-fingerprint.mjs': "const corpus = ['demo/fixtures', 'demo/golden'];\n",
'demo/golden/run.mjs': "const baselineRoot = 'demo/golden/baselines';\n",
'demo/golden/matrix.mjs': 'export const GOLDEN_SCENARIOS = [];\n',
'demo/golden/baselines/baselines-index.json': '{"scenarios":{}}',
'demo/golden/baselines/scene.png': 'binary',
'demo/golden/baselines/.gitkeep': '',
'test/golden-index.test.mjs': "const index = 'demo/golden/baselines/baselines-index.json';\n",
};
const tracked = Object.keys(files).sort();
const viaSmoke = closure('/virtual', ['demo/smoke_a.mjs'], { tracked, read: (f) => files[f] });
assert.ok(viaSmoke.includes('demo/golden/matrix.mjs'), 'код под demo/golden — по-прежнему вход');
assert.ok(!viaSmoke.some(isBaselineOverlay), `overlay не течёт через каталог: ${viaSmoke.join(', ')}`);
// golden сама называет каталог эталонов строкой — и всё равно получает их не
// раскрытием, а явным корнем manifest (CHECKS.golden.roots)
const viaGolden = closure('/virtual', ['demo/golden/run.mjs'], { tracked, read: (f) => files[f] });
assert.ok(!viaGolden.some(isBaselineOverlay), 'каталог overlay по строке — тоже не раскрывается');
assert.ok(CHECKS.golden.roots.includes('demo/golden/**'), 'эталоны входят в golden корнем');
// явная ссылка на файл overlay — честная зависимость, она остаётся
const viaTest = closure('/virtual', ['test/golden-index.test.mjs'], { tracked, read: (f) => files[f] });
assert.ok(viaTest.includes('demo/golden/baselines/baselines-index.json'));
assert.deepEqual(BASELINE_OVERLAY, ['demo/golden/baselines/**']);
assert.equal(isBaselineOverlay('demo/golden/baselines/baselines-index.json'), true);
assert.equal(isBaselineOverlay('demo/golden/matrix.mjs'), false);
});
test('#573: на живом дереве индекс эталонов — вход golden и ничьей другой реюзной job', () => {
// путь собран из кусков: литерал сделал бы индекс входом frontend через этот тест
const index = p('demo', 'golden', 'baselines', 'baselines-index.json');
for (const check of ['smoke', 'performance_smoke', 'geometry_parity', 'backend']) {
assert.ok(!MANIFEST[check].has(index), `${check} не читает эталоны`);
}
assert.ok(MANIFEST.golden.has(index));
});
test('замыкание останавливается на копиях бандла (класс D)', () => {
const files = {
'demo/smoke_a.mjs': "import '../custom_components/houseplan/frontend/houseplan-card.js';\n",
'custom_components/houseplan/frontend/houseplan-card.js': "import './houseplan-assets/x.js';\n",
'custom_components/houseplan/frontend/houseplan-assets/x.js': '',
};
const tracked = Object.keys(files).sort();
const spec = { entries: ['demo/smoke_a.mjs'], roots: [] };
const saved = CHECKS.__virtual;
CHECKS.__virtual = spec;
try {
const inputs = inputsOf('__virtual', '/virtual', { tracked, read: (f) => files[f] });
assert.deepEqual(inputs, ['demo/smoke_a.mjs']);
} finally {
if (saved) CHECKS.__virtual = saved; else delete CHECKS.__virtual;
}
});
test('каждая проверка объявлена, у тяжёлых job включён реюз, у остальных нет', () => {
assert.deepEqual(REUSE_JOBS, ['smoke', 'golden', 'performance_smoke', 'geometry_parity', 'backend']);
for (const name of CHECK_NAMES) {
assert.ok(MANIFEST[name].size > 0, `${name}: пустой manifest`);
assert.ok(Array.isArray(CHECKS[name].entries) && Array.isArray(CHECKS[name].roots), name);
}
assert.throws(() => inputsOf('nope', ROOT), /неизвестная проверка/);
});
test('§8.1 представители: каждая категория каждой тяжёлой job — её вход', () => {
const expect = {
geometry_parity: {
source: ['src/junction-limits.ts', 'src/space-geometry.ts',
'custom_components/houseplan/junction_limits.py', 'custom_components/houseplan/wall_segment_model.py'],
tests: ['tests_backend/junction_parity.py'],
fixtures: ['test/fixtures/junction-limits-parity.json'],
config: ['tsconfig.junction-parity.json', 'tsconfig.json'],
toolchain: ['package.json', 'package-lock.json', '.nvmrc', '.python-version',
'.github/workflows/validate.yml'],
protocol: ['scripts/fix-test-build.mjs', 'scripts/gate-reuse.mjs', 'scripts/check-inputs.mjs'],
},
backend: {
source: ['custom_components/houseplan/websocket_api.py', 'scripts/support-relay/relay.py',
'scripts/support-relay/hp_relay/app.py', 'scripts/sh3d-convert/convert.mjs', 'scripts/dump-config-schema.py'],
tests: ['tests_backend/test_ha_websocket.py', 'tests_backend/conftest.py', 'scripts/support-relay/tests/test_relay.py'],
fixtures: ['scripts/sh3d-convert/golden/two-levels.space-1.json', 'scripts/config-schema.json',
'test/fixtures/real-plan-first-floor.json'],
config: ['pyproject.toml', 'pytest.ini', 'scripts/backend-coverage-baseline.txt'],
toolchain: ['tests_backend/requirements.txt', 'custom_components/houseplan/manifest.json', '.github/workflows/validate.yml'],
protocol: ['scripts/gate-reuse.mjs', 'scripts/check-inputs.mjs', 'scripts/ci-proof.mjs'],
},
smoke: {
source: ['src/houseplan-card.ts', 'src/logic.ts'],
tests: ['demo/smoke_infinite_canvas.mjs', 'demo/guard/verify-guard.mjs', 'demo/benchmark_glow.mjs'],
fixtures: ['demo/fixtures/large-house.mjs', 'demo/fixtures/wall-draw-click.mjs'],
config: ['rollup.config.mjs', 'tsconfig.json'],
toolchain: ['package.json', 'package-lock.json', '.github/workflows/validate.yml'],
protocol: ['demo/serve.mjs', 'demo/srv/demo.html', 'demo/bundle-freshness.mjs',
'demo/editor-runtime-compat.mjs', 'demo/iso-runtime-compat.mjs', 'scripts/smoke-select.mjs',
'scripts/ci-proof.mjs'],
},
golden: {
source: ['src/houseplan-card.ts'],
tests: ['demo/golden/run.mjs', 'demo/golden/matrix.mjs', 'demo/golden/harness.mjs'],
fixtures: ['demo/golden/baselines/geometry-view-dark-fit.png', 'demo/golden/baselines/baselines-index.json',
'demo/fixtures/visual-matrix.mjs'],
config: ['rollup.config.mjs', 'tsconfig.json'],
toolchain: ['package.json', '.github/workflows/validate.yml'],
protocol: ['demo/serve.mjs', 'demo/srv/demo.html', 'demo/bundle-freshness.mjs',
'demo/editor-runtime-compat.mjs', 'scripts/ci-proof.mjs'],
},
performance_smoke: {
source: ['src/houseplan-card.ts'],
tests: ['demo/benchmark_glow.mjs', 'demo/benchmark_large_house.mjs', 'demo/performance/compare.mjs'],
fixtures: ['demo/fixtures/large-house.mjs', 'demo/performance/budgets-glow-smoke.json',
'demo/performance/budgets-isometric-smoke.json', 'demo/performance/budgets-interaction-smoke.json'],
config: ['rollup.config.mjs'],
toolchain: ['package.json', '.github/workflows/validate.yml'],
protocol: ['demo/serve.mjs', 'demo/srv/demo.html', 'demo/editor-runtime-compat.mjs',
'demo/performance/evaluate.mjs', 'scripts/ci-proof.mjs'],
},
};
for (const [job, categories] of Object.entries(expect)) {
for (const [category, files] of Object.entries(categories)) {
for (const file of files) {
assert.ok(MANIFEST[job].has(file), `${job}/${category}: ${file} не вход`);
const { affected, unknown } = checksAffectedBy([file], ROOT, { manifest: MANIFEST });
assert.ok(affected.has(job), `${job}/${category}: ${file} не классифицируется`);
assert.deepEqual(unknown, [], file);
}
}
}
});
test('#542: каждый динамический cross-runtime input выбирает backend без глобального fallback', () => {
const cases = [
[p('src', 'plan-optimizer.ts'), p('tests_backend', 'test_validation.py')],
[p('src', 'logic.ts'), p('tests_backend', 'test_support_package.py')],
[p('demo', 'fixtures', 'large-house.mjs'), p('tests_backend', 'test_validation.py')],
[p('demo', 'fixtures', 'visual-matrix.mjs'), p('tests_backend', 'test_validation.py')],
];
for (const [input, consumer] of cases) {
assert.ok(MANIFEST.backend.has(consumer), `${consumer}: consumer не входит в backend`);
assert.ok(MANIFEST.backend.has(input), `${consumer} читает ${input}, но manifest его потерял`);
const { affected, unknown } = checksAffectedBy([input], ROOT, { manifest: MANIFEST });
assert.ok(affected.has('backend'), `${input}: backend не выбран`);
assert.ok(affected.size < CHECK_NAMES.length, `${input}: сработал глобальный fallback вместо точного owner`);
assert.deepEqual(unknown, [], input);
}
const unrelated = p('demo', 'fixtures', 'wall-draw-click.mjs');
assert.ok(!MANIFEST.backend.has(unrelated), `${unrelated}: точные roots нельзя расширять до всего каталога`);
assert.ok(!checksAffectedBy([unrelated], ROOT, { manifest: MANIFEST }).affected.has('backend'));
});
test('§8.1 обратная проба (AC6): UI — не вход backend, backend — не вход браузерных job без причины', () => {
for (const file of ['src/houseplan-card.ts', 'src/houseplan-editor-runtime.ts', 'src/iso-overlays.ts', 'package.json']) {
assert.ok(!MANIFEST.backend.has(file), `backend зависит от ${file}`);
assert.ok(!checksAffectedBy([file], ROOT, { manifest: MANIFEST }).affected.has('backend'), file);
}
// smoke_infinite_canvas запускает backend-валидацию как процесс — это
// честная зависимость; но tests_backend/** браузерным job не нужны
for (const job of ['smoke', 'golden', 'performance_smoke']) {
assert.ok(![...MANIFEST[job]].some((f) => f.startsWith('tests_backend/')), `${job} читает tests_backend`);
}
for (const file of ['src/houseplan-card.ts', 'src/houseplan-editor-runtime.ts',
'custom_components/houseplan/websocket_api.py']) {
assert.ok(!MANIFEST.geometry_parity.has(file), `geometry_parity зависит от ${file}`);
}
});
test('§5.5 лист покрытия: ни одного неизвестного исполняемого файла, ни одной лишней записи NOT_AN_INPUT', () => {
const { unknown, declaredButCovered } = coverage(ROOT, { manifest: MANIFEST });
assert.deepEqual(unknown, [], 'исполняемый файл без хозяина: впишите в CHECKS или в NOT_AN_INPUT с причиной');
assert.deepEqual(declaredButCovered, [], 'запись NOT_AN_INPUT лишняя — файл и так вход');
for (const [glob, reason] of NOT_AN_INPUT) assert.ok(reason.length > 10, `${glob}: нужна причина`);
});
test('неизвестный вход расширяет до всех проверок и называется; документация и не-входы — нет', () => {
const ghost = p('scripts', 'ghost-gate.mjs');
const { affected, unknown } = checksAffectedBy([ghost], ROOT, { manifest: MANIFEST });
assert.deepEqual(unknown, [ghost]);
assert.deepEqual([...affected].sort(), [...CHECK_NAMES].sort());
assert.ok(isExecutableInput(ghost));
assert.ok(!isExecutableInput(p('docs', 'ghost.md')));
assert.ok(isDeclaredNotAnInput(p('demo', 'shot_sun.mjs')));
const quiet = checksAffectedBy([p('docs', 'ghost.md'), p('demo', 'shot_sun.mjs')], ROOT, { manifest: MANIFEST });
assert.deepEqual(quiet.unknown, []);
assert.equal(quiet.affected.size, 0);
});
test('CLI: --check печатает входы, --why объясняет цепочку, --coverage зелёный на текущем дереве', () => {
// fileURLToPath, не URL.pathname (#496): на Windows pathname даёт «/C:/…», Node ищет C:\C:\… .
const script = fileURLToPath(new URL('../scripts/check-inputs.mjs', import.meta.url));
const backend = execFileSync('node', [script, '--check=backend'], { encoding: 'utf8' }).trim().split('\n');
assert.ok(backend.includes('scripts/support-relay/relay.py'));
const why = execFileSync('node', [script, '--check=backend', '--why=scripts/sh3d-convert/convert.mjs'], { encoding: 'utf8' });
assert.match(why, /^scripts\/sh3d-convert\/convert\.mjs\n/);
assert.match(why, /tests_backend\/test_sh3d_convert\.py/);
const cov = execFileSync('node', [script, '--coverage'], { encoding: 'utf8' });
assert.equal(cov.trim(), '');
});