diff --git a/scripts/check-inputs.mjs b/scripts/check-inputs.mjs index 77d9de14..30688e84 100755 --- a/scripts/check-inputs.mjs +++ b/scripts/check-inputs.mjs @@ -143,9 +143,11 @@ const PY_FROM = /^\s*from\s+([\w.]+)\s+import/gm; const PY_IMPORT = /^\s*import\s+([\w.]+)/gm; const PY_PATH_JOIN = /((?:"[\w.-]+"\s*\/\s*)+"[\w.-]+")/g; const REL_EXEC_LITERAL = /['"]((?:\.\.?\/)*[\w.-]+(?:\/[\w.-]+)*\.(?:mjs|py))['"]/g; +const REL_DOC_LITERAL = /['"]((?:\.\.?\/)+[\w.-]+(?:\/[\w.@-]+)*\.md)['"]/gi; const toPosix = (p) => p.replaceAll('\\', '/'); const BINARY = /\.(png|jpe?g|gif|webp|svg|ico|woff2?|ttf|pdf|zip)$/i; +const DIRECTORY_DOCUMENTATION = /\.md$/i; /** `test-build/foo.js` — скомпилированный `src/foo.ts` (tsconfig.test.json). */ const mapTestBuild = (rel) => { @@ -200,6 +202,13 @@ export function referencesOf(file, rawText) { for (const re of [JS_IMPORT, JS_DYNAMIC, JS_REQUIRE]) { for (const m of text.matchAll(re)) resolveJsSpecifier(file, m[1]).forEach((p) => code.add(norm(p))); } + // Документ внутри названного каталога не является runtime-входом сам по + // себе. Но точный относительный путь к Markdown в исполняемом коде — это + // явная зависимость (например, release-gate действительно читает README). + for (const m of text.matchAll(REL_DOC_LITERAL)) { + const rel = norm(posix.join(posix.dirname(file), m[1])); + if (!rel.startsWith('..')) data.add(rel); + } } if (file.endsWith('.py')) { // pytest подхватывает conftest.py каталога без импорта — это код теста @@ -279,12 +288,12 @@ export function closure(root, entries, { tracked = trackedFiles(root), stopAt = } for (const ref of data) { if (trackedSet.has(ref)) { note(ref, file); seen.add(ref); continue; } - // каталог по строке — данные; бинарные файлы под ним код по строке не - // читает, а overlay эталонов принадлежит только своей проверке (#573): - // и то и другое входит в golden явным корнем + // Каталог по строке — данные. Бинарные файлы и документация под ним код + // по одной строке-каталогу не читает; точный Markdown-путь выше остаётся + // честной зависимостью. Overlay эталонов принадлежит только golden (#573). if (isDir(ref)) { for (const f of tracked) { - if (f.startsWith(`${ref}/`) && !BINARY.test(f) && !isBaselineOverlay(f)) { note(f, file); seen.add(f); } + if (f.startsWith(`${ref}/`) && !BINARY.test(f) && !DIRECTORY_DOCUMENTATION.test(f) && !isBaselineOverlay(f)) { note(f, file); seen.add(f); } } } } @@ -301,7 +310,7 @@ const BUILD_INPUTS = ['src/**', 'package.json', 'package-lock.json', 'rollup.con 'scripts/bundle-sync.mjs', 'scripts/bundle-tree.mjs']; /** Протокол браузерного харнеса: страница, сервер, гард исключений, compat-хелперы. */ const BROWSER_PROTOCOL = ['demo/serve.mjs', 'demo/srv/demo.html', 'demo/bundle-freshness.mjs', - 'demo/editor-runtime-compat.mjs', 'demo/iso-runtime-compat.mjs', 'demo/guard/**', 'demo/helpers/hp-test.mjs']; + 'demo/editor-runtime-compat.mjs', 'demo/iso-runtime-compat.mjs', 'demo/guard/*.mjs', 'demo/helpers/hp-test.mjs']; const WORKFLOW = ['.github/workflows/validate.yml']; /** Протокол реюза: кто считает ключ, тот и вход (§5.1 protocol). */ const REUSE_PROTOCOL = ['scripts/gate-reuse.mjs', 'scripts/check-inputs.mjs', 'scripts/ci-proof.mjs']; @@ -351,7 +360,7 @@ export const CHECKS = { }, golden: { entries: ['demo/golden/run.mjs'], - roots: [...BUILD_INPUTS, 'demo/golden/**', ...BROWSER_PROTOCOL, ...REUSE_PROTOCOL, ...WORKFLOW], + roots: [...BUILD_INPUTS, ...BASELINE_OVERLAY, ...BROWSER_PROTOCOL, ...REUSE_PROTOCOL, ...WORKFLOW], reuse: true, }, performance_smoke: { diff --git a/scripts/mutation-registry.mjs b/scripts/mutation-registry.mjs index c8049125..9fb5a9c7 100644 --- a/scripts/mutation-registry.mjs +++ b/scripts/mutation-registry.mjs @@ -5218,6 +5218,47 @@ const MUTANT_DEFINITIONS = [ replace: 'export const GUARDED_DATA_ROOTS = []; // mutant: asset data is not audited', }], }, + { + id: 'harness-directory-docs-leak-into-inputs', + guard: 'node --test --test-name-pattern="#672" test/check-inputs.test.mjs test/gate-reuse.test.mjs', + because: '#672: строка-каталог обозначает runtime-корпус, а не документацию рядом с ним; ' + + 'без фильтра README снова выбирает тяжёлые jobs и меняет их reuse keys', + patches: [{ + file: 'scripts/check-inputs.mjs', + find: ' if (f.startsWith(`${ref}/`) && !BINARY.test(f) && !DIRECTORY_DOCUMENTATION.test(f) && !isBaselineOverlay(f)) { note(f, file); seen.add(f); }', + replace: ' if (f.startsWith(`${ref}/`) && !BINARY.test(f) && !isBaselineOverlay(f)) { note(f, file); seen.add(f); }', + }], + }, + { + id: 'explicit-markdown-read-dropped-from-inputs', + guard: 'node --test --test-name-pattern="#672: документация каталога" test/check-inputs.test.mjs', + because: '#672: фильтр каталогов не должен скрывать Markdown, который код читает по точному пути', + patches: [{ + file: 'scripts/check-inputs.mjs', + find: ' for (const m of text.matchAll(REL_DOC_LITERAL)) {', + replace: " for (const m of ''.matchAll(REL_DOC_LITERAL)) { // mutant: explicit docs disappear", + }], + }, + { + id: 'browser-protocol-recaptures-guard-readme', + guard: 'node --test --test-name-pattern="#672: README каталогов" test/check-inputs.test.mjs', + because: '#672: browser protocol must name executable guards, not every document in demo/guard', + patches: [{ + file: 'scripts/check-inputs.mjs', + find: " 'demo/editor-runtime-compat.mjs', 'demo/iso-runtime-compat.mjs', 'demo/guard/*.mjs', 'demo/helpers/hp-test.mjs'];", + replace: " 'demo/editor-runtime-compat.mjs', 'demo/iso-runtime-compat.mjs', 'demo/guard/**', 'demo/helpers/hp-test.mjs'];", + }], + }, + { + id: 'golden-root-recaptures-golden-readme', + guard: 'node --test --test-name-pattern="#672: README каталогов" test/check-inputs.test.mjs', + because: '#672: golden needs the accepted baseline overlay, not a broad root that also owns README', + patches: [{ + file: 'scripts/check-inputs.mjs', + find: ' roots: [...BUILD_INPUTS, ...BASELINE_OVERLAY, ...BROWSER_PROTOCOL, ...REUSE_PROTOCOL, ...WORKFLOW],', + replace: " roots: [...BUILD_INPUTS, 'demo/golden/**', ...BROWSER_PROTOCOL, ...REUSE_PROTOCOL, ...WORKFLOW],", + }], + }, { id: 'guard-inputs-ignore-wrapper-defaults', guard: 'node --test --test-name-pattern="#492 §8.2" test/mutation-gate.test.mjs', @@ -12140,8 +12181,8 @@ const MUTANT_DEFINITIONS = [ + 'а ключи, которые proof предъявляет как «те же входы», меняются от PNG', patches: [{ file: 'scripts/check-inputs.mjs', - find: " if (f.startsWith(`${ref}/`) && !BINARY.test(f) && !isBaselineOverlay(f)) { note(f, file); seen.add(f); }", - replace: " if (f.startsWith(`${ref}/`) && !BINARY.test(f)) { note(f, file); seen.add(f); }", + find: " if (f.startsWith(`${ref}/`) && !BINARY.test(f) && !DIRECTORY_DOCUMENTATION.test(f) && !isBaselineOverlay(f)) { note(f, file); seen.add(f); }", + replace: " if (f.startsWith(`${ref}/`) && !BINARY.test(f) && !DIRECTORY_DOCUMENTATION.test(f)) { note(f, file); seen.add(f); }", }], }, { diff --git a/test/check-inputs.test.mjs b/test/check-inputs.test.mjs index eb84bb9b..93d51fd6 100755 --- a/test/check-inputs.test.mjs +++ b/test/check-inputs.test.mjs @@ -77,7 +77,7 @@ test('ссылки: Python — пакеты репозитория, относи assert.ok(refs.data.includes('scripts/config-schema.json')); }); -test('замыкание: код транзитивно, данные — листья, каталог — все текстовые файлы под ним', () => { +test('замыкание: код транзитивно, данные — листья, каталог — runtime-файлы под ним', () => { const files = { 'demo/smoke_a.mjs': "import './serve.mjs';\nconst x = 'demo/fixtures';\n", 'demo/serve.mjs': "import './compat.mjs';\n", @@ -86,6 +86,7 @@ test('замыкание: код транзитивно, данные — лис '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': '', }; @@ -100,6 +101,7 @@ test('замыкание: код транзитивно, данные — лис // картинка под каталогом не берётся, комментарий не ссылка 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'); }); @@ -128,7 +130,8 @@ test('#573: раскрытие каталога не выдаёт overlay эта // раскрытием, а явным корнем 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 корнем'); + 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')); @@ -137,6 +140,39 @@ test('#573: раскрытие каталога не выдаёт overlay эта 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'); diff --git a/test/gate-reuse.test.mjs b/test/gate-reuse.test.mjs index a79ef65b..d5124376 100644 --- a/test/gate-reuse.test.mjs +++ b/test/gate-reuse.test.mjs @@ -171,6 +171,22 @@ test('#573: приёмка эталонов меняет только ключ g } }); +test('#672: README внутри каталогов харнесса не меняют ключи тяжёлых проверок', () => { + 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/benchmark_glow.mjs', "import './serve.mjs';\nconst serverRoot = 'demo/srv';\n"); + const before = keys(dir); + put(['demo', 'golden', 'README.md'].join('/'), 'golden docs\n'); + put(['demo', 'guard', 'README.md'].join('/'), 'guard docs\n'); + put(['demo', 'srv', 'reference', 'device-icons', 'README.md'].join('/'), 'reference docs\n'); + assert.deepEqual(keys(dir), before); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + test('harness edits are isolated to their own job (#208)', () => { const { dir, put } = makeTree(); const only = (changed) => {