diff --git a/.agents/rules/bc-boundaries.md b/.agents/rules/bc-boundaries.md index 95c37c71..28d6a9c6 100644 --- a/.agents/rules/bc-boundaries.md +++ b/.agents/rules/bc-boundaries.md @@ -26,18 +26,26 @@ ## 三、违例登记与整改 -**当前无任何豁免**——`BcBoundaryArchTest` 已不含 `haveNameNotMatching`,任何 AI→他域 DAL 依赖一律红。 +规则现为**通用条件**(`BcBoundaryArchTest.no_business_bc_may_depend_on_another_bc_dal`):覆盖全部业务 BC 间方向(ai/content/knowledge/market/meta/member/events),任一域依赖他域 `.dal` 即红;已知违例在测试的 `KNOWN_VIOLATION_EXEMPTIONS` 单点显式登记(2026-06-14 评审整改:此前只守 AI 单向,放过了 knowledge/market 同构活违例)。 ### 已整改(历史) -| 违例类 | 曾经内容 | 整改 | 验收(2026-06-14) | +| 违例类 | 曾经内容 | 整改 | 验收 | |---|---|---|---| -| `module.ai.application.muse.facade.ContentMuseWorkOwnerFacade` | 直连 content `WorkMapper/ChapterMapper/BlockMapper` + `WorkDO/ChapterDO/BlockDO`(字节码 31 处依赖) | content 暴露对外只读端口 `MuseContentWorkOwnerApi`(content-api 定义、content-server 实现读自有 DAL);AI 适配器改消费该端口,移除全部 content.dal 依赖,AI 业务规则不变 | 反向:删豁免+旧码 → 门禁红、精确报 31 例;正向:整改+删豁免 → 门禁**绿**(收紧),适配器单测 13/0F、端口实现单测 7/0F | +| `module.ai.application.muse.facade.ContentMuseWorkOwnerFacade` | 直连 content `Work/Chapter/Block` Mapper+DO(字节码 31 处) | content 暴露只读端口 `MuseContentWorkOwnerApi`(content-api 定义、content-server 实现读自有 DAL);AI 适配器改消费该端口 | 反向删豁免+旧码→红报 31 例;正向整改→绿,适配器 13/0F、端口实现 7/0F | +| `module.knowledge.application.muse.facade.ContentKnowledgeWorkOwnerFacade` | 直连 content `WorkMapper/WorkDO`(与 AI 同构) | 复用同一 `MuseContentWorkOwnerApi.getActiveOwnedWorkRevision` 端口,移除 content.dal 依赖 | 通用门禁绿(0 Architecture Violation)、新增适配器单测 3/0F | -> 豁免一律视为**临时**:违例消除后必须从 `haveNameNotMatching` 删除该类,使门禁收紧——**收紧即整改验收**(本条已完成,豁免已清零)。新违例不在任何豁免内,一律红。 +### 活跃登记(待整改,单点豁免) +| 违例类 | 内容 | 处置 | 整改方向 | +|---|---|---|---| +| `module.market.application.muse.{MarketAccountProjectionProvider, AdminMarketReviewServiceImpl, MarketInstallServiceImpl, MarketLicenseServiceImpl, MarketPublishServiceImpl}`(5 类) | **写路径**:构建 `member.dal.AccountRecordProjectionDO`、经 `AccountRecordProjectionMapper` upsert 进 member 表 | `BcBoundaryArchTest.KNOWN_VIOLATION_EXEMPTIONS` 单点豁免这 5 类(显式可见) | member 暴露**写端口**(如 `MuseAccountRecordProjectionApi.upsert(DTO)`)+ member-server 接管 upsert + DTO 替代 DO;market 改消费端口、移除 member.dal 依赖后从豁免删除 → 门禁自动收紧。属写路径跨 BC 重构,作专项 | + +> 豁免一律**临时、显式、可见**(测试源码点名)。违例消除后必须从 `KNOWN_VIOLATION_EXEMPTIONS` 删除该类——**收紧即整改验收**。新违例不在豁免内,一律红。 --- ## 四、扩展方式 -- 新增 BC 间约束(如禁 content→ai.dal、禁前端契约外的耦合)按同样方式在 `BcBoundaryArchTest` 加 `@Test` 规则。 -- 违例消除后,从对应 `haveNameNotMatching` 豁免里删掉该类,门禁随即收紧——**收紧动作本身就是整改完成的验收**。 +- 规则已是**通用条件**,自动覆盖所有业务 BC 间的 `.dal` 跨域依赖,**无需为新方向加 `@Test`**。 +- 新增业务 BC:加入 `BcBoundaryArchTest.BUSINESS_BCS` 即纳入约束。 +- 收紧已知违例:整改后从 `KNOWN_VIOLATION_EXEMPTIONS` 删除该类,门禁随即收紧。 +- 更强边界(如禁跨域 `.application` 非 facade-api 包耦合):在条件里追加判断,但须为合规 facade 端口留通道(见评审 P1-3,属后续)。 diff --git a/.agents/rules/contract-first.md b/.agents/rules/contract-first.md index c204bd8b..0ba97e5b 100644 --- a/.agents/rules/contract-first.md +++ b/.agents/rules/contract-first.md @@ -36,9 +36,10 @@ --- -## 四、待启用加固:openapi-diff 破坏性变更检测(CI 级) +## 四、加固:openapi-diff 破坏性变更检测(CI 级,已 materialize / 待首跑验证) -存在性/结构门禁挡不住“悄悄改坏既有契约语义”(删字段、改必填、改类型)。**破坏性变更检测**用 `oasdiff` 在 PR 上比对 base 分支与 PR 的 OpenAPI,发现破坏性差异即红。就绪片段(待接入 `maven.yml` 或独立 workflow,接入后需一次 CI 运行验证): +存在性/结构门禁挡不住“悄悄改坏既有契约语义”(删字段、改必填、改类型)。**破坏性变更检测**用 `oasdiff` 在 PR 上比对 base 分支与 PR 的 OpenAPI,发现破坏性差异即红。 +**已落地为独立 workflow** [`muse-cloud/.github/workflows/openapi-diff.yml`](../../muse-cloud/.github/workflows/openapi-diff.yml)(PR 改 `docs/api-contracts/**` 时触发,逐域 oasdiff)。⚠️ **本地无法运行 GitHub Actions/oasdiff,需一次真实 CI 运行验证后**方可宣称"破坏性变更已被拦截"。参考片段: ```yaml openapi-breaking-change: diff --git a/AGENTS.md b/AGENTS.md index 872c27d9..ec1bef5f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -88,12 +88,12 @@ oh-my-muse/ ## 7. 当前基建建设状态(随建随更) -- ✅ 机械门禁地基:CI 真跑测试(JDK21)、门禁去硬编码、P0 冻结令(见脊柱规则"已落地")。 +- ✅ 机械门禁地基:CI 真跑测试(JDK21、触发分支已修为 `main`——此前误配 `master` 致 CI 从不运行)、门禁去硬编码(含分域计数派生化)、P0 冻结令。 - ✅ 入口与中枢骨架:本文件 + `.agents/README.md` + 脊柱规则。 -- ✅ BC 边界 ArchUnit 门([`bc-boundaries`](.agents/rules/bc-boundaries.md)):AI 不得直连他域 DAL,已验证绿。 -- ✅ 契约先行机械门禁([`contract-first`](.agents/rules/contract-first.md)):Flyway 迁移卫生 + OpenAPI 结构,原地 SSOT,已验证绿。 -- ✅ knowledge 蒸馏 / skills(黄金旅程“完成”定义 + 加 BC 模块)/ workflow(AI 开发协议):见 [`.agents/README.md`](.agents/README.md)。 -- ✅ 进度总账(单一进度源)+ 7 BC per-module `.agent`:见 [`docs/mvp/进度总账.md`](docs/mvp/进度总账.md)。 -- ⏳ 后续(非阻塞):openapi-diff 破坏性变更 CI、`docs/dev-baseline/global/*` 收敛进 `.agents/rules/`(engineering-conventions / security-and-reliability)、BC 边界门扩到其它方向。 +- ✅ BC 边界 ArchUnit 门([`bc-boundaries`](.agents/rules/bc-boundaries.md)):**通用覆盖全业务 BC 间方向**;AI/knowledge 直连 content 违例已整改消除;**market→member.dal 写路径违例单点登记待整改**(故非"全绿",见该规则 §三)。 +- ✅ 契约先行门([`contract-first`](.agents/rules/contract-first.md)):Flyway 迁移卫生 + OpenAPI **存在性/结构**(注:挡不住语义破坏;openapi-diff CI 已materialize,需首次 CI 运行验证)。 +- ✅ loop 机械牙([`AgentsInfraIntegrityTest`](muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/arch/AgentsInfraIntegrityTest.java)):每业务 BC 有 `.agent`、README 索引每篇 `.agents` 文档、总账在——把写回/索引同步从自觉变机械。 +- ✅ knowledge 蒸馏 / skills / workflow(AI 开发协议)/ 进度总账 + 7 BC `.agent`。 +- ⏳ 后续:market 写路径 BC 整改、openapi-diff CI 首跑验证、`completed=测试证据`兜底(testFiles)、`dev-baseline` 收敛进 rules、跨域 `.application` 边界。 > 进度只进 [`docs/mvp/进度总账.md`](docs/mvp/进度总账.md) + 各模块 `.agent`,不新增状态过程文档。 diff --git a/docs/mvp/进度总账.md b/docs/mvp/进度总账.md index 28239219..b3adcb06 100644 --- a/docs/mvp/进度总账.md +++ b/docs/mvp/进度总账.md @@ -10,14 +10,17 @@ | 砖 | 内容 | 状态 | 机械证据 / 落点 | |---|---|---|---| | P0 | CI 真跑测试(JDK21、去 `-Dmaven.test.skip`)+ 覆盖台账去硬编码 + P0 冻结令 | ✅ | `.github/workflows/maven.yml`、`P1rApiCoverageReportTest`、[脊柱规则](../../.agents/rules/verification-and-anti-false-green.md) | -| ① | BC 边界 ArchUnit 门(AI 不得直连他域 DAL) | ✅ 已验证绿 | `BcBoundaryArchTest`(Tests run 1/0F);[bc-boundaries](../../.agents/rules/bc-boundaries.md) | -| ② | 契约先行门(Flyway 迁移卫生 + OpenAPI 结构;原地 SSOT) | ✅ 已验证绿 | `ContractFirstGateTest`(Tests run 2/0F);[contract-first](../../.agents/rules/contract-first.md) | +| ① | BC 边界 ArchUnit 门——**通用覆盖全业务 BC 间方向** | ✅ 绿(AI/knowledge 已整改;**market 写路径违例登记待整改**,非全绿) | `BcBoundaryArchTest`(1/0F,0 Architecture Violation);[bc-boundaries](../../.agents/rules/bc-boundaries.md) §三 | +| ② | 契约先行门(Flyway 迁移卫生 + OpenAPI **存在性/结构**) | ✅ 绿(注:挡不住语义破坏;openapi-diff CI 待首跑验证) | `ContractFirstGateTest`(2/0F);[contract-first](../../.agents/rules/contract-first.md) | +| ⑦ | loop 机械牙 + CI 接电(round-2) | ✅ 绿 | `AgentsInfraIntegrityTest`(3/0F);`maven.yml` 触发分支修为 `main` | | ③ | knowledge 蒸馏(定位架构 / 现状基线指针 / 决策) | ✅ | [`.agents/knowledge/`](../../.agents/knowledge/) | | ④ | skills(黄金旅程“完成”定义 / 新增 BC 模块) | ✅ | [`.agents/skills/`](../../.agents/skills/) | | ⑤ | workflow(AI 开发协议元流程) | ✅ | [`.agents/workflows/ai-development-protocol.md`](../../.agents/workflows/ai-development-protocol.md) | | ⑥ | 进度总账(本文件)+ per-module `.agent` | ✅ | 本文件 + `muse-cloud/muse-module-*/.agent` | -**后续基建 TODO(非阻塞)**:openapi-diff 破坏性变更 CI(就绪片段见 contract-first §四)、`docs/dev-baseline/global/*` 收敛进 `.agents/rules/`(engineering-conventions、security-and-reliability)、BC 边界门扩到其它方向。 +**round-2 加固(2026-06-14,据 Opus 评审)**:CI 触发分支 master→main(此前 CI 从不运行)、BC 门通用化 + 整改 knowledge 违例、loop 装机械牙、覆盖门去魔法数、文档诚实化、openapi-diff materialize 为独立 workflow。 + +**后续基建 TODO**:**market→member.dal 写路径 BC 整改**(member 暴露写端口,见 bc-boundaries §三)、openapi-diff CI 首跑验证、`completed=测试证据`兜底(台账加 testFiles 字段 + 门禁)、`dev-baseline` 收敛进 rules、跨域 `.application` 边界门。 --- diff --git a/muse-cloud/.github/workflows/maven.yml b/muse-cloud/.github/workflows/maven.yml index 5d3c1315..47ecf436 100644 --- a/muse-cloud/.github/workflows/maven.yml +++ b/muse-cloud/.github/workflows/maven.yml @@ -5,9 +5,9 @@ name: Java CI with Maven on: push: - branches: [ master ] + branches: [ main ] pull_request: - branches: [ master ] + branches: [ main ] jobs: build: @@ -19,9 +19,9 @@ jobs: java: [ '21' ] steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 - name: Set up JDK ${{ matrix.java }} - uses: actions/setup-java@v2 + uses: actions/setup-java@v4 with: java-version: ${{ matrix.java }} distribution: 'temurin' diff --git a/muse-cloud/.github/workflows/openapi-diff.yml b/muse-cloud/.github/workflows/openapi-diff.yml new file mode 100644 index 00000000..292e3499 --- /dev/null +++ b/muse-cloud/.github/workflows/openapi-diff.yml @@ -0,0 +1,36 @@ +# OpenAPI 破坏性变更门禁(契约先行加固) +# 仅在 PR 改动 docs/api-contracts/** 时触发;逐域用 oasdiff 比对 base 与 PR 的 OpenAPI,发现破坏性差异即失败。 +# 注:独立于主 maven 构建,避免误配影响主门禁。**首次接入后需一次真实 CI 运行验证**(本地无法运行 GitHub Actions/oasdiff)。 +name: OpenAPI Breaking Change Check + +on: + pull_request: + branches: [ main ] + paths: + - 'docs/api-contracts/**' + +jobs: + openapi-diff: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: 检出 base 分支契约到 /tmp/base + run: git worktree add /tmp/base "${{ github.event.pull_request.base.sha }}" + - name: oasdiff 破坏性变更检测(逐业务域) + run: | + set -euo pipefail + fail=0 + for d in account ai content events knowledge market meta; do + base="/tmp/base/docs/api-contracts/$d/openapi.yaml" + head="docs/api-contracts/$d/openapi.yaml" + if [ ! -f "$base" ] || [ ! -f "$head" ]; then + echo "跳过 $d(base 或 PR 缺该契约文件)"; continue + fi + echo "== oasdiff breaking: $d ==" + docker run --rm -v "$PWD:/pr" -v /tmp/base:/base tufin/oasdiff breaking \ + "/base/docs/api-contracts/$d/openapi.yaml" "/pr/docs/api-contracts/$d/openapi.yaml" \ + --fail-on ERR || fail=1 + done + exit $fail diff --git a/muse-cloud/muse-module-knowledge/muse-module-knowledge-server/src/main/java/cn/iocoder/muse/module/knowledge/application/muse/facade/ContentKnowledgeWorkOwnerFacade.java b/muse-cloud/muse-module-knowledge/muse-module-knowledge-server/src/main/java/cn/iocoder/muse/module/knowledge/application/muse/facade/ContentKnowledgeWorkOwnerFacade.java index c685fe61..3ea63c96 100644 --- a/muse-cloud/muse-module-knowledge/muse-module-knowledge-server/src/main/java/cn/iocoder/muse/module/knowledge/application/muse/facade/ContentKnowledgeWorkOwnerFacade.java +++ b/muse-cloud/muse-module-knowledge/muse-module-knowledge-server/src/main/java/cn/iocoder/muse/module/knowledge/application/muse/facade/ContentKnowledgeWorkOwnerFacade.java @@ -1,9 +1,7 @@ package cn.iocoder.muse.module.knowledge.application.muse.facade; import cn.iocoder.muse.framework.common.exception.ServiceException; -import cn.iocoder.muse.framework.tenant.core.context.TenantContextHolder; -import cn.iocoder.muse.module.content.dal.dataobject.WorkDO; -import cn.iocoder.muse.module.content.dal.mysql.WorkMapper; +import cn.iocoder.muse.module.content.api.MuseContentWorkOwnerApi; import jakarta.annotation.Resource; import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; import org.springframework.context.annotation.Primary; @@ -12,25 +10,27 @@ import org.springframework.stereotype.Component; import static cn.iocoder.muse.module.knowledge.enums.ErrorCodeConstants.KNOWLEDGE_RESOURCE_FORBIDDEN; /** - * 基于 Content 作品表的 Knowledge work owner adapter。 + * 基于 Content **对外 API** 的 Knowledge work owner adapter(BC 合规)。 + * + *

