voyage/tests/lib/lib-imports.test.mjs
Kjell Tore Guttormsen efe981f7bc
refactor(cleanup): simplify pass on the R-a diff
engine-compare imports the coordinator's title limit and trim instead
of a copy whose reason went with Arm B; the autonomy gate exports one
parser and its test runs in-process with two CLI runs; one import
scanner in lib-imports; the fenced-section walker shared from
tests/helpers/md-section.mjs instead of six copies; plan-gate hash
in-process; living-paths scans once; routing drops the orchestrator
filter whose files are gone; small leftovers removed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 13:31:30 +01:00

72 lines
3.3 KiB
JavaScript

// 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');
});