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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
This commit is contained in:
Claude
2026-10-06 23:05:58 +00:00
committed by claude[bot]
parent cb97274ea7
commit 8c9c0089a4
+83 -18
View File
@@ -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(',');