diff --git a/docs/TESTING.md b/docs/TESTING.md index 6c1fbbc1..dfaeff23 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -102,7 +102,13 @@ manifest читают и `classify-changes.mjs` (job `changes`: job запуск Правила, которые стоит знать: - `validate.yml` — вход toolchain каждой job: правка workflow гоняет всё; -- `src/**` — вход только браузерных job; backend от UI не зависит, а +- `src/**` — вход только браузерных job, кроме точных cross-runtime входов + `src/plan-optimizer.ts` и `src/logic.ts`, которые backend pytest читает для + parity-контрактов; `demo/fixtures/large-house.mjs` и + `demo/fixtures/visual-matrix.mjs` так же явно входят в backend, потому что + pytest запускает их через Node dynamic import. Точные исключения не делают + весь UI или каталог fixtures входом backend (#542); backend от остального UI + не зависит, а `custom_components/houseplan/manifest.json` (версия) держит правило «кандидат релиза прогоняет всё» и для него; - **неизвестный исполняемый вход** — файл под `scripts/`, `demo/`, `test/`, @@ -119,6 +125,7 @@ manifest читают и `classify-changes.mjs` (job `changes`: job запуск `test/check-inputs.test.mjs`) держат представителей каждой категории входов и обратную пробу для UI ↔ backend; мутанты `manifest-drops-workflow-input`, `classify-unknown-input-is-unaffected`, `reuse-backend-hashes-ui`, +`backend-dynamic-inputs-dropped`, `guard-inputs-ignore-wrapper-defaults`, `registry-diff-not-selected`, `merge-pushes-unvalidated-candidate`, `merge-ignores-lease-rejection`, `nightly-does-not-wait` держат сам протокол. diff --git a/scripts/check-inputs.mjs b/scripts/check-inputs.mjs index fa6a15a7..0fc92aad 100755 --- a/scripts/check-inputs.mjs +++ b/scripts/check-inputs.mjs @@ -271,6 +271,21 @@ const BROWSER_PROTOCOL = ['demo/serve.mjs', 'demo/srv/demo.html', 'demo/bundle-f const WORKFLOW = ['.github/workflows/validate.yml']; /** Протокол реюза: кто считает ключ, тот и вход (§5.1 protocol). */ const REUSE_PROTOCOL = ['scripts/gate-reuse.mjs', 'scripts/check-inputs.mjs']; +/** + * Cross-runtime inputs, которые pytest читает динамически и которые поэтому + * нельзя вывести из статических import/string ссылок (#542). + * + * test_validation.py собирает путь к TS-константе через os.path.join и имя + * fixture через Python f-string -> Node dynamic import; test_support_package.py + * тем же способом читает logic.ts. Держим точные файлы, не src/** или + * demo/fixtures/**: остальные UI-входы не должны без причины запускать backend. + */ +const BACKEND_DYNAMIC_INPUTS = [ + 'src/plan-optimizer.ts', + 'src/logic.ts', + 'demo/fixtures/large-house.mjs', + 'demo/fixtures/visual-matrix.mjs', +]; export const CHECKS = { preflight: { @@ -315,6 +330,7 @@ export const CHECKS = { entries: ['tests_backend/**/*.py', 'scripts/support-relay/tests/**/*.py', 'scripts/dump-config-schema.py'], // manifest.json несёт версию: кандидат релиза обязан прогнать backend заново roots: ['custom_components/**/*.py', 'custom_components/houseplan/manifest.json', 'scripts/support-relay/**/*.py', + ...BACKEND_DYNAMIC_INPUTS, 'tests_backend/**', 'pyproject.toml', 'pytest.ini', 'scripts/backend-coverage-baseline.txt', 'scripts/config-schema.json', ...REUSE_PROTOCOL, ...WORKFLOW], reuse: true, diff --git a/scripts/mutation-gate.mjs b/scripts/mutation-gate.mjs index d379d47a..a4515322 100644 --- a/scripts/mutation-gate.mjs +++ b/scripts/mutation-gate.mjs @@ -3915,6 +3915,17 @@ const MUTANT_DEFINITIONS = [ replace: " roots: ['src/**', 'custom_components/**/*.py', 'custom_components/houseplan/manifest.json', 'scripts/support-relay/**/*.py',", }], }, + { + id: 'backend-dynamic-inputs-dropped', + guard: 'node --test --test-name-pattern="#542" test/check-inputs.test.mjs test/gate-reuse.test.mjs', + because: 'backend pytest reads selected TS sources and browser fixtures through os.path.join and a ' + + 'Python f-string dynamic import; without exact roots selection and reuse silently miss them (#542)', + patches: [{ + file: 'scripts/check-inputs.mjs', + find: ' ...BACKEND_DYNAMIC_INPUTS,', + replace: ' // mutant: dynamic cross-runtime inputs are absent', + }], + }, { 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 a1770cca..76074238 100755 --- a/test/check-inputs.test.mjs +++ b/test/check-inputs.test.mjs @@ -182,6 +182,27 @@ test('§8.1 представители: каждая категория кажд } }); +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}`); diff --git a/test/gate-reuse.test.mjs b/test/gate-reuse.test.mjs index d1cc5d3b..adea307d 100644 --- a/test/gate-reuse.test.mjs +++ b/test/gate-reuse.test.mjs @@ -32,10 +32,14 @@ const makeTree = () => { put('scripts/gate-reuse.mjs', '// reuse protocol\n'); put('scripts/check-inputs.mjs', '// manifest\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('demo/serve.mjs', "import './bundle-freshness.mjs';\n"); put('demo/bundle-freshness.mjs', 'export const fresh = 1;\n'); put('demo/srv/demo.html', '
\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'); @@ -176,6 +180,28 @@ test('#492 backend inputs the old HARNESS did not know: relay, schema, converter } }); +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 {