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, GUARDED_DATA_ROOTS, globToRegExp, inputsOf, isBaselineOverlay, isDeclaredNotAnInput, isExecutableInput, isGuardedInput, 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('замыкание: код транзитивно, данные — листья, каталог — runtime-файлы под ним', () => { 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/README.md': 'documentation', '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('demo/fixtures/README.md')); 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/baselines/**'), 'эталоны входят в golden корнем'); assert.ok(!CHECKS.golden.roots.includes('demo/golden/**'), 'весь каталог golden не должен захватывать README'); // явная ссылка на файл 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('#672: документация каталога не раскрывается, но явно читаемый Markdown остаётся входом', () => { const files = { 'demo/runner.mjs': "const root = 'demo/harness';\n", 'demo/harness/runtime.mjs': 'export const runtime = true;\n', 'demo/harness/config.json': '{}\n', 'demo/harness/README.md': 'not an implicit input\n', 'test/read-doc.test.mjs': "readFileSync(new URL('../demo/harness/README.md', import.meta.url), 'utf8');\n", }; const tracked = Object.keys(files).sort(); const implicit = closure('/virtual', ['demo/runner.mjs'], { tracked, read: (f) => files[f] }); assert.ok(implicit.includes('demo/harness/runtime.mjs')); assert.ok(implicit.includes('demo/harness/config.json')); assert.ok(!implicit.includes('demo/harness/README.md')); const explicit = closure('/virtual', ['test/read-doc.test.mjs'], { tracked, read: (f) => files[f] }); assert.ok(explicit.includes('demo/harness/README.md')); }); test('#672: README каталогов харнесса не выбирают проверки; реально читаемый README выбирает', () => { const implicitDocs = [ p('demo', 'golden', 'README.md'), p('demo', 'guard', 'README.md'), p('demo', 'srv', 'reference', 'device-icons', 'README.md'), ]; for (const file of implicitDocs) { const { affected, unknown } = checksAffectedBy([file], ROOT, { manifest: MANIFEST }); assert.deepEqual(unknown, [], file); assert.deepEqual([...affected], [], `${file}: ${[...affected].join(', ')}`); } const readByReleaseGate = p('demo', 'performance', 'README.md'); assert.ok(MANIFEST.frontend.has(readByReleaseGate), 'явное чтение в release-gate.test.mjs — вход frontend'); assert.ok(MANIFEST.performance_smoke.has(readByReleaseGate), 'performance README остаётся входом performance_smoke'); }); 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('#671: весь пакет мебели — точный вход frontend, а шрифты исключены явно', () => { const furniture = p('assets', 'furniture', 'houseplan-0.4.1', 'svg', 'menu', 'air_conditioner.svg'); const font = p('assets', 'fonts', 'Roboto-Regular.ttf'); assert.deepEqual(GUARDED_DATA_ROOTS, ['assets']); assert.ok(isGuardedInput(furniture)); assert.ok(MANIFEST.frontend.has(furniture), `${furniture}: frontend потерял пакет`); const { affected, unknown } = checksAffectedBy([furniture], ROOT, { manifest: MANIFEST }); assert.deepEqual([...affected], ['frontend']); assert.deepEqual(unknown, []); assert.ok(isDeclaredNotAnInput(font), `${font}: ручной генератор должен быть явным исключением`); assert.equal(MANIFEST.frontend.has(font), false); }); test('#671: новый assets/** без владельца не молчит, а расширяет прогон', () => { const orphan = p('assets', 'future-pack', 'new.bin'); const { affected, unknown } = checksAffectedBy([orphan], ROOT, { manifest: MANIFEST }); assert.deepEqual(unknown, [orphan]); assert.deepEqual([...affected].sort(), [...CHECK_NAMES].sort()); const synthetic = coverage('/virtual', { tracked: [orphan], manifest: Object.fromEntries(CHECK_NAMES.map((name) => [name, new Set()])), }); assert.deepEqual(synthetic.unknown, [orphan]); }); 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(), ''); });