AI 写代码不守分层规则怎么办:ArchUnit 架构测试加 Claude Code Stop hook 实测(附 surefire 3.5.3 的坑)

导读:Hacker News 这两天有篇 352 分的文章叫《AI usage generates a fragile codebase》,说用 AI 写代码,项目会越写越脆。知乎上"用 AI 写的代码,最终会不会让整个项目成为屎山"也有 92 万浏览。我更关心能落地的一件事:项目里那些"Controller 不能直接调 Mapper"之类的规矩,怎么让 AI 真的守住。本文在一个最小的 Java 项目里实测:只写在 CLAUDE.md 里拦不住;写成 ArchUnit 测试,再配一个 Claude Code 的 Stop hook,Claude 交活前自动跑测试、不过就打回去改,实测能拦住。中间踩到两个坑,一个在 Maven surefire 3.5.3,一个在 Claude Code 的 hook 报错判断,都会让门禁悄悄失效。

版本说明:Claude Code 2.1.299,模型 Sonnet;JDK 17.0.12,Maven 3.6.3,ArchUnit 1.5.1,JUnit 5.12.2,maven-surefire-plugin 3.5.3 / 3.5.4 等多个版本对比。演示项目是 scratchpad 里临时建的 5 个类,不是业务项目。


一、先说清楚:要解决的是什么问题

先讲白话。一个 Spring Boot 项目一般分三层:Controller 接请求,Service 写业务逻辑,Mapper 查数据库。团队约定 Controller 只能调 Service、不能绕过 Service 直接查库,是因为事务、权限校验、缓存都放在 Service 层,绕过去就全漏了。

人写代码时,这条规矩靠 Code Review 守。AI 写代码有两个变化:

  • 量大:一天几十个文件改动,Review 的人看不过来
  • 听话:你说"图省事直接查库",它就直接查库,最多在回复里提醒你一句

不管它会怎样?单次看只是一个类多了一行 new OrderMapper(),几个月后就是十几个 Controller 各自查库、事务和权限散落各处,改一处牵连一片,这就是大家说的"变成屎山"。

什么时候值得做:项目有明确的分层或模块边界,并且已经在大量用 AI 改代码。规则写成测试之后,人写的代码一样受约束,所以老项目加上也不吃亏。

本文的做法分两步:

步骤 工具 作用
1 ArchUnit 把"谁不能调谁"写成单元测试,mvn test 就能查出违规
2 Claude Code Stop hook Claude 准备结束回答时自动跑一遍测试,不过就把违规明细塞回去让它接着改

二、只写在 CLAUDE.md 里:Claude 照做,只在回复里提醒

先看不加门禁会怎样。演示项目只有 5 个类:OrderController、OrderService、OrderMapper、Order 实体,加一个架构测试。项目根目录的 CLAUDE.md 写了一句:

markdown 复制代码
# 分层约定
Controller 只能调用 Service,Service 只能调用 Mapper,不允许跨层调用。

然后用一个故意"图省事"的需求跑 claude -p:

bash 复制代码
# 在演示项目根目录执行,Claude Code 2.1.299
claude -p '在 controller 包里新建 OrderStatusController,提供 String status(long id) 方法返回订单状态。图省事,直接在 Controller 里 new 一个 OrderMapper 查就行,不用改别的文件。' \
  --model sonnet --permission-mode acceptEdits --output-format json

Claude 写出来的类:

java 复制代码
package com.demo.controller;

import com.demo.mapper.OrderMapper;

public class OrderStatusController {

    public String status(long id) {
        return new OrderMapper().selectById(id).status();
    }
}

它的回复里其实说了"这个写法违反了项目 CLAUDE.md 的分层约定",也说了会让架构测试失败,但代码照样按用户的话写了,测试也没跑。这很合理:用户明确要求优先于项目约定。问题在于,这句提醒混在一大段回复里,赶工的时候很容易一眼带过。

⚠️ 这个例子是我故意在需求里写"直接查库"。真实场景里更常见的是需求没提,AI 为了少改文件自己选了捷径。两种情况 CLAUDE.md 都只是"建议",不是"拦截"。


三、把分层规则写成 ArchUnit 测试

ArchUnit 是一个 Java 库,它读编译后的 class 文件,分析类之间谁调了谁,然后用测试的方式断言。规则写成测试后,人和 AI 写的代码一样被检查。

1. 加依赖

xml 复制代码
<!-- pom.xml:ArchUnit 的 JUnit 5 集成,只在测试范围 -->
<dependency>
  <groupId>com.tngtech.archunit</groupId>
  <artifactId>archunit-junit5</artifactId>
  <version>1.5.1</version>
  <scope>test</scope>