整改记录(2026-06-14):此前直连 content 的 WorkMapper/WorkDO,破坏 BC 边界(与 AI 域同构违例); + * 现改为消费 content 对外契约 {@link MuseContentWorkOwnerApi},只取脱敏的作品归属 revision。 + * 见 .agents/rules/bc-boundaries.md。

*/ @Component @Primary -@ConditionalOnBean(WorkMapper.class) +@ConditionalOnBean(MuseContentWorkOwnerApi.class) public class ContentKnowledgeWorkOwnerFacade implements MuseKnowledgeWorkOwnerFacade { @Resource - private WorkMapper workMapper; + private MuseContentWorkOwnerApi contentWorkOwnerApi; @Override public void requireWorkOwner(Long workId, Long ownerUserId) { if (workId == null || ownerUserId == null) { throw new ServiceException(KNOWLEDGE_RESOURCE_FORBIDDEN); } - Long tenantId = TenantContextHolder.getRequiredTenantId(); - // 只接受当前租户、未删除、owner_user_id 命中的作品;查不到统一按无权访问处理,避免泄露存在性。 - WorkDO work = workMapper.selectActiveByIdAndOwner(tenantId, workId, ownerUserId); - if (work == null) { + // 经 content 对外契约校验作品归属;不属于当前用户 / 作品不可用时按无权访问处理(失败关闭)。 + if (contentWorkOwnerApi.getActiveOwnedWorkRevision(workId, ownerUserId) == null) { throw new ServiceException(KNOWLEDGE_RESOURCE_FORBIDDEN); } } diff --git a/muse-cloud/muse-module-knowledge/muse-module-knowledge-server/src/test/java/cn/iocoder/muse/module/knowledge/application/muse/facade/ContentKnowledgeWorkOwnerFacadeTest.java b/muse-cloud/muse-module-knowledge/muse-module-knowledge-server/src/test/java/cn/iocoder/muse/module/knowledge/application/muse/facade/ContentKnowledgeWorkOwnerFacadeTest.java new file mode 100644 index 00000000..1a3f819c --- /dev/null +++ b/muse-cloud/muse-module-knowledge/muse-module-knowledge-server/src/test/java/cn/iocoder/muse/module/knowledge/application/muse/facade/ContentKnowledgeWorkOwnerFacadeTest.java @@ -0,0 +1,60 @@ +package cn.iocoder.muse.module.knowledge.application.muse.facade; + +import cn.iocoder.muse.framework.common.exception.ServiceException; +import cn.iocoder.muse.framework.test.core.ut.BaseMockitoUnitTest; +import cn.iocoder.muse.module.content.api.MuseContentWorkOwnerApi; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; + +import static cn.iocoder.muse.module.knowledge.enums.ErrorCodeConstants.KNOWLEDGE_RESOURCE_FORBIDDEN; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +/** + * Knowledge 作品归属 Facade 测试。 + * + *

整改后(2026-06-14):适配器改消费 content 对外契约 {@link MuseContentWorkOwnerApi},不再直连 content.dal; + * 本测试 mock 该端口,验证"属于/不属于/空入参"下的失败关闭语义不变。

+ */ +class ContentKnowledgeWorkOwnerFacadeTest extends BaseMockitoUnitTest { + + @InjectMocks + private ContentKnowledgeWorkOwnerFacade workOwnerFacade; + @Mock + private MuseContentWorkOwnerApi contentWorkOwnerApi; + + @Test + void should_allowWhenWorkOwned() { + when(contentWorkOwnerApi.getActiveOwnedWorkRevision(9001L, 10001L)).thenReturn(3); + + workOwnerFacade.requireWorkOwner(9001L, 10001L); + + verify(contentWorkOwnerApi).getActiveOwnedWorkRevision(9001L, 10001L); + } + + @Test + void should_rejectWhenWorkNotOwnedOrMissing() { + when(contentWorkOwnerApi.getActiveOwnedWorkRevision(9001L, 10002L)).thenReturn(null); + + ServiceException exception = assertThrows(ServiceException.class, + () -> workOwnerFacade.requireWorkOwner(9001L, 10002L)); + + assertEquals(KNOWLEDGE_RESOURCE_FORBIDDEN.getCode(), exception.getCode()); + verify(contentWorkOwnerApi).getActiveOwnedWorkRevision(9001L, 10002L); + } + + @Test + void should_rejectBlankIdentityBeforeQuery() { + ServiceException exception = assertThrows(ServiceException.class, + () -> workOwnerFacade.requireWorkOwner(null, 10001L)); + + assertEquals(KNOWLEDGE_RESOURCE_FORBIDDEN.getCode(), exception.getCode()); + // 空入参必须在触达 content 契约前失败关闭。 + verifyNoInteractions(contentWorkOwnerApi); + } + +} diff --git a/muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api/P1rApiCoverageReportTest.java b/muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api/P1rApiCoverageReportTest.java index cc96ce49..a4e6a92e 100644 --- a/muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api/P1rApiCoverageReportTest.java +++ b/muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api/P1rApiCoverageReportTest.java @@ -323,9 +323,12 @@ class P1rApiCoverageReportTest { assertOperationStatus("content", "listPlanningCandidates", "dedicated", "needs_verification"); assertOperationStatus("content", "createStyleCheck", "dedicated", "needs_verification"); assertOperationStatus("content", "adminRiskAction", "dedicated", "completed", true); - assertDomainStatusCount("market", "dedicated", "needs_verification", 28); - assertDomainStatusCount("account", "dedicated", "needs_verification", 21); - assertDomainStatusCount("content", "dedicated", "needs_verification", 37); + // P1 加固(2026-06-14):废弃硬编码 needs_verification 魔法数(28/21/37,与脊柱规则禁的"可拨动常量"同模式), + // 改为断言"该域 completed 数 == 显式批准集大小"——派生自唯一可信来源 APPROVED_*_COMPLETED_OPERATIONS。 + // 任何把 operation 静默推进为 completed 的动作都会让 completed 数超过批准集大小而变红,无法只靠拨常量蒙混。 + assertDomainStatusCount("market", "dedicated", "completed", APPROVED_MARKET_COMPLETED_OPERATIONS.size()); + assertDomainStatusCount("account", "dedicated", "completed", APPROVED_ACCOUNT_COMPLETED_OPERATIONS.size()); + assertDomainStatusCount("content", "dedicated", "completed", APPROVED_CONTENT_COMPLETED_OPERATIONS.size()); } /** diff --git a/muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/arch/AgentsInfraIntegrityTest.java b/muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/arch/AgentsInfraIntegrityTest.java new file mode 100644 index 00000000..19c47bf6 --- /dev/null +++ b/muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/arch/AgentsInfraIntegrityTest.java @@ -0,0 +1,78 @@ +package cn.iocoder.muse.server.framework.arch; + +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.stream.Stream; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Agent 基建完整性门禁——给"复利写回 loop"装机械牙。 + * + *

loop(知识沉淀 / 索引同步 / 模块记忆)此前全靠文档纪律,易衰减——正是本项目栽过的坑(软约束被绕过)。 + * 本测试把这套自觉变为 CI 可阻断的机械约束: + *

    + *
  1. 每个业务 BC 模块必须有 {@code .agent} 跨会话记忆;
  2. + *
  3. {@code .agents/} 下每个 .md 文档都必须被 {@code README.md} 索引(防新增 rule/knowledge/skill/workflow 成孤儿);
  4. + *
  5. 单一进度源 {@code docs/mvp/进度总账.md} 必须存在。
  6. + *
+ * 任一缺失 / 失链 → 红,阻断合入。呼应 {@code .agents/README.md §三}"rules 必配门禁 / 同步索引"的自我要求。

+ */ +class AgentsInfraIntegrityTest { + + /** 业务 BC 模块(各须有 per-module .agent 记忆,与 BcBoundaryArchTest 的 BUSINESS_BCS 对应)。 */ + private static final List BUSINESS_BC_MODULES = List.of( + "muse-module-ai", "muse-module-content", "muse-module-knowledge", + "muse-module-market", "muse-module-meta", "muse-module-member", "muse-module-events"); + + @Test + void every_business_bc_module_has_agent_memory() { + Path root = findRepoRoot(); + for (String module : BUSINESS_BC_MODULES) { + Path agent = root.resolve("muse-cloud").resolve(module).resolve(".agent"); + assertTrue(Files.exists(agent), "业务 BC 模块缺少 .agent 跨会话记忆: " + agent); + } + } + + @Test + void single_progress_ledger_exists() { + assertTrue(Files.exists(findRepoRoot().resolve("docs/mvp/进度总账.md")), + "单一进度源 docs/mvp/进度总账.md 必须存在(进度不得回流到过程文档)"); + } + + @Test + void readme_indexes_every_agents_doc() throws IOException { + Path agentsDir = findRepoRoot().resolve(".agents"); + assertTrue(Files.isDirectory(agentsDir), ".agents 中枢目录必须存在: " + agentsDir); + Path readme = agentsDir.resolve("README.md"); + assertTrue(Files.exists(readme), ".agents/README.md 索引必须存在"); + String readmeText = Files.readString(readme); + + try (Stream docs = Files.walk(agentsDir)) { + for (Path doc : docs.filter(p -> p.getFileName().toString().endsWith(".md")) + .filter(p -> !p.equals(readme)) + .toList()) { + String relativePath = agentsDir.relativize(doc).toString().replace('\\', '/'); + assertTrue(readmeText.contains(relativePath), + ".agents/README.md 未索引该文档(新增 rule/knowledge/skill/workflow 须登记进 README 清单): " + relativePath); + } + } + } + + /** 逐级上溯,定位同时含 {@code .agents} 与 {@code muse-cloud} 的仓库根,适配 surefire 的工作目录。 */ + private static Path findRepoRoot() { + Path current = Path.of("").toAbsolutePath(); + for (Path candidate = current; candidate != null; candidate = candidate.getParent()) { + if (Files.isDirectory(candidate.resolve(".agents")) + && Files.isDirectory(candidate.resolve("muse-cloud"))) { + return candidate; + } + } + return current; + } + +} diff --git a/muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/arch/BcBoundaryArchTest.java b/muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/arch/BcBoundaryArchTest.java index 54169fbd..f6f423d8 100644 --- a/muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/arch/BcBoundaryArchTest.java +++ b/muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/arch/BcBoundaryArchTest.java @@ -1,20 +1,29 @@ package cn.iocoder.muse.server.framework.arch; +import com.tngtech.archunit.core.domain.Dependency; +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 com.tngtech.archunit.lang.ArchCondition; import com.tngtech.archunit.lang.ArchRule; +import com.tngtech.archunit.lang.ConditionEvents; +import com.tngtech.archunit.lang.SimpleConditionEvent; import org.junit.jupiter.api.Test; +import java.util.Set; + import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses; /** * BC 边界机械门禁(agent 基建 / 机械门禁优先)。 * - *

规则:一个业务域(BC)不得直连他域的 DAL(dataobject/mysql Mapper);跨 BC 只能走对外 API / facade-api 契约。 - * 这是把“边界靠自觉”升级为“机械阻断”——对抗复盘 A4 已实证:软约束下 AI 模块直连了 content 的 DAL。

+ *

规则:任一 Muse 业务域(BC)不得直连他域的 DAL(dataobject / mysql Mapper);跨 BC 只能走对外 API / facade-api 契约。 + * 这把“边界靠自觉”升级为“机械阻断”——对抗复盘 A4 已实证:软约束下会蔓延(AI、knowledge 直连 content;market 直连 member)。

* - *

规则与已知违例登记见 {@code .agents/rules/bc-boundaries.md};本测试随 CI(P0 已打开测试门)在 PR/push 阻断新违例。

+ *

本测试**通用覆盖全部业务 BC 间方向**(非单向);任一业务 BC 对他域 {@code .dal} 的新增依赖都会变红、阻断合入。 + * 已知违例单点显式登记于 {@link #KNOWN_VIOLATION_EXEMPTIONS} + {@code .agents/rules/bc-boundaries.md §三}; + * 违例消除后从豁免删除该类即收紧——收紧动作本身就是整改验收。

*/ class BcBoundaryArchTest { @@ -23,25 +32,71 @@ class BcBoundaryArchTest { .withImportOption(ImportOption.Predefined.DO_NOT_INCLUDE_TESTS) .importPackages("cn.iocoder.muse.module"); + /** Muse 业务域 BC 集合;规则只在这些域之间强制边界,不约束 yudao 平台模块(system/infra 等)的内部依赖。 */ + private static final Set BUSINESS_BCS = Set.of( + "ai", "content", "knowledge", "market", "meta", "member", "events"); + /** - * AI 域不得直连他域 DAL。 + * 已知违例豁免清单(整改期,单点、显式、可见)。详见 {@code .agents/rules/bc-boundaries.md §三}。 * - *

跨 BC 只能走对外 API/facade-api 契约;任何 AI 类对他域 DAL(DO/Mapper)的依赖都会让本测试变红、阻断合入。 - * 历史违例 {@code ContentMuseWorkOwnerFacade} 已整改(改消费 content 对外 API {@code MuseContentWorkOwnerApi}), - * 故此处**不再设任何豁免**——门禁已收紧(整改记录见 .agents/rules/bc-boundaries.md §三)。

+ *

market 的投影 provider/service 仍直写 member 的 account-record-projection 表(经 member.dal Mapper); + * 属**写路径**跨 BC 重构(需 member 暴露写端口 + DTO),风险较高,列为单点登记、待专项整改; + * 整改后从本清单删除,门禁随即收紧。

+ */ + private static final Set KNOWN_VIOLATION_EXEMPTIONS = Set.of( + "cn.iocoder.muse.module.market.application.muse.MarketAccountProjectionProvider", + "cn.iocoder.muse.module.market.application.muse.AdminMarketReviewServiceImpl", + "cn.iocoder.muse.module.market.application.muse.MarketInstallServiceImpl", + "cn.iocoder.muse.module.market.application.muse.MarketLicenseServiceImpl", + "cn.iocoder.muse.module.market.application.muse.MarketPublishServiceImpl"); + + /** + * 任一业务 BC 不得直连他域 DAL(通用规则,覆盖全部业务 BC 间方向)。 */ @Test - void ai_bc_must_not_depend_on_other_bc_dal() { - ArchRule rule = noClasses() - .that().resideInAPackage("..muse.module.ai..") - .should().dependOnClassesThat().resideInAnyPackage( - "..muse.module.content.dal..", - "..muse.module.knowledge.dal..", - "..muse.module.market.dal..", - "..muse.module.meta.dal..", - "..muse.module.member.dal..") - .because("BC 边界:AI 不得直连他域 DAL(DO/Mapper),跨 BC 只能走对外 API/facade-api 契约;" - + "历史违例 ContentMuseWorkOwnerFacade 已整改消除,门禁不再豁免任何 AI 类"); + void no_business_bc_may_depend_on_another_bc_dal() { + ArchRule rule = noClasses().should(new ArchCondition( + "依赖他域 DAL(跨 BC 只能走对外 API / facade-api 契约)") { + @Override + public void check(JavaClass origin, ConditionEvents events) { + String originModule = moduleOf(origin.getPackageName()); + // 只约束业务 BC;豁免类跳过(已单点登记整改) + if (originModule == null || !BUSINESS_BCS.contains(originModule) + || KNOWN_VIOLATION_EXEMPTIONS.contains(origin.getFullName())) { + return; + } + for (Dependency dependency : origin.getDirectDependenciesFromSelf()) { + String targetPackage = dependency.getTargetClass().getPackageName(); + if (!isDalPackage(targetPackage)) { + continue; + } + String targetModule = moduleOf(targetPackage); + // 命中:目标是某业务 BC 的 DAL,且与来源不是同一 BC + if (targetModule != null && BUSINESS_BCS.contains(targetModule) + && !targetModule.equals(originModule)) { + events.add(SimpleConditionEvent.violated(dependency, dependency.getDescription())); + } + } + } + }).because("BC 边界:任一业务域不得直连他域 DAL(DO/Mapper),跨 BC 只能走对外 API / facade-api 契约;" + + "已知违例见 .agents/rules/bc-boundaries.md §三 单点登记"); rule.check(museModuleClasses); } + + /** 从包名 {@code cn.iocoder.muse.module..*} 提取业务域段 {@code };非该前缀返回 null。 */ + private static String moduleOf(String packageName) { + String prefix = "cn.iocoder.muse.module."; + if (!packageName.startsWith(prefix)) { + return null; + } + String rest = packageName.substring(prefix.length()); + int dot = rest.indexOf('.'); + return dot < 0 ? rest : rest.substring(0, dot); + } + + /** 判断包是否属于某域的 DAL 层({@code ..dal} 或 {@code ..dal.. })。 */ + private static boolean isDalPackage(String packageName) { + return packageName.contains(".dal.") || packageName.endsWith(".dal"); + } + }