你让 Claude Code 加一个 formatPrice。PR 加了 formatPrice——同时多了 formatCurrency、formatPercent、formatCompact、parseAmount、validatePrice。这五个没一个被任何地方 call。PR 描述写「complete formatting utilities suite」。你要一个函数,它给你一个小框架。
最快修法:prompt 里写一个明确的 DO NOT add 清单,然后跑 pnpm dlx knip --fix --fix-type exports 把所有死 export 自动删掉。本页剩下的部分是让这个默认行为固定下来,这样你 review 时就不用再每次抓。
这是自动驾驶式的 over-engineering。Claude 默认走「complete solution」——训练数据里这种受表扬。但真实代码库里每个没用的 export 都是维护负担、新人困惑、bug 表面积。修法分两层:prompt + CLAUDE.md 限 scope 让它基本不发生,漏过去的在 review 时删(或自动 strip)。
你属于哪种情况?
| 你看到的现象 | 最可能的原因 | 跳到 |
|---|---|---|
| prompt 是泛话(“加 price helpers”) | scope 没限 | 原因 1、Step 1 |
多出来的 export 是一整个家族(formatX、parseX、validateX) | 「comprehensive」自动驾驶 | 原因 2、Step 4 |
| 新文件孤零零落在空目录 | 没有锚定模块大小的参照 | 原因 3 |
| prompt 里写了「module」「library」「suite」 | 复数框架被授权 | 原因 4 |
| 每个 session 都重犯 | CLAUDE.md 没写 YAGNI | 原因 5、Step 2 |
新 API 镜像 date-fns / lodash 形状 | 模式匹配了大库 | 原因 6 |
常见原因
按命中率从高到低。
1. Prompt 没限 scope
「加一个 price formatter」没说「只要我需要的那一个」。Claude 假设「和 price 相关的所有合理 utility」都在范围内,把全套打包送出。
如何判断:你 prompt 是泛话(“加 X helpers”)。改成「确切加 formatPrice(cents): string,其他都不要」重试。
2. Claude 默认「comprehensive」
训练数据里全是「完整 X 库」的例子。让它加一个函数,它给你一个函数 + 4 个明显的兄弟——这看起来”专业”。
如何判断:多出来的 helper 和要求的那个组成自然家族——这就是自动驾驶模式。
3. 没现有 utils 文件让它锚
Greenfield 区域,Claude 没「一个 utility 模块该多大」的参考,就默认设计一个看上去完整的模块。
如何判断:新文件落在没有同伴的目录——Claude 从零设计了模块形状。
4. Task 用词暗示了「库」框架
「Build a date utility module」读起来就是「构建 date utilities 库」。Claude 就给你构建库。本来你只是要 formatDateForInvoice。
如何判断:prompt 里有 module、library、suite、set of utilities 这种词——它们授权了复数。
5. CLAUDE.md 没写 YAGNI
没明确「不要为未来设计」规则,Claude 默认是「为合理的未来设计」。YAGNI(You Aren’t Gonna Need It)必须是显式 policy,不是假设的文化。
如何判断:grep -i "yagni\|unused\|only what's needed" CLAUDE.md 空。
6. Claude 模仿了训练里某个大库的形状
date-fns 有几百个 export。Claude 看过它就觉得「date utilities 意味着很多 export」——你项目不需要这么多,但 Claude 看不出来。
如何判断:helper 形状镜像某个流行库的 API——它是模式匹配,不是按你需求设计。
最短修复路径
按收益从高到低。Step 1、2 防它在发生前;Step 3 清理漏过去的。
Step 1:scope-bounded prompt
**确切**加这些:
- 函数:formatPrice(cents: number, currency: "USD"): string
- 文件:src/lib/formatPrice.ts
- 测试:src/lib/formatPrice.test.ts
- 3 个测试用例:0、正常、大金额
**不要**加:
- 其他 formatter(formatCurrency 等)——就算看着有用
- "utilities barrel" 或 index 文件
- 只被内部用的 helper——直接 inline
- export 上 JSDoc 之外的文档
DO NOT 清单是杠杆——没它,Claude 会用”合理增量”塞满。
Step 2:CLAUDE.md 写 YAGNI 规则
按 Claude Code memory 文档,CLAUDE.md 会被加载进每个 session,所以这里一段短 policy 就能改变整个项目的默认行为:
## YAGNI 政策
- 只写当前任务需要的代码。
- 不要为未来需求设计,除非已排期。
- 新 helper 至少要有 2 个 caller 才进 merge——1 个 caller 的代码就 inline。
- 新抽象至少 3 处具体用法——过早抽象 review 时拒收。
- "以后可能用"不是加代码的理由——删掉,真要用时再加。
根目录 CLAUDE.md 保持精简(文档建议大致控制在 300 行以内);prose 规则只是软提示、不是硬保证,所以 Step 6 把强制执行搬进 hook 和 CI。
Step 3:用 knip 自动 strip 没用的 export
PR 落地后,找出并删掉死 export。knip(截至 2026 年 6 月为 v6.x)是 TypeScript/JavaScript 项目上现在的标准工具,而且能原地 fix:
# 报告未使用的文件、export、依赖
pnpm dlx knip
# 只自动删掉没用的 export/type(最安全的范围)
pnpm dlx knip --fix --fix-type exports,types
# 让 knip 改过的文件用你项目的 formatter 重排
pnpm dlx knip --fix --fix-type exports,types --format
--fix 会把没用的 export 的 export 关键字去掉(降级成局部)、删掉没用的 default export;只有当你也想删掉「整个变成没人用」的文件时,才加 --allow-remove-files。改完后再跑一次纯 pnpm dlx knip 确认报告干净。如果你想要更窄的工具,pnpm dlx ts-unused-exports tsconfig.json 只报告未使用 export、不动文件。
Step 4:拒收宽文件、重 prompt 收窄
Claude 返了一个「完整 utilities」文件,就在同一 session 里顶回去,纠正才会保持:
你的文件有 6 个 export,任务只需 1 个。删掉:
- formatCurrency
- formatPercent
- formatCompact
- parseAmount
- validatePrice
只留 `formatPrice`。测试文件也只测 `formatPrice`,重写。
这会把 session 训练成在这一轮剩下的部分都保持最小。
Step 5:单 caller 的 helper inline 掉
Claude 建了个内部 helper 只一个地方用——inline:
// 差——只用一次的 helper
function calculateTax(amount: number): number {
return amount * 0.08;
}
function checkout(cart: Cart): Receipt {
const tax = calculateTax(cart.subtotal);
// ...
}
// 好——inline,无 helper
function checkout(cart: Cart): Receipt {
const tax = cart.subtotal * 0.08;
// ...
}
Helper 是给复用用的,一次性逻辑就 inline。
Step 6:用 hook + CI 让规则变成确定性的
CLAUDE.md 规则会被宽松解读;Claude Code hook 不会。把 knip 设成 PostToolUse hook,在每次编辑后跑,这样 Claude 在同一 session 里就能看到并修掉新的死 export。在 .claude/settings.json 里:
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{ "type": "command", "command": "pnpm dlx knip --no-progress || exit 2" }
]
}
]
}
}
退出码 2 会拦截并把失败反馈给 Claude,让它先清理再继续。在 CI 里镜像同一检查(把 pnpm dlx knip 设成必过 job),任何引入未使用 export 的 PR 直接 fail,自动驾驶的死代码就到不了 main。
Step 7:季度审现有 helper
helper 默默积累。把臃肿的文件挑出来:
# 每个 utils 文件多少 export
find src/lib src/utils -name "*.ts" -exec sh -c \
'echo "$(grep -c "^export" "$1") $1"' _ {} \; | sort -nr | head -20
对 export 多的文件 review 一下:实际被 call 的有哪些?没 call 的删掉(或让 knip --fix 来删)。
怎么确认修好了
pnpm dlx knip在改动的文件里报告 0 个未使用 export。- 新文件只 export 了任务要求的东西(
formatPrice这例里就是一个符号)。 - 构建和测试照样通过——
knip --fix只删了没人 import 的 export,所以不该有东西坏掉。 - Step 6 的
PostToolUsehook 现在会在以后某次编辑重新引入死 export 时拦截。
FAQ
knip --fix 会不会把动态用到的代码删掉(字符串键 import、DI 容器)?
有可能,因为静态分析看不到所有动态访问。先从 --fix-type exports,types 开始(它只把 export 降级成局部,不删逻辑),然后看 diff。在启用 --allow-remove-files 之前,把动态加载的文件加进 knip.json 的 entry/ignore。
为什么我说过一次,Claude 还是照犯?
每次 prompt 的指令只在那一轮有效。把规则搬进 CLAUDE.md(Step 2),它每个 session 都加载;再用 hook + CI 强制(Step 6),它就从「Claude 能绕过去的建议」变成确定性约束。
老 over-engineering,是不是该换模型?
不是。这是 prompting 和护栏问题,不是能力问题。Claude Code 只跑 Anthropic 模型(截至 2026 年 6 月为 Sonnet 4.6 和 Opus 4.7),两者对明确的 DO NOT add 清单和 YAGNI policy 都遵守得很好。这个行为来自模糊的 scope,不是模型档位。
knip vs ts-unused-exports vs ESLint 的 no-unused-vars——我该用哪个?
no-unused-vars 只抓文件内未使用的局部变量,抓不到未使用的 export。ts-unused-exports 能找未使用 export,但不动文件和依赖。knip 一遍就能找出未使用的文件、export、type、依赖,还能自动修——三者里最全。
YAGNI 政策该放 CLAUDE.md 还是 CI 检查?
两个都要。CLAUDE.md 塑造默认行为,让 Claude 一开始就少写死代码;CI(和 hook)是硬兜底。Claude Code memory 文档明确建议:不可商量的规则要在 CI 或 hook 里强制,别只靠 prose。
预防建议
CLAUDE.md写 YAGNI:新 helper 要 >= 2 caller,新抽象要 >= 3 用法- 每个代码 prompt 显式带
DO NOT add清单,挡住”合理增量” - 把
knip设成PostToolUsehook 并作为 CI 必过 job;新增未使用 export 直接 fail PR - review 时拒「以后可能用」推理——真要时再加
- prompt 不要写「build a library / module / utility suite」——这种词授权复数
- 一次性逻辑保持 inline,helper 只给复用用
相关阅读
标签: #Claude Code #排查 #排查 #YAGNI #过度工程