This commit is contained in:
2026-08-25 11:52:25 +08:00
parent d64706e97b
commit 4fbf8bfb53
16 changed files with 1101 additions and 16 deletions
+1
View File
@@ -32,6 +32,7 @@
| 海外卸柜完成事件回写 | 内部入口 `controller/InternalOverseasContainerEventController.java`、消费者 `service/OverseasContainerEventConsumer.java`、Inbox `entity/ContainerEventInboxEntity.java` 与 `mapper/ContainerEventInboxMapper.java`;事件模型见 `../common-event-contract/AGENTS.md` |
| 三期海运订舱与出仓前船期资料 | `oceanbooking/controller/PlatformOceanBookingController.java`、`oceanbooking/service/OceanBookingService.java`、`portschedule/controller/PlatformPortScheduleController.java`;订舱契约见 `src/main/resources/openapi/ocean-bookings-v1.yaml`,表结构见 `src/main/resources/db/phase3-ocean-booking-schema.sql` |
| 四期国内出仓后船节点定时查询 | `outbound/dispatch/service/OutboundDispatchService.java` 在确认出仓事务内幂等创建现有 `CarrierScheduleQuery`;编排与租约 `portschedule/service/CarrierScheduleQueryScheduler.java`,任务/十节点事实/异常事实/不可变 attempt `CarrierScheduleQueryService.java`,平台查询与人工补录入口 `PlatformCarrierScheduleQueryController.java` 的 `/{queryId}/manual-nodes`;停止只由实际 `EMPTY_RETURNED` 节点触发,不提供无节点停止入口;外部船司/轨迹调用经 `integration-service/`;标准见 `../../docs/四期/船节点标准化契约.md` |
| 船节点跨区交接事件 | `portschedule/service/CarrierScheduleNodeOutboxService.java`;实际节点落库后发布 `FIRST_LEG_CONTAINER_SHIPPING_NODE_RECORDED` V1,复用 `LoadingCompletedOutboxRepository`/Relay 投 Event Bridge;海外消费见 `../lastleg-service/src/main/java/com/baiyue/lastleg/shipping/service/ContainerShippingNodeConsumer.java` |
| Firstleg 业务单据编号 | `service/FirstlegDocumentNumberGenerator.java`;当前支持 `OB`、`TA`、`CB`、`CAB`、`LP` 前缀,按北京时间每日独立编号;具体业务归属以各调用服务为准 |
| 拖车与出仓调度 | `controller/PlatformTruckAppointmentController.java`、`outbound/dispatch/controller/PlatformOutboundDispatchController.java` |
| 报关协同 | `customs/controller/PlatformCustomsController.java`、`MerchantCustomsController.java` |
@@ -0,0 +1,189 @@
# Firstleg 内部模块化基线与首切片方案
## 1. 范围与生成方式
本基线只分析 `firstleg` 同一部署单元,不拆微服务,不改变业务流程、数据库结构、OpenAPI 或跨服务契约。分析输入为:
- `src/main/java`:429 个 Java 文件;948 条可解析的模块内类依赖;66 个物理包。
- `src/test/java`:169 个 Java 文件。与主源码合并分析时共 598 个 Java 文件、1,635 条模块内类依赖、69 个物理包。
可重复命令:
```powershell
node firstleg/scripts/analyze-firstleg-dependencies.mjs --source-set main --format markdown
node firstleg/scripts/analyze-firstleg-dependencies.mjs --source-set all --format json
node firstleg/scripts/analyze-firstleg-dependencies.mjs --source-set main --format dot
```
分析器解析显式内部 `import`、静态 `import` 及同包类型引用,输出物理包图、逻辑切片图、SCC、跨切片类依赖、Entity/DTO 泄漏和 Mapper 泄漏。Spring 字符串扫描路径、反射、MyBatis SQL 中的表引用不属于 Java 类型图,因此在本报告单独标注。
## 2. 当前 SCC
### 2.1 主源码物理包 SCC
当前只有一个多节点物理包 SCC:
```text
com.baiyue.firstleg.mapper
<-> com.baiyue.firstleg.repository
```
直接证据是全局 Repository 大量依赖全局 Mapper,同时 `InboundAllocationQueryMapper` 反向引用 `repository.PriceBookQueryRows`。这说明全局数据访问层尚不能按传统横向层直接单向化。
合并测试源码后,测试对生产类型的依赖把 SCC 扩大为下列十包;这不是生产运行时环,但表示测试也直接绑定了横向实现包:
```text
controller -> loading.repository -> loading.service -> mapper
-> oceanbooking.controller -> oceanbooking.repository -> oceanbooking.service
-> pricing -> repository -> service -> controller
```
### 2.2 逻辑切片 SCC
逻辑切片图存在一个八节点 SCC:
```text
booking-customs-trucking
finance-integration
inbound
measurement-pricing
outbound
shared-technical
shipping-nodes
split-loading
```
`customer-statistics` 不在 SCC 内。当前 36 条切片间边均冻结在 `src/test/resources/architecture/firstleg-slice-dependencies.json`;合同允许删除现有边,不允许增加新边。
主要双向依赖包括:
| 双向关系 | 直接证据 |
|---|---|
| 入库 ↔ 测量计价 | 入库编排直接使用测量/费用 Entity、Mapper;计价与客户费用查询直接使用入库单、状态和 Outbox |
| 入库 ↔ 分柜装柜 | 统一入库读模型读取装柜/PDA Mapper 与 Entity;装柜结算和事件发布读取入库状态、托盘与 Outbox |
| 分柜装柜 ↔ 出仓 | 货柜聚合读出仓历史;海外卸柜回写反向更新货柜/装柜事实 |
| 订舱报关拖车 ↔ 出仓 | 出仓门禁聚合报关/船期贡献者;报关和船期贡献者反向依赖出仓 DTO |
| 订舱报关拖车 ↔ 分柜装柜 | 订舱货柜分配使用装柜实体;报关直接读取装柜计划与明细 Mapper |
| 订舱报关拖车 ↔ 测量计价 | 价格簿校验影响订舱;供应商/渠道校验又由全局计价服务消费 |
## 3. 八个业务切片现状映射
类数量和跨切片依赖数量以主源码图为准;“出/入”均为类级直接依赖条数。
| 业务切片 | 当前物理位置与识别范围 | 类数 | 出/入 | 主要边界问题 |
|---|---|---:|---:|---|
| 入库 | 全局 `controller/service/repository/mapper/entity/dto` 中 `Inbound*`、`Fulfillment*`、`Assignment*`、`UnifiedInbound*` | 67 | 26/60 | 被计价和装柜大量反向读取;`InboundWorkflowRepository` 直接持有测量/费用 Mapper |
| 测量计价 | 全局层中的 `Measurement*`、`Mojia*`、`Pricing*`、`PriceBook*`、`Fba*`、`Dimension*`、`FeeItem*`、`InboundFee*`,以及 `pricing/` | 113 | 72/25 | 最大切片;对入库有 35 条直接依赖,其中 20 条为跨切片数据模型泄漏、6 条为 Mapper 泄漏 |
| 分柜装柜 | `split/`、`loading/` 加全局 `Loading*`、`SmartLoading*`、货柜与 PDA 类 | 82 | 50/32 | 同时依赖入库、订舱、出仓和共享层;全局 Repository 直接读取 `loading/split` Mapper/Entity |
| 订舱报关拖车 | `oceanbooking/`、`customs/`,全局 `TruckAppointment*`、`WarehouseSupplier*`、出仓门禁贡献者 | 68 | 25/13 | `CustomsRepository` 直接读取入库与装柜 Mapper;订舱 Repository 直接读取全局货柜 Entity |
| 出仓 | `outbound/dispatch/` 与全局 `OutboundDispatch*`、运输历史、海外卸柜回写 | 17 | 8/10 | 与装柜、订舱门禁双向;海外回写直接使用全局货柜 Mapper/Entity |
| 船节点 | `portschedule/` 中 `CarrierScheduleQuery/Node/Adapter/Attempt/Exception*` | 22 | 8/1 | 查询服务直接读取订舱、船期、货柜 Entity;节点 Outbox 直接使用入库 Outbox Mapper/Entity |
| 客户统计 | `customerstatistics/` | 4 | 1/0 | Java 依赖最低;唯一跨切片依赖为 Controller 调用 `FirstlegScopeGuard`。SQL 只读订舱、货柜和装柜表 |
| 财务集成 | 全局 `InternalFinance*`、`FinanceFeeSource*`、`FinanceCostSnapshot*`、付款凭证/快照类 | 20 | 17/6 | 直接读取入库费、柜次费、装柜及共享 Outbox 的 Entity/Mapper;继续受财务总控冻结的业务口径约束 |
共享技术层当前有 36 类,包含应用启动/配置、Scope Guard、单据号、部分跨服务 Client 及无法安全归属的全局模型。跨服务适配器按 `client` 包独立标识,不能因逻辑切片归属而被迁入应用核心。
## 4. 泄漏与逆向依赖基线
主源码共有 88 条跨切片 Entity/DTO 泄漏、31 条跨切片 Mapper 泄漏。高频项如下:
| 来源 → 目标 | Entity/DTO | Mapper | 代表性证据 |
|---|---:|---:|---|
| 测量计价 → 入库 | 20 | 6 | `FbaPricingRepository -> InboundOutboxEntity/Mapper`;客户费用查询直接使用 `InboundOrderDetail` |
| 分柜装柜 → 入库 | 7 | 6 | `LoadingDeliveryService -> InboundStatusHistoryEntity/Mapper`;装柜 Outbox 复用入库 Outbox |
| 入库 → 测量计价 | 4 | 4 | `InboundWorkflowRepository -> InboundFee/Measurement/Review Mapper` |
| 入库 → 分柜装柜 | 4 | 3 | `UnifiedInboundReadRepository` 直接读取装柜证据、PDA 任务、预装计划 Mapper/Entity |
| 财务集成 → 入库 | 4 | 2 | 财务来源 Outbox 直接复用 `InboundOutboxEntity/Mapper` |
| 订舱报关拖车 → 分柜装柜 | 3 | 3 | `CustomsRepository -> LoadingPlan/LoadingPlanItem Mapper` |
| 分柜装柜 → 订舱报关拖车 | 3 | 0 | `ContainerService -> OceanBookingContainer*Entity` |
| 船节点 → 入库 | 1 | 1 | `CarrierScheduleNodeOutboxService -> InboundOutboxEntity/Mapper` |
逆向依赖的判定不是简单的 `controller -> service -> repository` 层次关系,而是“业务核心直接依赖另一切片的持久化实现或 Entity”。首轮治理应优先用查询/命令 Port 替代这些直接引用,不共享领域 Entity。
## 5. 首切片选择:客户统计
选择 `customer-statistics`,不是因为预设,而是因为以下实际证据:
1. 仅 4 个生产类,已集中在单一顶层包;没有业务切片对其产生 Java 类型入向依赖。
2. 唯一跨切片 Java 依赖为 Web Controller 对共享 `FirstlegScopeGuard` 的调用,不涉及 Entity 或 Mapper 泄漏。
3. 不属于当前八节点 SCC,也没有跨服务 Feign/HTTP Client。
4. 现有调用面稳定:一个平台 GET 接口、一个 Admin API 消费者、两个后端测试。
5. 它仍包含一个明确的内部逆向依赖:应用 Service 直接依赖 MyBatis Mapper,适合用最小 Out Port 证明目标依赖方向。
限制:`CustomerTransitStatisticsMapper` 的 SQL 只读 `fl_ocean_booking`、`fl_container_booking`、`fl_loading_plan`、`fl_loading_plan_item`。首切片只隔离 Java 依赖,不宣称拥有这些表,也不改变 SQL、索引或统计口径。
## 6. 首切片目标目录与允许依赖
```text
customerstatistics/
adapter/in/web/
PlatformCustomerTransitStatisticsController.java
application/
CustomerTransitStatisticsQueryService.java
model/CustomerTransitStatisticsModels.java
port/out/CustomerTransitStatisticsReader.java
adapter/out/persistence/
CustomerTransitStatisticsMapper.java
```
允许方向:
```text
adapter.in.web -> application
adapter.out.persistence -> application.port.out
application -> application.model + common-core
```
额外允许项仅限 Web 适配器使用 `common-security`、Spring Web 和现有 `FirstlegScopeGuard`。禁止 `application` 依赖 MyBatis、Spring MVC、全局 `entity/mapper/repository/service` 或其他业务切片。
## 7. 精确迁移清单、调用方与测试
首切片实施时只迁移/新增以下文件:
| 当前文件 | 目标/动作 |
|---|---|
| `customerstatistics/controller/PlatformCustomerTransitStatisticsController.java` | 移到 `adapter/in/web/`,URI、权限和响应保持不变 |
| `customerstatistics/dto/CustomerTransitStatisticsDtos.java` | 移到 `application/model/`,保持 JSON 字段与数值语义不变 |
| `customerstatistics/service/CustomerTransitStatisticsService.java` | 移到 `application/` 并改名为 Query Service,只依赖 Out Port |
| `customerstatistics/mapper/CustomerTransitStatisticsMapper.java` | 移到 `adapter/out/persistence/`,实现/扩展只读 Out Port;SQL 原样保留 |
| 新增 `customerstatistics/application/port/out/CustomerTransitStatisticsReader.java` | 定义 `summarize(customerId)` 与只读投影,不暴露 MyBatis 类型 |
| `src/test/java/.../customerstatistics/CustomerTransitStatisticsServiceTest.java` | Mock Out Port,不再 Mock Mapper |
| `src/test/java/.../customerstatistics/CustomerTransitStatisticsContractTest.java` | 更新包引用,继续锁定 URI、权限、SQL、OpenAPI 与索引语义 |
| `BaiyueFirstlegApplication.java`、`BaiyueFirstlegApplicationContractTest.java` | 只有 Mapper 扫描路径因移动确需调整时修改;不改变其他扫描包 |
外部调用方只读核对,不在首切片修改:
- `firstleg/src/main/resources/openapi/customer-statistics-v1.yaml`
- `apps/admin-web/src/modules/container-loading/api/platformContainerLoading.ts`
- `apps/admin-web/src/modules/container-loading/api/platformContainerLoading.test.ts`
## 8. 回滚边界
- 不修改表、SQL 内容、接口路径、权限码、响应字段、统计定义、Nacos、部署或跨服务契约。
- 回滚只涉及上述客户统计 Java 包移动、Out Port 和对应测试;可整体恢复到现有四类结构。
- 若 Mapper 继承 Out Port 导致 MyBatis 代理行为或扫描合同失败,停止实施并恢复包内变更,不以增加全局扫描、共享 POM 依赖或兼容 Bean 绕过。
- 首切片完成前不开始第二切片。
## 9. 当前只读合同与后续协作
本轮已增加模块内、无第三方依赖的只读合同:
- `scripts/analyze-firstleg-dependencies.mjs`:生成 JSON/Markdown/DOT 和 Tarjan SCC。
- `src/test/resources/architecture/firstleg-slice-dependencies.json`:冻结当前切片边;历史边可减少,不可增加。
- `scripts/analyze-firstleg-dependencies.test.mjs`:验证基线无新增边,并锁定客户统计 4 类、唯一出向边、零入向边和不进入 SCC。
与其他架构专项的依赖:
- 专项一(拓扑与质量门禁):决定是否引入 ArchUnit、如何把本合同接入 Maven/CI、以及共享 POM 和全局质量门禁。当前未修改 POM;建议由专项一把本脚本作为 Firstleg 局部门禁接入,再以 ArchUnit 替换/增强源码级解析。
- 专项二(同步依赖与失败传播):在为其他切片定义应用 Port 前提供 Feign/HTTP 调用图和失败语义。客户统计当前没有跨服务 Client,因此首切片无需等待专项二;后续入库、财务集成、订舱和装柜切片必须协调。
## 10. 最小验证命令
```powershell
node --test firstleg/scripts/analyze-firstleg-dependencies.test.mjs
node firstleg/scripts/analyze-firstleg-dependencies.mjs --source-set main --format markdown
mvn -pl firstleg -am -Dtest=CustomerTransitStatisticsServiceTest,CustomerTransitStatisticsContractTest -Dsurefire.failIfNoSpecifiedTests=false test
```
当前只新增只读脚本、合同和报告,没有 Java 签名变化;前两条命令是本轮直接门禁。第三条用于首切片实际迁移后的 Maven 行为验证。
@@ -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);
});
@@ -0,0 +1,48 @@
{
"schemaVersion": 1,
"description": "Development-stage Firstleg slice dependency baseline. Existing edges may be removed; new edges require architecture review.",
"allowedSliceEdges": [
"booking-customs-trucking->inbound",
"booking-customs-trucking->measurement-pricing",
"booking-customs-trucking->outbound",
"booking-customs-trucking->shared-technical",
"booking-customs-trucking->split-loading",
"customer-statistics->shared-technical",
"finance-integration->inbound",
"finance-integration->measurement-pricing",
"finance-integration->shared-technical",
"finance-integration->split-loading",
"inbound->measurement-pricing",
"inbound->outbound",
"inbound->shared-technical",
"inbound->split-loading",
"measurement-pricing->booking-customs-trucking",
"measurement-pricing->finance-integration",
"measurement-pricing->inbound",
"measurement-pricing->shared-technical",
"outbound->booking-customs-trucking",
"outbound->shared-technical",
"outbound->shipping-nodes",
"outbound->split-loading",
"shared-technical->booking-customs-trucking",
"shared-technical->finance-integration",
"shared-technical->inbound",
"shared-technical->measurement-pricing",
"shared-technical->split-loading",
"shipping-nodes->booking-customs-trucking",
"shipping-nodes->inbound",
"shipping-nodes->shared-technical",
"shipping-nodes->split-loading",
"split-loading->booking-customs-trucking",
"split-loading->inbound",
"split-loading->measurement-pricing",
"split-loading->outbound",
"split-loading->shared-technical"
],
"sliceSpecificRules": {
"customer-statistics": {
"allowedOutgoing": ["shared-technical"],
"allowedIncoming": []
}
}
}