// 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 { readFileSync, readdirSync } from 'node:fs'; 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'); /** * The import specifiers in `text`: static and side-effect imports, re-exports * and dynamic imports with a string literal. */ function specifiers(text) { const re = /\bimport\s*\(\s*['"]([^'"]+)['"]\s*\)|\b(?:import|export)\s+(?:[^'";]*?\s+from\s+)?['"]([^'"]+)['"]/g; return [...text.matchAll(re)].map((m) => m[1] ?? m[2]); } /** * `file: specifier` for each import of `files` (repo-relative) that resolves * into one of `dirs` of the repo. `textOf` reads a file's text. */ function forbiddenImports(files, dirs, textOf = read) { const out = []; for (const rel of files) { for (const spec of specifiers(textOf(rel))) { if (!spec.startsWith('.') && !spec.startsWith('/')) continue; const target = relative(ROOT, resolve(ROOT, dirname(rel), spec)).split(sep).join('/'); if (dirs.some((d) => target === d || target.startsWith(`${d}/`))) out.push(`${rel}: ${spec}`); } } return out; } /** Every .mjs file under the repo-relative `dir`, recursively, repo-relative. */ function mjsFiles(dir) { const out = []; for (const e of readdirSync(join(ROOT, dir), { withFileTypes: true })) { const p = `${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 = "import x from '../../scripts/release-check.mjs';\n"; assert.deepEqual(forbiddenImports(['lib/review/x.mjs'], ['scripts'], () => planted), ['lib/review/x.mjs: ../../scripts/release-check.mjs'], 'control: the scan does not report a planted import from scripts/'); const files = mjsFiles('lib'); assert.ok(files.length > 10, `the scan found only ${files.length} files under lib/`); assert.deepEqual(forbiddenImports(files, ['scripts']), [], 'lib/ imports from scripts/'); }); 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', ]; test('arch: the review modules import no hooks/ code, and the scan reports a planted import (control)', () => { const planted = (rel) => (rel === REVIEW_LIB[0] ? "await import('../../hooks/scripts/pre-bash-executor.mjs');\n" : '') + read(rel); assert.deepEqual(forbiddenImports(REVIEW_LIB, ['hooks'], planted), [`${REVIEW_LIB[0]}: ../../hooks/scripts/pre-bash-executor.mjs`], 'control: the scan does not report a planted import from hooks/'); assert.deepEqual(forbiddenImports(REVIEW_LIB, ['hooks']), [], 'a review module imports hooks/ code'); });