// tests/lib/lib-imports.test.mjs // Architecture rule: code under lib/ never imports from scripts/ (or, for the // review modules, from hooks/). Each check runs on the real files and on a // planted import, so a scanner that sees nothing cannot pass. import { test } from 'node:test'; import { strict as assert } from 'node:assert'; import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { dirname, join, relative, resolve, sep } from 'node:path'; import { fileURLToPath } from 'node:url'; const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..'); const read = (rel) => readFileSync(join(ROOT, rel), 'utf8'); // ---- lib/ never imports from scripts/ ---------------------------------------- /** * The import specifiers in `text` (a file at `file`) that resolve into the * repo's scripts/ directory: static and side-effect imports, re-exports and * dynamic imports with a string literal. */ function importsIntoScripts(file, text) { const re = /\bimport\s*\(\s*['"]([^'"]+)['"]\s*\)|\b(?:import|export)\s+(?:[^'";]*?\s+from\s+)?['"]([^'"]+)['"]/g; const scripts = join(ROOT, 'scripts') + sep; const hits = []; for (const m of text.matchAll(re)) { const spec = m[1] ?? m[2]; if (!spec.startsWith('.') && !spec.startsWith('/')) continue; if (resolve(dirname(file), spec).startsWith(scripts)) hits.push(spec); } return hits; } /** Every .mjs file under `dir`, recursively. */ function mjsFiles(dir) { const out = []; for (const e of readdirSync(dir, { withFileTypes: true })) { const p = join(dir, e.name); if (e.isDirectory()) out.push(...mjsFiles(p)); else if (e.name.endsWith('.mjs')) out.push(p); } return out; } test('arch: lib/ never imports from scripts/, and the scan reports a planted import (control)', () => { const planted = importsIntoScripts(join(ROOT, 'lib', 'review', 'x.mjs'), "import x from '../../scripts/release-check.mjs';\n"); assert.deepEqual(planted, ['../../scripts/release-check.mjs'], 'control: the scan does not report a planted import from scripts/'); const files = mjsFiles(join(ROOT, 'lib')); assert.ok(files.length > 10, `the scan found only ${files.length} files under lib/`); const bad = files.flatMap((f) => importsIntoScripts(f, readFileSync(f, 'utf8')).map((s) => `${f.slice(ROOT.length + 1)}: ${s}`)); assert.deepEqual(bad, [], 'lib/ imports from scripts/'); }); // ---- the review modules import no scripts/ or hooks/ code --------------------- const REVIEW_LIB = [ 'lib/review/review-answers.mjs', 'lib/review/pre-existing.mjs', 'lib/review/review-rounds.mjs', 'lib/review/review-policy.mjs', 'lib/review/engine-compare.mjs', 'lib/review/instruction-scope.mjs', 'lib/review/review-finalize.mjs', 'lib/review/merge-evidence.mjs', 'lib/util/doors.mjs', ]; const FORBIDDEN_DIRS = ['scripts', 'hooks']; const PLANTED = "import { checkReview } from '../../scripts/release-check.mjs';\n"; /** The specifiers of a module's import lines: `import … from`, bare `import '…'`, `export … from`, `import('…')`. */ function importSpecifiers(src) { const out = []; for (const m of src.matchAll(/^[ \t]*import\b(?!\s*\()[^;]*?(['"])([^'"\n]+)\1/gm)) out.push(m[2]); for (const m of src.matchAll(/^[ \t]*export\s*(?:\*|\{)[^;]*?\bfrom\s*(['"])([^'"\n]+)\1/gm)) out.push(m[2]); for (const m of src.matchAll(/\bimport\s*\(\s*(['"])([^'"\n]+)\1\s*\)/g)) out.push(m[2]); return out; } /** `file: specifier` for each import of `files` (paths under `root`) that resolves into scripts/ or hooks/ of `root`. */ function forbiddenImports(root, files) { const out = []; for (const rel of files) { const abs = join(root, rel); for (const spec of importSpecifiers(readFileSync(abs, 'utf8'))) { if (!spec.startsWith('.') && !spec.startsWith('/')) continue; const target = relative(root, resolve(dirname(abs), spec)).split(sep).join('/'); if (FORBIDDEN_DIRS.some((d) => target === d || target.startsWith(`${d}/`))) out.push(`${rel}: ${spec}`); } } return out; } test('arch: the review modules import no scripts/ or hooks/ code', () => { assert.deepEqual([...REVIEW_LIB].sort(), [ 'lib/review/engine-compare.mjs', 'lib/review/instruction-scope.mjs', 'lib/review/merge-evidence.mjs', 'lib/review/pre-existing.mjs', 'lib/review/review-answers.mjs', 'lib/review/review-finalize.mjs', 'lib/review/review-policy.mjs', 'lib/review/review-rounds.mjs', 'lib/util/doors.mjs', ], 'the scanner reads exactly these nine files'); for (const rel of REVIEW_LIB) assert.ok(existsSync(join(ROOT, rel)), `missing file ${rel}`); // The real files: nothing. assert.deepEqual(forbiddenImports(ROOT, REVIEW_LIB), [], 'a review module imports scripts/ or hooks/ code'); const tmp = mkdtempSync(join(tmpdir(), 'lib-imports-')); try { for (const rel of REVIEW_LIB) { mkdirSync(dirname(join(tmp, rel)), { recursive: true }); writeFileSync(join(tmp, rel), read(rel)); } // Control: the copy reads like the real files. assert.deepEqual(forbiddenImports(tmp, REVIEW_LIB), [], 'Control: the clean copy reports nothing'); // A planted import of a scripts/ module is reported. writeFileSync(join(tmp, REVIEW_LIB[0]), PLANTED + read(REVIEW_LIB[0])); assert.deepEqual(forbiddenImports(tmp, REVIEW_LIB), [`${REVIEW_LIB[0]}: ../../scripts/release-check.mjs`], 'the scanner reports the planted import of ../../scripts/release-check.mjs'); // The clean copy again: nothing. writeFileSync(join(tmp, REVIEW_LIB[0]), read(REVIEW_LIB[0])); assert.deepEqual(forbiddenImports(tmp, REVIEW_LIB), [], 'the clean copy again reports nothing'); } finally { rmSync(tmp, { recursive: true, force: true }); } });