327 lines
15 KiB
JavaScript
327 lines
15 KiB
JavaScript
#!/usr/bin/env node
|
|
|
|
import fs from 'node:fs';
|
|
import path from 'node:path';
|
|
import process from 'node:process';
|
|
|
|
const moduleRoot = path.resolve(import.meta.dirname, '..');
|
|
const sourceRoots = {
|
|
main: path.join(moduleRoot, 'src', 'main', 'java'),
|
|
test: path.join(moduleRoot, 'src', 'test', 'java'),
|
|
};
|
|
const internalPrefix = 'com.baiyue.lastleg.';
|
|
|
|
const sliceRules = [
|
|
['dispatch-delivery', /(?:^|\.)(?:dispatch|delivery|outbound)(?:\.|$)/],
|
|
['relabel-transfer-discard', /(?:^|\.)(?:relabel|transfer|discard)(?:\.|$)/],
|
|
['inbound-pda', /(?:^|\.)(?:inboundplan|palletops|pda)(?:\.|$)/],
|
|
['inventory', /(?:^|\.)inventory(?:\.|$)/],
|
|
['dropship', /(?:^|\.)dropship(?:\.|$)/],
|
|
['procurement-trucking-looseboard', /(?:^|\.)(?:procurement|trucking|looseboard)(?:\.|$)/],
|
|
['overseas-master-data', /(?:^|\.)masterdata(?:\.|$)/],
|
|
['tailleg-handoff', /(?:^|\.)(?:controller|dto|entity|mapper|model|repository|service|shipping)(?:\.|$)/],
|
|
];
|
|
|
|
function parseArgs(argv) {
|
|
const options = { sourceSet: 'main', format: 'json', graph: 'slice', output: null, check: null };
|
|
for (let index = 0; index < argv.length; index += 1) {
|
|
const argument = argv[index];
|
|
if (argument === '--source-set') options.sourceSet = argv[++index];
|
|
else if (argument === '--format') options.format = argv[++index];
|
|
else if (argument === '--graph') options.graph = argv[++index];
|
|
else if (argument === '--output') options.output = argv[++index];
|
|
else if (argument === '--check') options.check = argv[++index];
|
|
else if (argument === '--help') {
|
|
process.stdout.write('Usage: node scripts/analyze-lastleg-dependencies.mjs [--source-set main|test|all] [--format json|markdown|dot] [--graph slice|package] [--output FILE] [--check CONTRACT.json]\n');
|
|
process.exit(0);
|
|
} else throw new Error(`Unknown argument: ${argument}`);
|
|
}
|
|
if (!['main', 'test', 'all'].includes(options.sourceSet)) throw new Error(`Invalid source set: ${options.sourceSet}`);
|
|
if (!['json', 'markdown', 'dot'].includes(options.format)) throw new Error(`Invalid format: ${options.format}`);
|
|
if (!['slice', 'package'].includes(options.graph)) throw new Error(`Invalid graph: ${options.graph}`);
|
|
return options;
|
|
}
|
|
|
|
function walkJavaFiles(root) {
|
|
if (!fs.existsSync(root)) return [];
|
|
const result = [];
|
|
const pending = [root];
|
|
while (pending.length > 0) {
|
|
const current = pending.pop();
|
|
for (const entry of fs.readdirSync(current, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
const absolute = path.join(current, entry.name);
|
|
if (entry.isDirectory()) pending.push(absolute);
|
|
else if (entry.isFile() && entry.name.endsWith('.java')) result.push(absolute);
|
|
}
|
|
}
|
|
return result.sort();
|
|
}
|
|
|
|
function stripCommentsAndStrings(source) {
|
|
return source
|
|
.replace(/\/\*[\s\S]*?\*\//g, ' ')
|
|
.replace(/\/\/[^\r\n]*/g, ' ')
|
|
.replace(/"(?:\\.|[^"\\])*"/g, '""')
|
|
.replace(/'(?:\\.|[^'\\])*'/g, "''");
|
|
}
|
|
|
|
function classifySlice(fqcn) {
|
|
for (const [slice, expression] of sliceRules) {
|
|
if (expression.test(fqcn)) return slice;
|
|
}
|
|
return 'shared-technical';
|
|
}
|
|
|
|
function classifyKind(fqcn) {
|
|
const packageName = fqcn.slice(0, fqcn.lastIndexOf('.'));
|
|
if (/(?:^|\.)(?:client|gateway)(?:\.|$)/.test(packageName)) return 'cross-service-adapter';
|
|
if (/(?:^|\.)(?:adapter|controller|repository|mapper|config|security)(?:\.|$)/.test(packageName)) return 'technical-adapter';
|
|
if (/(?:^|\.)(?:entity|dto|model)(?:\.|$)/.test(packageName)) return 'data-model';
|
|
return 'business-application';
|
|
}
|
|
|
|
function parseSource(file, sourceSet) {
|
|
const raw = fs.readFileSync(file, 'utf8');
|
|
const source = stripCommentsAndStrings(raw);
|
|
const packageName = source.match(/\bpackage\s+([\w.]+)\s*;/)?.[1];
|
|
const typeName = path.basename(file, '.java');
|
|
if (!packageName) throw new Error(`Missing package declaration: ${file}`);
|
|
const fqcn = `${packageName}.${typeName}`;
|
|
const imports = [...source.matchAll(/\bimport\s+(?:static\s+)?([\w.]+)(?:\.\*)?\s*;/g)].map(match => match[1]);
|
|
return {
|
|
file: path.relative(moduleRoot, file).replaceAll(path.sep, '/'),
|
|
sourceSet,
|
|
packageName,
|
|
typeName,
|
|
fqcn,
|
|
imports,
|
|
source,
|
|
slice: classifySlice(fqcn),
|
|
kind: classifyKind(fqcn),
|
|
};
|
|
}
|
|
|
|
function collectSources(sourceSet) {
|
|
const selected = sourceSet === 'all' ? ['main', 'test'] : [sourceSet];
|
|
return selected.flatMap(set => walkJavaFiles(sourceRoots[set]).map(file => parseSource(file, set)));
|
|
}
|
|
|
|
function resolveDependencies(sources) {
|
|
const knownTypes = new Map(sources.map(source => [source.fqcn, source]));
|
|
const typesByPackage = new Map();
|
|
for (const source of sources) {
|
|
if (!typesByPackage.has(source.packageName)) typesByPackage.set(source.packageName, []);
|
|
typesByPackage.get(source.packageName).push(source);
|
|
}
|
|
const dependencies = [];
|
|
const seen = new Set();
|
|
for (const source of sources) {
|
|
const candidates = new Set();
|
|
for (const imported of source.imports) {
|
|
if (knownTypes.has(imported)) candidates.add(imported);
|
|
else if (imported.startsWith(internalPrefix)) {
|
|
const owner = [...knownTypes.keys()].find(type => imported.startsWith(`${type}.`));
|
|
if (owner) candidates.add(owner);
|
|
}
|
|
}
|
|
for (const samePackageType of typesByPackage.get(source.packageName) ?? []) {
|
|
if (samePackageType.fqcn !== source.fqcn && new RegExp(`\\b${samePackageType.typeName}\\b`).test(source.source)) {
|
|
candidates.add(samePackageType.fqcn);
|
|
}
|
|
}
|
|
for (const targetFqcn of candidates) {
|
|
if (targetFqcn === source.fqcn) continue;
|
|
const key = `${source.fqcn}->${targetFqcn}`;
|
|
if (seen.has(key)) continue;
|
|
seen.add(key);
|
|
dependencies.push({ source: source.fqcn, target: targetFqcn });
|
|
}
|
|
}
|
|
return dependencies.sort((a, b) => `${a.source}\0${a.target}`.localeCompare(`${b.source}\0${b.target}`));
|
|
}
|
|
|
|
function aggregateEdges(dependencies, nodeOf) {
|
|
const counts = new Map();
|
|
for (const dependency of dependencies) {
|
|
const source = nodeOf(dependency.source);
|
|
const target = nodeOf(dependency.target);
|
|
if (source === target) continue;
|
|
const key = `${source}\0${target}`;
|
|
counts.set(key, (counts.get(key) ?? 0) + 1);
|
|
}
|
|
return [...counts].map(([key, count]) => {
|
|
const [source, target] = key.split('\0');
|
|
return { source, target, count };
|
|
}).sort((a, b) => `${a.source}\0${a.target}`.localeCompare(`${b.source}\0${b.target}`));
|
|
}
|
|
|
|
function stronglyConnectedComponents(nodes, edges) {
|
|
const adjacency = new Map(nodes.map(node => [node, []]));
|
|
for (const edge of edges) adjacency.get(edge.source)?.push(edge.target);
|
|
const indexByNode = new Map();
|
|
const lowLink = new Map();
|
|
const stack = [];
|
|
const onStack = new Set();
|
|
const components = [];
|
|
let nextIndex = 0;
|
|
function visit(node) {
|
|
indexByNode.set(node, nextIndex);
|
|
lowLink.set(node, nextIndex);
|
|
nextIndex += 1;
|
|
stack.push(node);
|
|
onStack.add(node);
|
|
for (const target of adjacency.get(node) ?? []) {
|
|
if (!indexByNode.has(target)) {
|
|
visit(target);
|
|
lowLink.set(node, Math.min(lowLink.get(node), lowLink.get(target)));
|
|
} else if (onStack.has(target)) {
|
|
lowLink.set(node, Math.min(lowLink.get(node), indexByNode.get(target)));
|
|
}
|
|
}
|
|
if (lowLink.get(node) === indexByNode.get(node)) {
|
|
const component = [];
|
|
let current;
|
|
do {
|
|
current = stack.pop();
|
|
onStack.delete(current);
|
|
component.push(current);
|
|
} while (current !== node);
|
|
components.push(component.sort());
|
|
}
|
|
}
|
|
for (const node of [...nodes].sort()) if (!indexByNode.has(node)) visit(node);
|
|
return components.filter(component => component.length > 1).sort((a, b) => b.length - a.length || a[0].localeCompare(b[0]));
|
|
}
|
|
|
|
function buildReport(sourceSet) {
|
|
const sources = collectSources(sourceSet);
|
|
const sourceByFqcn = new Map(sources.map(source => [source.fqcn, source]));
|
|
const dependencies = resolveDependencies(sources);
|
|
const packages = [...new Set(sources.map(source => source.packageName))].sort();
|
|
const slices = [...new Set(sources.map(source => source.slice))].sort();
|
|
const packageEdges = aggregateEdges(dependencies, fqcn => sourceByFqcn.get(fqcn).packageName);
|
|
const sliceEdges = aggregateEdges(dependencies, fqcn => sourceByFqcn.get(fqcn).slice);
|
|
const directCrossSliceDependencies = dependencies.filter(edge => sourceByFqcn.get(edge.source).slice !== sourceByFqcn.get(edge.target).slice);
|
|
const modelLeaks = directCrossSliceDependencies.filter(edge => sourceByFqcn.get(edge.target).kind === 'data-model');
|
|
const mapperLeaks = directCrossSliceDependencies.filter(edge => /\.mapper(?:\.|$)/.test(sourceByFqcn.get(edge.target).packageName));
|
|
return {
|
|
schemaVersion: 1,
|
|
generatedFrom: sourceSet,
|
|
moduleRoot: 'lastleg-service',
|
|
summary: {
|
|
javaFiles: sources.length,
|
|
internalClassDependencies: dependencies.length,
|
|
packages: packages.length,
|
|
slices: slices.length,
|
|
crossSliceClassDependencies: directCrossSliceDependencies.length,
|
|
crossSliceModelLeaks: modelLeaks.length,
|
|
crossSliceMapperLeaks: mapperLeaks.length,
|
|
},
|
|
sliceDefinitions: Object.fromEntries(sliceRules.map(([name, expression]) => [name, expression.source]).concat([['shared-technical', 'fallback']])),
|
|
classes: sources.map(({ source, ...entry }) => entry),
|
|
classDependencies: dependencies,
|
|
packageEdges,
|
|
sliceEdges,
|
|
packageSccs: stronglyConnectedComponents(packages, packageEdges),
|
|
sliceSccs: stronglyConnectedComponents(slices, sliceEdges),
|
|
directCrossSliceDependencies,
|
|
modelLeaks,
|
|
mapperLeaks,
|
|
};
|
|
}
|
|
|
|
function renderMarkdown(report) {
|
|
const leaks = [...report.modelLeaks, ...report.mapperLeaks]
|
|
.filter((edge, index, entries) => entries.findIndex(candidate => candidate.source === edge.source && candidate.target === edge.target) === index);
|
|
return [
|
|
'# Lastleg internal dependency report', '',
|
|
`- Source set: ${report.generatedFrom}`,
|
|
`- Java files: ${report.summary.javaFiles}`,
|
|
`- Internal class dependencies: ${report.summary.internalClassDependencies}`,
|
|
`- Packages: ${report.summary.packages}`,
|
|
`- Cross-slice class dependencies: ${report.summary.crossSliceClassDependencies}`,
|
|
`- Cross-slice model leaks: ${report.summary.crossSliceModelLeaks}`,
|
|
`- Cross-slice mapper leaks: ${report.summary.crossSliceMapperLeaks}`,
|
|
`- Package SCCs (>1 node): ${report.packageSccs.length}`,
|
|
`- Slice SCCs (>1 node): ${report.sliceSccs.length}`,
|
|
'', '## Slice edges', '', '| From | To | Class edges |', '|---|---|---:|',
|
|
...report.sliceEdges.map(edge => `| ${edge.source} | ${edge.target} | ${edge.count} |`),
|
|
'', '## Slice SCCs', '',
|
|
...(report.sliceSccs.length ? report.sliceSccs.map(component => `- ${component.join(' -> ')}`) : ['- None']),
|
|
'', '## Package SCCs', '',
|
|
...(report.packageSccs.length ? report.packageSccs.map(component => `- ${component.join(' -> ')}`) : ['- None']),
|
|
'', '## Cross-slice model and mapper leaks', '',
|
|
...(leaks.length ? leaks.map(edge => `- \`${edge.source}\` -> \`${edge.target}\``) : ['- None']),
|
|
'',
|
|
].join('\n');
|
|
}
|
|
|
|
function renderDot(report, graph) {
|
|
const edges = graph === 'package' ? report.packageEdges : report.sliceEdges;
|
|
const nodes = graph === 'package'
|
|
? [...new Set(report.classes.map(entry => entry.packageName))].sort()
|
|
: [...new Set(report.classes.map(entry => entry.slice))].sort();
|
|
const lines = [`digraph lastleg_${graph}s {`, ' rankdir=LR;'];
|
|
for (const node of nodes) lines.push(` "${node}";`);
|
|
for (const edge of edges) lines.push(` "${edge.source}" -> "${edge.target}" [label="${edge.count}"];`);
|
|
lines.push('}', '');
|
|
return lines.join('\n');
|
|
}
|
|
|
|
function checkContract(report, contractPath) {
|
|
const absolute = path.resolve(process.cwd(), contractPath);
|
|
const contract = JSON.parse(fs.readFileSync(absolute, 'utf8'));
|
|
const allowed = new Set(contract.allowedSliceEdges ?? []);
|
|
const actual = report.sliceEdges.map(edge => `${edge.source}->${edge.target}`);
|
|
const violations = actual.filter(edge => !allowed.has(edge)).map(edge => `new slice dependency: ${edge}`);
|
|
const classesByName = new Map(report.classes.map(entry => [entry.fqcn, entry]));
|
|
for (const [fqcn, expectedFile] of Object.entries(contract.requiredClassLocations ?? {})) {
|
|
const actualClass = classesByName.get(fqcn);
|
|
if (!actualClass) violations.push(`required class missing: ${fqcn}`);
|
|
else if (actualClass.file !== expectedFile) violations.push(`class location changed: ${fqcn} (${actualClass.file})`);
|
|
}
|
|
const applicationRule = contract.applicationDependencyRule;
|
|
if (applicationRule) {
|
|
const applicationClasses = report.classes.filter(entry => entry.packageName.startsWith(applicationRule.packagePrefix));
|
|
for (const entry of applicationClasses) {
|
|
for (const imported of entry.imports) {
|
|
if ((applicationRule.forbiddenImportPrefixes ?? []).some(prefix => imported.startsWith(prefix))) {
|
|
violations.push(`forbidden application import: ${entry.fqcn} -> ${imported}`);
|
|
}
|
|
}
|
|
}
|
|
for (const edge of report.classDependencies) {
|
|
if (edge.source.startsWith(applicationRule.packagePrefix)
|
|
&& edge.target.startsWith(internalPrefix)
|
|
&& !edge.target.startsWith(applicationRule.allowedInternalPrefix)) {
|
|
violations.push(`forbidden application dependency: ${edge.source} -> ${edge.target}`);
|
|
}
|
|
}
|
|
}
|
|
for (const [slice, rule] of Object.entries(contract.sliceSpecificRules ?? {})) {
|
|
const outgoing = report.sliceEdges.filter(edge => edge.source === slice).map(edge => edge.target).sort();
|
|
const incoming = report.sliceEdges.filter(edge => edge.target === slice).map(edge => edge.source).sort();
|
|
if (JSON.stringify(outgoing) !== JSON.stringify([...(rule.allowedOutgoing ?? [])].sort())) {
|
|
violations.push(`${slice} outgoing dependencies changed: ${outgoing.join(', ')}`);
|
|
}
|
|
if (JSON.stringify(incoming) !== JSON.stringify([...(rule.allowedIncoming ?? [])].sort())) {
|
|
violations.push(`${slice} incoming dependencies changed: ${incoming.join(', ')}`);
|
|
}
|
|
}
|
|
if (violations.length > 0) {
|
|
process.stderr.write(`Lastleg dependency contract violations in ${contractPath}:\n${violations.map(item => `- ${item}`).join('\n')}\n`);
|
|
process.exitCode = 1;
|
|
}
|
|
}
|
|
|
|
const options = parseArgs(process.argv.slice(2));
|
|
const report = buildReport(options.sourceSet);
|
|
if (options.check) checkContract(report, options.check);
|
|
const rendered = options.format === 'json'
|
|
? `${JSON.stringify(report, null, 2)}\n`
|
|
: options.format === 'markdown'
|
|
? renderMarkdown(report)
|
|
: renderDot(report, options.graph);
|
|
if (options.output) fs.writeFileSync(path.resolve(process.cwd(), options.output), rendered, 'utf8');
|
|
else process.stdout.write(rendered);
|