test: add architecture and OpenAPI validation tooling
This commit is contained in:
@@ -73,7 +73,7 @@ function classifySlice(fqcn) {
|
||||
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 (/\.(adapter|controller|repository|mapper|config|security)(?:\.|$)/.test(packageName)) return 'technical-adapter';
|
||||
if (/\.(entity|dto)(?:\.|$)/.test(packageName)) return 'data-model';
|
||||
return 'business-application';
|
||||
}
|
||||
@@ -218,7 +218,8 @@ function buildReport(sourceSet) {
|
||||
crossSliceMapperLeaks: mapperLeaks.length,
|
||||
},
|
||||
sliceDefinitions: Object.fromEntries(sliceRules.map(([name, expression]) => [name, expression.source]).concat([['shared-technical', 'fallback']])),
|
||||
classes: sources.map(({ source, imports, ...entry }) => entry),
|
||||
classes: sources.map(({ source, ...entry }) => entry),
|
||||
classDependencies: dependencies,
|
||||
packageEdges,
|
||||
sliceEdges,
|
||||
packageSccs,
|
||||
@@ -265,8 +266,43 @@ function checkContract(report, contractPath) {
|
||||
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`);
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -281,4 +317,3 @@ const rendered = options.format === 'json'
|
||||
: renderDot(report);
|
||||
if (options.output) fs.writeFileSync(path.resolve(process.cwd(), options.output), rendered, 'utf8');
|
||||
else process.stdout.write(rendered);
|
||||
|
||||
|
||||
@@ -25,10 +25,11 @@ 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',
|
||||
'com.baiyue.firstleg.customerstatistics.adapter.in.web.PlatformCustomerTransitStatisticsController',
|
||||
'com.baiyue.firstleg.customerstatistics.adapter.out.persistence.CustomerTransitStatisticsMapper',
|
||||
'com.baiyue.firstleg.customerstatistics.application.CustomerTransitStatisticsService',
|
||||
'com.baiyue.firstleg.customerstatistics.application.model.CustomerTransitStatisticsDtos',
|
||||
'com.baiyue.firstleg.customerstatistics.application.port.out.CustomerTransitStatisticsReader',
|
||||
]);
|
||||
const outgoing = report.sliceEdges.filter(edge => edge.source === 'customer-statistics');
|
||||
const incoming = report.sliceEdges.filter(edge => edge.target === 'customer-statistics');
|
||||
@@ -37,3 +38,17 @@ test('customer statistics remains an independently packaged low-coupling slice',
|
||||
assert.equal(report.sliceSccs.some(component => component.includes('customer-statistics')), false);
|
||||
});
|
||||
|
||||
test('customer statistics application layer only depends inward', () => {
|
||||
const report = analyze();
|
||||
const prefix = 'com.baiyue.firstleg.customerstatistics.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);
|
||||
}
|
||||
const outwardDependencies = report.classDependencies.filter(edge => edge.source.startsWith(prefix)
|
||||
&& edge.target.startsWith('com.baiyue.firstleg.') && !edge.target.startsWith(prefix));
|
||||
assert.deepEqual(outwardDependencies, []);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user