chore(agent-infra): round-2 加固——据 Opus 评审堵 harness 漏洞

独立 Opus 评审发现、并经我核验为真的关键漏洞,本轮整改(JDK21 实跑验证全绿):

- P0-1 CI 接上电:maven.yml 触发分支 master→main(此前误配 master、仓库无该分支 → CI 从不运行,
  所有门禁形同离线)、checkout/setup-java 升 v4。
- P0-2 BC 门通用化:ArchUnit 改为通用条件,覆盖全部业务 BC 间 .dal 方向(原仅守 AI 单向);
  整改 knowledge 同构违例(ContentKnowledgeWorkOwnerFacade 改消费 MuseContentWorkOwnerApi);
  market→member.dal 写路径违例单点登记待整改(KNOWN_VIOLATION_EXEMPTIONS + bc-boundaries §三)。
- loop 机械牙:新增 AgentsInfraIntegrityTest(每业务 BC 有 .agent、README 索引每篇 .agents 文档、总账在),
  把写回/索引同步从自觉变机械。
- P1-1 去魔法数:覆盖门分域计数 28/21/37 改为派生自 APPROVED_*_COMPLETED_OPERATIONS.size()。
- openapi-diff materialize 为独立 workflow(待首跑验证)。
- 文档诚实化:AGENTS/总账/bc-boundaries 订正"已验证绿"等过度声称为与实际相符(BC 门 market 待整改、契约门仅存在性/结构)。

验证:JDK21 mvn -pl muse-server -am 实跑 7 类门禁/单测全绿(BcBoundary 1/0F 且 0 Architecture Violation、
AgentsInfraIntegrity 3/0F、ContractFirst 2/0F、P1rApiCoverageReport 7/0F、knowledge/ai/content 适配器单测全绿),BUILD SUCCESS。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
lili 2026-06-14 05:39:16 -07:00
parent e181669197
commit 2cff86b808
11 changed files with 296 additions and 52 deletions

View File

@ -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,属后续)。

View File

@ -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:

View File

@ -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`,不新增状态过程文档。

View File

@ -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` 边界门。
---

View File

@ -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'

View File

@ -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

View File

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

View File

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

View File

@ -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());
}
/**

View File

@ -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"装机械牙
*
* <p>loop(知识沉淀 / 索引同步 / 模块记忆)此前全靠文档纪律,易衰减正是本项目栽过的坑(软约束被绕过)
* 本测试把这套自觉变为 CI 可阻断的机械约束:
* <ol>
* <li>每个业务 BC 模块必须有 {@code .agent} 跨会话记忆;</li>
* <li>{@code .agents/} 下每个 .md 文档都必须被 {@code README.md} 索引(防新增 rule/knowledge/skill/workflow 成孤儿);</li>
* <li>单一进度源 {@code docs/mvp/进度总账.md} 必须存在</li>
* </ol>
* 任一缺失 / 失链 ,阻断合入呼应 {@code .agents/README.md §}"rules 必配门禁 / 同步索引"的自我要求</p>
*/
class AgentsInfraIntegrityTest {
/** 业务 BC 模块(各须有 per-module .agent 记忆,与 BcBoundaryArchTest 的 BUSINESS_BCS 对应)。 */
private static final List<String> 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<Path> 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;
}
}

View File

@ -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 基建 / 机械门禁优先
*
* <p>规则:一个业务域(BC)不得直连他域的 DAL(dataobject/mysql Mapper); BC 只能走对外 API / facade-api 契约
* 边界靠自觉升级为机械阻断对抗复盘 A4 已实证:软约束下 AI 模块直连了 content DAL</p>
* <p>规则:任一 Muse 业务域(BC)不得直连他域的 DAL(dataobject / mysql Mapper); BC 只能走对外 API / facade-api 契约
* 边界靠自觉升级为机械阻断对抗复盘 A4 已实证:软约束下会蔓延(AIknowledge 直连 content;market 直连 member)</p>
*
* <p>规则与已知违例登记见 {@code .agents/rules/bc-boundaries.md};本测试随 CI(P0 已打开测试门) PR/push 阻断新违例</p>
* <p>本测试**通用覆盖全部业务 BC 间方向**(非单向);任一业务 BC 对他域 {@code .dal} 的新增依赖都会变红阻断合入
* 已知违例单点显式登记于 {@link #KNOWN_VIOLATION_EXEMPTIONS} + {@code .agents/rules/bc-boundaries.md §};
* 违例消除后从豁免删除该类即收紧收紧动作本身就是整改验收</p>
*/
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<String> BUSINESS_BCS = Set.of(
"ai", "content", "knowledge", "market", "meta", "member", "events");
/**
* AI 域不得直连他域 DAL
* 已知违例豁免清单(整改期,单点显式可见)详见 {@code .agents/rules/bc-boundaries.md §}
*
* <p> BC 只能走对外 API/facade-api 契约;任何 AI 类对他域 DAL(DO/Mapper)的依赖都会让本测试变红阻断合入
* 历史违例 {@code ContentMuseWorkOwnerFacade} 已整改(改消费 content 对外 API {@code MuseContentWorkOwnerApi}),
* 故此处**不再设任何豁免**门禁已收紧(整改记录见 .agents/rules/bc-boundaries.md §)</p>
* <p>market 的投影 provider/service 仍直写 member account-record-projection ( member.dal Mapper);
* **写路径** BC 重构( member 暴露写端口 + DTO),风险较高,列为单点登记待专项整改;
* 整改后从本清单删除,门禁随即收紧</p>
*/
private static final Set<String> 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<JavaClass>(
"依赖他域 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.<X>.*} 提取业务域段 {@code <X>};非该前缀返回 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");
}
}