优化
This commit is contained in:
@@ -0,0 +1,284 @@
|
||||
#!/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 (/\.(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, imports, ...entry }) => entry),
|
||||
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));
|
||||
if (additions.length > 0) {
|
||||
process.stderr.write(`New slice dependencies are not allowed by ${contractPath}:\n${additions.map(edge => `- ${edge}`).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);
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
const moduleRoot = path.resolve(import.meta.dirname, '..');
|
||||
const analyzer = path.join(moduleRoot, 'scripts', 'analyze-firstleg-dependencies.mjs');
|
||||
const contract = path.join(moduleRoot, 'src', 'test', 'resources', 'architecture', 'firstleg-slice-dependencies.json');
|
||||
|
||||
function analyze() {
|
||||
return JSON.parse(execFileSync(process.execPath, [analyzer, '--source-set', 'main', '--format', 'json'], {
|
||||
cwd: moduleRoot,
|
||||
encoding: 'utf8',
|
||||
}));
|
||||
}
|
||||
|
||||
test('current source does not add slice dependencies beyond the baseline', () => {
|
||||
execFileSync(process.execPath, [analyzer, '--source-set', 'main', '--format', 'dot', '--check', contract], {
|
||||
cwd: moduleRoot,
|
||||
encoding: 'utf8',
|
||||
});
|
||||
});
|
||||
|
||||
test('customer statistics remains an independently packaged low-coupling slice', () => {
|
||||
const report = analyze();
|
||||
const classes = report.classes.filter(entry => entry.slice === 'customer-statistics');
|
||||
assert.deepEqual(classes.map(entry => entry.fqcn).sort(), [
|
||||
'com.baiyue.firstleg.customerstatistics.controller.PlatformCustomerTransitStatisticsController',
|
||||
'com.baiyue.firstleg.customerstatistics.dto.CustomerTransitStatisticsDtos',
|
||||
'com.baiyue.firstleg.customerstatistics.mapper.CustomerTransitStatisticsMapper',
|
||||
'com.baiyue.firstleg.customerstatistics.service.CustomerTransitStatisticsService',
|
||||
]);
|
||||
const outgoing = report.sliceEdges.filter(edge => edge.source === 'customer-statistics');
|
||||
const incoming = report.sliceEdges.filter(edge => edge.target === 'customer-statistics');
|
||||
assert.deepEqual(outgoing, [{ source: 'customer-statistics', target: 'shared-technical', count: 1 }]);
|
||||
assert.deepEqual(incoming, []);
|
||||
assert.equal(report.sliceSccs.some(component => component.includes('customer-statistics')), false);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user