减法审计 · Subtraction Audit
一次 AI 对话的历史就是一部腐化史:AI 写代码太容易、太膨胀、太容易发散,人写机器写都一样,代码最终会腐化。这套方法把"防腐化"变成可重复的流程:证据驱动的减法审计 + 风险分级执行 + 机器门禁。
核心原则(先读,永远不要跳过)
- 门禁 > 散文:约定必须变成可执行检查(退出码非零即失败),否则 Agent 不会遵守。AI 对强制门禁的遵从远高于对散文式约定的遵从。
- 测试不是真理:测试钉住的是当前行为,不是正确行为——行为可能是历史妥协的产物。测试必须能为你报告的机制而失败,否则它在撒谎。
- 删除是一等公民:简化是常规工作流,不是大扫除。每月的删除行数为零 = 腐化警报。
- 决策必须写下来:每条非平凡删除配一条决策记录(Problem / 证据 / 代价 / 回退条件)。
- 证据先行:没有消费证据的候选一律不报。"看起来没用"不是证据,rg 才是。
何时使用
- 用户想"清理一下仓库"、"找找死代码"、"这些能不能删"
- 用户担心 AI 写的代码膨胀/发散/不可维护
- 用户想给仓库(尤其是 AI 主力开发的仓库)搭建质量门禁
工作流
Phase 0 · 先懂架构(最重要,最容易翻车)
在声称任何东西"死"之前,先回答这些——跳过这步等于把报告建立在地基上:
- 入口在哪?路由/端点如何挂载?(静态 import / 动态 require / 框架自动发现如 Next.js pages / 字符串路径)
- 有没有同名活文件?(例:
routes/agent/feedback.ts疑似死,但routes/feedback.ts是活的并挂在/api/feedback——两个不同文件) - 构建/部署怎么描述它?(docker-compose、deploy manifest、runbook、README 声称 vs 实际)
- 有没有看起来像"占位/骨架"但其实是生产核心的东西?(小包可能是队列消费者、worker、调度器)
- 环境变量/配置旋钮有没有被外部引用(.env.example、部署平台 env、容器编排)
- 数据库表 / 队列 / 事件名有没有被字符串引用
反例教训(真实事故):一个 101 行的"worker 骨架"其实是生产必需的 DB 队列消费者(轮询 generation_tasks 表)。部署清单里没有它 ≠ 它可有可无——那可能是配置漂移,甚至意味着生产队列已停摆。别把"部署配置里没有"当成"不需要"。
Phase 1 · 基线测量
- 死代码基线:
npx knip --reporter json(未使用导出 / 类型 / 文件 / 依赖 / 重复实现 / 二进制) - 仓库卫生:误提交文件(提交消息与内容不符的)、
.gitignore覆盖、.env是否被跟踪(安全项,必须查)、被提交的大产物/工具输出、空目录、死门禁(脚本存在但没接 CI/钩子) - CI/钩子盘点:
.github/、husky pre-commit 实际跑了什么;lint/test/typecheck 有没有任何自动流程执行。零 CI 是腐化积累的根因,要单独列成系统性问题 - 记录基线数字——之后每次清理都应下降,只许降不许升
Phase 2 · 分域并行调查
按域派并行子代理(api / web / worker / shared / 根与脚本),每个域用同一套方法论提示词:
- 调查类别:① 死代码(零生产引用,仅测试引用也算,标注证据)② 重复表示(同一事实两份镜像:两个对象镜像同一批字段、手写类型与生成类型并存)③ 手搓 vs 成熟依赖(协议解析/重试/glob/diff/节流——npm 成熟包或 Node 内置可替代)④ 投机性泛化(没 owner 的配置旋钮、占位服务、注释掉的大块、只被测试保护来维护未用 API)⑤ 死依赖(package.json 有、源码零 import)
- 铁律:每个候选先全仓库搜消费方,区分生产 / 测试 / 文档三类消费;无证据不报;末尾必须给"有意排除"清单(已核实为活/有意保留的)
Phase 3 · 高危复核(删除前必做)
对每个候选追加复核,尤其是 api/web 路由与公共导出:
- 全仓库 rg,含动态引用与字符串路径(
import.meta.glob、require(、.route("字符串")) - 同名活文件检查(见 Phase 0 教训)
- 框架自动发现检查(Next.js pages/API routes 会被框架自动注册——knip 在这里会误报;确认应用用的是框架路由还是 React Router 等显式路由)
- 部署/文档引用检查(deploy manifest、runbook、env)
- 测试是否钉死了它不存在(如
doesNotMatch(/<Component/)断言)——那是删除的助攻证据
Phase 4 · 风险分级
| 级别 | 含义 | 处置 |
|---|---|---|
| 🟢 绿 | 证据闭环(生产+测试+文档零消费,无同名/动态/框架引用) | 可放心删 |
| 🟡 黄 | 删前确认(是否存在运行时 parse?本地实现是否自持一份?合并后是否要冒烟?) | 先查再删 |
| 🔴 红 | 需产品决策(两条实现路径是否都活着?生产依赖哪个?部署是否受影响?) | 单独立项,问人 |
Phase 5 · 分批次执行
- 每批 = 一类候选(卫生 → 死文件 → 死导出 → 重复合并 → 死依赖),不要混批
- 每批配一条决策记录(
DECISIONS.md:Problem / 证据 / 代价 / 回退条件) - 每批后跑验证:
tsc --noEmit(全包,不是只查一半)+ lint + knip 基线不升 - 涉及生产组件的改动(队列消费者、worker、部署相关、env 旋钮)改后必须跑冒烟测试
Phase 6 · 系统修复(比删代码重要)
- 零 CI 是根因:建最小 CI——lint + test + typecheck 全包 + knip 基线,失败即红
- 钩子补齐:pre-commit 跑 lint/typecheck(staged 范围)
- 把"删除行数"放进月度复盘指标:为零就是警报
交付物模板(报告结构)
- 腐化存量基线(knip 数字)
- 系统性问题(CI/门禁/部署漂移——单独列,比删代码重要)
- P0 卫生(误提交/产物/ignore)→ P1 死文件 → P2 死导出 → P3 重复实现 → P4 死状态 → 死依赖
- 安全专项(.env 是否入库、密钥扫描)
- 有意排除清单(已核实为活的)
- 分风险执行计划 + 必须由产品回答的问题清单
常见陷阱
- 把"部署清单里没有"当成"不需要"——可能是配置漂移或生产停摆(worker 教训)
- 被 knip 误报带偏——框架自动发现、动态 require、字符串引用都会造成误报,必须人工复核
- 只见树木——只删明显死符号,漏掉文件级重复生命周期/防御机制最重的地方(检查:两个文件实现同一份 withRetry、两套并行组件树)
- 一次全删不验证——必须分批 + 每批验证基线
- 不区分生产/测试/文档消费——"只有测试引用"也是死代码,但要标注证据
- 不问产品问题就删 🔴 级候选
配套模板
子代理分域提示词骨架(Phase 2 用):
你是代码简化审计员。审计目标:<路径>(<规模>)。只读,绝不修改文件、不跑构建。
方法:只报有证据的候选。每个候选先在**整个仓库**用 rg 搜消费方,区分"生产代码消费" vs "只有测试/文档消费"。没有消费证据的一律不报。
重点找:①死代码 ②重复表示 ③手搓 vs 成熟依赖 ④投机性泛化 ⑤死依赖。
产出:Markdown 表格,按置信度排序,最多 15 条。每列:候选(文件:行+符号)| 证据(rg 搜了什么)| 建议动作 | 置信度(高/中/低)。末尾列"有意排除的"。中文输出。
决策记录模板(Phase 5 用):
# DECISION: <删除/合并/降级> <对象>
## Problem
<当前 API/文件/依赖是什么,消费证据(生产 N 处 / 仅测试 / 仅文档)>
## 证据
<rg 命令与结果;同名/动态/框架引用检查结果>
## 代价 / 我们放弃什么
<最强的反对理由;未来可能需要的场景>
## 回退条件
<什么情况下要恢复>
## 验证
<tsc / lint / knip 基线对比;冒烟范围>
参考来源
- 方法学源自 DeepSeek Harness:
dsh-find-simplificationsskill、AGENTS.md 约定、144 条简化类 Agent Notes、质量门禁记录、postmortem 0002/0003(测试不是真理) - 实战案例:berryon 减法审计(4 域并行 + knip 基线 + 风险分级),教训已内嵌于本文