feat(fleet): enforce reviewed trips and original fee coverage for dispatch
This commit is contained in:
@@ -3,6 +3,7 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import process from 'node:process';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
|
||||
const moduleRoot = path.resolve(import.meta.dirname, '..');
|
||||
const sourceRoots = {
|
||||
@@ -12,6 +13,7 @@ const sourceRoots = {
|
||||
const internalPrefix = 'com.baiyue.lastleg.';
|
||||
|
||||
const sliceRules = [
|
||||
['fleet', /^com\.baiyue\.lastleg\.fleet(?:\.|$)/],
|
||||
['security-projection', /(?:^|\.)security\.(?:controller|dto|service)(?:\.|$)/],
|
||||
['dispatch-delivery', /(?:^|\.)(?:dispatch|delivery|outbound)(?:\.|$)/],
|
||||
['relabel-transfer-discard', /(?:^|\.)(?:relabel|transfer|discard)(?:\.|$)/],
|
||||
@@ -269,13 +271,34 @@ function renderDot(report, graph) {
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function checkContract(report, contractPath) {
|
||||
const absolute = path.resolve(process.cwd(), contractPath);
|
||||
const contract = JSON.parse(fs.readFileSync(absolute, 'utf8'));
|
||||
export function contractViolations(report, contract) {
|
||||
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 reviewed = new Set();
|
||||
const violations = [];
|
||||
if (contract.forbidPackageCycles) {
|
||||
for (const cycle of report.packageSccs) violations.push(`package cycle: ${cycle.join(' -> ')}`);
|
||||
}
|
||||
for (const edge of contract.reviewedClassDependencies ?? []) {
|
||||
if (!edge.source || !edge.target || !edge.reason?.trim() || /[*]/.test(edge.source + edge.target)) {
|
||||
violations.push('reviewed dependency requires exact classes and a rationale');
|
||||
continue;
|
||||
}
|
||||
reviewed.add(`${edge.source}->${edge.target}`);
|
||||
}
|
||||
const classesByName = new Map(report.classes.map(entry => [entry.fqcn, entry]));
|
||||
const governed = report.classDependencies.filter(edge => !reviewed.has(`${edge.source}->${edge.target}`));
|
||||
for (const edge of governed) {
|
||||
const from = classesByName.get(edge.source)?.slice, to = classesByName.get(edge.target)?.slice;
|
||||
if (from && to && from !== to && !allowed.has(`${from}->${to}`)) {
|
||||
violations.push(`unreviewed slice dependency: ${from}->${to}: ${edge.source}->${edge.target}`);
|
||||
}
|
||||
}
|
||||
for (const entry of report.classes) {
|
||||
if ((contract.forbiddenSlices ?? []).includes(entry.slice)) violations.push(`retired slice returned: ${entry.fqcn}`);
|
||||
for (const type of [entry.fqcn, ...entry.imports]) {
|
||||
if ((contract.forbiddenTypePrefixes ?? []).some(prefix => type.startsWith(prefix))) violations.push(`retired security type referenced: ${entry.fqcn} -> ${type}`);
|
||||
}
|
||||
}
|
||||
for (const [fqcn, expectedFile] of Object.entries(contract.requiredClassLocations ?? {})) {
|
||||
const actualClass = classesByName.get(fqcn);
|
||||
if (!actualClass) violations.push(`required class missing: ${fqcn}`);
|
||||
@@ -300,8 +323,11 @@ function checkContract(report, contractPath) {
|
||||
}
|
||||
}
|
||||
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();
|
||||
// A reviewed class reference does not authorize any other consumer of that slice.
|
||||
const outgoing = [...new Set(governed.filter(edge => classesByName.get(edge.source)?.slice === slice)
|
||||
.map(edge => classesByName.get(edge.target)?.slice).filter(target => target && target !== slice))].sort();
|
||||
const incoming = [...new Set(governed.filter(edge => classesByName.get(edge.target)?.slice === slice)
|
||||
.map(edge => classesByName.get(edge.source)?.slice).filter(source => source && source !== slice))].sort();
|
||||
if (JSON.stringify(outgoing) !== JSON.stringify([...(rule.allowedOutgoing ?? [])].sort())) {
|
||||
violations.push(`${slice} outgoing dependencies changed: ${outgoing.join(', ')}`);
|
||||
}
|
||||
@@ -309,19 +335,25 @@ function checkContract(report, contractPath) {
|
||||
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;
|
||||
}
|
||||
return violations;
|
||||
}
|
||||
|
||||
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);
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) {
|
||||
const options = parseArgs(process.argv.slice(2));
|
||||
const report = buildReport(options.sourceSet);
|
||||
if (options.check) {
|
||||
const contract = JSON.parse(fs.readFileSync(path.resolve(process.cwd(), options.check), 'utf8'));
|
||||
const violations = contractViolations(report, contract);
|
||||
if (violations.length) {
|
||||
process.stderr.write(`Lastleg dependency contract violations in ${options.check}:\n${violations.map(item => `- ${item}`).join('\n')}\n`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -1,13 +1,41 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
import { contractViolations } from './analyze-lastleg-dependencies.mjs';
|
||||
|
||||
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';
|
||||
|
||||
test('fleet stays a business slice and its policies have no infrastructure dependencies', () => {
|
||||
const report = analyze();
|
||||
const classes = report.classes.filter(entry => entry.fqcn.startsWith('com.baiyue.lastleg.fleet.'));
|
||||
assert.ok(classes.some(entry => entry.fqcn.endsWith('.DriverService')));
|
||||
assert.ok(classes.every(entry => entry.slice === 'fleet'));
|
||||
assert.equal(report.classes.find(entry => entry.fqcn === 'com.baiyue.lastleg.dispatch.fleet.DispatchFleetAdapter').slice, 'dispatch-delivery');
|
||||
const policyClasses = classes.filter(entry => entry.packageName === 'com.baiyue.lastleg.fleet.domain');
|
||||
assert.equal(policyClasses.length, 3);
|
||||
for (const entry of policyClasses) assert.ok(entry.imports.every(imported => imported.startsWith('java.')));
|
||||
assert.deepEqual(report.sliceEdges.filter(edge => edge.source === 'fleet').map(edge => edge.target).sort(), ['overseas-master-data', 'shared-technical']);
|
||||
for (const edge of report.classDependencies.filter(edge => edge.source.startsWith('com.baiyue.lastleg.fleet.') && edge.target.startsWith(vehiclePrefix))) {
|
||||
assert.ok(edge.target.startsWith(vehiclePrefix + '.application.'), 'fleet must use vehicle application ports, not persistence adapters');
|
||||
}
|
||||
assert.equal(report.packageSccs.some(component => component.some(name => name.startsWith('com.baiyue.lastleg.fleet.'))), false);
|
||||
assert.equal(report.classDependencies.some(edge => edge.source.startsWith('com.baiyue.lastleg.fleet.')
|
||||
&& /com\.baiyue\.lastleg\.(dispatch|delivery)\./.test(edge.target)), false);
|
||||
const dispatchEdges = report.classDependencies.filter(edge => edge.source.startsWith('com.baiyue.lastleg.dispatch.')
|
||||
&& edge.target.startsWith('com.baiyue.lastleg.fleet.'));
|
||||
assert.ok(dispatchEdges.length > 0);
|
||||
assert.ok(dispatchEdges.every(edge => /\.(FleetDispatchGateway|FleetDispatchBindingGuard|FleetTripExecutionService|FleetExecutionModels|FleetCompletionModels)$/.test(edge.target)),
|
||||
'dispatch must use fleet ports and guards, not its persistence mappers');
|
||||
const incomingEdges = report.classDependencies.filter(edge => edge.source.startsWith('com.baiyue.lastleg.transfer.') && edge.target.startsWith('com.baiyue.lastleg.fleet.'));
|
||||
assert.ok(incomingEdges.length > 0);
|
||||
assert.ok(incomingEdges.every(edge => edge.source === 'com.baiyue.lastleg.transfer.fleet.FleetIncomingAdapter' && /\.(FleetBookingIncomingGateway|FleetDispatchGateway)$/.test(edge.target)), 'incoming adapter uses fleet ports only');
|
||||
});
|
||||
|
||||
function analyze(sourceSet = 'main') {
|
||||
return JSON.parse(execFileSync(process.execPath, [analyzer, '--source-set', sourceSet, '--format', 'json'], {
|
||||
cwd: moduleRoot,
|
||||
@@ -43,6 +71,7 @@ test('vehicle master data remains the reviewed low-coupling first-slice candidat
|
||||
'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.VehicleAssignmentGuard',
|
||||
'com.baiyue.lastleg.masterdata.vehicle.application.port.out.VehicleRepository',
|
||||
]);
|
||||
const outgoing = report.classDependencies.filter(edge => edge.source.startsWith(vehiclePrefix)
|
||||
@@ -53,8 +82,9 @@ test('vehicle master data remains the reviewed low-coupling first-slice candidat
|
||||
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.ok(incoming.length > 0);
|
||||
assert.ok(incoming.every(edge => edge.source.startsWith('com.baiyue.lastleg.fleet.') && edge.target.startsWith(vehiclePrefix + '.application.')));
|
||||
assert.equal(report.packageSccs.some(component => component.some(name => name.startsWith(vehiclePrefix))), 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);
|
||||
});
|
||||
@@ -63,7 +93,7 @@ test('vehicle application only depends inward and stays free of adapter framewor
|
||||
const report = analyze();
|
||||
const prefix = `${vehiclePrefix}.application`;
|
||||
const applicationClasses = report.classes.filter(entry => entry.packageName.startsWith(prefix));
|
||||
assert.equal(applicationClasses.length, 3);
|
||||
assert.equal(applicationClasses.length, 4);
|
||||
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);
|
||||
@@ -75,24 +105,49 @@ test('vehicle application only depends inward and stays free of adapter framewor
|
||||
assert.deepEqual(outwardDependencies, []);
|
||||
});
|
||||
|
||||
test('security projection stays isolated from Lastleg business slices', () => {
|
||||
test('retired Lastleg security projection stays absent under the unified Auth contract', () => {
|
||||
const report = analyze();
|
||||
const projectionClasses = report.classes.filter(entry => entry.slice === 'security-projection');
|
||||
const projectionNames = new Set(projectionClasses.map(entry => entry.fqcn));
|
||||
assert.deepEqual(projectionClasses.map(entry => entry.fqcn).sort(), [
|
||||
'com.baiyue.lastleg.security.controller.LastlegSecurityController',
|
||||
'com.baiyue.lastleg.security.dto.LastlegSecurityDtos',
|
||||
'com.baiyue.lastleg.security.service.LastlegSecurityProjectionRedisMirror',
|
||||
'com.baiyue.lastleg.security.service.LastlegSecurityProjectionService',
|
||||
'com.baiyue.lastleg.security.service.LastlegSecurityValidationService',
|
||||
'com.baiyue.lastleg.security.service.SecurityProjectionMonotonicity',
|
||||
]);
|
||||
assert.deepEqual(report.sliceEdges.filter(edge => edge.source === 'security-projection'), [
|
||||
{ source: 'security-projection', target: 'shared-technical', count: 1 },
|
||||
]);
|
||||
assert.deepEqual(projectionClasses, []);
|
||||
assert.deepEqual(report.sliceEdges.filter(edge => edge.source === 'security-projection'), []);
|
||||
assert.deepEqual(report.sliceEdges.filter(edge => edge.target === 'security-projection'), []);
|
||||
assert.equal(report.sliceSccs.some(component => component.includes('security-projection')), false);
|
||||
const businessConsumers = report.classDependencies.filter(edge => projectionNames.has(edge.target)
|
||||
&& !projectionNames.has(edge.source));
|
||||
assert.deepEqual(businessConsumers, []);
|
||||
assert.match(readFileSync(path.join(moduleRoot, 'pom.xml'), 'utf8'), /<artifactId>baiyue-common-security<\/artifactId>/);
|
||||
});
|
||||
|
||||
test('reviewed references do not allow another consumer into the same slice', () => {
|
||||
const report = analyze();
|
||||
const baseline = JSON.parse(readFileSync(contract, 'utf8'));
|
||||
assert.deepEqual(contractViolations(report, baseline), []);
|
||||
const source = 'com.baiyue.lastleg.controller.LastlegOrderController';
|
||||
const target = 'com.baiyue.lastleg.masterdata.exchange.mapper.MonthlyExchangeRateMapper';
|
||||
report.classDependencies.push({ source, target });
|
||||
assert.ok(contractViolations(report, baseline).some(message => message.includes(`${source}->${target}`)));
|
||||
});
|
||||
|
||||
test('restoring a retired projection or importing its missing type still fails', () => {
|
||||
const baseline = JSON.parse(readFileSync(contract, 'utf8'));
|
||||
const report = analyze();
|
||||
const service = report.classes.find(entry => entry.fqcn.endsWith('.FleetTripService'));
|
||||
service.imports.push('com.baiyue.lastleg.security.service.LastlegSecurityValidationService');
|
||||
assert.ok(contractViolations(report, baseline).some(message => message.includes('retired security type referenced')));
|
||||
service.imports.pop();
|
||||
report.classes.push({ ...service, fqcn: 'com.baiyue.lastleg.security.service.LastlegSecurityValidationService', slice: 'security-projection' });
|
||||
assert.ok(contractViolations(report, baseline).some(message => message.includes('retired slice returned')));
|
||||
});
|
||||
|
||||
test('reviewed references cannot bypass the vehicle application boundary or use wildcard grants', () => {
|
||||
const report = analyze(), baseline = JSON.parse(readFileSync(contract, 'utf8'));
|
||||
const source = `${vehiclePrefix}.application.VehicleApplicationService`, target = `${vehiclePrefix}.adapter.out.persistence.VehicleMapper`;
|
||||
report.classDependencies.push({ source, target });
|
||||
baseline.reviewedClassDependencies.push({ source, target, reason: 'invalid attempted waiver' });
|
||||
assert.ok(contractViolations(report, baseline).some(message => message.includes('forbidden application dependency')));
|
||||
baseline.reviewedClassDependencies.push({ source: 'com.baiyue.lastleg.*', target, reason: 'invalid wildcard' });
|
||||
assert.ok(contractViolations(report, baseline).some(message => message.includes('requires exact classes')));
|
||||
});
|
||||
|
||||
test('package cycles are rejected even when every slice edge is otherwise allowed', () => {
|
||||
const report = analyze(), baseline = JSON.parse(readFileSync(contract, 'utf8'));
|
||||
assert.deepEqual(report.packageSccs, []);
|
||||
report.packageSccs.push(['com.baiyue.lastleg.delivery.dto', 'com.baiyue.lastleg.delivery.model']);
|
||||
assert.ok(contractViolations(report, baseline).some(message => message.startsWith('package cycle:')));
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user