diff --git a/docs/TESTING.md b/docs/TESTING.md index a00157a0..4fb41e9c 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -358,12 +358,12 @@ manifest читают и `classify-changes.mjs` (job `changes`: job запуск не зависит, а `custom_components/houseplan/manifest.json` (версия) держит правило «кандидат релиза прогоняет всё» и для него; -- **неизвестный исполняемый вход** — файл под `scripts/`, `demo/`, `test/`, - `tests_backend/`, `.github/`, `custom_components/`, `src/`, которого нет в - manifest ни одной проверки, — расширяет прогон до полного набора и называется +- `assets/furniture/**` — вход `frontend` (#671); `assets/fonts/**` — явное исключение ручного `generate-pdf-font.mjs`; +- **неизвестный охраняемый вход** — исполняемый файл под `scripts/`, `demo/`, `test/`, `tests_backend/`, `.github/`, `custom_components/`, `src/` либо tracked-файл под `assets/**`, которого нет в manifest ни + одной проверки, — расширяет прогон до полного набора и называется в summary. Лист покрытия (`node scripts/check-inputs.mjs --coverage`, `test/check-inputs.test.mjs`) требует, чтобы каждый такой файл был чьим-то - входом либо стоял в `NOT_AN_INPUT` с причиной: новый скрипт без записи — + входом либо стоял в `NOT_AN_INPUT` с причиной: новый скрипт или asset без записи — красный юнит, не вечное расширение прогонов; - **overlay принятых эталонов** (`demo/golden/baselines/**`, #573) — вход только `golden`, у которой он стоит явным корнем. Раскрытие каталога по diff --git a/scripts/check-inputs.mjs b/scripts/check-inputs.mjs index 77d21796..77d9de14 100755 --- a/scripts/check-inputs.mjs +++ b/scripts/check-inputs.mjs @@ -17,8 +17,9 @@ // полного набора (§5.2): «не знаю» не равно «не влияет». // // Лист покрытия (§5.5, test/check-inputs.test.mjs): каждый отслеживаемый -// исполняемый файл обязан входить в manifest хотя бы одной проверки либо в -// NOT_AN_INPUT с причиной. Новый скрипт без записи — красный тест, не тихое +// исполняемый файл и каждый файл под явно охраняемым корнем данных +// обязан входить в manifest хотя бы одной проверки либо в NOT_AN_INPUT с +// причиной. Новый скрипт или asset без записи — красный тест, не тихое // расширение прогонов навсегда. import { execFileSync } from 'node:child_process'; @@ -30,6 +31,14 @@ import { isMainModule } from './spawn-portable.mjs'; export const EXECUTABLE_ROOTS = ['scripts', 'demo', 'test', 'tests_backend', '.github', 'custom_components', 'src']; export const EXECUTABLE_EXT = /\.(mjs|cjs|js|ts|py|json|ya?ml|html|toml|txt|sh)$/; +/** + * Корни данных, где каждый tracked-файл обязан иметь владельца или явное + * исключение. В отличие от EXECUTABLE_ROOTS здесь важны и бинарные файлы: + * тесты могут читать SVG/TTF по динамически собранному пути, который сканер не + * выведет из одного строкового литерала (#671). + */ +export const GUARDED_DATA_ROOTS = ['assets']; + /** Копии бандла и результаты сборки: класс D, входом не являются. */ export const BUILD_OUTPUT = [ 'dist/**', 'custom_components/houseplan/frontend/**', 'demo/srv/assets/**', @@ -77,6 +86,7 @@ export const NOT_AN_INPUT = [ ['scripts/support-relay/deploy/**', 'деплой relay на стенд'], ['scripts/wsl-setup.sh', 'установка локального Linux/WSL-контура с пинами CI (#496), ручной запуск'], ['scripts/windows-toolchain.ps1', 'изолированная установка и запуск Windows toolchain с пинами CI (#557), ручной запуск'], + ['assets/fonts/**', 'исходный TTF читает только ручной generate-pdf-font.mjs; Validate использует закоммиченный результат'], ['.github/ISSUE_TEMPLATE/**', 'шаблоны issue GitHub, не исполняются'], ['.githooks/**', 'локальные хуки'], ]; @@ -323,7 +333,7 @@ export const CHECKS = { entries: ['test/*.test.mjs', 'scripts/no-new-any.mjs', 'scripts/no-new-private-writes.mjs', 'scripts/render-layout-read.mjs', 'scripts/bundle-budget.mjs', 'scripts/fix-test-build.mjs', 'scripts/unused-locals-gate.mjs'], // demo/helpers/** — область no-new-private-writes (#629): гейт читает их текст. - roots: [...BUILD_INPUTS, 'test/**', 'tsconfig*.json', 'scripts/monolith-baseline.json', 'demo/smoke_*.mjs', 'demo/benchmark_*.mjs', 'demo/guard/*.mjs', 'demo/helpers/**', ...WORKFLOW], + roots: [...BUILD_INPUTS, 'assets/furniture/**', 'test/**', 'tsconfig*.json', 'scripts/monolith-baseline.json', 'demo/smoke_*.mjs', 'demo/benchmark_*.mjs', 'demo/guard/*.mjs', 'demo/helpers/**', ...WORKFLOW], }, changed_mutants: { entries: ['scripts/mutation-*.mjs', 'scripts/*-guard.mjs', 'test/*.test.mjs', 'demo/smoke_*.mjs', 'tests_backend/**/*.py'], @@ -401,6 +411,10 @@ export function manifest(root = process.cwd(), options = {}) { export const isExecutableInput = (file) => EXECUTABLE_ROOTS.some((r) => file === r || file.startsWith(`${r}/`)) && EXECUTABLE_EXT.test(file) && !isBuildOutput(file); +/** Вход, который не имеет права тихо выпасть из manifest. */ +export const isGuardedInput = (file) => isExecutableInput(file) + || GUARDED_DATA_ROOTS.some((r) => file === r || file.startsWith(`${r}/`)); + export const isDeclaredNotAnInput = (file) => NOT_AN_INPUT.some(([glob]) => globToRegExp(glob).test(file)); /** @@ -414,19 +428,19 @@ export function checksAffectedBy(files, root = process.cwd(), options = {}) { for (const file of files) { let known = false; for (const name of CHECK_NAMES) if (man[name].has(file)) { affected.add(name); known = true; } - if (!known && isExecutableInput(file) && !isDeclaredNotAnInput(file)) unknown.push(file); + if (!known && isGuardedInput(file) && !isDeclaredNotAnInput(file)) unknown.push(file); } if (unknown.length) for (const name of CHECK_NAMES) affected.add(name); return { affected, unknown }; } -/** Лист покрытия (§5.5): исполняемые файлы, которые никто не считает своими. */ +/** Лист покрытия (§5.5): исполняемые и охраняемые data-файлы без владельца. */ export function coverage(root = process.cwd(), options = {}) { const tracked = options.tracked || trackedFiles(root); const man = options.manifest || manifest(root, { ...options, tracked }); const covered = new Set(); for (const name of CHECK_NAMES) for (const f of man[name]) covered.add(f); - const unknown = tracked.filter((f) => isExecutableInput(f) && !covered.has(f) && !isDeclaredNotAnInput(f)); + const unknown = tracked.filter((f) => isGuardedInput(f) && !covered.has(f) && !isDeclaredNotAnInput(f)); // запись NOT_AN_INPUT лишняя, если ВСЕ её файлы и так чьи-то входы const declaredButCovered = NOT_AN_INPUT.map(([glob]) => glob).filter((glob) => { const hits = tracked.filter((f) => globToRegExp(glob).test(f)); diff --git a/scripts/mutation-registry.mjs b/scripts/mutation-registry.mjs index 4ad64fd9..c8049125 100644 --- a/scripts/mutation-registry.mjs +++ b/scripts/mutation-registry.mjs @@ -5196,6 +5196,28 @@ const MUTANT_DEFINITIONS = [ replace: ' // mutant: dynamic cross-runtime inputs are absent', }], }, + { + id: 'furniture-assets-dropped-from-frontend-inputs', + guard: 'node --test --test-name-pattern="#671: весь пакет мебели" test/check-inputs.test.mjs', + because: 'тесты frontend читают assets/furniture по составным путям; без явного корня ' + + 'правка пакета не выбирает frontend и не запускает сверку generated-модулей (#671)', + patches: [{ + file: 'scripts/check-inputs.mjs', + find: " roots: [...BUILD_INPUTS, 'assets/furniture/**', 'test/**',", + replace: " roots: [...BUILD_INPUTS, 'test/**', // mutant: furniture package is not a frontend input", + }], + }, + { + id: 'assets-data-root-not-guarded', + guard: 'node --test --test-name-pattern="#671: новый assets" test/check-inputs.test.mjs', + because: 'лист покрытия, который смотрит только на исполняемые расширения, снова ' + + 'пропустит любой новый бинарный или текстовый пакет данных под assets/** (#671)', + patches: [{ + file: 'scripts/check-inputs.mjs', + find: "export const GUARDED_DATA_ROOTS = ['assets'];", + replace: 'export const GUARDED_DATA_ROOTS = []; // mutant: asset data is not audited', + }], + }, { id: 'guard-inputs-ignore-wrapper-defaults', guard: 'node --test --test-name-pattern="#492 §8.2" test/mutation-gate.test.mjs', diff --git a/test/check-inputs.test.mjs b/test/check-inputs.test.mjs index 1dd32977..eb84bb9b 100755 --- a/test/check-inputs.test.mjs +++ b/test/check-inputs.test.mjs @@ -4,7 +4,8 @@ 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, + GUARDED_DATA_ROOTS, globToRegExp, inputsOf, isBaselineOverlay, isDeclaredNotAnInput, isExecutableInput, + isGuardedInput, manifest, referencesOf, stripComments, } from '../scripts/check-inputs.mjs'; @@ -258,6 +259,32 @@ test('#542: каждый динамический cross-runtime input выбир 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}`);