dead-code-pruner: Repeatable Large-Scale Dead Code Cleanup with AST
Code in large projects rarely grows in one clean pass. An internationalization check, a rollout switch, or an old feature flag may start as a small business guard. Over time, it spreads into screens, services, utilities, analytics, resource loading, and fallback logic.
When that condition finally becomes fixed, say the code should always take the domestic path or always disable an old branch, the source code does not clean itself. if (false) is only the surface-level problem. Behind it, there may be helpers, empty methods, constant-return methods, meaningless callbacks, and dead branches.
Manual cleanup is possible, but doing it across millions of lines quickly turns into a long game of spot-the-difference. IDE inspections catch some cases, and compilers or R8 can remove part of it in the final artifact, but the source-level complexity is still there.
So I built a tool for this exact kind of cleanup: dead-code-pruner.
It Is Not Just About Ifs
Dead code cleanup is not just about deleting a few lines.
First, the main path becomes shorter. When old branches stay in the source, every reader has to keep asking: “Can this still run?” Once the condition no longer has business meaning, that cost is pure noise.
Second, later refactoring becomes safer. Dead branches often reference old models, old APIs, old resources, and old analytics. As long as they remain, every refactor has to account for paths that no longer execute.
Third, code search and code review become cleaner. Searching for a method name is much less useful when the results are full of historical branches. Reviewers also waste time checking whether an old path still matters.
There is another benefit that has become more obvious recently: cleaner source code makes it easier for AI to produce the right code.
This does not mean “removing dead code eliminates AI hallucinations”. It does not. More precisely, an AI agent also has to infer project intent from files, references, call chains, and local context. The more dead code it reads, the more invalid patterns and distracting examples it sees.
In large projects, this matters:
- Old branches consume context window space, delaying or excluding the code that actually matters
- Deprecated helpers can make the agent assume there is still another business path to preserve
- Repeated but invalid checks can encourage unnecessary defensive branches in generated code
- Call chains polluted with unreachable nodes can interfere with bug localization and implementation
So the value for AI is the same as the value for humans: less noise, shorter paths, fewer misleading signals. The larger the project, the more this matters.
Thinking Through the Options
There are many ways to clean dead code at scale, but each has a different boundary.
| Approach | Good For | Problem |
|---|---|---|
| Manual cleanup | Small areas with strong business context | Expensive, hard to repeat, easy to miss cascades |
| IDE Inspection | Single-file and simple if(true/false) cases | Weak on cross-file references, constant-return helpers, and cascades |
| Compiler / R8 / ProGuard | Optimizing the final artifact | Source complexity remains, so review and maintenance cost stay |
| Regex scripts | Narrow text replacement | Hard to distinguish comments, strings, declarations, calls, and control flow |
| AI Agent | Assisting analysis, generating tools, explaining diffs | Each file still has to be read, reasoned about, edited, and checked, so large projects become very slow and the result is not stable enough |
| AST tool | Clear rules, large scale, repeatable execution | Requires engineering work around syntax and safety boundaries |
AI agents are very useful for writing scripts, adding tests, and explaining why a case should or should not be removed. But asking an agent to “delete all unused internationalization code in the whole project” is risky.
The reason is simple: this task is not one decision. It is thousands of small decisions. Each decision must consistently distinguish code from comments, declarations from calls, normal methods from framework entry points, same-name methods from real references. If even a small part of those decisions is unstable, the resulting diff becomes hard to review.
Throughput is another problem. Agents like Claude, Cursor, and Codex do not simply scan text once. For each file, they retrieve context, reason about it, generate edits, and inspect the result. In a repository with around twenty thousand source files, even touching only the relevant subset can easily take dozens of hours. For a large Android project in this range, 100 hours would not be surprising.
dead-code-pruner moves the repetitive part into a deterministic tool. The input is an explicit constant mapping, the cleanup rules live in code, and tests cover the important boundaries. The same input should produce the same output every time.
Design Goal
The goal is not to “delete as much as possible”. The goal is to delete conservatively and repeatedly.
It starts from a config file:
replacements: - pattern: "AppConfig.IS_INTERNATIONAL" value: false - pattern: "FeatureFlags.LEGACY_MODE" value: falseThe tool only trusts the facts in that config: some expressions are now always true or false. Every later cleanup step is derived from those facts.
The main goals are:
- Handle cross-file cascading dead code, not just replace one piece of text
- Keep diffs readable instead of destroying indentation and formatting
- Treat annotations, inheritance, dynamic entry points, selectors, and storyboards conservatively
- Support multi-language and multi-platform repositories
- Provide test suites so forks can safely adjust cleanup rules
Current support covers 5 target ecosystems:
| Ecosystem | Languages | Extensions |
|---|---|---|
| Android | Java, Kotlin | .java, .kt, .kts |
| Java/Kotlin Server | Java, Kotlin | .java, .kt, .kts |
| Go Server | Go | .go |
| Swift iOS | Swift | .swift |
| Dart/Flutter | Dart | .dart |
Each language has a dedicated Language Adapter (pruner/adapters/) that defines framework entry points, visibility rules, and protected method names, ensuring language-idiomatic safety boundaries are correctly enforced.
AST as the Base
dead-code-pruner uses tree-sitter to parse source code. tree-sitter produces an AST, an abstract syntax tree.
For this tool, the value of AST is not that it is fancy. The value is that boundaries become explicit.
It tells the tool:
- This is an
if_statement; here is the condition, then branch, and else branch - This is a
binary_expression; here are the left side, operator, and right side - This is a
return_statement; the returned expression may be a boolean literal - This is a
method_declaration; here are the method name, parameters, modifiers, and body
Regex can approximate some of this, but it gets fragile quickly. For example, true || A && B contains two operators in text form, but the AST clearly shows that A && B is the right operand of ||, so it can be removed as a whole. The tool does not end up deleting half an expression.
Constant replacement is a small exception. Because the user config provides literal patterns like AppConfig.IS_INTERNATIONAL, the tool keeps a lightweight tokenizer that splits source into code and non-code segments. Replacement only happens in code segments, so // AppConfig.IS_INTERNATIONAL and "AppConfig.IS_INTERNATIONAL" are not accidentally changed.
Cleanup Pipeline
The pipeline is a 3-phase + cleanup system where each phase loops until convergence:
flowchart TB
subgraph Phase1 ["Phase 1: Expressions and Control Flow"]
S1[Constant Replacement] --> S1b[Local Constant Propagation]
S1b --> S2[Simple Boolean Simplification]
S2 --> S3[Compound Boolean / Ternary]
S3 --> S4[If-Branch Elimination]
S4 --> S1d[Unreachable Code Removal]
S1d --> S1c[Unused Boolean Var Cleanup]
S1c -- more changes --> S1
end
subgraph Phase2 ["Phase 2: Constant-Return Method Inlining"]
T1["Scan constant methods<br/>(bool/int/float/string/null)"] --> T2[Replace Call Sites]
T2 --> T3["Cascade: Re-run Phase 1"]
T3 -- new opportunities --> T1
end
subgraph Phase3 ["Phase 3: Project-Level Dead Methods"]
U1[Unified Project Scan] --> U2[Safety Analysis + Class Hierarchy]
U2 --> U3[Remove / Replace Call Sites]
U3 --> U4["Cascade: Local Re-simplification"]
U4 --> U5[Rebuild Reference Index]
U5 --> U6[Delete Method Definitions]
end
subgraph Cleanup ["Cleanup: Empty Classes & Files"]
C1[Detect Empty Classes] --> C2[Cross-Project Reference Check]
C2 --> C3[Delete Empty Classes / Files]
end
Phase1 --> Phase2
Phase2 --> QG1["Quality Gate (pre-Phase 3)"]
QG1 --> Phase3
Phase3 --> Cleanup
Cleanup --> QG2["Final Quality Gate"]
QG2 --> Done((Done))
Why does it need to loop? Because dead code cleanup is often cascading.
if (AppConfig.IS_INTERNATIONAL) { showInternational();} else { if (isLegacyI18n()) { showLegacy(); }}Once AppConfig.IS_INTERNATIONAL is fixed to false, the first pass can remove the showInternational() branch. But this is not the end. showLegacy() may disappear, isLegacyI18n() may become caller-free, and isLegacyI18n() may itself call another constant-return helper.
A single pass leaves this tail behind. Convergence is the shape this kind of cleanup needs.
Phase 1: Expressions and Control Flow
The first layer handles expressions and control flow inside a single file. It includes 7 sub-steps that loop until convergence:
Step 1: Constant Replacement
Configured patterns become source-level literals:
// Beforeif (AppConfig.IS_INTERNATIONAL) { showInternational();}// Afterif (false) { showInternational();}Step 1b: Local Constant Propagation
Detects immutable boolean locals (final boolean / val / let) and replaces their uses with the literal value:
// Beforefinal boolean isIntl = true;if (isIntl) { ... }// Afterif (true) { ... }// The isIntl declaration is removed when no references remainThis step is scope-aware: propagation only happens within the declaring scope, never across methods or classes.
Steps 2-3: Boolean Simplification
| Input | Output |
|---|---|
!true | false |
!false | true |
true == false | false |
false != true | true |
true && expr | expr |
false && expr | false |
true || expr | true |
false || expr | expr |
true ? A : B | A |
false ? A : B | B |
Step 4: If-Branch Elimination
| Input | Output |
|---|---|
if (true) { A } | A |
if (true) { A } else { B } | A |
if (false) { A } | Removed |
if (false) { A } else { B } | B |
if (false) { A } else if (X) { B } | if (X) { B } |
Kotlin if expressions are also handled:
val title = if (false) oldTitle else newTitle// → val title = newTitleStep 1d: Unreachable Code Removal
Statements after return, throw, break, continue, and after if-else blocks where all branches exit:
if (true) { return;}deadCode(); // ← removedmoreCode(); // ← removedStep 1c: Unused Boolean Variable Cleanup
final boolean declarations that have no remaining uses after propagation are removed.
The hard part is not the boolean value itself. It is the engineering around it. Expanded branches need to be re-indented, otherwise the diff becomes painful. In Java, if a branch contains local variable declarations, braces may need to be preserved so scope does not leak. Deletion must not cross case / default labels, or a neighboring switch branch may be swallowed.
Phase 2: Constant-Return Method Inlining
In real projects, many checks are not written directly as fields. They are wrapped in methods:
private boolean isInternational() { return false;}
void render() { if (isInternational()) { renderInternational(); }}If the tool only performs constant replacement, this code does not change. It has to scan zero-argument constant-return methods first, then replace call sites with the literal:
void render() { if (false) { renderInternational(); }}Then the cascade re-runs Phase 1 (only on modified files, not the entire project), and the renderInternational() branch is removed.
Phase 2 supports these constant return types:
| Type | Example |
|---|---|
| boolean | return true;, return false; |
| int / long | return 0;, return -1; |
| float / double | return 3.14; |
| String | return "intl"; |
| null / nil | Detected but not inlined (type inference safety) |
Processing scope:
- Private methods: replace same-file bare calls + delete definition
- Static methods: handle cross-file
ClassName.method()calls + delete definition - Kotlin property access: Java getters like
isXxxaccessed as properties are also detected
There is also a small cleanup detail. If a standalone statement like shouldUseI18n(); becomes false;, that line is meaningless, so standalone true; / false; / value; statements are removed.
Phase 3: Project-Level Dead Method Cleanup
The final layer is project-level analysis.
Unified Scan
The tool performs a single-pass unified scan that collects all analysis data at once:
flowchart LR
A["Read Source Files"] --> B["Scan Method Candidates"]
A --> C["Build Reference Index"]
A --> D["Build Class Hierarchy"]
B --> E["Safety Decision"]
C --> E
D --> E
E --> F["Replace Boolean Calls / Remove Void Calls"]
F --> G["Local Simplification"]
G --> H["Rebuild Reference Index"]
H --> I["Delete Unreferenced Definitions"]
The scan collects three kinds of data:
- Method candidates: name, return kind, parameter count, modifiers, owning class (with line range as unique identifier), declaration range
- Reference index: method name to files that may contain call sites (including Kotlin property access forms)
- Class hierarchy: inheritance, final classes, interfaces, abstract classes, implements relationships
The reference index does not only look for method() in source files. Swift #selector, selector="..." in Storyboard / XIB, and semantic references in XML / JSON / plist files are also indexed. String interpolation in Kotlin/Dart/Swift is handled correctly so that code inside templates is not mistaken for string content.
Method Identity
Each method candidate is tracked with two identity keys:
semantic_method_key:(module, package, class, name, param_count)— for variant conflict detection in multi-module projects_method_key:(filepath, class, name, param_count, decl_start, decl_end)— for tracking processed methods
Same-name methods in different classes are handled precisely through two mechanisms:
- Same-file:
class_lines(class start/end line numbers) restricts the replacement scope - Cross-file: only qualified
ClassName.method()calls are replaced
Safety Promotion
The safe_to_inline flag on dead method candidates determines whether they can be processed:
| Method Type | Initial Flag | Mechanism |
|---|---|---|
| private | safe=true | Adapter marks directly |
| static | safe=true | Adapter marks directly |
| public/protected (instance) | safe=false | Promoted by _pre_check_public_methods after verifying no references |
For public/protected instance methods, the tool checks same-file and cross-file references. The cross-file check leverages class hierarchy information: if the class has no subclasses and is not an interface/abstract class, bare calls in files that do not mention the class name are not considered references, reducing false positives.
Call-Site Processing + Definition Deletion
After call sites are changed, the tool rebuilds the reference index before deleting method definitions. Call-site rewriting has already changed the project, so references may have changed too. That ordering matters.
Empty Class & File Cleanup
After Phase 3, the tool detects classes left empty after method deletion, checks for cross-file references, and removes them if unreferenced. Files left with only package / import declarations are deleted entirely.
Safety Boundaries
The tool is deliberately conservative.
4-Layer Safety Gates
| Layer | When | Mechanism |
|---|---|---|
| Per-transformation | After each edit | AST re-parse; new syntax errors trigger rollback |
| Pre-Phase 3 | After Phase 1+2 | Full check of all modified files for AST errors |
| Dangling check | Before Phase 4 deletion | Verifies no residual calls exist in the file |
| Final gate | After entire pipeline | Full re-check, rolling back problematic files |
Conservative Rules
- Annotated methods are kept;
@Override, DI, routing, AOP, and serialization entry points may be called at runtime abstract,open,override, andnativemethods are kept- Interface, protocol, and abstract class methods are kept as contracts
- Parameter count must match;
render()andrender(dialog)are not the same method - Same-name methods in different classes are handled precisely via class_lines scoping and class name qualification
- Chained calls are not rewritten blindly;
method().subscribe()is not a plain standalone call - Swift selectors and Storyboard / XIB actions are added to the reference index
- Methods with parameters do not have their call sites replaced (arguments may have side effects), but their definitions can still be deleted
- Multi-module projects isolate method analysis per module, preventing cross-module name collisions
Language Adapters
Each language has a dedicated adapter:
| Adapter | Protected Content |
|---|---|
| Java/Kotlin | Android lifecycle, RecyclerView adapter, Service/BroadcastReceiver, Spring/Jakarta DI, Dagger/Hilt, JVM standard (hashCode/equals/toString), RxJava/Coroutines |
| Go | Exported functions (uppercase), test/benchmark functions, init/main |
| Swift | UIKit/SwiftUI lifecycle, @objc/@IBAction, Storyboard selectors |
| Dart | Flutter widget lifecycle (build/initState/dispose), _ prefix private naming |
This conservatism leaves some code for manual follow-up, but that is the right tradeoff. The most important quality of an automatic cleanup tool is not how much it appears to delete. It is whether people trust it enough to run it repeatedly.
Multi-Module Project Support
The tool automatically detects the build system and module structure:
| Build System | Detection | Module Isolation |
|---|---|---|
| Gradle (Android / JVM) | settings.gradle / settings.gradle.kts | Each :module analyzed independently |
| Maven | pom.xml child modules | Each subdirectory analyzed independently |
| Go | go.mod | Root module + nested modules |
| Dart/Flutter | pubspec.yaml | Root package + packages/ sub-packages |
| Xcode | .xcworkspace / .xcodeproj | Single workspace scope |
Module awareness ensures that ModuleA:Utils.isEnabled() and ModuleB:Utils.isEnabled() are tracked and analyzed independently.
Usage
Install dependencies:
git clone https://github.com/OldJii/dead-code-pruner.gitcd dead-code-pruner
pip install -r requirements.txtPrepare a config:
replacements: - pattern: "AppConfig.IS_INTERNATIONAL" value: false - pattern: "FeatureFlags.LEGACY_MODE" value: falseRun it:
# Full pipelinepython3 -m pruner /path/to/your/project --config pruner.yaml
# Preview mode (no file modifications)python3 -m pruner /path/to/your/project --config pruner.yaml --dry-run
# Run specific phasespython3 -m pruner /path/to/your/project --phases 1python3 -m pruner /path/to/your/project --phases 1,2I usually run it on a separate branch and review the diff directly. The output should have a clear shape: checks disappear, branches disappear, constant-return helpers disappear, and empty methods disappear.
Test Files
The project provides two test entry points:
python3 tests/run_tests.pypython3 tests/run_project_tests.pytests/run_tests.py covers expression and control-flow cleanup across Java, Kotlin, Go, Swift, and Dart (Steps 1-4 + local constant propagation + unreachable code removal).
tests/run_project_tests.py covers project-level cleanup, including:
tests/step5_dir: constant-return method inlining, cross-file static calls, Kotlin companion methods, same-name conflicts, source-set variant safetytests/step6_basic: empty void methods, constant-return booleans, annotation skipping, parameter mismatch, chained calls, inner-class scopetests/step6_enhanced: inheritance hierarchy, public/protected instance method promotion, second-pass cascades, multi-line expressions, enum overridestests/full_pipeline_semantic: full-pipeline semantic tests, including Swift selectors and Storyboard dynamic references, cross-phase cascading
If you fork the project and change cleanup logic, add a case first, then run these two suites. The test files are effectively the boundary documentation.
In Practice
The first real use case was a large Android project where a global internationalization condition had become fixed.
The project was roughly:
19,685source files17,695Java files1,990Kotlin files- About
3.2million lines of source code
The goal was to remove a fixed internationalization path. The checks appeared in UI rendering, resource selection, business branches, fallback logic, utilities, and a few cross-module helpers.
The actual run took about 10 minutes. If the same task were handed to an AI agent to understand and edit file by file, the time cost would be in a different order of magnitude.
Final results:
1,400+files modified- About
3,900transformations (constant folding, call replacement, method inlining, definition deletion, etc.) - Net reduction of approximately
13,000lines - Compilation result: 0 compile errors, passed on first try
Most changes followed the same pattern:
- Internationalization checks were replaced with fixed boolean values
if/elsebranches kept only the path that could actually run- Helpers like
isInternational()/isOversea()were inlined - Empty methods and constant-return methods exposed by inlining were removed
- Call chains serving only the old branch disappeared
- Empty classes and files with only imports were deleted
A diff like this looks large, but the shape is regular. Review is less about re-understanding every business branch line by line, and more about confirming that the tool follows the same cleanup rules consistently.
To me, passing compilation matters more than the number of deleted lines. Large automatic cleanup only becomes practical when the boundaries are conservative and the output is stable.
Closing
Dead code cleanup is easy to underestimate. It is not as visible as a new feature, and it does not always produce a neat performance number. But in a long-lived project, removing logic that is known to be unreachable reduces the cost of every future implementation, refactor, review, and AI-assisted coding session.
dead-code-pruner does not do anything magical. Given explicit constant facts, it uses AST and project-level indexes to propagate those facts until the code converges.
For this kind of tool, the important qualities are not cleverness. They are determinism, conservatism, testability, and repeatability.