大型项目里的代码很少是一次性长出来的。一个功能开关判断、一个灰度开关、一个历史功能 flag,最开始可能只是为了控制一条业务路径。时间久了,它会散落到页面、服务、工具类、埋点、资源加载和兜底逻辑里。
等这个判断终于确定下来,比如永远走国内逻辑,或者永远关闭某条旧链路,源码并不会自动变干净。if (false) 只是最表层的问题,更麻烦的是它后面还会拖着一串 helper、空方法、恒返回方法、无意义的回调和废弃分支。
手动清理当然可以,但在几百万行代码里做这件事,很容易变成一场漫长的找茬游戏。IDE 能提示一部分,编译器和构建期代码收缩与优化工具,例如 Android 的 R8,也能在产物层面消掉一部分,但源码里的复杂度仍然留在那里。
所以写了一个工具来专门处理这类场景:dead-code-pruner。
这个工具已经在一个约 100 万行代码规模的大型 Android 项目中完成过全量清理,结果如下:
| 指标 | 结果 |
|---|---|
| 完整清理耗时 | 约 10 分钟 |
| 修改文件 | 2300+ |
| 净删除代码 | 40000+ 行 |
| 编译结果 | 首次通过 |
清理解决的不是几行 if
死代码清理的收益,不只是少几行代码。
第一是主路径会变短。旧分支留在代码里,读代码的人必须不断判断“这里现在还会走吗”。当判断本身已经没有业务意义时,这种理解成本就是纯噪音。
第二是后续重构会更稳。废弃逻辑经常引用旧模型、旧接口、旧资源和旧埋点。只要它们还在,重构时就要额外考虑一堆其实不会运行的路径。
第三是代码搜索和 code review 会更干净。搜索一个方法名时,结果里混着大量历史分支,很容易误判影响面。review 里看到一段旧分支,也会浪费精力确认它是不是还有效。
还有一个现在越来越明显的收益:死代码减少后,AI Agent 理解项目时面对的噪音也会更少。
AI Agent 需要从文件、引用、调用链和局部上下文中判断代码意图。无效代码越多,检索结果中混入的历史路径就越多:
- 旧分支会占用上下文窗口,让真正相关的代码更晚出现,甚至无法进入上下文
- 废弃 helper 会让 AI 误以为仍有另一套业务路径需要兼容
- 重复但已经失效的判断会增加不必要的实现分支
- 调用链中混入不会运行的节点,会干扰 bug 定位和逻辑补全
清理死代码不能保证 AI 一定写对代码,但可以减少它理解项目时需要过滤的无效信息。
方案思考
| 方案 | 适合场景 | 问题 |
|---|---|---|
| 手动清理 | 小范围、强业务语义 | 成本高,不可重复,容易漏掉级联代码 |
| IDE Inspection | 单文件、简单 if(true/false) | 对跨文件引用、恒返回方法和级联删除不够用 |
| 编译器 / 构建期代码收缩与优化工具(如 Android R8) | 优化最终产物 | 源码复杂度仍然存在,review 和维护成本不变 |
| 正则脚本 | 很窄的文本替换 | 难区分注释、字符串、声明、调用和控制流边界 |
| AI Agent | 辅助分析、生成工具、解释 diff | 全仓库批量执行的成本高,结果不够稳定和可重复 |
| AST 工具 | 规则明确、规模大、需要重复执行 | 需要为语法和安全边界编写工程化规则 |
AI Agent 很适合帮忙编写脚本、补充测试、分析语法树,或者解释某个 case 为什么不能删除。但如果直接让它“把全项目中不用的历史功能代码删掉”,它需要持续检索上下文、分析依赖、生成修改并逐个验证。任务扩展到几千个文件后,执行成本和结果审查成本都会迅速上升。
这类任务还要求稳定地区分代码和注释、声明和调用、普通方法和框架入口、同名方法和真实目标。如果后续需要调整判断标准或重新界定处理范围,一套确定性的工具也比一次性的 Agent 执行过程更容易复现、审计、回滚和重新运行。
因此,AI Agent 更适合辅助开发和完善清理工具,而不是直接承担大规模、全仓库的机械清理工作。最终,我选择了 Python 脚本结合 AST 分析的方案。
这套方案最大的优势是确定性和可控性:在输入、规则与代码版本不变的情况下,执行过程和输出结果也保持一致。如果结果不符合预期,可以直接回滚,调整分析策略或清理规则后重新执行,而不需要依赖一次无法稳定复现的模型推理过程。
从源码到 AST
AST(Abstract Syntax Tree,抽象语法树)是源码的结构化表示。它不关心空格和换行长什么样,而是把程序拆成“条件”“操作符”“调用”“方法声明”等带层级的节点。
dead-code-pruner 使用 tree-sitter 做语法解析。tree-sitter 能给出每个节点的类型、子节点和字节范围,又支持多种语言,适合做这种需要保留原文大部分格式的源码改写。
例如这段 Java:
if (true && (false || isLegacyMode())) { RetiredFeature.renderLegacy();}在 AST 中可以简化理解为:
if_statement├─ condition: binary_expression (&&)│ ├─ left: true│ └─ right: parenthesized_expression│ └─ binary_expression (||)│ ├─ left: false│ └─ right: method_invocation: isLegacyMode()└─ consequence: block └─ method_invocation: RetiredFeature.renderLegacy()工具因此知道 (false || isLegacyMode()) 是 && 的完整右操作数,可以先利用短路规则折叠两层表达式,而不会删掉半截条件:
if (true && (false || isLegacyMode())) {if (isLegacyMode()) { RetiredFeature.renderLegacy();}如果只靠正则查找 true && ...,还要自行处理括号嵌套、多行调用、注释和字符串。规则一多,实际上就是在重写一个不完整的语法解析器。
AST 划定安全替换边界
配置替换只会发生在 AST 识别出的有效代码区域内,不会进入注释或字符串字面量。因此,Java/Kotlin 文本块、Go 反引号字符串、Swift 多行字符串、Dart raw string 和嵌套块注释中的文本,都不会被当作普通代码直接替换。
AST 解决的是单次改写的语法边界,但单个文件无法回答一个方法是否还在其他文件中被使用。要继续删除级联失去引用的方法、字段和类,还需要项目级视角。
全局清理机制
单个 AST 只能回答“这个文件里有什么”,而死代码清理还必须回答“谁在其他文件里引用它”。
所以主流程分成两层:
- Phase 1 在单个文件内传播已知常量,持续简化表达式和控制流,直到当前文件不再变化。
- Phase 2 建立项目级声明、引用和契约索引,再通过“清理声明 → 重新简化变化文件 → 刷新索引”的循环迭代到稳定。
flowchart TB
subgraph P1["第一行 · Phase 1:全部源码文件逐文件运行"]
direction LR
Start((开始)) --> G1
G1["Step 1 · 替换配置常量<br/>Step 2 · 传播局部常量"]
G1 --> G2["Step 3 · 简化基础布尔表达式<br/>Step 4 · 简化复合表达式"]
G2 --> G3["Step 5 · 简化语言特有表达式<br/>Step 6 · 消除死分支"]
G3 --> G4["Step 7 · 删除不可达代码<br/>Step 8 · 清除无用布尔变量"]
G4 --> Stable{"当前文件<br/>是否收敛?"}
Stable -->|"否:再跑一轮"| G1
end
subgraph P2["第二行 · Phase 2:项目清理循环"]
direction RL
S1["Step 1<br/>统一扫描项目"] --> S2["Step 2<br/>清理死声明"]
S2 -->|"有变化"| S3["Step 3<br/>变化文件重跑 Phase 1<br/>复用第一行 Steps 1–8"]
S3 -->|"Phase 1 收敛后"| S4["Step 4<br/>增量刷新索引"]
S4 -->|"下一轮"| S2
S2 -->|"无变化,收敛"| S5["Step 5<br/>删除空类与空文件"]
S5 --> QG2["终态<br/>质量门"]
QG2 --> Done((完成))
end
P1 -->|"所有文件收敛"| QG1["Phase 间质量门"]
QG1 -->|"进入 Step 1"| P2
为什么需要循环?因为死代码清理经常是级联发生的。
final boolean legacyEnabled = FeatureFlags.LEGACY_MODE;if (!legacyEnabled && (LegacyFeatureController.RETIRED_DEFAULT || isLegacyMode())) { RetiredFeature.renderLegacy();} else { renderCurrent();}当两个配置常量都固定为 false 时,Phase 1 可以把整个条件简化为 isLegacyMode(),但还不能只凭单文件视角删除它。
Phase 2 证明 isLegacyMode() 是可内联的布尔恒返回方法后,调用处会变成 false,从而再次触发 Phase 1 的分支清理。方法定义和跨文件的空类也会随之失去引用。
final boolean legacyEnabled = FeatureFlags.LEGACY_MODE;if (!legacyEnabled && (LegacyFeatureController.RETIRED_DEFAULT || isLegacyMode())) { RetiredFeature.renderLegacy();} else { renderCurrent();}renderCurrent();只运行一轮,后续暴露出来的代码就会继续残留。循环到收敛,才是这类清理真正需要的执行方式。
流程详解
Phase 1:源码简化
第一个 Phase 处理单个文件内部的表达式和控制流。八个 Step 会按照实际执行顺序循环运行,直到当前文件不再变化。
从理解角度,这八个 Step 可以分成四组:
| 阶段 | 对应 Step | 作用 |
|---|---|---|
| 注入已知事实 | Step 1 | 将配置中的常量事实写入源码 |
| 传播并折叠表达式 | Step 2–5 | 传播局部常量,简化布尔和语言特有表达式 |
| 简化控制流 | Step 6–7 | 删除死分支和不可达语句 |
| 清理传播残留 | Step 8 | 删除已经失去使用处的布尔变量 |
下面的八个 Step 始终沿用同一个 Java 示例。
原始代码:
void render(boolean shouldStop) { final boolean legacyEnabled = FeatureFlags.LEGACY_MODE;
if (!legacyEnabled && LegacyFeatureController.RETIRED_DEFAULT) { RetiredFeature.renderLegacy(); } else { renderCurrent(); }
if (shouldStop) { return; } else { throw new IllegalStateException("render stopped"); }
unreachableLegacyCleanup();}配置中已经明确:
replacements: - pattern: "FeatureFlags.LEGACY_MODE" value: false - pattern: "LegacyFeatureController.RETIRED_DEFAULT" value: falseStep 1:替换配置常量
首先把配置中的常量映射替换成源码级字面量:
void render(boolean shouldStop) { final boolean legacyEnabled = FeatureFlags.LEGACY_MODE; final boolean legacyEnabled = false;
if (!legacyEnabled && LegacyFeatureController.RETIRED_DEFAULT) { && false) { RetiredFeature.renderLegacy(); } else { renderCurrent(); }
if (shouldStop) { return; } else { throw new IllegalStateException("render stopped"); }
unreachableLegacyCleanup();}配置替换只处理 AST 确认的代码节点,不会因为注释或字符串中出现相同文本而发生误替换。
Step 2:传播局部常量
检测 final boolean、val、let 等不可变布尔局部变量,将其使用处替换为字面量:
void render(boolean shouldStop) { final boolean legacyEnabled = false;
if (!legacyEnabled if (!false && false) { RetiredFeature.renderLegacy(); } else { renderCurrent(); }
if (shouldStop) { return; } else { throw new IllegalStateException("render stopped"); }
unreachableLegacyCleanup();}这一步是 scope-aware 的,只在变量声明所在的有效作用域内传播,不会跨方法或跨类替换同名标识符。
Step 3:简化基础布尔表达式
Step 3 处理取反和布尔字面量之间的比较:
| 输入 | 输出 |
|---|---|
!true | false |
!false | true |
true == false | false |
false != true | true |
当前示例中的 !false 会被折叠为 true:
void render(boolean shouldStop) { final boolean legacyEnabled = false;
if (!false if (true && false) { RetiredFeature.renderLegacy(); } else { renderCurrent(); }
if (shouldStop) { return; } else { throw new IllegalStateException("render stopped"); }
unreachableLegacyCleanup();}Step 4:简化复合表达式
Step 4 处理短路布尔运算和三元表达式:
| 输入 | 输出 |
|---|---|
true && expr | expr |
false && expr | false |
true || expr | true |
false || expr | expr |
true ? A : B | A |
false ? A : B | B |
当前示例中的 true && false 会继续折叠为 false:
void render(boolean shouldStop) { final boolean legacyEnabled = false;
if (true && false) { if (false) { RetiredFeature.renderLegacy(); } else { renderCurrent(); }
if (shouldStop) { return; } else { throw new IllegalStateException("render stopped"); }
unreachableLegacyCleanup();}Step 3 和 Step 4 经常连续发生:前一步产生新的布尔字面量,后一步再利用短路语义消除更大的表达式。
Step 5:简化语言特有表达式
不同语言中,同一种业务判断可能对应不同的语法节点。例如 Kotlin 的 if expression、表达式函数和属性访问,都不能完全按照 Java statement 的方式处理。
Step 5 会将当前语言的特殊表达式交给对应的 Language Adapter,在普通死分支消除之前完成必要的语言级简化。
当前示例是 Java,没有触发额外的语言特有改写,因此代码保持不变。其他语言的具体规则放在后面的 Language Adapter 章节中统一说明。
Step 6:消除死分支
| 输入 | 输出 |
|---|---|
if (true) { A } | A |
if (true) { A } else { B } | A |
if (false) { A } | 删除 |
if (false) { A } else { B } | B |
if (false) { A } else if (X) { B } | if (X) { B } |
真实改写时,分支外壳也会一起移除,不会留下无意义的 if (true) 或 if (false):
void render(boolean shouldStop) { final boolean legacyEnabled = false;
if (false) { RetiredFeature.renderLegacy(); } else { renderCurrent(); } renderCurrent();
if (shouldStop) { return; } else { throw new IllegalStateException("render stopped"); }
unreachableLegacyCleanup();}Step 7:删除不可达代码
Step 7 会删除:
return、throw、break、continue后面的语句if-else所有分支都确定终止后的后续语句
当前示例中,if 分支执行 return,else 分支执行 throw。无论进入哪条路径,后面的 unreachableLegacyCleanup() 都不可能执行:
void render(boolean shouldStop) { final boolean legacyEnabled = false;
renderCurrent();
if (shouldStop) { return; } else { throw new IllegalStateException("render stopped"); }
unreachableLegacyCleanup();}这类判断不能只看前一个文本语句,而需要理解整个控制流结构是否已经在所有路径上终止。
Step 8:清除无用布尔变量
经过局部常量传播后,legacyEnabled 已经没有任何使用处。Step 8 会删除对应的不可变布尔变量声明:
void render(boolean shouldStop) { final boolean legacyEnabled = false;
renderCurrent();
if (shouldStop) { return; } else { throw new IllegalStateException("render stopped"); }}八个 Step 完成后,当前文件会重新进入 Step 1。只要新一轮仍然产生变化,就继续循环;直到整个文件不再变化,才算达到 Phase 1 的稳定状态。
这里最容易出问题的并不是布尔判断本身,而是改写后的工程细节:
- 分支展开后会重新缩进,保证生成的 diff 仍然可读
- Java 分支包含局部变量声明时,会按需保留
{},避免变量作用域泄漏 - 删除不可达代码时,不会跨过
case/default标签进入相邻的 switch 分支
Phase 1 解决了文件内部能够根据已知事实直接证明的问题。接下来还需要项目级索引判断:哪些方法、字段和类已经因为这些修改失去了所有有效引用。
Phase 2:项目清理
Phase 2 将跨文件能力建立在同一次项目扫描和同一组索引之上,再通过五个 Step 完成增量收敛。
Step 1:统一扫描项目
工具会对整个项目执行一次统一扫描,并在同一次源码读取和解析过程中收集后续分析需要的项目事实:
flowchart LR
A["读取源码文件"] --> B["扫描方法候选"]
A --> C["建立引用索引"]
A --> D["提取契约与类层级"]
A --> E["扫描字段候选"]
B --> F["统一项目快照"]
C --> F
D --> F
E --> F
统一扫描会集中收集:
- 方法和字段的声明位置、类型、修饰符及所属类
- 每个方法可能在哪些文件中被调用
- 类、接口、继承和实现关系
- 不同语言中的抽象契约与协议关系
- 字段的可见性、访问方式和模块边界
- 普通源码调用之外的框架配置和语言级间接引用
这些结果会共同组成一个统一的项目快照,后续的候选判断、调用处处理和定义删除都基于这份快照完成,而不是由多个步骤分别重新扫描整个仓库。
Step 2:清理死声明
Step 2 是 Phase 2 的核心。它不是简单地搜索一个方法名是否出现过,而是依次完成候选发现、引用确认、调用处改写和定义删除。
1. 找出可能需要清理的候选
工具首先从项目快照中找出可能需要处理的方法和字段,例如:
- 零参数布尔恒返回方法
- 零参数空方法
- 其他已经没有有效引用的方法
- 没有有效读取位置的不可变字段
- 因前一轮修改而刚刚失去引用的声明
语言级私有只代表声明具备候选资格,并不意味着可以直接删除。接口方法、抽象契约、重写方法、带注解成员和框架入口会优先排除。
2. 确认引用是否真正指向当前声明
判断引用时不能只看方法名。
同一个项目中可能同时存在:
A.render();B.render();render();render(dialog);工具还会结合模块、所在文件、所属类、参数数量和声明位置区分重载及同名方法。
同一文件中的调用改写会限制在方法所属类的范围内;跨文件改写则只处理能够通过类名或其他明确身份信息确定目标的方法调用。无法唯一确认目标时,候选会被保留。
公开声明是否允许继续进入删除判断,还取决于当前项目能否覆盖全部源码消费者。应用和 SDK 对“零引用”的解释不同,这部分会在后面的多模块项目支持中详细说明。
3. 根据候选类型选择不同的处理方式
| 候选类型 | 调用点处理 | 定义处理 |
|---|---|---|
| 零参数布尔恒返回方法 | 替换为 true 或 false | 引用清空后删除 |
| 零参数空方法 | 删除无副作用的独立调用语句 | 引用清空后删除 |
| 其他常量 getter | 不展开仍然存活的调用 | 全项目确认零引用后删除 |
| 有参或普通方法 | 不主动改写调用处 | 引用形式已被覆盖且确认零引用时才删除 |
| Kotlin/JVM 属性 | 将 getter/property 别名纳入引用索引 | 任一别名有引用时保留源属性 |
| 不可变字段 | 不改写仍然存活的引用 | 按项目边界确认可以删除时才处理 |
有参方法不会通过直接删除调用语句来清理,因为实参求值可能产生副作用:
unusedMethod(loadData());即使 unusedMethod 本身已经没有意义,loadData() 仍然可能修改状态、执行 I/O 或抛出异常。因此,普通有参调用不会按照空方法调用的方式直接删除。
同样,普通方法体中没有显式调用也不代表方法一定没有引用。函数值、回调注册、框架调度和语言特有的方法引用都可能绕过普通调用语法。只有对应 Language Adapter 已经覆盖相关引用形式,并且项目索引能够确认零引用时,才会删除定义。
4. 先处理调用处,再删除定义
对于能够保持原有语义的候选,工具会先修改调用处,再刷新引用关系。只有确认定义已经没有任何有效引用时,才会删除定义。
这个顺序非常关键,因为调用处修改后,项目中的引用关系已经发生了变化。
例如,布尔恒返回方法的调用点会先变成字面量,空方法的独立调用会被删除,随后才清理对应定义:
private boolean isLegacyMode() { return false;}
private void recordLegacyExposure() {}
void render() { recordLegacyExposure(); if (isLegacyMode()) { if (false) { RetiredFeature.renderLegacy(); } else { renderCurrent(); }}字段也以完整声明为单位处理。对于一条语句声明多个变量的情况:
private int used, unused;只有其中每个变量都满足删除条件时,才会删除整条声明,避免破坏仍然存活的字段。
private static final boolean RETIRED_DEFAULT = false;Step 2 完成一轮后,只要有任何源码发生变化,就会进入下一步,让这些变化继续触发文件内简化。
Step 3:变化文件重跑 Phase 1
只有被死声明清理修改过的文件会再次进入 Phase 1。
例如,上一步将 isLegacyMode() 替换为 false 后,会留下新的死分支:
void render() { if (false) { RetiredFeature.renderLegacy(); } else { renderCurrent(); } renderCurrent();}这些文件会重新执行 Phase 1 的八个 Step,直到文件内部再次收敛。
它们不会重新经过最初的全项目扫描,也不会让整个仓库重新运行一遍 Phase 1。
Step 4:增量刷新项目索引
变化文件在 Phase 1 中重新收敛后,工具会更新它们在项目快照中的记录:
flowchart LR
Changed["本轮变化文件"] --> Drop["移除这些文件的旧事实"]
Drop --> Rescan["重新解析与扫描"]
Rescan --> Merge["合并到项目索引"]
Merge --> Next["下一轮死声明清理"]
刷新时会先移除变化文件原来的声明、引用和契约事实,再写入重新解析后的结果。
这样可以避免已经删除的接口方法、调用关系或类型信息继续残留在索引中,同时保留所有未变化文件的扫描结果。
索引更新完成后,Phase 2 会重新进入 Step 2,继续寻找下一批因为本轮修改而失去引用的声明。
Step 5:删除空类与空文件
当项目级声明清理不再产生变化后,工具会检测成员已经全部被删除的空类。
只有确认项目中没有其他文件继续引用这个类时,才会删除类声明。如果文件随后只剩 package、import 等非声明内容,整个源文件也会被删除。
diff --git a/RetiredFeature.java b/RetiredFeature.javadeleted file mode 100644--- a/RetiredFeature.java+++ /dev/null@@-package com.example.legacy;--final class RetiredFeature {-}这个 diff 表示 RetiredFeature.java 整个文件被删除,而不是留下一个只有 package 和 import 的空壳。
全流程合并后的最终 diff
上面每个 Step 的小 diff,都是同一个 Legacy 功能清理过程中的局部切片。
将 Phase 1 的文件内收敛、Phase 2 的声明清理与索引刷新合在一起,最终项目级 diff 会形成下面这种结果:
diff --git a/LegacyFeatureController.java b/LegacyFeatureController.java--- a/LegacyFeatureController.java+++ b/LegacyFeatureController.java@@ final class LegacyFeatureController {- private static final boolean RETIRED_DEFAULT = false;-- private boolean isLegacyMode() {- return false;- }-- private void recordLegacyExposure() {}- void render() {- final boolean legacyEnabled = FeatureFlags.LEGACY_MODE;- recordLegacyExposure();- if (!legacyEnabled- && (LegacyFeatureController.RETIRED_DEFAULT || isLegacyMode())) {- RetiredFeature.renderLegacy();- return;- unreachableLegacyCleanup();- } else {- renderCurrent();- }+ renderCurrent(); } }
diff --git a/LegacyScreen.kt b/LegacyScreen.kt--- a/LegacyScreen.kt+++ b/LegacyScreen.kt@@-val title = if (false) legacyTitle else currentTitle+val title = currentTitle
diff --git a/RetiredFeature.java b/RetiredFeature.javadeleted file mode 100644--- a/RetiredFeature.java+++ /dev/null@@-package com.example.legacy;--final class RetiredFeature {- static void renderLegacy() {}-}这个结果同时体现了:
- 配置常量替换
- 局部常量传播
- 布尔表达式折叠
- 死分支删除
- 不可达代码删除
- 恒返回布尔方法内联
- 空方法调用和定义清理
- 无用字段删除
- 空类和空文件清理
前面的 Phase 描述的是代码按照什么顺序变化,但能不能执行一次具体修改,还取决于贯穿整个流程的安全规则、语言规则和项目边界。
贯穿两个 Phase 的能力
Phase 1 和 Phase 2 说明的是代码按照什么顺序被处理。
真正决定一次修改是否允许执行的,还有三类贯穿全流程的能力:
- 语法和引用安全检查
- 不同语言的适配规则
- 模块与项目边界判断
安全边界
对自动清理工具来说,少删一些只是留下人工处理空间,删错一次却可能改变程序行为。
因此,只要引用关系、动态入口或项目边界无法确认,工具就会保留对应代码。
4 层安全门控
| 层次 | 时机 | 机制 |
|---|---|---|
| 单次改写检查 | 每次修改后 | AST 重解析,新增语法错误则回滚到修改前 |
| Phase 间 | Phase 1 后 | 进入项目级删除前全量检查所有已修改文件 |
| 残留引用检查 | 删除定义前 | 验证文件中无残留调用 |
| 终态质量门 | 全流程结束后 | 再次全量检查,回滚有问题的文件 |
保守规则
- 带注解、由依赖注入、路由或其他框架调用的方法会被保留,因为它们可能没有普通源码调用点
abstract、open、override、native方法,以及接口、协议和抽象类中的契约方法,不会按普通死方法处理- 方法名相同不代表目标相同,工具还会检查模块、所属类、参数数量和声明位置
- 链式调用不会按普通独立语句删除,例如
method().subscribe()仍然使用了方法返回值 - Swift selector、Storyboard/XIB 事件、Android XML 回调,以及 XML、JSON、plist 中的标识符会进入引用判断
- 有参方法不会通过删除调用语句来清理,只有项目级零引用和语言适配规则都能证明安全时,才会删除定义
- 多模块项目会分别建立声明身份和引用关系,不会让其他模块中的同名方法干扰当前判断
保守策略会留下一些可以人工继续处理的代码,但这是值得的。
自动清理工具最重要的不是看起来删得多,而是让人敢于在大型项目中重复运行。
Language Adapter
AST 节点只能描述语法结构,但不同语言对入口函数、继承契约、属性访问、方法引用和动态调用的表达方式并不相同。
Language Adapter 负责将这些差异转换成统一的候选、引用和保护规则。
| Adapter | 特殊入口与契约 | 容易误判的引用或语法 |
|---|---|---|
| Java | Android/JVM 回调、注解、接口和抽象类 | 重载、静态导入、元数据引用、switch 作用域 |
| Kotlin | 顶层 main、注解、默认参数、继承与实现关系 | getter/property 别名、尾随 lambda、表达式函数、if expression |
| Go | main、init、测试入口、导出声明和结构化接口 | 接收者方法、函数值、分组参数和反引号字符串 |
| Swift | 协议、扩展、访问级别和参数标签 | selector、Storyboard/XIB、函数引用和多行字符串 |
| Dart | main、元数据注解、抽象契约、实现关系和下划线私有声明 | 方法引用、箭头函数、可选参数和 raw string |
例如,Kotlin 中的 if 可以直接作为表达式产生值:
val title = if (false) legacyTitle else currentTitleval title = currentTitleKotlin 属性还可能通过 getter 名称或 property 语法被访问:
class FeatureState { val legacyEnabled: Boolean get() = false}调用者既可能使用:
state.legacyEnabled也可能在 Java 中使用:
state.getLegacyEnabled();因此,工具不能只检查源码中是否还存在 legacyEnabled,而需要将属性名和生成的 getter 形式关联到同一个声明身份。
其他语言的入口和引用形式也采用相同原则:Language Adapter 负责识别语言差异,项目级流程只消费统一后的候选、引用和保护信息。
多模块项目支持
“零引用”在应用和库中含义不同。
应用或部署服务通常能够看到所有源码调用者,可以视为 closed world;库和 SDK 可能被仓库外部代码调用,因此属于 open world。
| 项目边界 | 典型项目 | 零引用声明的处理方式 |
|---|---|---|
| Closed world | 应用、可执行程序、部署服务 | 可以继续判断公开但零引用的声明是否可删 |
| Open world | 库、SDK、可发布模块 | 公开 API 默认保留,只清理能够证明不会被外部调用的声明 |
工具会检测构建系统和模块结构,让不同模块分别建立声明身份和引用关系:
| 构建系统 | 检测方式 | 模块隔离 |
|---|---|---|
| Gradle(Android / JVM) | settings.gradle / settings.gradle.kts | 每个 :module 独立分析 |
| Maven | pom.xml 子模块 | 每个子目录独立分析 |
| Go | go.mod | 根模块和嵌套模块 |
| Dart / Flutter | pubspec.yaml | 根包和 packages/ 子包 |
| Xcode | .xcworkspace / .xcodeproj | 工作区作为一个分析边界 |
因此,不同模块中的 Utils.isEnabled() 会被独立跟踪,不会因为名称相同而互相干扰。
每个模块也会根据自身用途判断项目边界,不能直接使用应用模块的删除规则处理 SDK 模块。
自动判断遵循以下原则:
- 应用、可执行程序、服务、Flutter App、Xcode App,以及带有部署配置的模块,会被视为 closed world 的证据
- 在应用或服务的 Gradle 构建中,没有发布配置的内部库模块会跟随宿主使用 closed world
- 发布配置、独立库和 SDK 属于 open world 的证据
- 如果 closed world 和 open world 证据冲突,优先按照 open world 处理
- 无法判断的独立项目默认按照 open world 处理
这一区分非常关键。
在应用中,项目内零引用通常意味着全部源码消费者都无法到达,因此可以继续判断无引用的 public static 或顶层声明是否可删。
在 SDK 中,仓库外仍然可能存在调用者,所以公开 API 和外部可见类型必须默认保留。
性能与并行处理
性能优化的核心不是让单个 AST 操作快几毫秒,而是减少全仓库级工作的重复次数。
主要策略包括:
- 单个文件进入 Phase 1 后会直接循环到稳定,不需要为了同一个文件反复遍历整个项目
- 方法、字段、引用、契约和类层级信息会在同一次项目扫描中统一收集
- Phase 2 后续轮次只处理发生变化的文件,处理量主要取决于当前轮次的变化范围
- 项目达到并行收益阈值后,文件转换、项目扫描和字段引用分析会分配给多个工作进程
- 小项目保持串行,避免进程创建和数据传输成本超过并行收益
- 用于回滚和终态统计的原始文件快照只保存一次,并在两个 Phase 之间复用
- 字段引用会先按文件提取标识符,再与候选集合统一匹配,避免为每个候选字段重新扫描整个仓库
这些优化共同决定了工具能否从“可以处理几个文件的脚本”,变成能够在大型项目中完整运行的工程化工具。
使用
安装依赖:
git clone https://github.com/OldJii/dead-code-pruner.gitcd dead-code-pruner
pip install -r requirements.txt准备 pruner.yaml:
replacements: - pattern: "FeatureFlags.LEGACY_MODE" value: false - pattern: "LegacyFeatureController.RETIRED_DEFAULT" value: false执行:
CLI 会自动查找当前目录中的 pruner.yaml,因此完整管道不需要额外传入配置文件参数:
python3 -m pruner /path/to/your/project建议在独立分支中执行,完成后检查完整 diff,并使用目标项目原有的编译、静态检查和自动化测试作为最终验收。
支持的项目生态
项目会根据扩展名和构建结构选择语言适配器与模块边界,目前覆盖五类生态:
| 生态 | 语言 | 扩展名 |
|---|---|---|
| Android | Java、Kotlin | .java、.kt、.kts |
| JVM 服务 | Java、Kotlin | .java、.kt、.kts |
| Go 服务 | Go | .go |
| iOS | Swift | .swift |
| Flutter | Dart | .dart |
在 Android 之外使用
语法适配和自动化测试已经覆盖 Java、Kotlin、Go、Swift 和 Dart,但目前的大规模真实项目验证主要来自 Android。
其他生态的框架入口、代码生成方式和动态调度规则可能不同,因此更适合先从独立分支和小范围验证开始。
如果发现某种语法没有按照预期清理,不要直接对大项目放宽删除规则,而是将它固化成一个最小可复现用例:
- 保留一份最小输入源码和期望输出
- 确认缺口来自 AST 节点识别、引用索引还是框架入口保护
- 让 AI Agent 协助分析语法树和现有 Language Adapter
- 由人确认新增规则的安全边界
- 先让新用例通过,再运行现有回归集
- 最后将新规则应用到目标仓库
先从小范围变更开始
跨语言清理的关键不是“语法能够解析”,而是“目标项目中的隐式入口已经被理解”。在新生态中建议先限定目录、检查代码差异,并使用目标项目自己的编译和测试作为最终验收。
修改 Language Adapter 后的完整回归命令
python3 tests/run_tests.pypython3 tests/run_project_tests.pypython3 tests/test_language_matrix.pypython3 tests/test_project_boundary.py结尾
死代码清理很容易被低估。
它不像新功能那样显眼,也不像性能优化那样容易产生直观的对比数字。但对于一个长期演进的大型项目,清理掉已经确定不会再执行的逻辑,本质上是在给之后的每一次开发、重构、代码审查和 AI 辅助编码减负。
dead-code-pruner 接收明确的常量事实,再通过 AST 和项目级索引将这些事实稳定地传播到表达式、控制流、方法、字段和类型,直到整个项目不再产生新的变化。
这类工具最重要的不是一次删除多少代码,而是确定性、保守性、可测试和可重复。