mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 21:46:49 +00:00
724c88c201
A mechanical prettier --write over server/src, server/tests, server/scripts and shared/src with the workspaces' .prettierrc; nothing but layout and import order changes, and every file keeps the same set of imports. shared is formatted throughout and its format baseline is empty. The server baseline keeps the 35 files that Prettier would grow past their lint:size limit (they need a split first, which is not a formatting change), and the generated entity files stay as their generator writes them.
563 lines
23 KiB
JavaScript
563 lines
23 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 (and the
|
|
* layers in LAYERS, src/nest-rpc, src/nest-mcp and src/mcp,
|
|
* each counted as one domain): 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.
|
|
* foreignRepositories
|
|
* a class in src/nest/<A>/ (or one of LAYERS) reaching the
|
|
* repository of an entity another domain owns, through
|
|
* @InjectRepository(X) or a getRepository(X) call on an
|
|
* EntityManager. Ownership is
|
|
* the map in scripts/repository-owners.json (entity class ->
|
|
* owning domain folder); an injected entity missing from it
|
|
* stops the run. Keyed by domain and entity, so a second
|
|
* injection of an entity the domain already reads is not new.
|
|
* Asking the owner instead (a service such as
|
|
* trip-membership's TripAccessService) is what shrinks it.
|
|
* providersImportOrchestrator
|
|
* a file under a providers/ folder of src/nest/<A>/
|
|
* importing (value or type) A's own orchestrator,
|
|
* nest/<A>/<A>.service.ts, or MapsService from any domain.
|
|
* A provider knows how to ask one source; which source
|
|
* answers is the orchestrator's decision, so the dependency
|
|
* only ever points from the orchestrator to its providers.
|
|
*
|
|
* 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 { existsSync, readdirSync, readFileSync, realpathSync, statSync, writeFileSync } from 'node:fs';
|
|
import { createRequire } from 'node:module';
|
|
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',
|
|
'foreignRepositories',
|
|
'providersImportOrchestrator',
|
|
];
|
|
|
|
/**
|
|
* 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',
|
|
// The maps domain's outbound clients. MapsModule exports them so enrichment
|
|
// asks Google and Wikimedia directly instead of through a MapsService facade.
|
|
'maps/providers/google-places.provider.ts',
|
|
'maps/providers/osm.client.ts',
|
|
'maps/providers/wikimedia.client.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;
|
|
}
|
|
|
|
/**
|
|
* Layers outside src/nest that work like a domain of their own: the plugin RPC
|
|
* kit, the MCP decorator layer and the process-wide MCP state. They import
|
|
* domains and domains import them, so they take part in domainCycles and
|
|
* foreignRepositories under their folder name; moving a file out of src/nest
|
|
* does not take its edges out of the count.
|
|
*/
|
|
export const LAYERS = ['nest-rpc', 'nest-mcp', 'mcp'];
|
|
|
|
/** The domain a src/-relative path belongs to: nest/<domain>/..., one of LAYERS, or null. */
|
|
export const domainOf = (file) => {
|
|
const nest = file.match(/^nest\/([^/]+)\//)?.[1];
|
|
if (nest) return nest;
|
|
const top = file.split('/')[0];
|
|
return file.includes('/') && LAYERS.includes(top) ? top : null;
|
|
};
|
|
|
|
/** The orchestrators no provider may import: its own domain's main service, and MapsService everywhere. */
|
|
function isOrchestratorOf(providerFile, target) {
|
|
const domain = domainOf(providerFile);
|
|
return target === `nest/${domain}/${domain}.service.ts` || target === 'nest/maps/maps.service.ts';
|
|
}
|
|
|
|
const isProviderFile = (file) => /^nest\/[^/]+\/(?:.+\/)?providers\//.test(file);
|
|
|
|
export function isPublic(target) {
|
|
// A layer outside src/nest is a toolkit every domain is written against.
|
|
if (!target.startsWith('nest/')) return true;
|
|
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 entity classes a file reaches a repository of: `@InjectRepository(X)`
|
|
* and `<anything>.getRepository(X)`, read from the syntax tree so a comment or
|
|
* a string naming them does not count.
|
|
*/
|
|
export function repositoryEntitiesOf(text, fileName = 'file.ts') {
|
|
const source = ts.createSourceFile(fileName, text, ts.ScriptTarget.Latest, false, ts.ScriptKind.TS);
|
|
const found = [];
|
|
const visit = (node) => {
|
|
if (ts.isCallExpression(node) && node.arguments.length > 0 && ts.isIdentifier(node.arguments[0])) {
|
|
const callee = node.expression;
|
|
const name = ts.isIdentifier(callee)
|
|
? callee.text
|
|
: ts.isPropertyAccessExpression(callee)
|
|
? callee.name.text
|
|
: null;
|
|
if (name === 'InjectRepository' || name === 'getRepository') found.push(node.arguments[0].text);
|
|
}
|
|
ts.forEachChild(node, visit);
|
|
};
|
|
visit(source);
|
|
return found;
|
|
}
|
|
|
|
/** Every repository a class under src/nest or one of LAYERS reaches: { domain, entity, file }. */
|
|
export function collectRepositoryInjections(serverDir) {
|
|
const src = join(serverDir, 'src');
|
|
const found = [];
|
|
for (const top of ['nest', ...LAYERS]) {
|
|
const dir = join(src, top);
|
|
if (!existsSync(dir)) continue;
|
|
for (const file of walk(dir)) {
|
|
const rel = relative(src, file).split(sep).join('/');
|
|
const domain = domainOf(rel);
|
|
if (!domain) continue;
|
|
for (const entity of repositoryEntitiesOf(readFileSync(file, 'utf8'), file)) {
|
|
found.push({ domain, entity, file: rel });
|
|
}
|
|
}
|
|
}
|
|
return found;
|
|
}
|
|
|
|
/** scripts/repository-owners.json: entity class -> owning domain. Missing is an empty map. */
|
|
export function readOwners(path) {
|
|
if (!existsSync(path)) return {};
|
|
const parsed = JSON.parse(readFileSync(path, 'utf8'));
|
|
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
throw new Error('scripts/repository-owners.json must map each entity class to its owning domain');
|
|
}
|
|
return parsed;
|
|
}
|
|
|
|
/** foreignRepositories keys, plus the injected entities the owner map does not know. */
|
|
export function analyseRepositories(injections, owners) {
|
|
const foreign = new Set();
|
|
const unowned = new Set();
|
|
for (const { domain, entity, file } of injections) {
|
|
const owner = owners[entity];
|
|
if (typeof owner !== 'string') {
|
|
unowned.add(`${entity} (${file})`);
|
|
continue;
|
|
}
|
|
if (owner !== domain) foreign.add(`${domain} -> ${entity} (owned by ${owner})`);
|
|
}
|
|
return { foreignRepositories: [...foreign].sort(), unowned: [...unowned].sort() };
|
|
}
|
|
|
|
/** 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();
|
|
const providersImportOrchestrator = 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}`);
|
|
if (isProviderFile(e.from) && isOrchestratorOf(e.from, e.to))
|
|
providersImportOrchestrator.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),
|
|
foreignRepositories: [],
|
|
providersImportOrchestrator: sorted(providersImportOrchestrator),
|
|
};
|
|
}
|
|
|
|
/** 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.',
|
|
foreignRepositories:
|
|
"This injects the repository of a table another domain owns. Ask the owner's service instead (e.g. TripAccessService for trip visibility), or move the code to the owning domain.",
|
|
providersImportOrchestrator:
|
|
'A provider sits below the orchestrator that chooses between providers. Hand it what it needs as an argument, or move the shared piece into a helpers file both import.',
|
|
};
|
|
|
|
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);
|
|
const { foreignRepositories, unowned } = analyseRepositories(
|
|
collectRepositoryInjections(serverDir),
|
|
readOwners(join(serverDir, 'scripts', 'repository-owners.json')),
|
|
);
|
|
if (unowned.length) {
|
|
// An entity without an owner is an injection the rule cannot judge.
|
|
for (const u of unowned)
|
|
console.error(`FAIL no owner for injected entity ${u}: add it to scripts/repository-owners.json`);
|
|
return 1;
|
|
}
|
|
found.foreignRepositories = foreignRepositories;
|
|
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;
|
|
}
|
|
}
|