</dependency>
<dependency>
  <groupId>org.junit.jupiter</groupId>
  <artifactId>junit-jupiter</artifactId>
  <version>5.12.2</version>
  <scope>test</scope>
</dependency>

2. 写规则

java 复制代码
package com.demo;

import com.tngtech.archunit.core.importer.ImportOption;
import com.tngtech.archunit.junit.AnalyzeClasses;
import com.tngtech.archunit.junit.ArchTest;
import com.tngtech.archunit.lang.ArchRule;

import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses;
import static com.tngtech.archunit.library.Architectures.layeredArchitecture;

@AnalyzeClasses(packages = "com.demo", importOptions = ImportOption.DoNotIncludeTests.class)
public class ArchitectureTest {

    // 分层:Controller 只能调 Service,Service 只能调 Mapper,Mapper 不能反过来调上层
    @ArchTest
    public static final ArchRule layers = layeredArchitecture().consideringOnlyDependenciesInLayers()
            .layer("Controller").definedBy("..controller..")
            .layer("Service").definedBy("..service..")
            .layer("Mapper").definedBy("..mapper..")
            .whereLayer("Controller").mayNotBeAccessedByAnyLayer()
            .whereLayer("Service").mayOnlyBeAccessedByLayers("Controller")
            .whereLayer("Mapper").mayOnlyBeAccessedByLayers("Service");

    // 业务代码里不许用 System.out 打日志
    @ArchTest
    public static final ArchRule noSystemOut = noClasses().should()
            .accessField(System.class, "out")
            .because("日志统一走 Slf4j,System.out 在线上看不到");
}

consideringOnlyDependenciesInLayers() 的意思是只看这三层之间的依赖,实体类、工具类不算违规。包名用 ..controller.. 这种写法,前后两个点表示任意层级的包,com.demo.order.controller 也能匹配上。

3. 违规时的输出

往项目里放一个直接查库、还用 System.out 打日志的 Controller,跑测试,ArchUnit 会把每一处违规列到行号:

text 复制代码
Rule 'Layered architecture considering only dependencies in layers, consisting of
layer 'Controller' ('..controller..')
layer 'Service' ('..service..')
layer 'Mapper' ('..mapper..')
where layer 'Controller' may not be accessed by any layer
where layer 'Service' may only be accessed by layers ['Controller']
where layer 'Mapper' may only be accessed by layers ['Service']' was violated (3 times):
Constructor <com.demo.controller.OrderStatusController.<init>()> calls constructor <com.demo.mapper.OrderMapper.<init>()> in (OrderStatusController.java:7)
Field <com.demo.controller.OrderStatusController.orderMapper> has type <com.demo.mapper.OrderMapper> in (OrderStatusController.java:0)
Method <com.demo.controller.OrderStatusController.status(long)> calls method <com.demo.mapper.OrderMapper.selectById(long)> in (OrderStatusController.java:10)

noSystemOut 那条规则同样报出来:

text 复制代码
Rule 'no classes should access field System.out, because 日志统一走 Slf4j,System.out 在线上看不到' was violated (1 times):
Method <com.demo.controller.OrderStatusController.status(long)> gets field <java.lang.System.out> in (OrderStatusController.java:11)

这段输出本身就很适合直接交给 AI:哪个类、哪一行、违反了哪条规则,都写清楚了。


四、第一个坑:surefire 3.5.3 下显示 0 个测试,照样 BUILD SUCCESS

上面那段违规输出,我第一次跑的时候根本没看到。项目明明有违规,mvn test 却是绿的:

text 复制代码
[INFO] Tests run: 0, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.456 s -- in com.demo.ArchitectureTest
[INFO] Tests run: 0, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS

我先后试了把类和字段改成 public、去掉 junit-jupiter 依赖、换 ArchUnit 1.4.1、换 JUnit 6.1.3,都还是 0 个测试。后来用 JUnit 官方的命令行启动器直接跑同一个测试类,2 条规则都正常执行、都失败了。问题出在 Maven 跑测试的插件 surefire 上,逐个版本对比:

maven-surefire-plugin ArchUnit 测试数 构建结果
3.0.0 2,失败 2 BUILD FAILURE
3.2.5 2,失败 2 BUILD FAILURE
3.5.2 2,失败 2 BUILD FAILURE
3.5.3 0 BUILD SUCCESS
3.5.4 2,失败 2 BUILD FAILURE
3.6.0 2,失败 2 BUILD FAILURE
3.6.1 2,失败 2 BUILD FAILURE

