Claude Code 写了一堆没人用的 helper:6 个 over-engineering 触发 + YAGNI 强制

你要一个函数,PR 多送 5 个「以后可能用」的——在 prompt + CLAUDE.md 限 scope,用 knip --fix 自动删死 export,再在 CI 里锁死。

你让 Claude Code 加一个 formatPrice。PR 加了 formatPrice——同时多了 formatCurrencyformatPercentformatCompactparseAmountvalidatePrice。这五个没一个被任何地方 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 是一整个家族(formatXparseXvalidateX「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 里有 modulelibrarysuiteset 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 来删)。

怎么确认修好了

  1. pnpm dlx knip 在改动的文件里报告 0 个未使用 export
  2. 新文件只 export 了任务要求的东西(formatPrice 这例里就是一个符号)。
  3. 构建和测试照样通过——knip --fix 只删了没人 import 的 export,所以不该有东西坏掉。
  4. Step 6 的 PostToolUse hook 现在会在以后某次编辑重新引入死 export 时拦截。

FAQ

knip --fix 会不会把动态用到的代码删掉(字符串键 import、DI 容器)? 有可能,因为静态分析看不到所有动态访问。先从 --fix-type exports,types 开始(它只把 export 降级成局部,不删逻辑),然后看 diff。在启用 --allow-remove-files 之前,把动态加载的文件加进 knip.jsonentry/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 只抓文件内未使用的局部变量,抓不到未使用的 exportts-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 设成 PostToolUse hook 并作为 CI 必过 job;新增未使用 export 直接 fail PR
  • review 时拒「以后可能用」推理——真要时再加
  • prompt 不要写「build a library / module / utility suite」——这种词授权复数
  • 一次性逻辑保持 inline,helper 只给复用用

相关阅读

标签: #Claude Code #排查 #排查 #YAGNI #过度工程