From 8c9c0089a4cd1226f2ecd7951b1a264b91f45211 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 00:40:30 +0300 Subject: [PATCH] test(hooks): derive the pre-push fixture from the hook's import tree (#766) HOOK_FILES in test/pre-push-gate.test.mjs listed the hook's modules by hand. After #729 process-gate pulled in review-doc-guard and friends, and #737 broke Validate with a new transitive import (model-usage.mjs) that the list did not carry: the temporary repository lacked the module and the hook died with ERR_MODULE_NOT_FOUND. The list is now computed: the hook itself, the scripts it runs by path (`$repo_root/scripts/...`, today process-gate.mjs and pre-push-gate.mjs), and the transitive closure of their local imports (static import/export ... from, side-effect imports, literal dynamic import() and new URL(..., import.meta.url) reads). gate-small.mjs stays out: the test writes its stub. A referenced file that does not exist fails loudly instead of shortening the list. On the current tree the derived set equals the old manual one. Witness: a copy of the hook tree with a new module imported from model-usage.mjs (with its own import, a dynamic import and a data read) gets all four files automatically. Checked by hand: the old manual list with such an import makes the real-hook test fail with ERR_MODULE_NOT_FOUND, the derived list passes. Module names in the witness are built from base names, because check-inputs reads a `scripts/...` string literal as a data leaf and stops following that module's imports. Issue: #766 User-Visible: no Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd --- test/pre-push-gate.test.mjs | 101 +++++++++++++++++++++++++++++------- 1 file changed, 83 insertions(+), 18 deletions(-) diff --git a/test/pre-push-gate.test.mjs b/test/pre-push-gate.test.mjs index 4844ddff..174b80a5 100644 --- a/test/pre-push-gate.test.mjs +++ b/test/pre-push-gate.test.mjs @@ -3,7 +3,7 @@ import assert from 'node:assert/strict'; import { spawnSync } from 'node:child_process'; import { chmodSync, copyFileSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync, existsSync } from 'node:fs'; import { tmpdir } from 'node:os'; -import { join } from 'node:path'; +import { dirname, join, relative, resolve, sep } from 'node:path'; import { fileURLToPath } from 'node:url'; import { @@ -128,24 +128,89 @@ test('gateEnv снимает все GIT_*: набор не должен писа // ---- настоящая связка хука ------------------------------------------------ const REPO = fileURLToPath(new URL('..', import.meta.url)); -// Всё, что исполняет хук: сам хук, pre-push-gate и его импорты, процессный гейт. -const HOOK_FILES = [ - '.githooks/pre-push', - 'scripts/pre-push-gate.mjs', - 'scripts/branch-state.mjs', - 'scripts/process-gate.mjs', - 'scripts/change-classes.mjs', // #701: классы изменений — общие для гейта и трейлеров - 'scripts/validate-commit-provenance.mjs', - 'scripts/bundle-policy.mjs', // #657: правило бандла в проверке происхождения - 'scripts/bundle-tree.mjs', - 'scripts/spawn-portable.mjs', - // #729: правило 10 судит черновик якорями review-doc-guard и треком process-track. - 'scripts/review-doc-guard.mjs', - 'scripts/process-track.mjs', - 'scripts/change-risk.mjs', - 'scripts/review-result-gate.mjs', - 'scripts/model-usage.mjs', // #737: review-doc-guard собирает строку расхода модели +const HOOK = '.githooks/pre-push'; + +/** + * Локальные модули, которые подтягивает `source`: статические `import`/`export + * … from`, `import './x'`, `import('./x')` литералом и чтения + * `new URL('./x', import.meta.url)`. + */ +const LOCAL_REFS = [ + /^\s*(?:import|export)\s+(?:[\w\s{},*$]|\/\/[^\n]*)*?\bfrom\s*['"](\.{1,2}\/[^'"]+)['"]/gm, + /^\s*import\s*['"](\.{1,2}\/[^'"]+)['"]/gm, + /\bimport\(\s*['"](\.{1,2}\/[^'"]+)['"]\s*\)/g, + /\bnew URL\(\s*['"](\.{1,2}\/[^'"]+)['"],\s*import\.meta\.url\s*\)/g, ]; + +/** + * Всё, что исполняет хук в `root` (#766): сам хук, скрипты, которые он зовёт + * по пути `$repo_root/scripts/…`, и транзитивное замыкание их локальных + * импортов. Прежде список вёлся руками и отставал от импортов: #737 уронил + * Validate новым `model-usage.mjs`, которого в нём не было. `gate-small.mjs`, + * который pre-push-gate запускает процессом, сюда не входит — его заглушку + * пишет тест. + */ +function hookFiles(root) { + const entries = [...new Set([...readFileSync(join(root, HOOK), 'utf8').matchAll(/\$repo_root\/(scripts\/[\w.-]+\.mjs)/g)] + .map((m) => m[1]))]; + const seen = new Set(); + const visit = (file) => { + const rel = relative(root, file).split(sep).join('/'); + assert.ok(!rel.startsWith('..'), `${rel}: модуль хука вне репозитория`); + if (seen.has(rel)) return; + assert.ok(existsSync(file), `${rel}: модуль хука не найден`); + seen.add(rel); + if (!/\.m?js$/.test(file)) return; + const source = readFileSync(file, 'utf8'); + for (const pattern of LOCAL_REFS) { + for (const m of source.matchAll(pattern)) visit(resolve(dirname(file), m[1])); + } + }; + for (const entry of entries) visit(join(root, entry)); + return { entries, files: [HOOK, ...[...seen].sort()] }; +} +const HOOK_FILES = hookFiles(REPO).files; + +test('#766: файлы fixture хука — из дерева импортов; новый транзитивный импорт попадает сам', () => { + const { entries, files } = hookFiles(REPO); + assert.deepEqual(entries, ['process-gate', 'pre-push-gate'].map((name) => `scripts/${name}.mjs`), 'скрипты, которые хук зовёт по пути'); + // Свидетели прежних провалов: #729 (process-gate тянет review-doc-guard) и #737. + // Имена без литерала `scripts/…`: такой литерал check-inputs читает как + // данные-лист и дальше импорты модуля для job frontend не обходит. + for (const name of ['review-doc-guard', 'process-track', 'change-risk', 'model-usage', 'bundle-tree']) { + assert.ok(files.includes(`scripts/${name}.mjs`), `${name}.mjs в fixture хука`); + } + assert.ok(!files.includes('scripts/gate-small.mjs'), 'набор gate:small тест подменяет заглушкой'); + + // Копия дерева хука; в глубину цепочки (pre-push-gate → process-gate → + // review-doc-guard → model-usage) добавлен новый модуль с собственным импортом. + const root = mkdtempSync(join(tmpdir(), 'hp-766-hook-')); + try { + for (const file of files) { + mkdirSync(join(root, file, '..'), { recursive: true }); + copyFileSync(join(REPO, file), join(root, file)); + } + const usage = join(root, 'scripts', 'model-usage.mjs'); + writeFileSync(usage, `import { deeper } from './zz-766-new.mjs'; +${readFileSync(usage, 'utf8')}`); + writeFileSync(join(root, 'scripts/zz-766-new.mjs'), [ + "export { deeper } from './zz-766-deeper.mjs';", + "export const lazy = () => import('./zz-766-lazy.mjs');", + "export const data = new URL('./zz-766-data.json', import.meta.url);", + '', + ].join('\n')); + for (const name of ['zz-766-deeper.mjs', 'zz-766-lazy.mjs']) writeFileSync(join(root, 'scripts', name), 'export const deeper = 1;\n'); + writeFileSync(join(root, 'scripts/zz-766-data.json'), '{}\n'); + const derived = hookFiles(root).files; + assert.deepEqual(derived.filter((file) => !files.includes(file)), + ['scripts/zz-766-data.json', 'scripts/zz-766-deeper.mjs', 'scripts/zz-766-lazy.mjs', 'scripts/zz-766-new.mjs']); + // Импорт без файла — громкий отказ, а не тихо короче список. + rmSync(join(root, 'scripts/zz-766-lazy.mjs')); + assert.throws(() => hookFiles(root), /zz-766-lazy\.mjs: модуль хука не найден/); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); // Заглушка набора: код выхода и журнал вызовов задаёт тест. const GATE_STUB = `import { appendFileSync } from 'node:fs'; const leaked = Object.keys(process.env).filter((key) => /^GIT_/i.test(key)).sort().join(',');