mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 05:26:47 +00:00
e40bc71e9f
Every domain's *.rpc.ts was written against rpc-kit, rpc-params, rpc-errors and PluginGuards, and every domain module imported PluginGuardsModule, all of it inside src/nest/plugins, while PluginsRuntimeModule imports those same domains so their handlers exist at boot. That made the plugins domain part of every cycle. The kit imports no domain but the protocol tables and permissions/addons for the guards, so it moves to src/nest-rpc beside src/nest-mcp, with its tests, its README and its extraction rule; the runtime module keeps its domain imports, which the boot-time coverage check of the wire surface needs in any subset app.
409 lines
17 KiB
JavaScript
409 lines
17 KiB
JavaScript
#!/usr/bin/env node
|
|
/*
|
|
* lint:boundaries: the server's import graph may only get cleaner.
|
|
*
|
|
* Nest resolves a module's `imports` array while the file is still loading, so
|
|
* an import cycle shows up at runtime as "imports[1] is undefined" (see
|
|
* src/nest/storage/storage.module.ts), and every new reach into another
|
|
* domain's internals is one more place a split has to untangle later. Both
|
|
* were held only by review. This walks every .ts file under src/ with the
|
|
* TypeScript parser and checks five rules against scripts/import-boundaries-baseline.json:
|
|
*
|
|
* fileCycles a runtime import edge between two files that sit on the
|
|
* same cycle. Type-only imports and lazy ones (import(),
|
|
* a require() inside a function) are erased or deferred, so
|
|
* they cannot take part in a load-time cycle and are skipped.
|
|
* So are the imports of src/db/entities/: MikroORM relations
|
|
* point both ways by design and are read through `() => X`
|
|
* thunks after every file has loaded.
|
|
* domainCycles the same, between src/nest/<domain> folders: domain A
|
|
* imports from B while B (directly or through others)
|
|
* imports from A. Keyed by the domain pair, so a further
|
|
* import along a dependency that already exists is not new;
|
|
* a new dependency between two domains of the same cycle is.
|
|
* The shared kernel folders are left out here and held by
|
|
* the next rule instead.
|
|
* sharedImportsDomain
|
|
* a file of the shared kernel (src/nest/common, database,
|
|
* app-config, auth-core), which every domain imports,
|
|
* importing from a domain. That ties the domain into every other one.
|
|
* domainInternals a file in src/nest/<A>/ importing (value or type) a
|
|
* file of src/nest/<B>/ that is not B's public surface.
|
|
* Public is what the codebase already treats that way: the
|
|
* *.module, *.service, *.guard, *.decorator, *.types,
|
|
* *.interface, *.contract, *.constants and *.logger files,
|
|
* the shared kernel folders (common, database, app-config)
|
|
* and the few entry points in PUBLIC_FILES. Keyed by the
|
|
* importing domain, so moving a file inside A changes nothing.
|
|
* dbImportsNest a file under src/db/ importing from src/nest/. The data
|
|
* layer sits below the domains, never the other way round.
|
|
*
|
|
* Each rule's baseline lists today's violations. A violation not in it fails
|
|
* the check, and so does an entry that no longer occurs, until --update drops
|
|
* it: a stale entry would let the same violation back in unseen.
|
|
*
|
|
* npm run lint:boundaries check against the baseline (CI)
|
|
* npm run lint:boundaries -- --update drop the entries that no longer occur;
|
|
* it never adds one
|
|
*
|
|
* --dir=<path> points the check at another server root (the unit tests use it).
|
|
*/
|
|
import { createRequire } from 'node:module';
|
|
import { existsSync, readdirSync, readFileSync, realpathSync, statSync, writeFileSync } from 'node:fs';
|
|
import { dirname, join, relative, resolve, sep } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
const require = createRequire(import.meta.url);
|
|
const ts = require('typescript');
|
|
|
|
export const RULES = ['fileCycles', 'domainCycles', 'sharedImportsDomain', 'domainInternals', 'dbImportsNest'];
|
|
|
|
/**
|
|
* Folders under src/nest whose every file is shared infrastructure, open to all domains.
|
|
* auth-core holds the request-auth primitives (the guards, the decorators, JWT
|
|
* verification, ephemeral tokens) every controller needs; it imports no domain.
|
|
*/
|
|
export const SHARED_DOMAINS = new Set(['common', 'database', 'app-config', 'auth-core']);
|
|
|
|
/** File name suffixes that make up a domain's public surface. */
|
|
export const PUBLIC_SUFFIXES = [
|
|
'.module.ts',
|
|
'.service.ts',
|
|
'.guard.ts',
|
|
'.decorator.ts',
|
|
'.types.ts',
|
|
'.interface.ts',
|
|
'.contract.ts',
|
|
'.constants.ts',
|
|
'.logger.ts',
|
|
];
|
|
|
|
/**
|
|
* Entry points that are public although their name does not say so: the
|
|
* gates and helpers every controller shares. (The plugin RPC kit every
|
|
* domain's *.rpc.ts is written against sits outside src/nest, in
|
|
* src/nest-rpc, next to src/nest-mcp.) Adding to this list is a design
|
|
* decision for review, not a way past the check.
|
|
*/
|
|
export const PUBLIC_FILES = [
|
|
'audit/client-ip.ts',
|
|
'addons/addon-gate.ts',
|
|
'addons/mcp-addon-gate.ts',
|
|
];
|
|
|
|
function walk(dir, files = []) {
|
|
for (const name of readdirSync(dir)) {
|
|
const path = join(dir, name);
|
|
if (statSync(path).isDirectory()) walk(path, files);
|
|
else if (/\.ts$/.test(name) && !/\.d\.ts$/.test(name)) files.push(path);
|
|
}
|
|
return files;
|
|
}
|
|
|
|
function resolveSpecifier(fromFile, spec) {
|
|
if (!spec.startsWith('.')) return null;
|
|
const base = resolve(dirname(fromFile), spec.replace(/\.js$/, ''));
|
|
for (const candidate of [`${base}.ts`, join(base, 'index.ts'), base]) {
|
|
if (existsSync(candidate) && statSync(candidate).isFile()) return candidate;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/** True when every name an import declaration brings in is type-only. */
|
|
function isTypeOnlyImport(node) {
|
|
const clause = node.importClause;
|
|
if (!clause) return false; // `import './x'` runs the file.
|
|
if (clause.isTypeOnly) return true;
|
|
if (clause.name) return false;
|
|
const bindings = clause.namedBindings;
|
|
if (!bindings || !ts.isNamedImports(bindings)) return false;
|
|
return bindings.elements.length > 0 && bindings.elements.every((el) => el.isTypeOnly);
|
|
}
|
|
|
|
function isTypeOnlyExport(node) {
|
|
if (node.isTypeOnly) return true;
|
|
const clause = node.exportClause;
|
|
return Boolean(clause && ts.isNamedExports(clause) && clause.elements.length > 0 && clause.elements.every((el) => el.isTypeOnly));
|
|
}
|
|
|
|
/** Every relative import of a file: { spec, runtime } where runtime means eager and not erased. */
|
|
export function importsOf(text, fileName = 'file.ts') {
|
|
const sf = ts.createSourceFile(fileName, text, ts.ScriptTarget.ES2022, true, ts.ScriptKind.TS);
|
|
const found = [];
|
|
const add = (spec, runtime) => found.push({ spec, runtime });
|
|
for (const stmt of sf.statements) {
|
|
if (ts.isImportDeclaration(stmt) && ts.isStringLiteral(stmt.moduleSpecifier)) {
|
|
add(stmt.moduleSpecifier.text, !isTypeOnlyImport(stmt));
|
|
} else if (ts.isExportDeclaration(stmt) && stmt.moduleSpecifier && ts.isStringLiteral(stmt.moduleSpecifier)) {
|
|
add(stmt.moduleSpecifier.text, !isTypeOnlyExport(stmt));
|
|
} else if (
|
|
ts.isImportEqualsDeclaration(stmt) &&
|
|
ts.isExternalModuleReference(stmt.moduleReference) &&
|
|
ts.isStringLiteral(stmt.moduleReference.expression)
|
|
) {
|
|
add(stmt.moduleReference.expression.text, !stmt.isTypeOnly);
|
|
}
|
|
}
|
|
// require() and import() anywhere: eager only when the require sits in top-level code.
|
|
const visit = (node, depth) => {
|
|
if (ts.isCallExpression(node) && node.arguments.length === 1 && ts.isStringLiteral(node.arguments[0])) {
|
|
const isRequire = ts.isIdentifier(node.expression) && node.expression.text === 'require';
|
|
const isDynamic = node.expression.kind === ts.SyntaxKind.ImportKeyword;
|
|
if (isRequire || isDynamic) add(node.arguments[0].text, isRequire && depth === 0);
|
|
}
|
|
const inner = ts.isFunctionLike(node) ? depth + 1 : depth;
|
|
ts.forEachChild(node, (child) => visit(child, inner));
|
|
};
|
|
for (const stmt of sf.statements) {
|
|
if (!ts.isImportDeclaration(stmt) && !ts.isExportDeclaration(stmt) && !ts.isImportEqualsDeclaration(stmt)) visit(stmt, 0);
|
|
}
|
|
return found;
|
|
}
|
|
|
|
/** All resolved relative import edges under src/, as src-relative POSIX paths. */
|
|
export function collectEdges(serverDir) {
|
|
const src = join(serverDir, 'src');
|
|
if (!existsSync(src) || !statSync(src).isDirectory()) {
|
|
throw new Error(`src/ does not exist under ${serverDir}: the check would pass without looking at anything`);
|
|
}
|
|
const rel = (p) => relative(src, p).split(sep).join('/');
|
|
const edges = [];
|
|
const unresolved = [];
|
|
for (const file of walk(src)) {
|
|
for (const { spec, runtime } of importsOf(readFileSync(file, 'utf8'), file)) {
|
|
if (!spec.startsWith('.')) continue;
|
|
const target = resolveSpecifier(file, spec);
|
|
if (!target) {
|
|
unresolved.push(`${rel(file)} -> ${spec}`);
|
|
continue;
|
|
}
|
|
if (target.startsWith(src + sep)) edges.push({ from: rel(file), to: rel(target), runtime });
|
|
}
|
|
}
|
|
return { edges, unresolved };
|
|
}
|
|
|
|
/** Tarjan's strongly connected components; returns a node -> component id map for components of size > 1. */
|
|
function cyclicComponents(graph) {
|
|
let index = 0;
|
|
const idx = new Map();
|
|
const low = new Map();
|
|
const onStack = new Set();
|
|
const stack = [];
|
|
const component = new Map();
|
|
let next = 0;
|
|
const strong = (v) => {
|
|
// Iterative, so a deep import chain cannot overflow the call stack.
|
|
const work = [[v, 0]];
|
|
idx.set(v, index);
|
|
low.set(v, index);
|
|
index++;
|
|
stack.push(v);
|
|
onStack.add(v);
|
|
while (work.length) {
|
|
const frame = work[work.length - 1];
|
|
const [node, i] = frame;
|
|
const targets = [...(graph.get(node) ?? [])];
|
|
if (i < targets.length) {
|
|
frame[1]++;
|
|
const w = targets[i];
|
|
if (!idx.has(w)) {
|
|
idx.set(w, index);
|
|
low.set(w, index);
|
|
index++;
|
|
stack.push(w);
|
|
onStack.add(w);
|
|
work.push([w, 0]);
|
|
} else if (onStack.has(w)) {
|
|
low.set(node, Math.min(low.get(node), idx.get(w)));
|
|
}
|
|
continue;
|
|
}
|
|
work.pop();
|
|
if (work.length) {
|
|
const parent = work[work.length - 1][0];
|
|
low.set(parent, Math.min(low.get(parent), low.get(node)));
|
|
}
|
|
if (low.get(node) === idx.get(node)) {
|
|
const members = [];
|
|
let w;
|
|
do {
|
|
w = stack.pop();
|
|
onStack.delete(w);
|
|
members.push(w);
|
|
} while (w !== node);
|
|
if (members.length > 1) {
|
|
for (const m of members) component.set(m, next);
|
|
next++;
|
|
}
|
|
}
|
|
}
|
|
};
|
|
for (const v of graph.keys()) if (!idx.has(v)) strong(v);
|
|
return component;
|
|
}
|
|
|
|
function cycleEdges(pairs) {
|
|
const graph = new Map();
|
|
for (const [a, b] of pairs) {
|
|
if (a === b) continue;
|
|
if (!graph.has(a)) graph.set(a, new Set());
|
|
if (!graph.has(b)) graph.set(b, new Set());
|
|
graph.get(a).add(b);
|
|
}
|
|
const component = cyclicComponents(graph);
|
|
const out = new Set();
|
|
for (const [a, targets] of graph) {
|
|
for (const b of targets) {
|
|
if (component.has(a) && component.get(a) === component.get(b)) out.add(`${a} -> ${b}`);
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
const domainOf = (file) => file.match(/^nest\/([^/]+)\//)?.[1] ?? null;
|
|
|
|
export function isPublic(target) {
|
|
const inner = target.slice('nest/'.length);
|
|
const domain = inner.split('/')[0];
|
|
if (SHARED_DOMAINS.has(domain)) return true;
|
|
if (PUBLIC_FILES.some((p) => (p.endsWith('/') ? inner.startsWith(p) : inner === p))) return true;
|
|
return PUBLIC_SUFFIXES.some((s) => target.endsWith(s));
|
|
}
|
|
|
|
/** The violations of every rule, each a sorted list of stable string keys. */
|
|
export function analyse(edges) {
|
|
const runtime = edges.filter((e) => e.runtime);
|
|
const fileCycles = cycleEdges(runtime.filter((e) => !e.from.startsWith('db/entities/')).map((e) => [e.from, e.to]));
|
|
|
|
const domainPairs = [];
|
|
for (const e of runtime) {
|
|
const a = domainOf(e.from);
|
|
const b = domainOf(e.to);
|
|
if (a && b && a !== b && !SHARED_DOMAINS.has(a) && !SHARED_DOMAINS.has(b)) domainPairs.push([a, b]);
|
|
}
|
|
const domainCycles = cycleEdges(domainPairs);
|
|
|
|
const sharedImportsDomain = new Set();
|
|
const domainInternals = new Set();
|
|
const dbImportsNest = new Set();
|
|
for (const e of edges) {
|
|
const a = domainOf(e.from);
|
|
const b = domainOf(e.to);
|
|
if (a && b && SHARED_DOMAINS.has(a) && !SHARED_DOMAINS.has(b)) sharedImportsDomain.add(`${e.from} -> ${e.to}`);
|
|
if (a && b && a !== b && !isPublic(e.to)) domainInternals.add(`${a} -> ${e.to.slice('nest/'.length)}`);
|
|
if (e.from.startsWith('db/') && e.to.startsWith('nest/')) dbImportsNest.add(`${e.from} -> ${e.to}`);
|
|
}
|
|
const sorted = (set) => [...set].sort();
|
|
return {
|
|
fileCycles: sorted(fileCycles),
|
|
domainCycles: sorted(domainCycles),
|
|
sharedImportsDomain: sorted(sharedImportsDomain),
|
|
domainInternals: sorted(domainInternals),
|
|
dbImportsNest: sorted(dbImportsNest),
|
|
};
|
|
}
|
|
|
|
/** The baseline as committed. Missing, unreadable or malformed stops the run. */
|
|
export function readBaseline(path) {
|
|
let parsed;
|
|
try {
|
|
parsed = JSON.parse(readFileSync(path, 'utf8'));
|
|
} catch (err) {
|
|
throw new Error(`scripts/import-boundaries-baseline.json cannot be read: ${err.message}`);
|
|
}
|
|
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
throw new Error('scripts/import-boundaries-baseline.json must be an object with one list per rule');
|
|
}
|
|
for (const rule of RULES) {
|
|
if (!Array.isArray(parsed[rule]) || !parsed[rule].every((entry) => typeof entry === 'string')) {
|
|
throw new Error(`scripts/import-boundaries-baseline.json: "${rule}" must be a list of strings`);
|
|
}
|
|
}
|
|
const unknown = Object.keys(parsed).filter((key) => !RULES.includes(key));
|
|
if (unknown.length) throw new Error(`scripts/import-boundaries-baseline.json: unknown rule(s) ${unknown.join(', ')}`);
|
|
return parsed;
|
|
}
|
|
|
|
export function compare(baseline, found) {
|
|
const added = {};
|
|
const gone = {};
|
|
for (const rule of RULES) {
|
|
const allowed = new Set(baseline[rule]);
|
|
const now = new Set(found[rule]);
|
|
added[rule] = found[rule].filter((key) => !allowed.has(key));
|
|
gone[rule] = baseline[rule].filter((key) => !now.has(key));
|
|
}
|
|
return { added, gone };
|
|
}
|
|
|
|
const HINTS = {
|
|
fileCycles:
|
|
'This import closes a load-time cycle. Move the shared piece into a file both can import, or make the import type-only.',
|
|
domainCycles:
|
|
'This makes two domains depend on each other. Invert the dependency (an event, a port the other side implements) or move the shared piece into a domain both can import.',
|
|
domainInternals:
|
|
"This reaches into another domain's internals. Go through its service or module, or move the piece into a *.types.ts/*.contract.ts file it publishes.",
|
|
sharedImportsDomain:
|
|
'The shared kernel is imported by every domain, so it may not import one. Move the piece into the kernel, or inject it from the domain.',
|
|
dbImportsNest: 'src/db sits below src/nest. Move what the data layer needs into src/db (or src/utils) instead.',
|
|
};
|
|
|
|
function main(argv) {
|
|
const dirArg = argv.find((a) => a.startsWith('--dir='));
|
|
const serverDir = dirArg ? resolve(dirArg.slice('--dir='.length)) : fileURLToPath(new URL('..', import.meta.url));
|
|
const baselinePath = join(serverDir, 'scripts', 'import-boundaries-baseline.json');
|
|
const update = argv.includes('--update');
|
|
|
|
const { edges, unresolved } = collectEdges(serverDir);
|
|
if (unresolved.length) {
|
|
// An import the walker cannot follow is an edge it cannot judge: stop rather than pass on a partial graph.
|
|
for (const u of unresolved) console.error(`FAIL unresolved import ${u}`);
|
|
return 1;
|
|
}
|
|
const found = analyse(edges);
|
|
let baseline = readBaseline(baselinePath);
|
|
if (update) {
|
|
const now = Object.fromEntries(RULES.map((rule) => [rule, new Set(found[rule])]));
|
|
baseline = Object.fromEntries(RULES.map((rule) => [rule, baseline[rule].filter((key) => now[rule].has(key)).sort()]));
|
|
writeFileSync(baselinePath, JSON.stringify(baseline, null, 2) + '\n');
|
|
}
|
|
|
|
const { added, gone } = compare(baseline, found);
|
|
let failed = 0;
|
|
for (const rule of RULES) {
|
|
for (const key of added[rule]) {
|
|
console.error(`FAIL ${rule}: ${key}. ${HINTS[rule]}`);
|
|
failed++;
|
|
}
|
|
}
|
|
for (const rule of RULES) {
|
|
for (const key of gone[rule]) {
|
|
console.error(`FAIL ${rule}: ${key} is in scripts/import-boundaries-baseline.json but no longer occurs.`);
|
|
failed++;
|
|
}
|
|
}
|
|
if (RULES.some((rule) => gone[rule].length)) {
|
|
console.error(
|
|
'Run npm run lint:boundaries -- --update to drop it with the change that fixed it: ' +
|
|
'a stale entry would let the same violation back in unseen.',
|
|
);
|
|
}
|
|
console.log(
|
|
`boundaries: ${edges.length} import(s); held at baseline: ` + RULES.map((rule) => `${rule} ${baseline[rule].length}`).join(', '),
|
|
);
|
|
return failed ? 1 : 0;
|
|
}
|
|
|
|
// Compared by real path, so the check still runs when the script is started through a symlink.
|
|
const isCli =
|
|
Boolean(process.argv[1]) && existsSync(process.argv[1]) && realpathSync(process.argv[1]) === fileURLToPath(import.meta.url);
|
|
if (isCli) {
|
|
try {
|
|
process.exitCode = main(process.argv.slice(2));
|
|
} catch (err) {
|
|
console.error(`FAIL ${err.message}`);
|
|
process.exitCode = 1;
|
|
}
|
|
}
|