Files
baiyue-firstleg/firstleg/scripts/analyze-firstleg-dependencies.mjs
T

320 lines
14 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.firstleg.';
const sliceRules = [
['customer-statistics', /(?:^|\.)(?:customerstatistics)(?:\.|$)/],
['finance-integration', /(?:InternalFinance|FinanceFeeSource|FinanceCostSnapshot|FeePaymentVoucher|FeePaymentSnapshot|ContainerFeeSourceSnapshot)/],
['shipping-nodes', /(?:CarrierScheduleQuery|CarrierScheduleNode|CarrierScheduleAdapter|CarrierScheduleAttempt|CarrierScheduleException)/],
['booking-customs-trucking', /(?:^|\.)(?:oceanbooking|customs)(?:\.|$)|(?:TruckAppointment|WarehouseSupplier|PortSchedule|DispatchGate)/],
['outbound', /(?:^|\.)outbound(?:\.|$)|(?:OutboundDispatch|ContainerTransport|OverseasContainerEvent)/],
['split-loading', /(?:^|\.)(?:split|loading)(?:\.|$)|(?:Loading|SmartLoading|Container(?:Booking|Overview|Service|Dtos)|PdaPick)/],
['measurement-pricing', /(?:^|\.)pricing(?:\.|$)|(?:Measurement|MeasureReview|Mojia|Pricing|PriceBook|PriceChannel|PriceOrigin|PriceWarehouse|Fba|Dimension|FeeItem|MerchantInboundBilling|InboundFee)/],
['inbound', /(?:Inbound|Fulfillment|Assignment|UnifiedInbound|CartonLabel)/],
];
function parseArgs(argv) {
const options = { sourceSet: 'main', format: 'json', 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 === '--output') options.output = argv[++index];
else if (argument === '--check') options.check = argv[++index];
else if (argument === '--help') {
process.stdout.write('Usage: node scripts/analyze-firstleg-dependencies.mjs [--source-set main|test|all] [--format json|markdown|dot] [--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}`);
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 (packageName.includes('.client')) return 'cross-service-adapter';
if (/\.(adapter|controller|repository|mapper|config|security)(?:\.|$)/.test(packageName)) return 'technical-adapter';
if (/\.(entity|dto)(?:\.|$)/.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 packageSccs = stronglyConnectedComponents(packages, packageEdges);
const sliceSccs = stronglyConnectedComponents(slices, sliceEdges);
const directCrossSliceDependencies = dependencies.filter(edge => sourceByFqcn.get(edge.source).slice !== sourceByFqcn.get(edge.target).slice);
const modelLeaks = directCrossSliceDependencies.filter(edge => ['data-model'].includes(sourceByFqcn.get(edge.target).kind));
const mapperLeaks = directCrossSliceDependencies.filter(edge => /\.mapper(?:\.|$)/.test(sourceByFqcn.get(edge.target).packageName));
return {
schemaVersion: 1,
generatedFrom: sourceSet,
moduleRoot: 'firstleg',
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,
sliceSccs,
directCrossSliceDependencies,
modelLeaks,
mapperLeaks,
};
}
function renderMarkdown(report) {
const lines = [
'# Firstleg internal dependency report', '',
`- Source set: ${report.generatedFrom}`,
`- Java files: ${report.summary.javaFiles}`,
`- Internal class dependencies: ${report.summary.internalClassDependencies}`,
`- 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', '',
...[...report.modelLeaks, ...report.mapperLeaks]
.filter((edge, index, entries) => entries.findIndex(candidate => candidate.source === edge.source && candidate.target === edge.target) === index)
.map(edge => `- \`${edge.source}\` -> \`${edge.target}\``),
'',
];
return lines.join('\n');
}
function renderDot(report) {
const lines = ['digraph firstleg_slices {', ' rankdir=LR;'];
for (const edge of report.sliceEdges) 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 additions = actual.filter(edge => !allowed.has(edge));
const violations = additions.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(`Firstleg 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);
if (options.output) fs.writeFileSync(path.resolve(process.cwd(), options.output), rendered, 'utf8');
else process.stdout.write(rendered);