优化
This commit is contained in:
@@ -23,20 +23,22 @@ const sliceRules = [
|
||||
];
|
||||
|
||||
function parseArgs(argv) {
|
||||
const options = { sourceSet: 'main', format: 'json', output: null, check: null };
|
||||
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] [--output FILE] [--check CONTRACT.json]\n');
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -254,9 +256,14 @@ function renderMarkdown(report) {
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function renderDot(report) {
|
||||
const lines = ['digraph lastleg_slices {', ' rankdir=LR;'];
|
||||
for (const edge of report.sliceEdges) lines.push(` "${edge.source}" -> "${edge.target}" [label="${edge.count}"];`);
|
||||
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');
|
||||
}
|
||||
@@ -267,6 +274,30 @@ function checkContract(report, contractPath) {
|
||||
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();
|
||||
@@ -290,6 +321,6 @@ const rendered = options.format === 'json'
|
||||
? `${JSON.stringify(report, null, 2)}\n`
|
||||
: options.format === 'markdown'
|
||||
? renderMarkdown(report)
|
||||
: renderDot(report);
|
||||
: renderDot(report, options.graph);
|
||||
if (options.output) fs.writeFileSync(path.resolve(process.cwd(), options.output), rendered, 'utf8');
|
||||
else process.stdout.write(rendered);
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
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-lastleg-dependencies.mjs');
|
||||
const contract = path.join(moduleRoot, 'src', 'test', 'resources', 'architecture', 'lastleg-slice-dependencies.json');
|
||||
const vehiclePrefix = 'com.baiyue.lastleg.masterdata.vehicle';
|
||||
|
||||
function analyze(sourceSet = 'main') {
|
||||
return JSON.parse(execFileSync(process.execPath, [analyzer, '--source-set', sourceSet, '--format', 'json'], {
|
||||
cwd: moduleRoot,
|
||||
encoding: 'utf8',
|
||||
}));
|
||||
}
|
||||
|
||||
test('current source does not add slice dependencies beyond the Lastleg baseline', () => {
|
||||
execFileSync(process.execPath, [analyzer, '--source-set', 'main', '--format', 'dot', '--check', contract], {
|
||||
cwd: moduleRoot,
|
||||
encoding: 'utf8',
|
||||
});
|
||||
});
|
||||
|
||||
test('main, test, and combined source sets expose package dependency graphs', () => {
|
||||
for (const sourceSet of ['main', 'test', 'all']) {
|
||||
const dot = execFileSync(process.execPath, [analyzer, '--source-set', sourceSet, '--format', 'dot', '--graph', 'package'], {
|
||||
cwd: moduleRoot,
|
||||
encoding: 'utf8',
|
||||
});
|
||||
assert.match(dot, /^digraph lastleg_packages \{/);
|
||||
assert.match(dot, /com\.baiyue\.lastleg/);
|
||||
}
|
||||
});
|
||||
|
||||
test('vehicle master data remains the reviewed low-coupling first-slice candidate', () => {
|
||||
const report = analyze();
|
||||
const classes = report.classes.filter(entry => entry.fqcn.startsWith(vehiclePrefix));
|
||||
assert.deepEqual(classes.map(entry => entry.fqcn).sort(), [
|
||||
'com.baiyue.lastleg.masterdata.vehicle.adapter.in.web.PlatformVehicleController',
|
||||
'com.baiyue.lastleg.masterdata.vehicle.adapter.out.persistence.VehicleMapper',
|
||||
'com.baiyue.lastleg.masterdata.vehicle.adapter.out.persistence.VehiclePersistenceAdapter',
|
||||
'com.baiyue.lastleg.masterdata.vehicle.adapter.out.persistence.VehicleRecord',
|
||||
'com.baiyue.lastleg.masterdata.vehicle.application.VehicleApplicationService',
|
||||
'com.baiyue.lastleg.masterdata.vehicle.application.model.VehicleModels',
|
||||
'com.baiyue.lastleg.masterdata.vehicle.application.port.out.VehicleRepository',
|
||||
]);
|
||||
const outgoing = report.classDependencies.filter(edge => edge.source.startsWith(vehiclePrefix)
|
||||
&& !edge.target.startsWith(vehiclePrefix));
|
||||
const incoming = report.classDependencies.filter(edge => edge.target.startsWith(vehiclePrefix)
|
||||
&& !edge.source.startsWith(vehiclePrefix));
|
||||
assert.deepEqual(outgoing, [{
|
||||
source: 'com.baiyue.lastleg.masterdata.vehicle.adapter.in.web.PlatformVehicleController',
|
||||
target: 'com.baiyue.lastleg.security.OverseasPermissions',
|
||||
}]);
|
||||
assert.deepEqual(incoming, []);
|
||||
assert.equal(report.sliceSccs.some(component => component.includes('overseas-master-data')), false);
|
||||
assert.equal(classes.some(entry => entry.kind === 'cross-service-adapter'), false);
|
||||
assert.equal(classes.some(entry => entry.imports.some(imported => /RestClient|\.client\./.test(imported))), false);
|
||||
});
|
||||
|
||||
test('vehicle application only depends inward and stays free of adapter frameworks', () => {
|
||||
const report = analyze();
|
||||
const prefix = `${vehiclePrefix}.application`;
|
||||
const applicationClasses = report.classes.filter(entry => entry.packageName.startsWith(prefix));
|
||||
assert.equal(applicationClasses.length, 3);
|
||||
for (const entry of applicationClasses) {
|
||||
assert.equal(entry.imports.some(imported => imported.startsWith('org.springframework.web')), false);
|
||||
assert.equal(entry.imports.some(imported => imported.startsWith('org.apache.ibatis')), false);
|
||||
assert.equal(entry.imports.some(imported => imported.startsWith('com.baomidou.mybatisplus')), false);
|
||||
assert.equal(entry.imports.some(imported => /RestClient|\.client\./.test(imported)), false);
|
||||
}
|
||||
const outwardDependencies = report.classDependencies.filter(edge => edge.source.startsWith(prefix)
|
||||
&& edge.target.startsWith('com.baiyue.lastleg.') && !edge.target.startsWith(prefix));
|
||||
assert.deepEqual(outwardDependencies, []);
|
||||
});
|
||||
Reference in New Issue
Block a user