From 0e0b6f8264013c88b855a9cc6a8fbb1fbaabd926 Mon Sep 17 00:00:00 2001
From: ouyangxq <775153912@qq.com>
Date: Tue, 25 Aug 2026 12:22:14 +0800
Subject: [PATCH] test: add architecture and OpenAPI validation tooling
---
.gitignore | 1 +
...rstleg-internal-modularization-baseline.md | 25 ++-
firstleg/pom.xml | 1 +
.../scripts/analyze-firstleg-dependencies.mjs | 45 ++++-
.../analyze-firstleg-dependencies.test.mjs | 23 ++-
.../CustomerStatisticsArchitectureTest.java | 80 ++++++++
.../firstleg-slice-dependencies.json | 16 ++
pom.xml | 6 +
scripts/java-topology-contract.test.mjs | 18 +-
scripts/openapi-validation/package-lock.json | 180 ++++++++++++++++++
scripts/openapi-validation/package.json | 16 ++
.../openapi-validation/validate-openapi.mjs | 83 ++++++++
.../validate-openapi.test.mjs | 67 +++++++
13 files changed, 544 insertions(+), 17 deletions(-)
create mode 100644 firstleg/src/test/java/com/baiyue/firstleg/customerstatistics/CustomerStatisticsArchitectureTest.java
create mode 100644 scripts/openapi-validation/package-lock.json
create mode 100644 scripts/openapi-validation/package.json
create mode 100644 scripts/openapi-validation/validate-openapi.mjs
create mode 100644 scripts/openapi-validation/validate-openapi.test.mjs
diff --git a/.gitignore b/.gitignore
index 533ecf24c..dc4b3792c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,4 +1,5 @@
target/
+node_modules/
.docker-context/
*.log
.idea/
diff --git a/firstleg/docs/firstleg-internal-modularization-baseline.md b/firstleg/docs/firstleg-internal-modularization-baseline.md
index 3a37feacd..7dbecb0d3 100644
--- a/firstleg/docs/firstleg-internal-modularization-baseline.md
+++ b/firstleg/docs/firstleg-internal-modularization-baseline.md
@@ -4,8 +4,9 @@
本基线只分析 `firstleg` 同一部署单元,不拆微服务,不改变业务流程、数据库结构、OpenAPI 或跨服务契约。分析输入为:
-- `src/main/java`:429 个 Java 文件;948 条可解析的模块内类依赖;66 个物理包。
-- `src/test/java`:169 个 Java 文件。与主源码合并分析时共 598 个 Java 文件、1,635 条模块内类依赖、69 个物理包。
+- 首切片实施前 `src/main/java`:429 个 Java 文件;948 条可解析的模块内类依赖;66 个物理包。
+- 首切片实施前 `src/test/java`:169 个 Java 文件。与主源码合并分析时共 598 个 Java 文件、1,635 条模块内类依赖、69 个物理包。
+- 首切片实施后 `src/main/java`:430 个 Java 文件;949 条可解析的模块内类依赖;67 个物理包。增加项仅为只读 Out Port。
可重复命令:
@@ -78,7 +79,7 @@ split-loading
| 订舱报关拖车 | `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 只读订舱、货柜和装柜表 |
+| 客户统计 | `customerstatistics/adapter`、`customerstatistics/application` | 5 | 1/0 | 已完成首切片;唯一跨切片依赖为 Web Adapter 调用 `FirstlegScopeGuard`。SQL 只读订舱、货柜和装柜表 |
| 财务集成 | 全局 `InternalFinance*`、`FinanceFeeSource*`、`FinanceCostSnapshot*`、付款凭证/快照类 | 20 | 17/6 | 直接读取入库费、柜次费、装柜及共享 Outbox 的 Entity/Mapper;继续受财务总控冻结的业务口径约束 |
共享技术层当前有 36 类,包含应用启动/配置、Scope Guard、单据号、部分跨服务 Client 及无法安全归属的全局模型。跨服务适配器按 `client` 包独立标识,不能因逻辑切片归属而被迁入应用核心。
@@ -104,7 +105,7 @@ split-loading
选择 `customer-statistics`,不是因为预设,而是因为以下实际证据:
-1. 仅 4 个生产类,已集中在单一顶层包;没有业务切片对其产生 Java 类型入向依赖。
+1. 实施前仅 4 个生产类且已集中在单一顶层包;实施后为原 4 类加 1 个只读 Out Port,没有业务切片对其产生 Java 类型入向依赖。
2. 唯一跨切片 Java 依赖为 Web Controller 对共享 `FirstlegScopeGuard` 的调用,不涉及 Entity 或 Mapper 泄漏。
3. 不属于当前八节点 SCC,也没有跨服务 Feign/HTTP Client。
4. 现有调用面稳定:一个平台 GET 接口、一个 Admin API 消费者、两个后端测试。
@@ -138,7 +139,7 @@ application -> application.model + common-core
## 7. 精确迁移清单、调用方与测试
-首切片实施时只迁移/新增以下文件:
+首切片已按以下清单迁移/新增:
| 当前文件 | 目标/动作 |
|---|---|
@@ -170,7 +171,7 @@ application -> application.model + common-core
- `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。
+- `scripts/analyze-firstleg-dependencies.test.mjs`:验证基线无新增边,并锁定客户统计目标 5 类/目录、唯一出向边、零入向边、不进入 SCC 和 application 依赖禁令。
与其他架构专项的依赖:
@@ -182,8 +183,18 @@ application -> application.model + common-core
```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 test-compile
mvn -pl firstleg -am -Dtest=CustomerTransitStatisticsServiceTest,CustomerTransitStatisticsContractTest -Dsurefire.failIfNoSpecifiedTests=false test
```
-当前只新增只读脚本、合同和报告,没有 Java 签名变化;前两条命令是本轮直接门禁。第三条用于首切片实际迁移后的 Maven 行为验证。
+首切片已经发生 Java 包与依赖签名变化,三条命令均为直接门禁;另需执行 `mvn -pl firstleg -am test-compile` 覆盖全部测试源码调用点。
+## 11. 首切片实施结果
+
+- 四个旧包类已删除,没有转发兼容层;目标五层目录已由 Node 合同锁定。
+- `CustomerTransitStatisticsReader.SummaryProjection` 只包含 `customerId`、`containerCount`、`totalTeu`、`unknownContainerCount`、`asOf`,不暴露 MyBatis、Entity 或其他切片类型。
+- 依赖方向由 `application Service -> MyBatis Mapper` 改为 `application Service -> application Port <- persistence Mapper`。
+- 主源码由 429 类/948 条内部依赖/66 包变为 430 类/949 条内部依赖/67 包;36 条切片间边、88 条模型泄漏、31 条 Mapper 泄漏及全局 `mapper <-> repository` SCC 均未增加。客户统计仍只有 `adapter.in.web -> FirstlegScopeGuard` 一条跨切片边,且没有入向边。
+- 根 Mapper 扫描只把旧精确包替换为新精确包,没有扩大扫描范围或增加兼容 Bean。
+- 验证结果:Node 合同 3/3;`test-compile` 成功;客户统计加根扫描定向测试 7/7;`mvn -pl firstleg -am test` 共 854 个测试,0 失败、0 错误、3 跳过,构建成功。
+- 首次并发执行 `test-compile` 时,其他专项同时写入共享 `target/classes` 导致若干无关 class 文件瞬时缺失;源符号核对正常,独立重跑 `test-compile` 与完整测试均成功。
diff --git a/firstleg/pom.xml b/firstleg/pom.xml
index 02098cbe8..1a35216ef 100644
--- a/firstleg/pom.xml
+++ b/firstleg/pom.xml
@@ -23,6 +23,7 @@
org.springframework.bootspring-boot-starter-actuator
com.mysqlmysql-connector-jruntime
org.springframework.bootspring-boot-starter-testtest
+ com.tngtech.archunitarchunittest
org.projectlomboklomboktrue
org.springframework.bootspring-boot-maven-plugin
diff --git a/firstleg/scripts/analyze-firstleg-dependencies.mjs b/firstleg/scripts/analyze-firstleg-dependencies.mjs
index e2de4acf8..5b252d7a8 100644
--- a/firstleg/scripts/analyze-firstleg-dependencies.mjs
+++ b/firstleg/scripts/analyze-firstleg-dependencies.mjs
@@ -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);
-
diff --git a/firstleg/scripts/analyze-firstleg-dependencies.test.mjs b/firstleg/scripts/analyze-firstleg-dependencies.test.mjs
index 9cf71321f..9bcb61d64 100644
--- a/firstleg/scripts/analyze-firstleg-dependencies.test.mjs
+++ b/firstleg/scripts/analyze-firstleg-dependencies.test.mjs
@@ -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, []);
+});
diff --git a/firstleg/src/test/java/com/baiyue/firstleg/customerstatistics/CustomerStatisticsArchitectureTest.java b/firstleg/src/test/java/com/baiyue/firstleg/customerstatistics/CustomerStatisticsArchitectureTest.java
new file mode 100644
index 000000000..252da501c
--- /dev/null
+++ b/firstleg/src/test/java/com/baiyue/firstleg/customerstatistics/CustomerStatisticsArchitectureTest.java
@@ -0,0 +1,80 @@
+package com.baiyue.firstleg.customerstatistics;
+
+import com.baiyue.firstleg.customerstatistics.application.port.out.CustomerTransitStatisticsReader;
+import com.baiyue.firstleg.service.FirstlegScopeGuard;
+import com.tngtech.archunit.base.DescribedPredicate;
+import com.tngtech.archunit.core.domain.JavaClass;
+import com.tngtech.archunit.core.domain.JavaClasses;
+import com.tngtech.archunit.core.importer.ClassFileImporter;
+import com.tngtech.archunit.core.importer.ImportOption;
+import org.junit.jupiter.api.Test;
+
+import static com.tngtech.archunit.core.domain.JavaClass.Predicates.resideInAPackage;
+import static com.tngtech.archunit.core.domain.JavaClass.Predicates.resideOutsideOfPackage;
+import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.classes;
+import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses;
+
+class CustomerStatisticsArchitectureTest {
+ private static final String FIRSTLEG = "com.baiyue.firstleg..";
+ private static final String CUSTOMER_STATISTICS = "com.baiyue.firstleg.customerstatistics..";
+ private static final String APPLICATION = "com.baiyue.firstleg.customerstatistics.application..";
+ private static final String OUTPUT_PORT = "com.baiyue.firstleg.customerstatistics.application.port.out..";
+ private static final String PERSISTENCE = "com.baiyue.firstleg.customerstatistics.adapter.out.persistence..";
+ private static final String WEB = "com.baiyue.firstleg.customerstatistics.adapter.in.web..";
+
+ private static final JavaClasses PRODUCTION_CLASSES = new ClassFileImporter()
+ .withImportOption(ImportOption.Predefined.DO_NOT_INCLUDE_TESTS)
+ .withImportOption(ImportOption.Predefined.DO_NOT_INCLUDE_JARS)
+ .importPackages("com.baiyue.firstleg");
+
+ @Test
+ void applicationModelAndPortsStayFreeOfWebAndPersistenceFrameworks() {
+ noClasses().that().resideInAPackage(APPLICATION)
+ .should().dependOnClassesThat().resideInAnyPackage(
+ "org.springframework.web..",
+ "org.apache.ibatis..",
+ "com.baomidou.mybatisplus..")
+ .check(PRODUCTION_CLASSES);
+ }
+
+ @Test
+ void applicationOnlyDependsOnItsOwnSliceWithinFirstleg() {
+ classes().that().resideInAPackage(APPLICATION)
+ .should().onlyDependOnClassesThat(
+ resideOutsideOfPackage(FIRSTLEG).or(resideInAPackage(APPLICATION)))
+ .check(PRODUCTION_CLASSES);
+ }
+
+ @Test
+ void persistenceAdapterCollaboratesThroughTheApplicationOutputPort() {
+ classes().that().resideInAPackage(PERSISTENCE)
+ .should().onlyDependOnClassesThat(
+ resideOutsideOfPackage(CUSTOMER_STATISTICS).or(resideInAPackage(OUTPUT_PORT)))
+ .andShould().beAssignableTo(CustomerTransitStatisticsReader.class)
+ .check(PRODUCTION_CLASSES);
+ }
+
+ @Test
+ void webAdapterOnlyUsesApplicationAndScopeGuardWithinFirstleg() {
+ DescribedPredicate scopeGuard = new DescribedPredicate<>("FirstlegScopeGuard") {
+ @Override
+ public boolean test(JavaClass javaClass) {
+ return javaClass.getName().equals(FirstlegScopeGuard.class.getName());
+ }
+ };
+ classes().that().resideInAPackage(WEB)
+ .should().onlyDependOnClassesThat(
+ resideOutsideOfPackage(FIRSTLEG)
+ .or(resideInAPackage(APPLICATION))
+ .or(resideInAPackage(WEB))
+ .or(scopeGuard))
+ .check(PRODUCTION_CLASSES);
+ }
+
+ @Test
+ void otherFirstlegProductionPackagesDoNotDependOnCustomerStatistics() {
+ noClasses().that().resideOutsideOfPackage(CUSTOMER_STATISTICS)
+ .should().dependOnClassesThat().resideInAPackage(CUSTOMER_STATISTICS)
+ .check(PRODUCTION_CLASSES);
+ }
+}
diff --git a/firstleg/src/test/resources/architecture/firstleg-slice-dependencies.json b/firstleg/src/test/resources/architecture/firstleg-slice-dependencies.json
index 4607d9891..308cda8bc 100644
--- a/firstleg/src/test/resources/architecture/firstleg-slice-dependencies.json
+++ b/firstleg/src/test/resources/architecture/firstleg-slice-dependencies.json
@@ -39,6 +39,22 @@
"split-loading->outbound",
"split-loading->shared-technical"
],
+ "requiredClassLocations": {
+ "com.baiyue.firstleg.customerstatistics.adapter.in.web.PlatformCustomerTransitStatisticsController": "src/main/java/com/baiyue/firstleg/customerstatistics/adapter/in/web/PlatformCustomerTransitStatisticsController.java",
+ "com.baiyue.firstleg.customerstatistics.adapter.out.persistence.CustomerTransitStatisticsMapper": "src/main/java/com/baiyue/firstleg/customerstatistics/adapter/out/persistence/CustomerTransitStatisticsMapper.java",
+ "com.baiyue.firstleg.customerstatistics.application.CustomerTransitStatisticsService": "src/main/java/com/baiyue/firstleg/customerstatistics/application/CustomerTransitStatisticsService.java",
+ "com.baiyue.firstleg.customerstatistics.application.model.CustomerTransitStatisticsDtos": "src/main/java/com/baiyue/firstleg/customerstatistics/application/model/CustomerTransitStatisticsDtos.java",
+ "com.baiyue.firstleg.customerstatistics.application.port.out.CustomerTransitStatisticsReader": "src/main/java/com/baiyue/firstleg/customerstatistics/application/port/out/CustomerTransitStatisticsReader.java"
+ },
+ "applicationDependencyRule": {
+ "packagePrefix": "com.baiyue.firstleg.customerstatistics.application",
+ "allowedInternalPrefix": "com.baiyue.firstleg.customerstatistics.application",
+ "forbiddenImportPrefixes": [
+ "org.springframework.web",
+ "org.apache.ibatis",
+ "com.baomidou.mybatisplus"
+ ]
+ },
"sliceSpecificRules": {
"customer-statistics": {
"allowedOutgoing": ["shared-technical"],
diff --git a/pom.xml b/pom.xml
index cbb4a2780..0e706d52c 100644
--- a/pom.xml
+++ b/pom.xml
@@ -66,6 +66,7 @@
3.13.0
3.4.1
0.8.12
+ 1.5.0
2.3.3
@@ -115,6 +116,11 @@
springdoc-openapi-starter-webflux-ui
${springdoc.version}
+
+ com.tngtech.archunit
+ archunit
+ ${archunit.version}
+
diff --git a/scripts/java-topology-contract.test.mjs b/scripts/java-topology-contract.test.mjs
index 180022fb0..736e5e8af 100644
--- a/scripts/java-topology-contract.test.mjs
+++ b/scripts/java-topology-contract.test.mjs
@@ -117,12 +117,28 @@ test('Jenkins cannot invoke the Node contract without declaring a Node runtime',
if (!pipeline.includes('java-topology-contract.test.mjs')) continue
assert.match(
pipeline,
- /(?:nodejs\s*\(|NodeJSInstallation|docker(?:\.image\([^)]*node:|\s+run[^\n]*\bnode:))/,
+ /(?:nodejs\s*\(|NodeJSInstallation|docker(?:\.image\([^)]*node:|\s+run[^\n]*\bnode:))/,
`${path} must declare how Node is provided before invoking the topology contract`
)
}
})
+test('Maven verify publishes JaCoCo reports without enforcing a threshold', async () => {
+ const pom = await read('pom.xml')
+ const gitlab = await read('.gitlab-ci.yml')
+ const jacoco = pom.match(/\s*org\.jacoco<\/groupId>[\s\S]*?<\/plugin>/)?.[0] ?? ''
+
+ assert.match(jacoco, /jacoco-maven-plugin<\/artifactId>/)
+ assert.match(jacoco, /prepare-agent<\/goal>/)
+ assert.match(jacoco, /verify<\/phase>/)
+ assert.match(jacoco, /report<\/goal>/)
+ assert.match(jacoco, /HTML<\/format>/)
+ assert.match(jacoco, /XML<\/format>/)
+ assert.doesNotMatch(jacoco, /check<\/goal>|/)
+ assert.match(gitlab, /^ - "\*\*\/target\/site\/jacoco\/"$/m)
+ assert.match(gitlab, /^ - "\*\*\/target\/jacoco\.exec"$/m)
+})
+
test('regional Compose and Nacos manifests cover the service-map topology', async () => {
const services = parseServiceMap(await read('deploy/service-map.yml'))
const composeByRegion = new Map([
diff --git a/scripts/openapi-validation/package-lock.json b/scripts/openapi-validation/package-lock.json
new file mode 100644
index 000000000..a1127021a
--- /dev/null
+++ b/scripts/openapi-validation/package-lock.json
@@ -0,0 +1,180 @@
+{
+ "name": "baiyue-openapi-validation",
+ "version": "1.0.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "baiyue-openapi-validation",
+ "version": "1.0.0",
+ "dependencies": {
+ "@apidevtools/swagger-parser": "12.1.0"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/@apidevtools/json-schema-ref-parser": {
+ "version": "14.0.1",
+ "resolved": "https://registry.npmjs.org/@apidevtools/json-schema-ref-parser/-/json-schema-ref-parser-14.0.1.tgz",
+ "integrity": "sha512-Oc96zvmxx1fqoSEdUmfmvvb59/KDOnUoJ7s2t7bISyAn0XEz57LCCw8k2Y4Pf3mwKaZLMciESALORLgfe2frCw==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/json-schema": "^7.0.15",
+ "js-yaml": "^4.1.0"
+ },
+ "engines": {
+ "node": ">= 16"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/philsturgeon"
+ }
+ },
+ "node_modules/@apidevtools/openapi-schemas": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/@apidevtools/openapi-schemas/-/openapi-schemas-2.1.0.tgz",
+ "integrity": "sha512-Zc1AlqrJlX3SlpupFGpiLi2EbteyP7fXmUOGup6/DnkRgjP9bgMM/ag+n91rsv0U1Gpz0H3VILA/o3bW7Ua6BQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/@apidevtools/swagger-methods": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/@apidevtools/swagger-methods/-/swagger-methods-3.0.2.tgz",
+ "integrity": "sha512-QAkD5kK2b1WfjDS/UQn/qQkbwF31uqRjPTrsCs5ZG9BQGAkjwvqGFjjPqAuzac/IYzpPtRzjCP1WrTuAIjMrXg==",
+ "license": "MIT"
+ },
+ "node_modules/@apidevtools/swagger-parser": {
+ "version": "12.1.0",
+ "resolved": "https://registry.npmjs.org/@apidevtools/swagger-parser/-/swagger-parser-12.1.0.tgz",
+ "integrity": "sha512-e5mJoswsnAX0jG+J09xHFYQXb/bUc5S3pLpMxUuRUA2H8T2kni3yEoyz2R3Dltw5f4A6j6rPNMpWTK+iVDFlng==",
+ "license": "MIT",
+ "dependencies": {
+ "@apidevtools/json-schema-ref-parser": "14.0.1",
+ "@apidevtools/openapi-schemas": "^2.1.0",
+ "@apidevtools/swagger-methods": "^3.0.2",
+ "ajv": "^8.17.1",
+ "ajv-draft-04": "^1.0.0",
+ "call-me-maybe": "^1.0.2"
+ },
+ "peerDependencies": {
+ "openapi-types": ">=7"
+ }
+ },
+ "node_modules/@types/json-schema": {
+ "version": "7.0.15",
+ "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
+ "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==",
+ "license": "MIT"
+ },
+ "node_modules/ajv": {
+ "version": "8.20.0",
+ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
+ "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==",
+ "license": "MIT",
+ "dependencies": {
+ "fast-deep-equal": "^3.1.3",
+ "fast-uri": "^3.0.1",
+ "json-schema-traverse": "^1.0.0",
+ "require-from-string": "^2.0.2"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/epoberezkin"
+ }
+ },
+ "node_modules/ajv-draft-04": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/ajv-draft-04/-/ajv-draft-04-1.0.0.tgz",
+ "integrity": "sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw==",
+ "license": "MIT",
+ "peerDependencies": {
+ "ajv": "^8.5.0"
+ },
+ "peerDependenciesMeta": {
+ "ajv": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/argparse": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
+ "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
+ "license": "Python-2.0"
+ },
+ "node_modules/call-me-maybe": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/call-me-maybe/-/call-me-maybe-1.0.2.tgz",
+ "integrity": "sha512-HpX65o1Hnr9HH25ojC1YGs7HCQLq0GCOibSaWER0eNpgJ/Z1MZv2mTc7+xh6WOPxbRVcmgbv4hGU+uSQ/2xFZQ==",
+ "license": "MIT"
+ },
+ "node_modules/fast-deep-equal": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
+ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
+ "license": "MIT"
+ },
+ "node_modules/fast-uri": {
+ "version": "3.1.6",
+ "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.6.tgz",
+ "integrity": "sha512-7Ical1vFEMr0onbVzEDIreM22I4khW+fzyQPwvAFWBp1iwdshSZRsL4jjRvPG9JP1uiqMHRto+YU6R2/CzDz5Q==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fastify"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/fastify"
+ }
+ ],
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/js-yaml": {
+ "version": "4.3.1",
+ "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz",
+ "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/puzrin"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/nodeca"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "argparse": "^2.0.1"
+ },
+ "bin": {
+ "js-yaml": "bin/js-yaml.js"
+ }
+ },
+ "node_modules/json-schema-traverse": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
+ "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
+ "license": "MIT"
+ },
+ "node_modules/openapi-types": {
+ "version": "12.1.3",
+ "resolved": "https://registry.npmjs.org/openapi-types/-/openapi-types-12.1.3.tgz",
+ "integrity": "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==",
+ "license": "MIT",
+ "peer": true
+ },
+ "node_modules/require-from-string": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
+ "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ }
+ }
+}
diff --git a/scripts/openapi-validation/package.json b/scripts/openapi-validation/package.json
new file mode 100644
index 000000000..b270dd57e
--- /dev/null
+++ b/scripts/openapi-validation/package.json
@@ -0,0 +1,16 @@
+{
+ "name": "baiyue-openapi-validation",
+ "version": "1.0.0",
+ "private": true,
+ "type": "module",
+ "engines": {
+ "node": ">=20"
+ },
+ "scripts": {
+ "test": "node --test validate-openapi.test.mjs",
+ "validate": "node validate-openapi.mjs"
+ },
+ "dependencies": {
+ "@apidevtools/swagger-parser": "12.1.0"
+ }
+}
diff --git a/scripts/openapi-validation/validate-openapi.mjs b/scripts/openapi-validation/validate-openapi.mjs
new file mode 100644
index 000000000..16d47d6ed
--- /dev/null
+++ b/scripts/openapi-validation/validate-openapi.mjs
@@ -0,0 +1,83 @@
+import SwaggerParser from '@apidevtools/swagger-parser'
+import { readdir, readFile } from 'node:fs/promises'
+import path from 'node:path'
+import { fileURLToPath, pathToFileURL } from 'node:url'
+
+const OPENAPI_DIRECTORY = ['src', 'main', 'resources', 'openapi'].join('/')
+const OPENAPI_EXTENSIONS = new Set(['.json', '.yaml', '.yml'])
+const EXCLUDED_DIRECTORIES = new Set([
+ '.git', '.logs', 'dist', 'node_modules', 'output', 'outputs', 'reports', 'target'
+])
+
+export const repositoryRoot = path.resolve(fileURLToPath(new URL('../..', import.meta.url)))
+
+export async function discoverOpenApiFiles(root) {
+ const files = []
+ async function walk(directory) {
+ const entries = await readdir(directory, { withFileTypes: true })
+ for (const entry of entries) {
+ if (entry.isDirectory() && EXCLUDED_DIRECTORIES.has(entry.name)) continue
+ const absolute = path.join(directory, entry.name)
+ if (entry.isDirectory()) {
+ await walk(absolute)
+ continue
+ }
+ const relative = path.relative(root, absolute).replaceAll(path.sep, '/')
+ if (relative.includes(`/${OPENAPI_DIRECTORY}/`)
+ && OPENAPI_EXTENSIONS.has(path.extname(entry.name).toLowerCase())) files.push(relative)
+ }
+ }
+ await walk(root)
+ return files.sort()
+}
+
+export function parseServiceModules(serviceMap) {
+ return [...serviceMap.matchAll(/^ ([a-z][a-z0-9-]+):$/gm)].map((match) => match[1]).sort()
+}
+
+export function summarizeCoverage(files, serviceModules) {
+ const coveredServices = [...new Set(files.map((file) => file.split('/')[0]))].sort()
+ const codeFirstServices = serviceModules.filter((service) => !coveredServices.includes(service))
+ return { coveredServices, codeFirstServices }
+}
+
+export async function validateRepositoryOpenApi(root = repositoryRoot) {
+ const files = await discoverOpenApiFiles(root)
+ if (files.length === 0) throw new Error('No static OpenAPI documents were discovered')
+ const serviceMap = await readFile(path.join(root, 'deploy', 'service-map.yml'), 'utf8')
+ const serviceModules = parseServiceModules(serviceMap.replaceAll('\r\n', '\n'))
+ const coverage = summarizeCoverage(files, serviceModules)
+
+ const failures = []
+ for (const relative of files) {
+ try {
+ await SwaggerParser.validate(path.join(root, relative))
+ } catch (error) {
+ failures.push(`${relative}: ${error instanceof Error ? error.message : String(error)}`)
+ }
+ }
+ if (failures.length > 0) {
+ throw new Error(
+ `Discovered ${files.length} documents across ${coverage.coveredServices.length}/${serviceModules.length} services; `
+ + `code-first gaps: ${coverage.codeFirstServices.join(', ')}.\n`
+ + `OpenAPI validation failed for ${failures.length}/${files.length} documents:\n`
+ + failures.map((failure) => `- ${failure}`).join('\n')
+ )
+ }
+
+ return { files, serviceModules, ...coverage }
+}
+
+async function main() {
+ const result = await validateRepositoryOpenApi()
+ process.stdout.write(`OpenAPI validation passed: ${result.files.length} documents across ${result.coveredServices.length}/${result.serviceModules.length} services.\n`)
+ process.stdout.write(`Static OpenAPI services: ${result.coveredServices.join(', ')}\n`)
+ process.stdout.write(`Code-first services without static documents: ${result.codeFirstServices.join(', ')}\n`)
+}
+
+if (import.meta.url === pathToFileURL(process.argv[1]).href) {
+ main().catch((error) => {
+ process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`)
+ process.exitCode = 1
+ })
+}
diff --git a/scripts/openapi-validation/validate-openapi.test.mjs b/scripts/openapi-validation/validate-openapi.test.mjs
new file mode 100644
index 000000000..4c2eaeb24
--- /dev/null
+++ b/scripts/openapi-validation/validate-openapi.test.mjs
@@ -0,0 +1,67 @@
+import assert from 'node:assert/strict'
+import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import path from 'node:path'
+import test from 'node:test'
+
+import {
+ discoverOpenApiFiles,
+ parseServiceModules,
+ summarizeCoverage,
+ validateRepositoryOpenApi
+} from './validate-openapi.mjs'
+
+test('discovers new static OpenAPI documents without a handwritten file list', async (t) => {
+ const root = await mkdtemp(path.join(tmpdir(), 'baiyue-openapi-discovery-'))
+ t.after(() => rm(root, { recursive: true, force: true }))
+ const openapi = path.join(root, 'new-service', 'src', 'main', 'resources', 'openapi')
+ const ignored = path.join(root, 'new-service', 'target', 'src', 'main', 'resources', 'openapi')
+ await mkdir(openapi, { recursive: true })
+ await mkdir(ignored, { recursive: true })
+ await writeFile(path.join(openapi, 'new-api.yaml'), 'openapi: 3.0.3\n', 'utf8')
+ await writeFile(path.join(openapi, 'new-api.json'), '{"openapi":"3.0.3"}\n', 'utf8')
+ await writeFile(path.join(openapi, 'notes.md'), 'not an OpenAPI document\n', 'utf8')
+ await writeFile(path.join(ignored, 'ignored.yaml'), 'openapi: 3.0.3\n', 'utf8')
+
+ assert.deepEqual(await discoverOpenApiFiles(root), [
+ 'new-service/src/main/resources/openapi/new-api.json',
+ 'new-service/src/main/resources/openapi/new-api.yaml'
+ ])
+})
+
+test('derives static and code-first service coverage from service-map', () => {
+ const serviceModules = parseServiceModules('services:\n alpha:\n port: 1\n beta:\n port: 2\n')
+ assert.deepEqual(serviceModules, ['alpha', 'beta'])
+ assert.deepEqual(summarizeCoverage([
+ 'alpha/src/main/resources/openapi/alpha.yaml'
+ ], serviceModules), {
+ coveredServices: ['alpha'],
+ codeFirstServices: ['beta']
+ })
+})
+
+test('rejects an unresolved OpenAPI reference', async (t) => {
+ const root = await mkdtemp(path.join(tmpdir(), 'baiyue-openapi-ref-'))
+ t.after(() => rm(root, { recursive: true, force: true }))
+ const openapi = path.join(root, 'alpha', 'src', 'main', 'resources', 'openapi')
+ await mkdir(openapi, { recursive: true })
+ await mkdir(path.join(root, 'deploy'), { recursive: true })
+ await writeFile(path.join(root, 'deploy', 'service-map.yml'), 'services:\n alpha:\n port: 1\n', 'utf8')
+ await writeFile(path.join(openapi, 'broken.yaml'), `openapi: 3.0.3
+info:
+ title: Broken API
+ version: 1.0.0
+paths:
+ /broken:
+ get:
+ responses:
+ '200':
+ description: broken
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/Missing'
+`, 'utf8')
+
+ await assert.rejects(validateRepositoryOpenApi(root), /Missing \$ref pointer/)
+})