用 skill 维护老项目:排查、修复、迭代
不讲"你应该先画地图、再补测试"这类道理,讲具体怎么调:每个 skill 你说什么话它会启动、它内部按哪几步跑、产出长什么样、你怎么验收。12 个 skill,分三个阶段。
01速览与选表
读完这一篇,你能做到一件事:拿到一个你不熟的老仓库,知道在每一个卡点上该调哪个 skill、用什么话把它叫起来、它跑完你该看什么来判断"这次可以信"。
① skill 不是魔法,是一份写好的说明书。它不提升 AI 的智商,它把"每次都要重讲一遍的纪律"固化下来(skill:一段叫 SKILL.md 的文本,AI 在合适的时候自动读它并按里面的步骤干活)。
② 判断一个 skill 值不值钱,只看两处:它的步骤里有没有"可验证的关卡",它默认会不会动你的代码。
③ 三个阶段各有一批:排查(只读不写)→ 修复(每步有红绿)→ 迭代(合并前的闸门)。不要跳阶段。
你现在的处境 → 调哪个 skill
| 你现在的状态 | 调这个 | 在哪一节 |
|---|---|---|
| 刚接手,打开仓库不知道从哪看 | codebase-onboarding | §04 |
| 代码是自己(和 AI)写的,但业务已经说不清了 | code-to-doc-generator | §04 |
| 同一个词,两个人理解不一样;"当年为什么这么写"没人记得 | domain-modeling | §04 |
| 要往一块没测试的老代码里加功能 | working-with-legacy-code | §05 |
| 想删一批没人用的文件/导出/依赖 | refactor-clean | §05 |
| 只缺"把现状钉住"那层测试 | characterization-test-generator | §05 |
| 线上有个复现不出来的 bug / 说不清为什么变慢了 | diagnosing-bugs | §05 |
| 这块已经有测试了,要继续加功能 | tdd | §05 |
| 清了一大堆改动,准备合并 | code-review | §06 |
| 走通几条路了,想知道下一步挖哪里 | improve-codebase-architecture | §06 |
| 清理计划超过一天工时,需要分几次做 | to-spec → to-tickets | §06 |
| issue 和外部 PR 堆了一堆,不知道该先看哪个 | triage | §06 |
下面 30 秒速览,把三个阶段串成一条线:
02为什么这套纪律必须被固化下来
这一节很短,但它是"为什么要用 skill"的答案。如果你已经确信这一点,可以直接跳到 §03。
老项目难改,不是新问题
2000 年 4 月,Joel Spolsky 写了一篇影响很大的文章《Things You Should Never Do》(有些事你永远不该做),核心论点一句话:把软件从头重写,是一家软件公司能犯的最严重的战略错误。他举的例子是网景——它在 4.0 和 6.0 之间决定重写浏览器,结果 4.0 到 6.0 隔了大约三年(从来没有一个叫 5.0 的版本),这三年里只能看着对手把市场份额吃掉。
关键是机制:老代码里那些"丑"的地方,很多是买来的知识——两页长的函数、看起来多余的判断,不少是多年来一个个真实 bug 的补丁,每个当时可能花了好几周才定位。你重写一遍,等于把这些知识原地扔掉,而且你不会记得它们存在过。
还有一句题眼:"读代码比写代码难。" 正因为读起来痛苦,程序员就会产生"这坨东西之所以难读,是因为写得很糟"的错觉。Joel 的判断是:这个错觉经常是错的。
AI 在这上面又加了一层
目前最常被引用的一份证据是 GitClear 的《AI Copilot Code Quality》2025 年度报告:2.11 亿行代码改动,2020 年 1 月到 2024 年 12 月,来源含 Google、Microsoft、Meta 等企业的匿名私有仓库和 25 个最大的开源项目。
最有信息量的一条是:2024 年,"复制粘贴"第一次超过了"搬动代码"。 历史上开发者搬动代码(把函数提取成公共的、把这边的东西挪到那边)一直多于复制粘贴,而搬动代码是"有人在整理结构"的信号。这两条线交叉,说明整理结构的动作整体变少了。
机制:为什么 AI 会制造重复
你让它"给订单加个优惠券功能",它有两条路:A 先翻遍整个项目找到已有的算价逻辑再复用它(要求它先理解全局);B 直接生成一段完整的、自己就能跑通的算价代码(不要求它理解任何东西)。它能看到的上下文是有限的(上下文窗口:模型一次能"看到"的文字总量),而项目往往比这个窗口大得多。所以在绝大多数时候,B 是它唯一有把握的路。
结果就是你项目里会长出这种东西:
// src/services/order.ts —— 半年里陆续长出来的
export function calcTotal(items) // 3 月加的,现在只有两个地方还在用
export function calcTotalV2(items) // 5 月加的,线上在用的是这个
export function calcTotalWithCoupon(items) // 7 月加的,复制了 V2 又改两行
export function calcTotalForRefund(items) // 8 月加的,又复制了一份
这四份都能跑,也都在被用。这就是它最难的地方:不是"报错那种坏",是"改了一份、忘了另外三份"那种沉默的坏。
GitClear 这份报告是观察性的:它统计的是已合入代码的改动形态在几年间的聚合趋势,不是控制变量的实验。所以它支持"AI 普及的同时,代码重复度在上升、结构整理在减少"这个推断,但不能证明"是 AI 导致了所有这些下降"。
你还会看到其他口径的数字(比如"AI 生成的 PR 等待审核时间是人类的 4.6 倍、接受率只有 32.7%"这类,来自 2026 年对约 810 万个合并请求的分析)。别把这些和 GitClear 的直接相加或对比——一个看合入之后代码的形态,一个看改动在流程里怎么流转。
所以结论是:靠人记不住,要写下来
上面这些病,对策其实早就有了——Feathers 2004 年那本书里全写着:先补表征测试、找到接缝、一次只改一处、重构和功能分开提交。问题从来不是"不知道",是"每次都要重讲一遍"。
你对 AI 说一次"先跑测试再改",它会照做;说二十次,它会开始漏。这就是 skill 存在的意义:把纪律从"你的记忆力"里搬到"文件"里。
03skill 到底是什么:怎么装、怎么触发、怎么判断能不能用
它是一个文件,放在两个地方之一
一个 skill 就是一个名叫 SKILL.md 的文本文件,放在某个目录里:
| 放哪 | 路径 | 什么时候用这一档 |
|---|---|---|
| 项目级 | <项目根>/.claude/skills/<名字>/SKILL.md |
这个 skill 只对这个项目有意义(比如"我们这套订单模块的改法")。可以跟着仓库提交,团队共享。 |
| 个人级 | ~/.claude/skills/<名字>/SKILL.md |
你自己所有项目都想用(比如"怎么给老代码补表征测试")。装一次,处处有效。 |
文件开头有一段用三条短横线包起来的元信息(叫 frontmatter,就是"文件头"),里面最重要的是 description 那一行——AI 就是靠这一行决定要不要启动这个 skill 的。所以一个描述写得好的 skill,你根本不用记它的名字,说对话它自己就出来了。
三种把它叫起来的方式
codebase-onboarding 就可能自己接上。/code-review、/to-tickets。最可控,也是我下面推荐的方式。你会在 SKILL.md 的头部看到一行 disable-model-invocation: true。它的意思是:只有你明确敲名字它才会跑,AI 不能自己决定启动它。本文提到的 to-spec、to-tickets、triage、improve-codebase-architecture、wayfinder 都是这样——因为它们会往你的任务追踪系统里写东西,让 AI 自己触发太危险。
看到这一行,就说明你得记住它的名字。
怎么装一个社区 skill
本文会提到一批社区 skill(不是官方内置,是别人写了开源的)。装法基本就一种——把 SKILL.md 放进上面那两个目录之一:
# 装到个人级(所有项目都能用)
mkdir -p ~/.claude/skills/working-with-legacy-code
curl -fsSL "<那个 skill 的 SKILL.md 原始链接>" \
-o ~/.claude/skills/working-with-legacy-code/SKILL.md
# 或者用 skills 工具装(有些仓库支持)
npx skills add https://github.com/<作者>/<仓库> --skill <名字>
它们只有几百到几千字,五分钟能读完。而且同名 skill 往往有好几份不同实现——我在查的时候,codebase-onboarding 至少见到三份、refactor-clean 至少有四份。名字一样,步骤的严格程度并不一样。
读的时候只看两处:① 它的步骤里有没有"跑测试看红绿"这种可验证的关卡;② 它有没有"不确定就跳过"这类兜底规则。两条都没有的,别用在老项目上。
本机已装(你机器上大概率已经有了,直接敲名字就能用):code-review、diagnosing-bugs、tdd、domain-modeling、code-to-doc-generator、improve-codebase-architecture、to-spec、to-tickets、triage、wayfinder、codebase-design、research。
社区(要去装):working-with-legacy-code(作者 wondelai,v1.2.0,MIT)、refactor-clean、codebase-onboarding、characterization-test-generator。
内置(随 Claude Code 一起):simplify、security-review、init。
不确定自己装了哪些,列一下目录就知道:ls ~/.claude/skills/
skill 之外还有一层:CLAUDE.md 里最值钱的是"不做什么"
skill 是"一件事的说明书",CLAUDE.md 是"这个项目的常驻须知"。两者要一起用:skill 管流程,CLAUDE.md 管纪律。
写这份文件时,最反直觉的一条是:写"不做什么",比写"做什么"值钱得多。 因为"做什么"通常读代码就能看出来,而"这个目录不能删""这个写法是故意的,不要改成标准的"——这类信息代码里完全看不出来,而 AI 会非常自信地把它"改正"。
# CLAUDE.md(这份文件只写"从代码里看不出来"的东西)
## 项目
Node 20 + TypeScript,包管理器用 pnpm(不要用 npm install)。
## 常用命令
- 装依赖:pnpm i
- 类型检查:pnpm typecheck
- 跑测试:pnpm test -- --no-cache
## 护栏(这些是硬约束,优先级高于任何"顺手改进")
- legacy/ 目录已废弃,但被 3 个线上模块引用:不要删除、不要搬移其中任何文件。
- 认证走的是自定义中间件,不要"改写成标准的 Express 中间件"。
- 改任何文件之前,先说明你打算改哪些文件、为什么,等我确认。
- 只改我点名的文件。如果你觉得别的文件也该改,先列出来问我,不要直接动手。
- 一次提交只做一件事:重构的提交里不要夹功能改动。
问一句:"这件事 AI 读代码能不能自己看出来?" 能,就删掉。
只留它猜不到、而且会影响操作的东西:用哪个包管理器、测试必须带什么参数、以及这个老项目特有的、代码里看不出来的禁区("legacy/ 别删"就是典型——它读代码只会看到"这个目录好像没人用")。
规模参考:那个生成 CLAUDE.md 的 skill 自己定的上限是 100 行;Anthropic 的建议是不超过约 200 行;前沿模型的"指令预算"大约是 150–200 条。
什么时候该自己写一个
不用一上来就去找别人的 skill。最好的来源是你自己——你在这个项目上重复口述第三次的那段话,就是一个 skill 的雏形。
比如这几句,如果每次让 AI 改老代码你都要说一遍,那它就该被写下来:
## 改这个项目的代码之前,永远先做这三件事
1. 先跑 `pnpm test -- --no-cache`,把结果贴给我(红也要贴,我要知道基线)。
2. 只改我点名的文件。想改别的,先列出来问我。
3. 改完再跑一次同样那条命令,贴结果。红了就 `git revert`,不要手改回去。
把它存成 .claude/skills/<名字>/SKILL.md,开头加上用三条短横线包起来的 frontmatter(至少要有 name 和 description),就成了你自己的 skill。写法本站已有专篇:《定制自己的 Skill:把重复劳动变成一句话命令》。
04① 排查:三个只读的 skill
这一批的共同点是产出是文件,不是改动。所以风险最低,第一次接老项目就从这里开始。
六项扫描同时跑:清单文件(package.json / go.mod / Cargo.toml / pyproject.toml / pom.xml / build.gradle / Gemfile / composer.json / mix.exs / pubspec.yaml)· 框架指纹(next.config / nuxt.config / angular.json / vite.config / Django settings / Flask app factory / fastapi main)· 入口点(main.* / index.* / server.* / cmd/ / src/main/)· 两层目录快照(跳过 node_modules、vendor、.git、dist、build、__pycache__、.next)· 配置与工具(eslint / prettier / tsconfig.json / Makefile / Dockerfile / docker-compose* / .github/workflows/ / .env.example)· 测试结构(tests/ / __tests__/ / *_test.go / *.spec.ts / jest / vitest 配置)。
给出技术栈(语言版本、框架、数据库/ORM、构建工具、CI/CD)· 架构形态(单体 / monorepo / 微服务 / serverless)· 关键目录各自管什么 · 以及追踪一条真实请求:从入口 → 校验 → 业务逻辑 → 数据库。
命名约定(文件、组件、测试)· 代码模式(错误处理、依赖注入还是直接 import、状态管理、异步写法)· Git 约定(分支命名、提交风格、PR 流程)。如果这个仓库没有提交历史或者只有很浅的历史,它会跳过这一节并明确说"git 历史不可用或太浅,无法检测约定"——而不是猜一个给你。
① 一份上手导览:概览 / 技术栈 / 架构 / 关键入口 / 目录地图 / 请求生命周期 / 约定 / 常见任务 / 该去哪找。
② 一份 CLAUDE.md 草稿:技术栈 / 代码风格 / 测试 / 构建与运行 / 项目结构 / 约定。如果已经存在 CLAUDE.md,它会先读、然后增强,保留你原有的指令并标出改了哪里——不会直接覆盖。
"Onboard me to this codebase"
→ 跑完整四阶段,导览输出在对话里,CLAUDE.md 写进项目根目录
"Generate a CLAUDE.md for this project"
→ 只跑第 1–3 阶段,只产出 CLAUDE.md,不写导览
"Update the CLAUDE.md with current project conventions"
→ 先读现有文件,跑第 1–3 阶段,合并,标出新增了什么
- 用 Glob / Grep 扫,不要对每个文件调 Read——只在有歧义时才选择性读文件。
- 代码和配置冲突时,信代码。
- 尊重已有的 CLAUDE.md——增强,不替换。
- 导览要"2 分钟能扫完"。
- 不确定的地方标出来,不要猜。
- 反模式:CLAUDE.md 超过 100 行 · 把每个依赖都列出来 · 解释一眼就懂的目录名 · 抄 README。
它产出的那份 CLAUDE.md 打开数一下行数——超过 100 行,就是它在往里塞代码里本来就看得见的东西(第 §03 那两条判断标准)。然后从它的结论里挑 3 条去核对,比如它说"认证在 src/middleware/auth.ts",你就去那个文件看一眼。
| 模式 | 什么时候会进这个模式 | 它做什么 |
|---|---|---|
| A · 生成 | 你说"生成文档 / 写文档 / 没有文档 / 帮我写个 HLD" | 从代码逆向提炼,四轮走完:识别项目类型与入口 → 结构扫描提取信息骨架 → 逐模块深度分析 → 生成文档。 |
| B · 反向同步 | 你说"更新文档 / 同步文档 / 文档和代码对不上 / 文档漂移" | 以代码为真相源去校准已有文档。注意:它会直接跳去另一份流程文件,不再走四轮。 |
它的 SKILL.md 里写着一条硬阻断:文档类型不得从上下文推断或自主选择,在类型确认前分析步骤禁止启动。你只说了"帮我写个文档",它会停下来问你。
所以开口时就把类型说清楚,能省一轮:帮我写 HLD / 生成 PRD / 出一份运营手册。可选的类型覆盖了整条交付链路——BRD/MRD(立项)· PRD(需求)· HLD 概要设计(架构)· LLD 详细设计 · DDD 领域设计 · 编码指南 · 测试文档(BDD / SIT / E2E / UAT)· 运营手册 · SLI/SLO 监控 · CI/CD。
拿到文档后做一件事:找出里面至少 3 条"你不知道、而且是靠读代码才能得出"的结论,去代码里核实。如果通篇都是你本来就知道的东西,说明这次倒推的深度不够,把范围收窄到某一个模块再要一次。
CONTEXT.md(这个项目里每个词是什么意思的术语表)和 ADR(架构决策记录——"当初为什么这么选"的短文档,放在 docs/adr/ 下)。注意它区分得很清楚:仅仅读 CONTEXT.md 取词汇不算这个 skill,那只是一行习惯;这个 skill 是"你在改变这个模型"的时候用的。- 拿术语表挑刺:你说的词和 CONTEXT.md 里定义的不一致,它当场指出来——"你的术语表把 'cancellation' 定义为 X,但你似乎指的是 Y。是哪个?"
- 把模糊说法磨尖:你说"account",它会问"你指的是 Customer 还是 User?这是两个不同的东西"。
- 用具体场景压测:故意构造边界场景,逼你把概念之间的边界说清楚。
- 和代码交叉核对:你描述的行为和代码对不上时,它会把矛盾摆出来——"你的代码是整单取消,但你刚说支持部分取消。哪个是对的?"
- 当场更新 CONTEXT.md,不攒着批量写。
另外一条纪律值得注意:CONTEXT.md 里不许有任何实现细节——它只是术语表,不是规格、不是草稿本。
三条缺任何一条,就不建 ADR。 这个门槛是它防"文档泛滥"的机制——ADR 一多就没人看了。
最好的时机是你终于从某个老同事嘴里问出"当年为什么要这么写"的那一刻——趁热让它把这句话落成一个 ADR。这类知识不写下来,就随人一起走掉了。
做法:把那段对话原样讲给它听("我们当年用双写而不是事务,是因为要兼容一个已经不存在的旧系统"),它会按三个条件判断该不该建、该建在哪、怎么命名(docs/adr/0001-event-sourced-orders.md 这种格式)。
跑完这三个 skill,你大概会花掉 半天到一天,一行代码都没改。换来的是:一份导览、一份业务文档、一份术语表、可能还有几个 ADR。
这个投入值不值?只要这个项目你还要维护超过两周,就值——因为接下来每一次你和 AI 的对话,都建立在这些文件之上,而不是从零开始解释。
手工版:不想装 skill,或者它的输出你不满意时
codebase-onboarding 内部做的事并不神秘,下面这套你可以自己跑一遍。而且它有一个 skill 做不到的好处:每一步你都亲眼看了。
① 侦察:先问"哪里有什么",别急着问"怎么运转"
# 只看两层目录,别陷进去
ls -la
find . -maxdepth 2 -type d -not -path '*/node_modules*' -not -path '*/.git*' | sort
# 读"清单文件"——它描述现实;README 描述意图,而且经常过期
cat package.json # JS / TS 项目
cat pyproject.toml # Python 项目
cat go.mod # Go 项目
# 近半年哪些文件被改得最勤?热点通常就是核心
git log --since="6 months ago" --name-only --pretty=format: \
| sort | uniq -c | sort -rn | head -30
# 哪些文件最长?最长的几个通常最需要小心
git ls-files | xargs wc -l 2>/dev/null | sort -rn | head -20
② 跑起来,然后故意弄坏它
pnpm i && pnpm dev # 目标只有一个:让它在本机跑起来
# 跑起来之后,在三个位置打点:路由入口、核心计算函数、写库之前
console.log('[打点] 算价前', JSON.stringify(items))
console.log('[打点] 算价后', total)
跑不起来也是收获——跑不起来的原因本身就是重要线索(缺哪个环境变量、依赖哪个外部服务、哪个包被钉死在旧版本)。而"跑起来之后故意弄坏它"这一步产出的理解,比读十个文件都结实。
③ 产出必须是一个文件,不是一个记忆
这一步最容易被跳过,代价也最大:理解只存在你脑子里或某次对话里,那么这次对话结束它就没了,下一个人(包括三个月后的你)要从头再来一遍。
# CODEBASE-MAP.md
# 这份文件记录"现在实际是这样",不是"应该是什么"。
## 技术栈
Node 20 + TypeScript 5 + PostgreSQL 15;包管理器 pnpm(不要用 npm install)
## 入口
- 服务启动:src/server.ts
- HTTP 路由:src/routes/index.ts
- 定时任务:src/jobs/
## 一条请求怎么走
POST /orders → routes/orders.ts → services/order.ts → repositories/order.repo.ts → Postgres
## 隐藏行为(读代码看不出来的)
- 库存扣减是异步的,失败会静默重试 3 次,不会抛错给调用方
- /v1/users 已废弃,但被 3 个线上模块引用,不要删
## 怎么跑起来
pnpm i && pnpm dev # 需要 .env 里的 DATABASE_URL
④ 让 AI 画地图的话术
读这个仓库(先不要改任何文件)。只回答四件事:
1. 入口在哪里。一条请求从进来到返回,按顺序经过哪几个文件。
2. 哪些文件是"被依赖最多的"——列出被 import 次数最多的前 10 个。
3. 有没有循环依赖:如果有,具体是哪几个文件在互相引用。
4. 你判断哪些是核心业务逻辑、哪些是工具/胶水代码。
不确定的地方写"不确定",不要猜。
每个结论后面附上你看到它的文件路径和行号。
"不确定就写不确定" + "每条结论附文件路径和行号"。这两句把 AI 的输出从"看起来挺靠谱"变成了可以核对。没有这两句,你拿到的是一段无法验证的自信陈述。
拿到之后做一次抽查 3 条:从它的结论里挑 3 条,去它给的行号看一眼。3 条里错 1 条 → 把整张图降级成"待验证";错 2 条 → 扔掉重来,换一个更小的范围再问一次(比如只问"订单这一块怎么走")。
05② 修复:五个带红绿关卡的 skill
这一批会改代码。判断该不该用它,就看前面那两条:它有没有每步验证,它默认改多大范围。
先装上这一层:不管用哪个 skill,版本控制上的四件事都要做
这是所有"安全带"里最便宜的一层——不需要写任何测试,四条命令。skill 不会替你记住它们,但你可以在 CLAUDE.md 里写死。
# 1. 永远不在主干(main 分支,也就是线上跑的那份)上做清理,开一条专门的分支
git switch -c refactor/order-total
# 2. 逐个代码块决定要不要进这次提交(而不是整个文件一起进)
git add -p
# 3. 出事了整段撤掉,不要手动改回去
git revert <commit>
# 4. 改之前先看一遍自己到底改了什么
git diff --stat
其中 git add -p 值得单独解释:它让你逐个"代码块"决定进不进这次提交,而不是整个文件一起进。为什么清理时特别需要它——因为你经常遇到"同一个文件里,既有你想提交的那个重命名,又有 AI 顺手改的别的东西"。
git revert 和"手动改回去"的区别也值得说清:revert 是生成一次新的提交,把之前那次的效果反向撤销,历史完整可查;手动改回去则是又产生一次你想不起来的修改。回退时选前者。
它要求每次改动后按原则自评,报告当前得分,以及达到 10 分需要做的具体步骤。这是本文所有 skill 里最"可验收"的一个——你不需要判断它做得对不对,你只需要看它给自己打了几分、以及差在哪。
| 分数 | 它描述的状态 | 这说明什么 |
|---|---|---|
| 9–10 | 改动点在动之前就被表征测试覆盖;行为改动和重构拆成两步各自验证;依赖打破用了最小侵入的手法 | 这就是你要的状态 |
| 7–8 | 大部分改动点有测试,但偶尔把重构和行为改动混在一次提交里,或者做了没必要的大手术 | 能用,让它拆开提交 |
| 5–6 | 有一些表征测试,但关键路径还是靠"信"改的;sprout 出来的代码在堆积,却没有偿还计划 | 停下,先补测试 |
| 3–4 | 边祈祷边改 + 手工验证;测试是改完之后补的,断言的是新代码自己的行为 | 这次结果不可信 |
| 0–2 | 在纠缠的代码里做无测试改动;重构和行为改动混在一个提交里;提出"不如重写"而不是去写测试 | 它走偏了,重来 |
每次让它改完,直接问一句:"按这个 skill 的标准,这次改动打几分?差在哪几步?" 分数低于 7 就别合并——让它把差的那几步补上。这比你自己盯着 diff 判断快得多。
它把这个叫 "遗留代码的两难":安全地改需要有测试,但装上测试又需要先改代码。它的解法是把顺序钉死:
你这次到底要动哪一行、哪个分支。越具体越好。
测试点通常和改动点不是同一个地方——因为效果会沿着调用链扩散出去。你要找一个能"观察到这次改动效果"的位置。
把拦住你写测试的东西弄开(下一节讲手法)。这一步的改动必须"保守且机械",而且签名一个字不改——这样编译器会帮你指出所有受影响的地方。
到这一步才开始写表征测试。前面三步都是在为它铺路。
只有到这一步,真正的改动才发生——而且此时此刻它是在安全网里面动的。
它给这个顺序配了一句解释,值得记住:"编程就是一次只做一件事的艺术"——因为每一步都能被单独验证。反过来,行为改动和结构改动混在一个步骤里,出问题时就再也分不清是谁造成的。
如果那块地方今天真的测不了,它的建议不是"那就别写测试了",而是不要把新逻辑编进那坨没测试的东西里:
在一个 400 行的 process() 里加一段滞纳金计算。这段新逻辑没测试,而且它和周围 400 行没有任何边界。
新逻辑写成一个全新的、自带完整测试的小函数,从老代码里用一行调用它。
好处:新代码可以照常测试驱动开发;宿主只改了一行,所以"没被验证的爆炸半径"就一行。
Wrap(包裹)是另一个手法:把老函数改个名收起来(pay() → rawPay()),然后重建一个同名的 pay() 当外壳,在调用前后加新行为(审计日志之类的)。适合有多个调用点、或者那个类已经太臃肿的时候。
它明说:宿主那块老代码仍然没有测试,你只是"在一个坏街区里建了一栋好房子",sprout 出来的东西要记成技术债,下次再碰这块的时候要还。
它还有一条防滥用的提醒:sprout 是改动算法内部的一个战术动作,不是覆盖率的替代品。 你不能一直发芽、永远不给宿主补测试。
按侵入性从小到大:提取接口 · 把构造函数参数化 · 把方法参数化 · 提取并覆写工厂方法/取值方法 · 引入实例委托 · 适配参数 · 把方法拆成独立对象 · 子类化并覆写方法。
它的选择规则是:永远挑"能解开当前问题的最小侵入手法"。 其中两个最常用——
- 把构造函数参数化(它是主力手法):给它加一个参数并提供一个生产环境默认值,现有调用点一个字都不用改就能编译通过。
- 提取接口(它是最安全的动作):因为"加一个接口"这件事本身不可能改变行为。
反过来,它点名了接缝死掉的地方:构造器里干实事、全局变量、静态变量、写死的 I/O。以及一个判断:一个没有"启用点"的接缝是没用的——所谓启用点,就是"在这里决定用哪个实现"的那个位置。
最能说明问题的是"把构造函数参数化"这个最常用的手法。注意改完之后,生产代码的行为一个字没变:
// 改之前:这个函数没法单独测,因为它一跑就去连真数据库
function getOrderTotal(orderId) {
const db = new RealDatabase(process.env.DATABASE_URL)
return db.query('...')
}
// 改之后:多了一个参数,这个参数就是"接缝"
// 测试里可以传一个假的 db,生产代码里走默认值,行为完全不变
function getOrderTotal(orderId, db = defaultDb) {
return db.query('...')
}
它对这个手法的评价是"主力手法",原因就是那句:现有调用点一个字都不用改就能编译通过——所以它属于"保守且机械"的那一类前置改动。
- 效应草图:给每个变量、每个方法画一个气泡,谁影响谁就画一个箭头,一直追到所有"泄漏点"。目的是找到收窄点——那个"少量测试就能覆盖大片行为"的窄口。
- 特征草图:看一个巨大的类里面,方法和字段是怎么聚成几团的——团与团之间通常就是天然的拆分边界。
- 草稿式重构:在一条件上随便重构,目的只是为了看懂,然后整条扔掉。"洞见会留下来,代码不会。"
它的判断是:遗留代码工作的瓶颈是"理解",不是"打字"。
| 问自己 | 如果答案是"否" |
|---|---|
| 测试覆盖了你要改的代码吗? | 你就是在"边祈祷边改"——先跑算法,动之前把行为钉住 |
| 你能在测试里把这个类构造出来吗? | 依赖挡住了隔离——把构造函数参数化、提取接口、或者 sprout 出一个新类 |
| 测试能"感知到"你这次改动的效果吗? | 效果对断言不可见——去找一个可感知的位置 |
| 这次提交是"只有行为"还是"只有结构"? | 混了——拆开,两次提交中间跑一遍测试 |
| 你知道这次改动可能影响到哪些地方吗? | 爆炸半径未知——画效应草图,在收窄点上测 |
| 你的断言写的是"观察到的行为"吗? | 你在测试你的愿望——先探一次,读失败信息,把真实值钉下来 |
| 你选的这个接缝,是可选里最便宜的那个吗? | 做了没必要的手术——优先构造函数参数和 import 接缝 |
| 这次改完之后,这块的覆盖比之前好了吗? | 下次改它的成本和这次一样高——至少在最近的测试点留一个钉住测试 |
| 错误 | 为什么会失败 |
|---|---|
| 重构和行为改动放在一步里做 | 你分不清是哪一次编辑弄坏的 |
| 给老代码写"应该怎样"的测试 | 想象中的规格会大量失败,然后你会去"修"那些其实在承重的行为 |
| 什么都 mock | 测试钉住了实现细节,之后每次重构都会把它弄红 |
| 用大爆炸重写代替增量覆盖 | 旧系统还在动;重写上线晚,还会漏掉边界情况 |
| 做表征测试时顺手把 bug 修了 | 调用方和报表可能正依赖着那个"错"的行为 |
| 测试还不存在就先做侵入式清理 | 手工改动在没有任何网的情况下拿行为冒险 |
| 一直发芽,从不偿还 | 宿主始终没测试,芽会硬化成新的遗留代码 |
| 等一个专门的"补测试项目"立项 | 它永远不会被批;覆盖率永远不出现——改成跟着每一次改动长 |
它的触发词里包含这些原话:legacy code、no tests、untested codebase、how do I test this、seams、characterization tests、golden master、sprout method、afraid to change this code、monster method、inherited a messy codebase。所以你可以直接说:
"这块代码没有测试,我不敢改。用 working-with-legacy-code 的方法带我做:
先告诉我改动点和测试点分别在哪、要打通什么依赖。"
# 它跑完之后,追问这一句:
"按这个 skill 的标准给这次改动打分,并列出达到 10 分还差哪几步。"
一条边界要知道:它明确把"改造已经有测试的代码"路由给另一个 skill(refactoring-patterns),把"日常代码质量"路由给 clean-code。 这是好事——说明它知道自己管哪一段。
三组最容易搞混的词(混了会让整条流程走错方向)
| A | B | 混了会怎样 | |
|---|---|---|---|
| 重构 vs 重写 | 外部可观察行为一个字不变,只改内部结构。小步、可回退、每个中间状态都能发布。 | 丢掉旧的,按新思路再写一遍。中间状态是残缺的,无法发布。 | 选 B 的话,你会把老代码里那些"丑但承重"的历史补丁一起扔掉(§02 的网景案例)。分界线是那一句:"你能不能在每个中间状态都发布?" |
| 删死代码 vs 清理冗余 | 删的是没有任何地方引用的东西。工具能替你判断,风险低。refactor-clean 管这一段。 |
处理的是被引用很多次、而且几乎一样的东西。工具只能帮你找出来,不能替你决定合不合并。风险高。 | 用删死代码的心态去处理重复代码——你会把两段"碰巧长得一样"的代码合了,几个月后它们需求分叉,那个函数就变成一个怪物。顺序是先 A 后 B。 |
| 表征测试 vs 单元测试 | 写的是现状("空订单现在返回 -1")。它红了,说明你把它改坏了,或者现状变了——它不判断这个值对不对。 | 写的是需求("空订单应该返回 0")。它红了,说明代码错了,去修代码。 | 拿表征测试当需求测试看,你会忍不住"顺手把 -1 改成 0"——然后这一次纯重构就变成了一次行为改动,安全带当场失效:测试红了,但这次红不是"你改坏了",是"你真的改了行为",而这两件事看起来一模一样。 |
表征测试红了 → 先假设是自己改坏了 → 查清楚 → 如果确认"我确实想改这个行为",那就把它当成一次独立的功能改动:单独提交、单独工单,并且把那个表征测试改写成真正的单元测试(写明"应该是 0")。
knip(没被用到的导出、文件、依赖,JS/TS 最全)· depcheck(只查没用到的 npm 依赖)· ts-prune(TS 的未用导出)· vulture src/(Python)· deadcode ./...(Go)· cargo +nightly udeps(Rust)。如果一个都没装,它会退化成用 Grep 去找"导出了但没人 import"的东西。
安全档:没人用的工具函数、测试辅助函数、内部函数 → 可以直接删。
小心档:组件、API 路由、中间件 → 先查动态引用和外部使用者。
危险档:配置文件、入口文件、类型定义 → 动之前先搞清谁在用。
① 先跑完整测试套件,建立基线(全绿才继续)→ ② 用编辑工具外科手术式删掉这一个 → ③ 立刻重跑测试 → ④ 失败就 git checkout -- <文件> 当场还原并跳过这一项 → ⑤ 成功就处理下一个。
搜动态引用(import() / require() / __import__)· 搜字符串引用(配置文件里的路由名、组件名)· 判断这个符号是不是通过包的公开接口导出的 · 如果是发布的包,确认没有外部使用者。
找相似度 80% 以上的近似重复函数来合并、合并冗余的类型定义、删掉"没有增加任何价值"的包装函数和无意义的转发导出。
统计删掉了多少(函数 / 文件 / 依赖)、跳过了哪些(并注明原因是"测试失败")、省了多少行,最后一行是测试是否全绿。
第 1 步那些命令,你可以自己先跑一遍看看规模(不改任何东西):
# JS / TS:没被用到的文件、导出、依赖(覆盖面最广,先用它)
npx knip --reporter compact
# 只查没用到的 npm 依赖
npx depcheck
# Python:没被用到的代码
# --min-confidence 是"置信度门槛":调高只报更确定的,调低会有一堆误判
vulture src/ --min-confidence 80
先跑一遍、看一眼数量,再决定要不要让它动手。 如果 SAFE 档有 300 项,别一次跑完——分批,每批之后停下来看一眼 diff。
① 跑测试之前绝不删任何东西。
② 一次只删一个。(原子化的改动才容易回滚。)
③ 不确定就跳过——"宁可留着死代码,也不要搞坏生产"。
④ 清理的时候不要重构——先清理,重构以后再说,两件事分开。
这个 skill 只管"确定没人用的"。它会顺带做一点重复代码的合并(第 5 步),但那是最浅的一层——它不会替你判断"这两段重复该不该合并"。
而 AI 时代最典型的债务恰恰是后一种:那四份 calcTotal 全都在被引用,静态工具对它们完全无感。要处理那一层,得回到 working-with-legacy-code 的流程,一个个判断。
"跑一遍 refactor-clean,只处理安全档的项目,每删一个都跑测试。"
# 想更保守,可以自己加限制:
"跳过所有组件和路由,只删工具函数和测试辅助函数。"
refactor-clean 管不到的那一层:AI 造出来的重复
这是本文开头那个病(calcTotal 有四份)真正的解法,而它不在 refactor-clean 的射程里——那四份代码全都在被引用,静态工具对它们完全无感。
找它们要用另一类工具(克隆检测,即跨文件找出高度相似的代码块):
# jscpd:找出跨文件的高度相似代码块
# --min-lines 20 --min-tokens 50 表示"至少 20 行、50 个词"才算,避免报一堆 import
npx jscpd src/ --min-lines 20 --min-tokens 50 --reporters console
# 在自动流水线(CI,每次提交后自动跑的那套检查)里设一道闸:超 10% 重复率就直接失败
npx jscpd . --threshold 10
关于阈值:常见说法是健康范围在 5% 以下;有项目把闸门设在 3%,也有项目按更严格的 0.5% 要求自己。挑一个,然后在 CI 里卡住它——只有卡住了,趋势才会停下来。
这是整个清理工作里最需要动脑、也最不能交给工具的一步。判断标准是三类:
① 完全一样(字面上一模一样)→ 直接提取成公共函数,没什么好犹豫的。
② 同一件事(写法不同,但表达的是同一条业务规则,比如"满 199 免运费"在三个地方各写了一遍)→ 合并成唯一来源。这是收益最大的一类。但合并前先确认它们现在的行为真的一样——有时候两条规则看着一样,实际差一个四舍五入。
③ 碰巧一样(结构相似但背后是两件不相干的事,比如"用户列表分页"和"商品列表分页")→ 大概率不该合并。 硬合并会造出一个"既要满足用户需求、又要满足商品需求"的函数,以后两边需求一分开,它就变成一个满是 if 的怪物。别把"代码整洁"当教条用。
"这两段代码以后会不会朝不同的方向演变?" 会 → 别合。不会 → 合。
业务规则("满 199 免运费")答案是"不会";两个不同页面的分页逻辑答案是"很可能会"。还有一条叫"三"法则:同样的东西出现一两次先放着(那时你还没足够信息判断属于哪一类),出现第三次再动手。
拿不准就先别合——这是正确答案,不是拖延。 保留一份重复的成本是"以后改两处";错误合并的成本是"造出一个要同时满足两种需求的函数,而且它会越来越难改"。后者更贵。
很多人卡在"我不知道它应该返回什么,所以写不了测试"。这个配方精妙的地方就是它不需要你知道:
写 expect(calcTotal([])).toBe(-999)。对,故意写一个明显不对的数。
测试红了。失败信息会写着"期望 -999,实际收到 -1"。那个 -1 就是现状。
toBe(-1),并写一行注释解释这个值的来历。行为就被钉住了。
不一致就说明测试里有不确定的东西,先解决它。
// order.test.js —— 表征测试:记录的是"现在是这样",不是"应该是这样"
import { describe, it, expect } from 'vitest'
import { calcTotalV2 } from './order.js'
describe('calcTotalV2 —— 现状记录,不是需求', () => {
it('空订单返回 -1(是的,返回 -1,不是 0)', () => {
// 这个 -1 是现状。看起来像 bug,但先钉住,单独开一张单子去修。
expect(calcTotalV2([])).toBe(-1)
})
})
① 连跑两次,两次全绿且结果完全一致。 只要有过一次闪红,先解决那个不确定性再继续。带抖动的测试不是安全带,是噪音源。
② 在你动手改之前,这个测试得先红过一次。 做法:故意改坏一行,看测试有没有反应。没反应说明它根本没测到你关心的东西——很多人写了一个永远绿的测试而不自知。
如果这个函数输出的是一份报表或者一大坨 JSON,没法逐字段断言,就用黄金标准测试(也叫 approval testing、快照测试):第一次跑把完整输出原样存成文件 → 人工看一遍确认"对,这就是现在的样子"(这步不能省)→ 提交进仓库 → 以后每次都和它比。
输出里如果有时间戳、随机 ID、随机排序、本机路径,你钉住的就是噪音——测试会天天红,然后你就会开始习惯性地"更新快照",安全带就作废了。做法:把这些不稳定的部分替换成固定值(时间冻住、随机数种子固定、网络和文件读写挡掉)再存快照。
新手最容易做错的一件事:以为"补测试"就是"给这个模块补一套完整测试"。不是。只覆盖你这次要动的那些分支。
理由很实际:给整个模块补完整测试要花两周,而你的任务是三天内加一个优惠券功能。要两周的测试你根本不会写,于是你会跳过安全带直接上手——那才是最危险的结果。
它的原话是:"直接跳到假设,正是这个 skill 要防止的失败。" 没有能变红的命令,就不许进入第二阶段。
它还写了一句判断:如果你有一个"紧"的反馈回路,你一定会找到原因——二分查找、假设检验、插桩,全都只是在消费这个回路。如果没有,盯着代码看多久都救不了你。
- 在能触及 bug 的接缝上写一个失败的测试(单元 / 集成 / 端到端都行)
- 对跑着的开发服务器写 curl / HTTP 脚本
- 用固定的输入跑一次 CLI,把标准输出和"已知正确"的快照做 diff
- 无头浏览器脚本(Playwright / Puppeteer)驱动界面,断言 DOM / 控制台 / 网络
- 重放一次抓到的请求:把真实的网络请求、载荷或事件日志存到磁盘,单独喂进那条代码路径
- 临时脚手架:起一个最小子系统(一个服务、依赖全 mock),用一次函数调用跑通 bug 路径
- 属性测试 / 模糊测试循环:如果 bug 是"有时输出不对",跑 1000 个随机输入找失败模式
- 二分查找脚手架:bug 是在两个已知状态之间出现的,就自动化"在状态 X 启动、检查、重复",这样你能直接
git bisect run - 差分循环:同一个输入分别走旧版本和新版本,diff 输出
- 人来点的最后手段:如果必须有人点,就写脚本去驱动人,让回路仍然是有结构的
然后它要求你把这个回路当产品打磨:能不能更快?能不能让信号更锋利(断言具体症状,而不是"没崩")?能不能更确定(把时间钉住、把随机数种子固定、隔离文件系统、冻结网络)?——"一个 30 秒的抖动回路比没有回路好不了多少;一个 2 秒的确定性回路是调试超能力。"
| 阶段 | 做什么 | 出口条件 |
|---|---|---|
| 1 建回路 | 上面十种办法里挑一种造出红绿信号 | 能报出一条命令,你已经跑过至少一次(附输出),且它:能对这个 bug 变红 · 确定 · 快(秒级)· 能无人值守跑 |
| 2 复现 + 最小化 | 跑回路,看着它变红;然后把复现缩到"还能红的最小场景"——逐个砍输入、调用方、配置、数据、步骤,每砍一次重跑一次 | 剩下的每一个元素都是承重的:去掉任何一个,回路就变绿 |
| 3 提出假设 | 在测任何一条之前,先写 3–5 条排好序的假设(单条假设会锚死在第一个想法上)。每条必须是可证伪的:写出它的预测——"如果是 X 导致的,那么改 Y 会让 bug 消失" | 写不出预测的,那是感觉不是假设,丢掉或者磨尖它。把排序后的清单给用户看一眼再测 |
| 4 插桩 | 每个探针都必须对应第 3 阶段的一条预测。一次只改一个变量。 优先调试器/REPL(一个断点胜过十条日志),其次是在能区分假设的边界上打定向日志,绝不"全都打日志再 grep" | 每条调试日志打上唯一前缀(如 [DEBUG-a4f2]),清理时一条 grep 就能删干净。性能问题走另一条支线:先建立基线测量,再二分——先测量,后修 |
| 5 修复 + 回归测试 | 先写回归测试,再修——但前提是存在正确的接缝 | 正确接缝 = 测试跑的是真实调用点上真实发生的 bug 模式。如果只有太浅的接缝(bug 需要多个调用方,你只能单调用方测),在那里写回归测试只会给你假的信心。如果没有正确接缝,那本身就是发现——记下来,说明这个架构阻止了这个 bug 被锁死 |
| 6 清理 | 宣布完成之前必须做的 | 原始复现不再复现 · 回归测试通过(或者"没有合适接缝"已被记录)· 所有 [DEBUG-...] 插桩都删了(grep 前缀)· 临时脚手架删掉 · 把最终被证实的那条假设写进提交信息,让下一个调试的人学到东西 |
它明确要求:所有要展示的命令、输出、抓下来的东西,先把密钥替换成 <REDACTED>;把循环建在环境变量上,让凭据留在环境里而不是出现在展示内容里;抓下来的东西可能带认证头,只引用有信号的那几行。如果脱敏之后信息不够诊断,它会说清楚并来问你——而不是悄悄把明文贴出来。
"线上偶发:提交订单时大约 5% 的概率金额算成 0。复现不出来。诊断一下。"
→ 它会先去造能变红的回路,而不是先猜原因
"这两个月这个接口的 P95 从 120ms 涨到 400ms,找不到是哪次改动引入的。"
→ 走性能支线:先建基线测量,再二分
它的原话是:在写任何测试之前,先把要测的接缝写下来,和用户确认。没有确认过的接缝,不写任何测试。
理由很实在:你不可能什么都测,所以把接缝提前商定下来,是让测试精力落在关键路径和复杂逻辑上、而不是每个边界情况上的唯一办法。
它会问你一句:"公开接口是什么?我们应该测哪些接缝?" —— 所以调它之前,先想好这个问题的答案。
| 反模式 | 它长什么样 | 为什么在老项目里特别危险 |
|---|---|---|
| 耦合实现的测试 | mock 了内部协作者、测私有方法、或者从旁路验证(去查数据库而不是用接口) | 识别信号是:你重构了但行为没变,测试却红了。 老项目本来就靠重构往下走,这种测试会持续挡路 |
| 同义反复的断言 | 断言用和代码一样的方式把期望值重算了一遍(expect(add(a,b)).toBe(a+b)、手工按同样算法导出的快照、把一个常量和它自己比) |
它从构造上就永远通过,不可能和代码不一致——等于没测。期望值必须来自独立来源:一个已知正确的字面量、一个算过的例子、或者规格 |
| 横向切片 | 先把所有测试写完,再写所有实现 | 批量测试验证的是想象出来的行为:你测的是东西的形状而不是面向用户的行为,而且你在还没理解实现之前就把测试结构定死了。要按垂直切片走:一个测试 → 一个实现 → 重复,每个测试是一发"曳光弹",根据上一轮学到的东西调整 |
- 先红后绿。 先写失败的测试,然后只写刚好让它通过的代码。不要预判未来的测试,不要加投机性的功能。
- 一次一个切片。 一个接缝、一个测试、一个最小实现。
- 重构不属于这个循环。 它属于评审阶段(那个 skill 叫
code-review),不属于红 → 绿的实现循环。
第三条是这两篇文章的接缝:tdd 管"写新代码",working-with-legacy-code 管"改没测试的旧代码",code-review 管"改完之后"。 三个 skill 的地盘几乎不重叠。
06③ 迭代:四个装闸门的 skill
HEAD 和你指定的某个固定点之间的差异,沿两条轴:标准轴(符不符合这个仓库写下来的编码规范)和规格轴(改的东西是不是真的实现了最初那张工单或规格)。两条轴各跑一个子 agent,并行——目的是让它们不污染彼此的上下文,最后它把两份结果并排摆出来。代码处处符合规范,但实现的是错的东西 → 标准轴过,规格轴挂。
完全按工单做了,但违反了本项目的约定 → 规格轴过,标准轴挂。
它的原话是:分开报告,是为了阻止一条轴掩盖另一条。 所以它在最后汇总时明确要求:不要合并、不要跨轴重排——"那是这套分离机制存在的意义所在",而且它连"选一个总冠军问题"都不做,只给每条轴各自最严重的那一条。
就算你的仓库什么都没写,标准轴也总会带上这份来自 Fowler《重构》第三章的固定清单。两条约束是:仓库写了的规矩永远优先于这份基线(仓库认可某种做法时,就把对应的味道压掉);以及每一条永远是判断题(它写的是"可能的 Feature Envy",不是硬性违规),工具已经能查的项直接跳过。
| 味道 | 它长什么样 | 怎么处理 |
|---|---|---|
| 神秘命名 | 函数、变量或类型的名字看不出它在干什么、装的是什么 | 改名;如果起不出一个诚实的名字,说明设计本身是浑的 |
| 重复代码 | 同一段逻辑形状在改动里出现了不止一处 | 把共有的形状提出来,两边都调它 |
| 依恋情结 | 一个方法访问别的对象的数据比访问自己的还多 | 把这个方法搬到它"羡慕"的那份数据上去 |
| 数据泥团 | 同样那几个字段或参数总是一起出现(一个想诞生的类型) | 把它们打成一个类型,传那个类型 |
| 基本类型偏执 | 用一个基本类型或字符串顶替一个本该有自己的类型的概念 | 给这个概念一个自己的小类型 |
| 重复的 switch | 对同一个类型的同一套 switch 或 if 阶梯在改动里反复出现 | 换成多态,或者两边共用一个映射表 |
| 霰弹式修改 | 一次逻辑改动逼着你在很多文件里零散地改 | 把"总是一起变的东西"聚到一个模块里 |
| 发散式变化 | 一个文件因为好几种不相干的原因被改 | 拆开,让每个模块只因为一个原因变 |
| 夸夸其谈通用性 | 为规格里根本不存在的需求加了抽象、参数或钩子 | 删掉,内联回去,等真实需求出现再说 |
| 过长的消息链 | 调用方依赖了 a.b().c().d() 这种长链 | 把这个走法藏到第一个对象的一个方法里 |
| 中间人 | 一个类或函数大部分时间只是在往下转发 | 砍掉它,直接调真正的目标 |
| 被拒绝的遗赠 | 子类忽略了或者覆写掉了它继承来的大部分东西 | 放弃继承,改用组合 |
/code-review # 它会问你要固定点
"review since main" # 对比 main 的 merge-base
"review since HEAD~5"
"review since a3f9c21" # 某个具体 commit
它内部用的是三点语法 git diff <固定点>...HEAD——对比的是合并基点,这样不会把主干上的无关变化算进来。而且它会先确认固定点能解析、diff 不为空,再开两个子 agent——"一个错误的引用或空 diff 应该在这里就失败,而不是在两个并行子 agent 里面失败"。
它按这个顺序找:① 提交信息里的工单引用(#123、Closes #45)→ ② 你作为参数传进去的路径 → ③ docs/、specs/、.scratch/ 下和分支名匹配的文件 → ④ 找不到就问你。
如果你说"没有规格",它会跳过规格轴并报告"没有可用的规格"。 所以想让两条轴都跑起来,先确保你这个改动是从某张工单出发的——这本身就是个值得养成的习惯。
domain-modeling 产出的那些文件)来加权判断。codebase-design)| 词 | 它到底指什么 | 为什么这个词而不是别的 |
|---|---|---|
| 模块 | 任何有"接口"和"实现"的东西。刻意不分规模:一个函数、一个类、一个包,或者一个横跨多层的切片都算 | 不要用"组件""服务""单元"来替代——统一用词是这套词汇存在的全部意义 |
| 接口 | 调用方为了正确使用这个模块所必须知道的一切:类型签名,以及不变量、调用顺序约束、错误模式、必需的配置、性能特征 | 不要用"API"或"签名"替代——它们太窄,只指类型层面的表面 |
| 深度 | 接口上的杠杆:调用方(或测试)每学会一单位接口,能驱动多少行为。深 = 大量行为藏在小接口后面;浅 = 接口几乎和实现一样复杂 | 这是判断"这块该不该重构"的核心指标 |
| 接缝 | (来自 Feathers)一个你能不修改那个地方就改变行为的位置;也就是模块接口所坐落的位置 | 不要用"边界"替代——那个词在领域驱动设计里已经被"限界上下文"占用了。另外:接缝放在哪是一个独立的设计决定,和"接缝后面装什么"是两件事 |
| 杠杆 / 本地性 | 杠杆是调用方从深度里得到的东西(一份实现回本到 N 个调用点和 M 个测试);本地性是维护者得到的(改动、bug、知识、验证都集中在一处,而不是散到各个调用方——修一次,处处修好) | 这两个词分别是"给调用方的好处"和"给维护者的好处" |
- 探索——扫代码库,找架构摩擦点。
- 把候选摆成一份可视化 HTML 报告——让你能看,而不是读一长串文字。
- 拷问循环——你挑一个,它就带你把它问穿。
注意它给的是"候选清单和排序依据",不是命令。 而且它有一个前置条件:disable-model-invocation: true,也就是说 AI 不会自己启动它,你必须敲名字。
不是第一次接项目的时候——那时候你连哪里疼都不知道。正确的时机是:你已经走通了三四条路,手里有测试、有地图、有业务文档,然后问"下一步该系统性地挖哪里"。
一个搭配用法:先跑 /improve-codebase-architecture 拿到候选清单,把它挑中的那一条交给 working-with-legacy-code 去实际执行——一个负责"挖哪里",一个负责"怎么安全地挖"。
因为老项目清理最大的失败模式是"一次做太多"——一个几百文件的 diff,没人能审,你自己也不敢合。这两个 skill 把"一次做太多"变成了结构性的不可能:每张票是一个垂直切片,能单独交付、单独撤销。
而它们产出的票据长这样(这是它自带的模板):
# <NN>: <票据标题>
## Parent # 属于哪张父票
## What to build # 要做出什么
## Acceptance criteria # 验收标准
## Blocked by # 被哪几张票阻塞
"Blocked by"这一栏是老项目里最有价值的部分——它把"先补测试才能改这块"这种依赖关系显式写下来了,而不是留在你脑子里。
# 先聊清楚要做什么(可以先用 improve-codebase-architecture 挑出目标)
/to-spec # 综合刚才聊的,出一份规格
/to-tickets # 把规格拆成带阻塞关系的票据
两个 skill 都需要一个前置配置:问题追踪系统的接入(它们的 SKILL.md 里写着,如果 docs/agents/issue-tracker.md 不存在,会让你先跑一个初始化命令)。也就是说它们需要知道你用什么记任务——GitHub Issues、Linear、还是别的。本地也支持:每个票据一个文件、阻塞关系写成文字。
这两个(以及下面的 triage、上面的 improve-codebase-architecture)都带 disable-model-invocation: true。它们的共同点是"会往你的任务系统里写东西"——让 AI 自己决定往你的看板里写票据,风险太大,所以做成了必须你亲手敲。
这也是本文开头那条判断标准的由来:看它默认会不会动你的东西。 会动的那些,全部需要你显式调用。
它的原话:"一个 PR 就是一个附带了代码的 issue"——用同一套角色、同一套状态、同一台状态机,只有少数几处标了"对 PR 而言"的差异。
/triage # 先摆出"有哪些需要关注"
/triage #42 # 分诊具体某一个(issue 或 PR 都行)
它内置了一份"信息不足"的模板,所以它不会硬猜——缺信息时会照模板去问。它还支持恢复上一次的分诊会话。
老项目里它最有用的一步是"写成 agent-ready 的简报":把一个含混的 issue 变成一份 AI 能直接执行的说明。这正好接上第 5 节那个"四要素任务模板"——分诊负责把话说清楚,working-with-legacy-code 负责安全地做。
前面两个阶段都在解决"敢不敢改"。这一阶段解决的是另一个问题:改完了,你怎么知道这次可以合并?
答案不是"我看了 diff 觉得还行",而是几条互相独立的轴各出一份结论:规范轴、规格轴、安全轴(security-review)、测试轴。它们不能互相替代,也不能被合并成一句话——因为一个改动完全可以"规范全过但做错了事",也可以"做对了事但处处违规"。
07串起来:接手一个 AI 堆出来的订单后台,第一周
把上面的 skill 串成一条能照着抄的时间线。场景:一个 Node + TypeScript 的电商后台,30 多个文件,半年里由三个人(和他们各自的 AI)你一句我一句堆出来。没有测试,README 停在半年前的目录结构。
Day 1 · 只跑一个 skill,不改任何东西
/codebase-onboarding
→ 它会先并行扫六类配置,再追踪一条请求,最后给你导览 + 一份 CLAUDE.md 草稿
你的验收动作:打开它生成的 CLAUDE.md,数一下行数。超过 100 行,说明它在往里塞代码里本来就看得见的东西——删掉那些,只留"AI 猜不到而且会影响操作"的(用哪个包管理器、测试要带什么参数、legacy/ 不能删)。然后从它的导览里挑 3 条结论去核对。
Day 2 · 把"业务到底在干什么"倒推出来
"帮我写 HLD(概要设计),范围限定在订单和支付两个模块"
→ code-to-doc-generator 进模式 A:四轮走完,产出概要设计
/domain-modeling
→ 一边看文档一边把术语定下来,写进 CONTEXT.md
注意:code-to-doc-generator 有一条硬规则——文档类型必须你明确说出来,否则它会停下来问你。所以开口就把类型说清楚。
这一天你大概率会发现组织里对"订单""支付""退款"这几个词的理解并不一致。把它们当场定下来写进 CONTEXT.md——这就是 domain-modeling 的用法。如果在这个过程中你问出了"当年为什么用双写不用事务",立刻让它按三个条件判断要不要落成 ADR。
Day 3 · 装安全带,只覆盖你要动的那一小块
先人工看一眼,找到你要改的那个点。比如:算价逻辑在 order.ts 里有三份,calcTotalV2 才是线上在用的。
"这块代码没有测试,我不敢改。用 working-with-legacy-code 的方法带我做:
先告诉我改动点和测试点分别在哪、要打通什么依赖。"
# 或者只需要安全带、不需要完整算法时:
/characterization-test-generator
→ 只生成捕获当前行为的测试
两个硬指标:连跑两次结果完全一致;动手之前它得先红过一次(故意改坏一行验证)。
Day 4 · 让 skill 给自己打分,然后才动手
"按这个 skill 的标准给这次改动打分,并列出达到 10 分还差哪几步。"
→ 分数低于 7 就别往下走,先把差的几步补上
然后按它的五步算法走:定位改动点 → 找测试点 → 打通依赖 → 写测试 → 才做改动。如果那块地方今天真的测不了,用它给的 Sprout:新逻辑写成一个全新的、自带测试的小函数,从老代码里用一行调用它。
四次提交,每次只做一件事;每次之间跑一次测试,绿了就提交,红了就 git revert:
# 提交 1:只加测试文件,零行为改动
git add src/services/order.test.ts
git commit -m "test(order): 为 calcTotalV2 补表征测试"
# 提交 2:只改名和加注释,零行为改动
git commit -m "refactor(order): calcTotal 改名 legacyCalcTotal 并标注废弃"
# 提交 3:纯结构重构(提取函数),表征测试必须保持绿色
git commit -m "refactor(order): 提取 roundToCent,行为零变化"
# 提交 4:这才是功能。前面三次提交里一行功能都不夹带
git commit -m "feat(order): 支持优惠券叠加"
Day 5 · 删死代码,然后过闸门
"跑一遍 refactor-clean,只处理安全档的项目,每删一个都跑测试。"
→ 一次只删一个,红了当场 git checkout -- 还原并跳过
"review since main"
→ code-review 两轴并行审,标准轴 + 规格轴各出一份,不许合并
如果你的清理动了鉴权、输入校验、权限判断、外部请求这几类地方,再加一条 security-review——老项目里这几块的历史写法经常是不安全的,而你在"整理"它的时候很容易整理成另一种不安全。
你没有"清理完这个项目"。项目里还有两份重复的算价逻辑,还有一堆没人引用的工具函数。
你清理的是你走过的那一条路。 这正是它能安全的原因,也是它必须持续做的原因——下一周你走另一条路,再清一块。而 improve-codebase-architecture 就是用来回答"下一块该走哪条路"的。
08出问题怎么办
这一节是给你"遇到问题回来翻"的。左边找症状,右边拿对策。
| 症状 | 大概率的原因 | 怎么办 |
|---|---|---|
| skill 没被触发,AI 根本没理它 | 你的说法没命中它的 description;或者它带 disable-model-invocation: true(永远不会自动触发) |
直接敲名字,或者自然语言点名:"用 working-with-legacy-code 那个 skill 来改这块" |
| 装了两个同名 skill,行为对不上 | 同名往往有好几份不同实现,严格程度不一样 | 读一遍各自的 SKILL.md,留一份、删另一份。别让两份都装着 |
生成的 CLAUDE.md 有好几百行 |
它在把"读代码就能知道"的东西也写进去了 | 按那条判断法砍:"AI 读代码能不能自己看出来?能,就删掉。" 留 100 行以内 |
| 它跑完改完之后,diff 里有你没让它碰的文件 | 它跟着 import 走进了共享工具文件,或者"顺手"认为那里也该改 | 单独撤掉那些文件;把禁区写进 CLAUDE.md;任务里加一条"先列文件等我确认" |
| 它说"已修复",但没人说得清改了什么 | 它把"跑通了"当成了"改对了" | 要求它每次给 git diff --stat 和测试结果。只看"跑没跑通"不够 |
refactor-clean 删完之后测试红了一片,回滚很痛苦 |
它一次删了多个,或者基线本来就不是绿的 | 它自己的规则里就有解法:先跑基线(并记下清理前哪些测试本来就是红的)、一次只删一个、失败立刻还原并跳过。如果它没照做,把这三条明确写进你的指令里 |
| 表征测试写成了"应该返回 0" | 把现状和需求搞混了 | 回到它最初的使命:先探一次,读失败信息,看它真的返回什么。想改行为就单独开一张单子 |
| 测试第一次红、第二次绿 | 里面有不确定的东西:时间、随机数、网络、文件遍历顺序 | 先把这些替换掉或冻住,直到连跑两次完全一致。带抖动的测试不能当安全带 |
code-review 只出了一份报告,规格轴不见了 |
它没找到规格,跳过了那条轴 | 它的 SKILL.md 明确说会跳过并报"没有可用的规格"。想让两条轴都跑,先让改动从一张工单出发——或者用 /to-spec 补一份 |
to-tickets 说缺少配置 |
它需要知道你的任务追踪系统接在哪 | 按它的提示先跑一次初始化。它支持真实的追踪系统,也支持"每张票一个本地文件" |
| 装上 skill 之后 AI 反而变啰嗦、成本变高 | 上下文文件太长,agent 会过于勤勉地执行每一条指令——指令越多,它跑的测试越多、烧的推理越多 | 砍。一份 2026 年 2 月的研究(ETH Zurich 与 LogicStar.ai 的 Gloaguen 等人)测了四个 agent,发现自动生成的上下文文件平均让任务成功率降低约 3%、成本抬高 20% 以上;开发者手写的略好(约 +4%),但成本也涨了最多 19% |
| 对话聊得越久,AI 越开始做奇怪的事 | 上下文被自动压缩后发生了任务漂移 | 一次只做一件事,做完开新会话。把结论落进 CONTEXT.md / CLAUDE.md,别让它依赖对话记忆 |
上面那条"diff 里有你没让它碰的文件"不是个别现象,是反复出现的模式。几个公开可查的例子:
① 越界改文件。 Anthropic 的 claude-code 仓库里有一个 issue(#54240):用户只要求"给这个按钮包一层判断",agent 除了做这件事,还重构了周边代码,并且加了一条"kiosk 不应该锁定"的行为——那是从没被要求过的。用户追问时,它说不清为什么改。
② 三种反复出现的越界形状(Cursor 社区归纳,可以直接拿它当检查清单):在共享工具文件上"顺手帮忙清理"——跟着一个 import 走进某个被很多地方依赖的工具文件然后开始整理它;找错文件——改了 /v1/ 或 /deprecated/ 那个版本而不是当前在用的(老项目里这种目录特别多);级联重构幻觉——一个类型报错让它觉得"接口定义不对",改了接口,然后为了让类型对上把每个实现文件都改了,一个文件的补丁变成 20 个文件的架构级改动。
③ 长对话的任务漂移。 OpenAI 的 codex 仓库有一个 issue(#11315):对话变长后系统自动压缩上下文,压缩之后助手"回到了"更早的一个旧任务上,执行了不相关的命令。
把这三件事归纳成一句:agent 分不清"完成你要求的任务"和"因为它发现了更好的做法、所以重塑这个项目",而且它停不下来——因为在它看来,继续做看起来就是进展。一个有经验的维护者知道什么时候不写代码,目前的 agent 还没强到这个程度。
disable-model-invocation: true 的那批(to-spec、to-tickets、triage、improve-codebase-architecture、wayfinder)本来就不会自动跑;二是会改你代码的那批,自动触发的时机不由你控制。推荐的做法是显式调用:你自己决定现在处于哪个阶段,然后敲那个阶段该用的 skill。allowed-tools 这类字段限制自己能调哪些工具;③ 先在一个不那么要紧的仓库上试一次,看它实际改了什么;④ 老项目上优先用"只读不写"的那一批(codebase-onboarding、code-to-doc-generator),它们跑完你一行代码都没变。codebase-onboarding(拿地图)+ working-with-legacy-code(安全地改)+ 你机器上已经有的 code-review(合并前过闸)。这三件覆盖了"看不懂 → 不敢改 → 改完不敢合"这条主线,剩下的都是按需补。codebase-onboarding 那份导览、code-to-doc-generator 那份业务文档,都可能在某个细节上错了。方法很简单:从它的结论里挑 3 条去核对。区别只是这里的成本更高——如果 CLAUDE.md 是错的,接下来每一个会话都会带着这个错往下走。09什么时候 skill 不管用
一个只会说"装上就好"的方法是没用的。这一节讲边界。
| 你现在的情况 | 为什么 skill 帮不上 | 该做什么 |
|---|---|---|
| 正在赶上线,需求天天变 | 你会在最不能承受风险的时候引入风险;而且需求还在变,你整理出来的结构下个月又要改 | 先打补丁把需求满足。清理排到上线之后 |
| 这块代码马上要死了(产品下线、要被替换) | 清理它的回报是零。你花一周整理的代码,两个月后被删掉 | 什么都不做。把精力放到会活下来的那部分 |
| 没有测试,也没有任何人能跑起来这个项目 | 所有"修复类" skill 的前提都是能跑一次测试。跑不起来,红绿关卡就建不起来 | 先解决"能跑起来"。这件事 codebase-onboarding 会帮你定位(它会明确报出缺哪个环境变量、依赖哪个外部服务),但它不修 |
| 你连"这段代码为什么长这样"都不知道 | 对业务理解不够、系统边界不清楚的时候动手,那不叫技术进步,那叫赌博。skill 能保证"改不坏",但保证不了"改对" | 先搞清楚业务,或者去找当年写它的人聊。domain-modeling 可以帮你把问出来的东西固定住,但问本身要你去做 |
| 改动影响面很大(涉及钱、用户数据、合规) | 爆炸半径大于收益。一旦出事,收益远远抵不上 | 先把安全带建起来,或者干脆不动。可以的话先灰度(先让一小部分流量走新逻辑) |
| 想用 skill 替代"想清楚要做什么" | 本文所有 skill 都假设你知道目标是什么。to-spec 甚至明确写了"不采访用户,只综合你已经知道的"——你脑子里没有,它就综合不出来 |
自己先把"要做成什么样"想清楚。这件事没有 skill 能替 |
| 团队里其他人不用 AI 工具 | CLAUDE.md、CONTEXT.md、ADR 这些文件对他们是额外的、而且要维护的东西 |
这些文件本来就是对人也对 AI 的。但如果你判断团队接不住,就先放个人级目录(~/.claude/skills/)里,别往仓库塞 |
这些 skill 里,最核心的那一条纪律是 Feathers 二十多年前总结的:先覆盖,再修改;一次只做一件事;重构和行为改动分开。 skill 做的是把这条纪律的执行成本降到可以持续做下去——它没有让纪律本身变得不必要。
装了 skill 之后,分工没有变:AI 提供加速,不提供兜底。 方向判断、范围界定、质量兜底这三件事始终是人做的。skill 能做的是让"每次都要重讲一遍的纪律"不再依赖你的记忆力。
10术语表
| 术语 | 它是什么 | 为什么重要 / 什么时候用 · 别当什么用 |
|---|---|---|
| skill | 一份叫 SKILL.md 的说明书,放在 .claude/skills/ 或 ~/.claude/skills/ 下,AI 按里面的步骤干活 |
别当插件或程序用——它本身不执行任何东西,它是"指示 AI 去执行什么"。所以判断它值不值得装,看它的步骤有没有关卡 |
| frontmatter | 文件开头用三条短横线包起来的元信息区块,其中 description 决定 AI 什么时候启动它 |
为什么重要:一个 description 写得好的 skill,你不用记名字,说对话它自己就出来了。disable-model-invocation: true 也写在这里——意思是只有你敲名字它才跑 |
| 表征测试 characterization test |
把代码当前的行为记录下来,不判断它对不对 | 何时用:你要动一块没测试的老代码,而且不知道"正确"应该是什么。别当单元测试用——它红了只说明"现状变了",不说明"谁错了" |
| 黄金标准测试 golden master / 快照 |
把完整输出存成一个文件,以后每次跑都和它比对 | 何时用:输出是一整份报表或一大坨 JSON、没法逐字段断言时。别当万能药——存快照之前必须清掉时间戳和随机 ID 这类噪音 |
| 接缝 seam |
一个"不修改那个地方、就能改变那里行为"的位置;也是模块接口坐落的地方 | 为什么重要:它是让老代码变得可测的唯一手段。别当设计模式用——用能解开当前问题的最小的那个。另外:接缝放在哪是一个独立的设计决定,和接缝后面装什么是两件事 |
| 启用点 enabling point |
接缝上"决定用哪个实现"的那个位置 | 为什么重要:一个没有启用点的接缝是没用的——你能改变它,但没有地方去下这个决定。这是判断"这个接缝能不能用"的最后一问 |
| Sprout / Wrap | Sprout(发芽):新逻辑写成全新的、自带测试的函数,从老代码一行调用它。Wrap(包裹):老函数改个名收起来,重建一个同名外壳在前后加行为 | 何时用:宿主今天真的测不了,但你要加功能。别当覆盖率的替代品——宿主仍然没测试,sprout 出来的东西要记成技术债 |
| 反馈回路 feedback loop |
一个能跑出红绿的命令,它跑在真实的 bug 路径上、断言的是用户描述的那个具体症状 | 为什么重要:diagnosing-bugs 的第一阶段就是造它,而且它说"其余都是机械劳动"。别当"能跑起来就算"——它必须能对这个 bug 变红,断言"没报错"不算 |
| 收窄点 pinch point |
效应草图上"收窄"的地方——在这个位置写少量测试,就能覆盖上游一大片行为 | 何时用:你要给一个巨型方法或上帝类补测试,但不可能全覆盖。怎么找:画效应草图(每个变量/方法一个气泡,"谁影响谁"画箭头),找那个窄口 |
| 死代码 dead code |
没有任何地方引用它的代码 | 何时用:清理的第一批目标,便宜、快、立刻缩小范围。别当"静态工具报出来的都能删"——字符串拼出来的动态引用工具看不到 |
| 深 / 浅模块 | 深 = 大量行为藏在小接口后面;浅 = 接口几乎和实现一样复杂 | 为什么重要:这是判断"这块该不该重构"的核心指标。注意:这套词汇里用"模块"指任何有接口和实现的东西(函数、类、包都算),不要换成"组件""服务"——统一用词是这个概念存在的意义 |
| ADR 架构决策记录 |
"当初为什么这么选"的短文档,放在 docs/adr/ 下 |
何时用:三个条件同时成立才写——难以反悔 · 没有背景会让人困惑 · 是真实取舍的结果。别当日记写:缺任何一条就别建,ADR 一多就没人看了 |
| CONTEXT.md | 这个项目里每个词是什么意思的术语表 | 为什么重要:老项目里同一个词两个人理解不一样,是最贵的沟通成本。别当规格或草稿本——它应该完全不含实现细节 |
| 曳光弹 / 垂直切片 tracer bullet |
一个测试 → 一个实现 → 重复,每一轮都根据上一轮学到的东西调整 | 别当"先把所有测试写完再写实现"(那是横向切片)——批量测试验证的是想象出来的行为,而且你在理解实现之前就把测试结构定死了 |
99一句话总结
用 skill 维护老项目,就是按三个阶段各调一批:排查用只读的(codebase-onboarding 拿地图、code-to-doc-generator 倒推业务、domain-modeling 固定术语),修复用带红绿关卡的(working-with-legacy-code 先覆盖再改并且会给自己打分、refactor-clean 一次只删一个、characterization-test-generator 钉住现状、diagnosing-bugs 先造反馈回路),迭代用装闸门的(code-review 两轴并行审、to-spec/to-tickets 把大清理拆成能单独撤销的小票)——而判断一个 skill 值不值得信任,永远只看两处:它有没有可验证的关卡,它默认会不会动你的代码。
留给你的最后一件事
这篇文章里所有判断里,只有一条你没法在纸上验证——请拿你自己的项目做一次:
① 先看看你手上有什么。 跑一行 ls ~/.claude/skills/,对照 §03 末尾那张分类表,看看本文提到的哪些你已经有了。大概率 code-review、diagnosing-bugs、tdd 已经在里面了。
② 只装一个。 装 codebase-onboarding,挑一个你已经很熟的项目跑一遍(用熟项目,这样你能一眼看出它的结论对不对)。重点看两件事:它生成的 CLAUDE.md 有几行、它给的结论有哪几条是错的。
③ 挑一个最长的函数,用 characterization-test-generator 给它补一个表征测试,然后故意改坏一行,确认它真的会红。
做完这三步,你手上就有了第一个可以安全改动的角落。这就是整篇文章的全部——只是你要再来一次,在下一个角落。