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/) +})