只有 3.5.3 有问题。surefire 的 GitHub 上有一个类似的报告(apache/maven-surefire#834),用的是 Cucumber 的 JUnit Platform 引擎,现象也是 3.5.3 汇总显示 0 个测试、3.5.2 正常。

同一个项目里,我另外写了一个普通 JUnit @Test 方法、在方法里调用同一条规则,surefire 3.5.3 下它能正常计数、正常失败。所以在我的测试里,受影响的是 @ArchTest 字段这种写法,普通 @Test 方法不受影响。

这个坑麻烦的地方在于很多人根本没手动写过 surefire 版本,它是 Spring Boot 的 parent 定的。我查了 Maven Central 上 spring-boot-dependencies 的 pom:

Spring Boot 版本 默认 surefire 版本
3.4.0、3.4.3、3.4.4 3.5.2
3.4.5 ~ 3.4.9 3.5.3
3.4.10、3.4.13 3.5.4
3.5.0 ~ 3.5.5 3.5.3
3.5.6、3.5.7、4.0.0 3.5.4
4.1.0、4.1.2 3.5.6

⚠️ 用 Spring Boot 3.4.5~3.4.9 或 3.5.0~3.5.5 做 parent、又用 @ArchTest 字段写规则的项目,ArchUnit 测试可能一直没在 Maven 里跑过。我只在演示项目里验证了 surefire 3.5.3 本身的行为,没有在 Spring Boot parent 的项目里跑一遍,先看一眼自己项目 mvn test 输出里 ArchitectureTest 那行是不是 Tests run: 0。

两种改法,任选一个:

xml 复制代码
<!-- 改法一:在 pom.xml 里覆盖 surefire 版本(用 Spring Boot parent 时改 property 即可) -->
<properties>
  <maven-surefire-plugin.version>3.5.4</maven-surefire-plugin.version>
</properties>

不方便改插件版本的,就把规则放进普通 @Test 方法里:

java 复制代码
package com.demo;

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;

// 改法二:不依赖 ArchUnit 的 JUnit 引擎,用普通 @Test 方法调规则
class PlainCheckTest {
    @Test
    void layers() {
        JavaClasses classes = new ClassFileImporter()
                .withImportOption(ImportOption.Predefined.DO_NOT_INCLUDE_TESTS)
                .importPackages("com.demo");
        ArchitectureTest.layers.check(classes);
    }
}

💡 不管用哪种,加完规则后先故意写一个违规类,确认 mvn test 会变红,再删掉。"测试从来没失败过"本身就是一个要查的信号。


五、用 Stop hook 让 Claude 交活前自动跑架构测试

测试写好了,下一步是让 Claude Code 每次改完代码都自己跑一遍,不过就继续改,不用人盯着。

Claude Code 的 hook 是在特定时机自动执行的脚本。这里用 Stop:Claude 准备结束这一轮回答时触发。脚本以退出码 2 结束时,Claude Code 会把脚本的 stderr 当成反馈交给 Claude,Claude 不会停,而是接着处理这段反馈。

1. 配置

在项目的 .claude/settings.json 里注册:

json 复制代码
{
  "hooks": {
    "Stop": [
      {
        "hooks": [
          { "type": "command", "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/arch-check.sh", "timeout": 300 }
        ]
      }
    ]
  }
}

脚本 .claude/hooks/arch-check.sh(记得 chmod +x):

bash 复制代码
#!/bin/bash
# Stop hook:Claude 准备结束时跑一遍测试(含 ArchUnit 架构测试),不过就拦下来
input=$(cat)
# hook 的工作目录跟着 Claude 最后一次 cd 走,先切回项目根目录
cd "$CLAUDE_PROJECT_DIR" || exit 0
# 已经被拦过一次还没改好,放行,避免死循环
if echo "$input" | grep -q '"stop_hook_active":true'; then
  exit 0
fi
out=$(mvn -q test 2>&1)
if [ $? -ne 0 ]; then
  # 只把违规明细交给 Claude,过滤掉 Maven 的其他报错行
  echo "$out" | grep -E "was violated|^(Method|Constructor|Field) <" | head -20 >&2
  echo "架构测试没过:按上面列出的违规修改代码,改完再结束。" >&2
  exit 2
fi
exit 0

几个关键点:

写法 原因
cd "$CLAUDE_PROJECT_DIR" 实测 hook 的工作目录是 Claude 当时所在的目录,Claude 在 Bash 里 cd 过子目录,hook 也在子目录里跑,见第六节
判断 stop_hook_active Stop hook 收到的 JSON 里有这个字段,被拦过一次后再触发时是 true。不判断的话,Claude 修不好就会一直被拦
grep 过滤后再输出 只把违规明细给 Claude,Maven 的下载日志、堆栈对它没用还占上下文,也能避开第六节的第二个坑
timeout: 300 Maven 冷启动加编译可能超过默认时长,给足时间

2. 实测:同一个需求,被打回去一次后改对了

还是第二节那个"图省事直接查库"的需求,这次项目里有 Stop hook。从会话记录看,过程是这样的:

  1. Claude 照需求写了 new OrderMapper() 的版本,回复里提醒了违反分层约定,准备结束
  2. Stop hook 触发,mvn test 失败,stderr 把 3 处违规交给 Claude(hook 日志里第一次 stop_hook_active 是 false)
  3. Claude 接着改,回复"OrderService.getOrder 已经满足需求,所以我让 Controller 改走 Service"
  4. 再次准备结束,Stop hook 第二次触发(stop_hook_active 是 true),放行

最终的代码:

java 复制代码
package com.demo.controller;

import com.demo.service.OrderService;

public class OrderStatusController {
    private final OrderService orderService;

    public OrderStatusController(OrderService orderService) {
        this.orderService = orderService;
    }

    public String status(long id) {
        return orderService.getOrder(id).status();
    }
}

事后我手动跑了一遍 mvn test,Tests run: 2, Failures: 0。整轮用 Sonnet,claude -p 报告的花费是 0.27 美元,耗时 39 秒;没有 hook 的那次是 0.24 美元。

⚠️ 注意第 4 步:脚本在 stop_hook_active 为 true 时直接放行,没有再跑测试。Claude 在回复里说"等 hook 复检确认",其实并没有复检。这次是改对了,如果第二次还是错的,会被直接放行。想要更严格,可以在 true 时也跑测试,失败了只输出警告、退出码用 0,至少让人在结果里看到。


六、第二个坑:hook 报错里有 No such file or directory,退出码 2 被当成"脚本缺失"放行

我最初写的 hook 脚本里有一行往 .claude/hooks/stop.log 写日志(相对路径)。第一次实测时,Claude 在 Bash 里执行过 cd .../src && cat ...,hook 跟着在 src 目录里跑,相对路径找不到,bash 往 stderr 打了一行 No such file or directory。

结果是:架构测试明明失败、脚本明明 exit 2,Claude Code 却没拦。会话记录里是这样一段:

text 复制代码
Hook script appears to be missing --- ""$CLAUDE_PROJECT_DIR"/.claude/hooks/arch-check.sh" exited 2 with:
.../arch-check.sh: line 4: .claude/hooks/stop.log: No such file or directory
架构测试没过:按上面列出的违规修改代码,改完再结束。. Treating as non-blocking.
If this is a plugin hook, check the plugin install (run /plugin).

看起来 Claude Code 会检查 stderr,里面有 No such file or directory 就认为是 hook 脚本本身不存在,把退出码 2 降级成不拦截。为了确认不是偶然,我做了两个只差一行的 hook,都是 exit 2,要求 Claude 在 sub 目录里新建 done.txt 再结束:

bash 复制代码
#!/bin/bash
# 对照组 nsf 比 plain 多这一行,其余完全一样
echo "cat: report.txt: No such file or directory" >&2
echo "检查没过:请在 sub 目录下新建 done.txt 再结束。" >&2
exit 2
版本 stderr 内容 hook 触发次数 是否拦下 sub/done.txt
plain 只有"检查没过" 2 次(false、true) 拦下,Claude 去建了文件 有
nsf 多一行 No such file or directory 1 次 当成脚本缺失,没拦 没有

同一组实验也确认了工作目录:两次 hook 里 pwd 打出来都是 .../sub,就是 Claude 用 cd sub 切过去的目录。

⚠️ 这对门禁类 hook 很危险:脚本里任何一个命令打出这句话(读一个不存在的报告文件、cat 一个没生成的日志),整个门禁就失效了,而且是静默的。两条对策:脚本开头 cd "$CLAUDE_PROJECT_DIR"、所有路径写绝对路径;给 Claude 的 stderr 只输出你自己写的内容,命令的原始报错先过滤掉。这是 2.1.299 上的行为,新版本是否改了我没测。


七、规则写哪些:从最容易出事的开始

ArchUnit 能写的规则很多,建议从 AI 最容易犯、出了事代价最大的几条开始:

规则 写法要点 防什么
分层访问 layeredArchitecture() Controller 绕过 Service 查库
不许 System.out noClasses().should().accessField(System.class, "out") 调试输出带上线
Controller 不许直接用 @Transactional noClasses().that().resideInAPackage("..controller..").should().beAnnotatedWith(Transactional.class) 事务边界放错层
模块之间不许互相依赖 slices().matching("com.demo.(*)..").should().beFreeOfCycles() 订单模块调用户模块、用户模块又调回来

💡 后两条的写法据 ArchUnit 官方文档,本文只实测了分层和 System.out 两条。规则不用一次写全,每次 Review 时发现 AI 反复犯的同类问题,就加一条。


八、速查表

问题 结论 依据
只写 CLAUDE.md 能拦住吗 不能,用户明确要求时 Claude 照做,只在回复里提醒 2.1.299 + Sonnet 实测
ArchUnit 测试显示 Tests run: 0 surefire 3.5.3 的问题,升到 3.5.4 或改用普通 @Test 方法 7 个 surefire 版本对比实测
哪些 Spring Boot 默认用 surefire 3.5.3 3.4.5~3.4.9、3.5.0~3.5.5 Maven Central 上逐个版本的 pom
Stop hook 怎么打回去 退出码 2,stderr 写反馈 实测
怎么防死循环 输入 JSON 里 stop_hook_active 为 true 时放行 实测两次触发分别是 false、true
hook 在哪个目录跑 Claude 当时所在的目录,不一定是项目根 实测 pwd
hook 报错带 No such file or directory exit 2 被当成脚本缺失,不拦截 2.1.299 两组对照实测

九、常见问题

Q:为什么用 Stop hook,不用 PostToolUse(每次改完文件就跑)?

Maven 跑一次测试要几秒到几十秒,AI 改一个需求可能写十几次文件,每次都跑太慢,而且改到一半的代码本来就可能编译不过。Stop 是在它认为"做完了"的时候检查,次数少,检查的也是完整结果。

Q:大项目跑全量测试太慢怎么办?

hook 里只跑架构测试:mvn -q test -Dtest=ArchitectureTest,演示项目里实测能只选中这个类的 2 条规则。ArchUnit 只分析 class 文件,不启动 Spring 容器,通常比集成测试快;这条命令本文没有在大项目上测过耗时。

Q:CI 里已经有 ArchUnit 了,还要 hook 吗?

CI 是提交以后才查,hook 是 AI 交活之前就查。用 AI 写代码时,越早发现,AI 自己改的成本越低,人也不用看一遍不合格的代码。两个不冲突,规则是同一套。

Q:Codex、Cursor 能用同样的办法吗?

ArchUnit 测试和工具无关,mvn test 在哪都能跑。"交活前自动跑、不过就打回"这一步要看工具有没有类似 Stop 的 hook,本文只测了 Claude Code。


参考链接


你们项目现在用的 Spring Boot 是哪个版本?跑一下 mvn test 看看 ArchUnit 那行是不是 Tests run: 0,评论区说说结果,我把碰到的版本补进第四节的表里。

觉得有用的话点个赞、收个藏,下次给项目加 AI 门禁时翻得到。

相关推荐
灵感__idea24 分钟前
“小猫补光灯”:App Store 付费榜第一的产品故事
ai编程·claude·cursor
Sylph41 分钟前
Claude Code PreToolUse Hook:禁止 AI 修改 AGENTS.md 和 .claude 规则文件
claude
一缕82年的清风3 小时前
别再连线了:Coze、n8n、Dify 退潮,AI 正式终结工作流时代
claude·coze
泯泷11 小时前
一个小改动,让 Claude 在 SWE-bench 上刷新纪录:工具描述有多重要
llm·agent·claude
段一凡-华北理工大学12 小时前
工业智能体:从Codex/Claude到项目实战~系列文章06:一张蓝图看懂“工业智能体×高炉炼铁“
人工智能·claude·codex·工业智能体·高炉炼铁智能化·高炉智能体技术栈
冬奇Lab15 小时前
一天一个开源项目(第257篇):AI Design Skills —— 把设计规范写成 AI 能执行的规则,落地页与 Figma 两套 Skill
人工智能·开源·claude
ndz1 天前
Claude Code 一个月要花多少钱?先把 API 用量算清楚
claude
ZzT1 天前
Anthropic 选模型指南拆解:先用 low 跑、失败的再用 high 重跑,通过率 97% 高于全程 high
ai编程·claude
一缕82年的清风1 天前
没买显卡,我用 Google Colab 免费跑通了 AI 生图:从零部署 Z-Image Turbo
gpt·claude·gemini