dead-code-pruner: Completing a Repeatable, Large-Scale Dead Code Cleanup with ASTs
Code in a large project rarely appears all at once. A feature check, rollout switch, or legacy flag may begin by controlling a single business path. Given enough time, it spreads across screens, services, utility classes, analytics, resource loading, and fallback logic.
Even when that decision becomes permanent—always use one regional path, for example, or permanently disable an old flow—the source does not clean itself up. if (false) is only the most visible symptom; behind it often sits a chain of helpers, empty methods, constant-return methods, meaningless callbacks, and obsolete branches.
Manual cleanup is possible, but across millions of lines it quickly becomes a long game of spot-the-difference. An IDE can flag some cases, and compilers or build-time shrinkers and optimizers (such as Android’s R8) can remove some code from the final artifact, but the complexity remains in the source.
So I built a tool specifically for this problem: dead-code-pruner.
The tool has already completed a full cleanup on an Android project of roughly one million lines, with these results:
| Metric | Result |
|---|---|
| Complete cleanup time | About 10 minutes |
| Files changed | 2,300+ |
| Net code removed | 40,000+ lines |
| Build result | Passed on the first attempt |
Cleanup Is About More Than a Few if Statements
The value of dead code cleanup is not merely a smaller line count.
First, the main execution path becomes shorter. When an obsolete branch remains, every reader must keep asking, “Can this still run?” Once the condition no longer has business meaning, that cognitive cost is pure noise.
Second, later refactoring becomes safer. Retired logic often refers to old models, APIs, resources, or analytics. As long as those references remain, refactors must account for paths that will never execute.
Third, code search and code review become cleaner. Search results polluted by historical branches make the impact of a change easy to misjudge. Reviewers also waste time determining whether an old branch is still valid.
One benefit has become increasingly obvious: with less dead code, AI agents face less noise when understanding a project.
AI agents infer intent from files, references, call chains, and local context. The more invalid code a project contains, the more historical paths are mixed into retrieval results:
- Old branches consume context-window space, delaying relevant code or keeping it out entirely.
- Retired helpers can make the AI assume there is another business path it must preserve.
- Repeated but obsolete conditions create unnecessary implementation branches.
- Non-executable nodes in call chains interfere with bug localization and logic completion.
Removing dead code cannot guarantee that AI will write correct code, but it reduces the invalid information the agent must filter out while understanding the project.
Choosing an Approach
| Approach | Best suited for | Limitation |
|---|---|---|
| Manual cleanup | Small scopes with strong business semantics | Expensive, not repeatable, and prone to missing cascading code |
| IDE inspection | Single files and simple if(true/false) cases | Insufficient for cross-file references, constant-return methods, and cascading deletion |
| Compiler / build-time shrinker and optimizer (such as Android R8) | Optimizing the final artifact | Source complexity and review/maintenance cost remain unchanged |
| Regex script | Very narrow text replacement | Cannot reliably distinguish comments, strings, declarations, calls, and control-flow boundaries |
| AI agent | Assisting analysis, generating tools, and explaining diffs | Repository-wide batch execution is expensive, and results are less stable and repeatable |
| AST tool | Explicit rules, large scale, and repeatable runs | Requires engineered rules for syntax and safety boundaries |
AI agents are excellent at helping write scripts, add tests, analyze syntax trees, or explain why a particular case cannot be removed. But if they are directly told to “delete every unused legacy feature from this repository,” they must continuously retrieve context, analyze dependencies, generate edits, and validate each result. Once the task expands to thousands of files, both execution cost and review cost rise rapidly.
The task also requires a stable distinction between code and comments, declarations and calls, ordinary methods and framework entry points, and same-named methods and their actual targets. If the criteria or scope later changes, a deterministic tool is easier to reproduce, audit, roll back, and rerun than a one-off agent execution.
AI agents are therefore better suited to helping develop and improve the cleanup tool than to directly performing mechanical cleanup across an entire repository. I ultimately chose a Python script backed by AST analysis.
The main advantage of this approach is determinism and control: when the input, rules, and code version remain unchanged, the execution and output remain unchanged as well. If the result is not what I expected, I can roll it back, adjust the analysis or cleanup rules, and run it again without depending on a model reasoning process that cannot be reproduced reliably.
From Source Code to an AST
An AST—Abstract Syntax Tree—is a structured representation of source code. It ignores how spaces and line breaks look and instead decomposes a program into hierarchical nodes such as conditions, operators, calls, and method declarations.
dead-code-pruner uses tree-sitter for parsing. tree-sitter exposes each node’s type, children, and byte range, supports multiple languages, and is well suited to source rewriting that preserves most of the original formatting.
Consider this Java code:
if (true && (false || isLegacyMode())) { RetiredFeature.renderLegacy();}Its AST can be understood in this simplified form:
if_statement├─ condition: binary_expression (&&)│ ├─ left: true│ └─ right: parenthesized_expression│ └─ binary_expression (||)│ ├─ left: false│ └─ right: method_invocation: isLegacyMode()└─ consequence: block └─ method_invocation: RetiredFeature.renderLegacy()The tool therefore knows that (false || isLegacyMode()) is the complete right operand of &&. It can fold both layers with short-circuit rules without accidentally deleting only part of the condition:
if (true && (false || isLegacyMode())) {if (isLegacyMode()) { RetiredFeature.renderLegacy();}A regex looking only for true && ... would also need to handle nested parentheses, multiline calls, comments, and strings. Once enough rules accumulate, it is effectively becoming an incomplete parser.
The AST defines the boundary for safe replacement
Configured replacements occur only in valid code regions identified by the AST; they never enter comments or string literals. Text inside Java/Kotlin text blocks, Go raw strings, Swift multiline strings, Dart raw strings, and nested block comments is therefore never mistaken for ordinary code and replaced directly.
The AST provides the syntax boundary for an individual rewrite, but a single file cannot tell whether a method is still used elsewhere. Removing methods, fields, and classes that lose their references through cascading cleanup requires a project-wide view.
The Project-Wide Cleanup Mechanism
A single AST can answer “What is in this file?” Dead code cleanup must also answer “Who references it from another file?”
The main pipeline therefore has two layers:
- Phase 1 propagates known constants within each file and continuously simplifies expressions and control flow until the file stops changing.
- Phase 2 builds project-wide declaration, reference, and contract indexes, then iterates through “clean declarations → resimplify changed files → refresh indexes” until stable.
flowchart TB
subgraph P1["First row · Phase 1: process every source file"]
direction LR
Start((Start)) --> G1
G1["Step 1 · Replace configured constants<br/>Step 2 · Propagate local constants"]
G1 --> G2["Step 3 · Simplify basic boolean expressions<br/>Step 4 · Simplify compound expressions"]
G2 --> G3["Step 5 · Simplify language-specific expressions<br/>Step 6 · Eliminate dead branches"]
G3 --> G4["Step 7 · Remove unreachable code<br/>Step 8 · Remove unused boolean variables"]
G4 --> Stable{"Has the current<br/>file converged?"}
Stable -->|"No: run another round"| G1
end
subgraph P2["Second row · Phase 2: project-cleanup loop"]
direction RL
S1["Step 1<br/>Unified project scan"] --> S2["Step 2<br/>Clean dead declarations"]
S2 -->|"Changes found"| S3["Step 3<br/>Rerun Phase 1 on changed files<br/>Reuse Steps 1–8 from the first row"]
S3 -->|"After Phase 1 converges"| S4["Step 4<br/>Incrementally refresh indexes"]
S4 -->|"Next round"| S2
S2 -->|"No changes; converged"| S5["Step 5<br/>Remove empty classes and files"]
S5 --> QG2["Final<br/>quality gate"]
QG2 --> Done((Done))
end
P1 -->|"All files converged"| QG1["Inter-phase quality gate"]
QG1 -->|"Enter Step 1"| P2
Why loop? Because dead code cleanup often happens in cascades.
final boolean legacyEnabled = FeatureFlags.LEGACY_MODE;if (!legacyEnabled && (LegacyFeatureController.RETIRED_DEFAULT || isLegacyMode())) { RetiredFeature.renderLegacy();} else { renderCurrent();}Once both configured constants are fixed to false, Phase 1 can simplify the whole condition to isLegacyMode(), but it cannot delete that method from a single-file view alone.
After Phase 2 proves that isLegacyMode() is safe to inline as a constant-return boolean method, the call becomes false, triggering another round of Phase 1 branch cleanup. The method definition and a cross-file empty class then lose their final references as well.
final boolean legacyEnabled = FeatureFlags.LEGACY_MODE;if (!legacyEnabled && (LegacyFeatureController.RETIRED_DEFAULT || isLegacyMode())) { RetiredFeature.renderLegacy();} else { renderCurrent();}renderCurrent();A single pass leaves the code exposed by later changes behind. This kind of cleanup must run to convergence.
Pipeline in Detail
Phase 1: Source Simplification
The first phase handles expressions and control flow within a single file. Its eight steps run in their actual execution order and loop until the file stops changing.
Conceptually, the eight steps fall into four groups:
| Stage | Steps | Purpose |
|---|---|---|
| Inject known facts | Step 1 | Write the constants from configuration into source code |
| Propagate and fold expressions | Steps 2–5 | Propagate local constants and simplify boolean and language-specific expressions |
| Simplify control flow | Steps 6–7 | Remove dead branches and unreachable statements |
| Clean propagation residue | Step 8 | Remove boolean variables that no longer have any uses |
To anchor the sequence, consider this Java input:
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();}The configuration establishes two known facts:
replacements: - pattern: "FeatureFlags.LEGACY_MODE" value: false - pattern: "LegacyFeatureController.RETIRED_DEFAULT" value: falseStep 1: Replace Configured Constants
Replace configured patterns with source-level literals:
final boolean legacyEnabled = FeatureFlags.LEGACY_MODE;final boolean legacyEnabled = false;if (!legacyEnabled && (LegacyFeatureController.RETIRED_DEFAULT || isLegacyMode())) {if (!legacyEnabled && (false || isLegacyMode())) { RetiredFeature.renderLegacy();}Configured replacement operates only on code nodes confirmed by the AST. The same text appearing in a comment or string does not trigger a replacement.
Step 2: Propagate Local Constants
Detect immutable boolean locals such as final boolean, val, and let, then replace their uses with literals:
final boolean legacyEnabled = false;if (!legacyEnabled && (false || isLegacyMode())) {if (!false && (false || isLegacyMode())) { RetiredFeature.renderLegacy();}This step is scope-aware: propagation stays within the variable’s valid declaring scope and never replaces a same-named identifier across method or class boundaries.
Step 3: Simplify Basic Boolean Expressions
Step 3 handles negation and comparisons between boolean literals:
| Input | Output |
|---|---|
!true | false |
!false | true |
true == false | false |
false != true | true |
if (!false && (false || isLegacyMode())) {if (true && (false || isLegacyMode())) { RetiredFeature.renderLegacy();}Step 4: Simplify Compound Expressions
Step 4 handles short-circuit boolean operators and ternary expressions:
| Input | Output |
|---|---|
true && expr | expr |
false && expr | false |
true || expr | true |
false || expr | expr |
true ? A : B | A |
false ? A : B | B |
These two steps often happen back to back: Step 3 folds a negation, then Step 4 uses short-circuit rules to eliminate the entire right-hand expression.
if (true && (false || isLegacyMode())) {if (isLegacyMode()) { RetiredFeature.renderLegacy();}Step 5: Simplify Language-Specific Expressions
The same business condition can map to different syntax nodes in different languages. Kotlin if expressions, expression-bodied functions, and property access, for example, cannot all be handled as Java statements.
At this point the corresponding language adapter performs the required language-specific simplification before ordinary dead-branch elimination:
val title = if (false) legacyTitle else currentTitleval title = currentTitleThe running Java example does not require an additional language-specific rewrite at this step. The concrete rules for other languages are covered in the Language Adapters section below.
Step 6: Eliminate Dead Branches
| 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 } |
The actual rewrite removes the branch wrapper as well; it does not leave a meaningless if (true) behind:
if (false) { RetiredFeature.renderLegacy();} else { renderCurrent();}renderCurrent();Step 7: Remove Unreachable Code
Step 7 removes statements after return, throw, break, or continue, as well as statements after an if-else whose every branch terminates.
In the running example, one branch returns and the other throws, so unreachableLegacyCleanup() cannot run on any path:
void render(boolean shouldStop) { final boolean legacyEnabled = false;
renderCurrent();
if (shouldStop) { return; } else { throw new IllegalStateException("render stopped"); }
unreachableLegacyCleanup();}This decision cannot be made by looking only at the preceding text statement; it requires understanding whether every path through the entire control-flow structure terminates.
Step 8: Remove Unused Boolean Variables
After propagation, legacyEnabled has no remaining uses. Step 8 removes the corresponding immutable boolean declaration.
final boolean legacyEnabled = false;if (isLegacyMode()) { RetiredFeature.renderLegacy();}After Step 8, the file returns to Step 1. As long as another round produces a change, the loop continues; Phase 1 reaches stability only when the entire file stops changing.
The difficult part is not the boolean decision itself, but the surrounding engineering details:
- Expanded branches are re-indented so generated diffs remain readable.
- When a Java branch contains local variable declarations, braces are retained where necessary to prevent scope leakage.
- Deletion never crosses a
case/defaultlabel into an adjacent switch branch.
Phase 1 resolves everything that can be proved from known facts inside a file. Project-level indexes are still needed to determine which methods, fields, and classes have lost all valid references as a result.
Phase 2: Project Cleanup
Phase 2 shares one project scan and one set of indexes across its cross-file capabilities, then converges incrementally through five steps.
Step 1: Scan the Project Once
The tool performs one unified scan of the project and collects all analysis data at once:
flowchart LR
A["Read source files"] --> B["Scan method candidates"]
A --> C["Build reference index"]
A --> D["Extract contracts and class hierarchy"]
A --> E["Scan field candidates"]
B --> F["Unified project snapshot"]
C --> F
D --> F
E --> F
The unified scan collects the following project facts during the same source read and parse:
- Declaration locations, types, modifiers, and owning classes for methods and fields
- The files in which each method may be called
- Class, interface, inheritance, and implementation relationships
- Abstract contracts and protocol relationships in each language
- Field visibility, access forms, and module boundaries
- Framework configuration and language-level indirect references outside ordinary source calls
Together, these results form a unified project snapshot. Candidate selection, call-site handling, and definition deletion all use this snapshot instead of independently rescanning the repository in multiple steps.
Step 2: Clean Dead Declarations
Step 2 is the core of Phase 2. It does not merely search for whether a method name appears; it performs candidate discovery, reference confirmation, call-site rewriting, and definition deletion in sequence.
1. Find Candidates That May Need Cleanup
First, it finds declarations that may need cleanup, including zero-argument constant-return boolean methods, zero-argument empty methods, other methods with no valid references, immutable fields with no valid read sites, and declarations that have just lost their final reference in the previous round.
Language-level privacy only makes a declaration eligible; it does not make the declaration immediately removable. Contract methods on interfaces or abstract classes, overridden methods, annotated members, and framework entry points are excluded first.
2. Confirm That References Actually Target the Declaration
A method name alone is not enough to identify a reference. The tool also considers the module, file, owning class, parameter count, and declaration location to distinguish overloads and same-named methods. Rewrites within one file stay inside the owning class; cross-file rewrites are limited to calls whose target can be identified through the class name, preventing a same-named method in another class from being changed accidentally.
For example, all of these may coexist in one project:
A.render();B.render();render();render(dialog);If the target cannot be identified uniquely, the candidate is preserved. Whether public declarations may continue to deletion analysis also depends on whether the project boundary accounts for every source consumer; applications and SDKs interpret “zero references” differently.
3. Choose Handling Based on Candidate Type
Different candidates are handled differently:
| Candidate type | Call-site handling | Definition handling |
|---|---|---|
| Zero-argument constant-return boolean method | Replace with true or false | Delete after references are cleared |
| Zero-argument empty method | Remove side-effect-free standalone calls | Delete after references are cleared |
| Other constant getter | Do not expand calls that are still live | Delete after project-wide zero-reference proof |
| Method with parameters or ordinary method | Do not proactively rewrite call sites | Delete only when every reference form is covered and zero references are proven |
| Kotlin/JVM property | Include getter/property aliases in the reference index | Keep the source property while any alias remains referenced |
| Immutable field | Do not rewrite references that are still live | Delete only when the project boundary permits it |
Calls to methods with parameters are never removed as a cleanup shortcut because evaluating the arguments may have side effects. For an ordinary method body, “private with no method call in the text” is still not enough: function values, Swift selectors, Dart methods passed as values, Kotlin trailing lambdas, and framework dispatch can all create indirect references. A definition is deleted only when the language adapter recognizes these reference forms and the project index proves zero references; otherwise it is preserved.
For example:
unusedMethod(loadData());Even if unusedMethod itself has become meaningless, loadData() may still mutate state, perform I/O, or throw. An ordinary call with arguments is therefore never removed like a call to an empty zero-argument method.
4. Rewrite Call Sites Before Deleting Definitions
After processing call sites, the tool rebuilds the reference index before deciding whether method definitions can be deleted. The source has already changed, so reference relationships may have changed too. This ordering is critical.
For example, calls to constant-return boolean methods first become literals; the method definition and the now-meaningless empty-method call are removed after reference validation succeeds:
private boolean isLegacyMode() { return false;}
private void recordLegacyExposure() {}
void render() { recordLegacyExposure(); if (isLegacyMode()) { if (false) { RetiredFeature.renderLegacy(); } else { renderCurrent(); }}Fields are also removed as complete declarations. For a statement such as int used, unused; that declares multiple variables at once, the entire declaration is removed only when every variable satisfies the deletion conditions.
private static final boolean RETIRED_DEFAULT = false;If any source changes during this round, Step 2 proceeds to the next step so that the changes can expose further file-local simplifications.
Step 3: Rerun Phase 1 on Changed Files
Only files modified by dead declaration cleanup re-enter the Phase 1 simplification loop. Newly exposed constant expressions and dead branches can continue converging without another full-project scan.
After these files converge in Phase 1, they proceed directly to Step 4 to refresh the project indexes. They do not restart the initial project scan or rerun Phase 1 across the entire repository.
The if (false) left by the previous step is removed here:
void render() { if (false) { RetiredFeature.renderLegacy(); } else { renderCurrent(); } renderCurrent();}Step 4: Incrementally Refresh the Project Indexes
Before the next round, the tool reparses only the files changed in the current round. It first removes those files’ old records from the project snapshot, then writes their latest declarations, references, and contract information. Analysis results for unchanged files are reused, so the entire project does not need to be scanned again.
flowchart LR
Changed["Files changed this round"] --> Drop["Remove old facts for those files"]
Drop --> Rescan["Reparse and rescan"]
Rescan --> Merge["Merge into project indexes"]
Merge --> Next["Next dead declaration cleanup round"]
Removing the changed files’ old declaration, reference, and contract facts before merging the new scan prevents deleted interface methods, call relationships, or type information from lingering in the indexes. Phase 2 then returns to Step 2 to find the next declarations that lost their references in this round.
Step 5: Remove Empty Classes and Files
After project cleanup converges, the tool detects classes whose members have all been removed. It deletes a class declaration only after confirming that no other file in the project references that class. If the file then contains only non-declaration content such as package and import statements, the entire source file is deleted.
diff --git a/RetiredFeature.java b/RetiredFeature.javadeleted file mode 100644--- a/RetiredFeature.java+++ /dev/null@@-package com.example.legacy;--final class RetiredFeature {-}This diff represents deletion of the entire RetiredFeature.java file, rather than leaving behind an empty shell containing only package and import statements.
The Final Diff After the Full Pipeline
Each small diff above is one local slice of the same legacy-feature cleanup. Combining Phase 1’s per-file convergence with Phase 2’s declaration cleanup and index refresh produces a project-wide diff like this:
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() {}-}Together, this result demonstrates configured constant replacement, local propagation, boolean folding, dead-branch and unreachable-code removal, constant-return boolean method inlining, empty-method cleanup, unused field deletion, and finally empty class and file removal.
The phases describe the order in which code changes. Whether any concrete change is allowed still depends on the safety rules, language rules, and project boundaries that apply throughout the pipeline.
Capabilities Shared Across Both Phases
Phase 1 and Phase 2 describe the order in which code is processed.
Whether an individual change is allowed depends on three other capabilities that apply throughout the pipeline:
- Syntax and reference safety checks
- Adaptation rules for different languages
- Module and project-boundary decisions
Safety Boundaries
For an automatic cleanup tool, deleting too little merely leaves work for a person; one incorrect deletion can change program behavior. Whenever references, dynamic entry points, or project boundaries cannot be established confidently, the tool preserves the code.
Four Layers of Safety Gates
| Layer | When | Mechanism |
|---|---|---|
| Individual rewrite check | After each edit | Reparse the AST; roll back the edit if it introduces syntax errors |
| Inter-phase | After Phase 1 | Check every changed file before project-level deletion begins |
| Residual-reference check | Before definition deletion | Verify that no residual calls remain in the file |
| Final quality gate | After the complete pipeline | Check everything again and roll back problematic files |
Conservative Rules
- Annotated methods and methods invoked through dependency injection, routing, or similar frameworks are preserved because they may have no ordinary source-level call site.
abstract,open,override, andnativemethods, along with contract methods in interfaces, protocols, and abstract classes, are not treated as ordinary dead methods.- A shared name does not imply a shared target. The tool also checks the module, class, and parameter count to distinguish
render(),render(dialog), and same-named methods in different classes. - A chained call is not deleted as an ordinary standalone statement;
method().subscribe(), for example, still uses the method’s return value. - Dynamic entry points such as Swift selectors, Storyboard/XIB events, and Android XML callbacks enter the reference index, preventing decisions based only on source-level calls.
- Calls to methods with parameters are not removed as a cleanup shortcut. A definition is deleted only when project-wide zero-reference proof and the language-specific rules both establish that it is safe.
- Multi-module projects build declaration identities and reference relationships separately for each module, so a same-named method in another module cannot influence the current decision.
Conservatism leaves some code for manual follow-up, but that tradeoff is worthwhile. The most important quality of an automatic cleanup tool is not how much it appears to delete, but whether people trust it enough to run repeatedly.
Language Adapters
AST nodes describe syntax, but languages express entry points, inheritance contracts, property access, and dynamic calls differently. A language adapter converts those differences into a common set of candidate, reference, and protection rules.
| Adapter | Special entry points and contracts | References or syntax that are easy to misclassify |
|---|---|---|
| Java | Android/JVM callbacks, annotations, interfaces, and abstract classes | Overloads, static imports, metadata references, and switch scope |
| Kotlin | Top-level main, annotations, default parameters, inheritance, and implementation relationships | Getter/property aliases, trailing lambdas, expression-bodied functions, and if expressions |
| Go | main, init, test entry points, exported declarations, and structural interfaces | Receiver methods, function values, grouped parameters, and raw strings |
| Swift | Protocols, extensions, access levels, and parameter labels | Selectors, Storyboard/XIB events, function references, and multiline strings |
| Dart | main, metadata annotations, abstract contracts, implementation relationships, and underscore-private declarations | Method references, arrow functions, optional parameters, and raw strings |
For example, Kotlin if can directly produce a value:
val title = if (false) legacyTitle else currentTitleval title = currentTitleA Kotlin property may also be accessed through either property syntax or its generated getter:
class FeatureState { val legacyEnabled: Boolean get() = false}One caller may use:
state.legacyEnabledwhile Java code uses:
state.getLegacyEnabled();The tool therefore cannot check only whether legacyEnabled still appears in source. It must associate the property name and generated getter form with the same declaration identity. Other language-specific entry points and reference forms follow the same principle: adapters normalize the differences, while the project-level pipeline consumes a common set of candidates, references, and protection facts.
Multi-Module Project Support
“Zero references” means different things in an application and a library. An application or deployed service can normally see every source consumer and can be treated as a closed world. A library or SDK may be called by code outside the repository and therefore belongs to an open world.
| Project boundary | Typical projects | How zero-reference declarations are handled |
|---|---|---|
| Closed world | Applications, executables, deployed services | Public but unreferenced declarations may be evaluated for deletion |
| Open world | Libraries, SDKs, publishable modules | Public APIs are preserved by default; only declarations proven unavailable to external callers are cleaned |
The tool also detects the build system and module structure so each module can maintain its own declaration identities and reference relationships:
| Build system | Detection | Module isolation |
|---|---|---|
| Gradle (Android / JVM) | settings.gradle / settings.gradle.kts | Each :module is analyzed independently |
| Maven | Child modules in pom.xml | Each subdirectory is analyzed independently |
| Go | go.mod | Root module and nested modules |
| Dart / Flutter | pubspec.yaml | Root package and sub-packages under packages/ |
| Xcode | .xcworkspace / .xcodeproj | The workspace is treated as one analysis boundary |
As a result, same-named methods such as Utils.isEnabled() in different modules are tracked and analyzed independently. Each module also selects a project boundary according to its own purpose; the application module’s boundary cannot be used to make deletion decisions for an SDK module.
Automatic classification follows these principles:
- Applications, executables, services, Flutter apps, Xcode apps, and modules with deployment configuration count as closed-world evidence.
- In an application or service Gradle build, an internal library module without publishing configuration inherits the host’s closed world.
- Publishing configuration, standalone libraries, and SDKs count as open-world evidence.
- When open- and closed-world signals conflict, the open-world interpretation wins.
- A standalone project whose boundary cannot be determined defaults to open world.
This distinction matters. In an application, zero in-project references usually means every source consumer is unreachable, so unreferenced public static or top-level declarations can be considered for cleanup. In an SDK, callers may exist outside the repository, so public APIs and externally visible empty types must be preserved.
Performance and Parallel Processing
The core performance goal is not to shave a few milliseconds from one AST operation, but to reduce repeated repository-wide work.
The main strategies are:
- Phase 1 lets each file loop directly to stability during its turn, avoiding repeated project-wide traversal for the same file.
- Phase 2 collects methods, fields, references, contracts, and class hierarchy information in one unified scan instead of running several independent repository-wide indexing passes.
- Later rounds refresh only changed files. Analysis results for unchanged files are reused, so the amount of work depends mainly on the scope of the current round’s changes.
- Once a project reaches the threshold where parallelism pays off, file transformation, project scanning, and field-reference analysis are distributed across worker processes. Small projects remain sequential so process startup does not cost more than it saves.
- The original-file snapshots used for rollback and final statistics are saved once and reused across both phases, avoiding repeated copies of the entire repository.
- Field-reference analysis extracts identifiers per file and matches them against the candidate set in one pass, avoiding a separate project scan for every candidate field.
Together, these optimizations determine whether the tool remains a script that can process a handful of files or becomes an engineered tool that can complete a run on a large project.
Usage
Install the dependencies:
git clone https://github.com/OldJii/dead-code-pruner.gitcd dead-code-pruner
pip install -r requirements.txtPrepare pruner.yaml:
replacements: - pattern: "FeatureFlags.LEGACY_MODE" value: false - pattern: "LegacyFeatureController.RETIRED_DEFAULT" value: falseRun it:
The CLI automatically looks for pruner.yaml in the current directory, so the full pipeline does not require an additional configuration-file argument:
python3 -m pruner /path/to/your/projectRun it on a separate branch, inspect the complete diff afterward, and use the target project’s existing build, static checks, and automated tests as final acceptance checks.
Supported Project Ecosystems
The project selects language adapters and module boundaries from file extensions and build structure. It currently covers five ecosystems:
| Ecosystem | Languages | Extensions |
|---|---|---|
| Android | Java, Kotlin | .java, .kt, .kts |
| JVM services | Java, Kotlin | .java, .kt, .kts |
| Go services | Go | .go |
| iOS | Swift | .swift |
| Flutter | Dart | .dart |
Using It Outside Android
Syntax adapters and automated tests cover Java, Kotlin, Go, Swift, and Dart, but large-scale validation on real projects has so far come mainly from Android.
Framework entry points, code generation, and dynamic dispatch differ across ecosystems, so start with a separate branch and narrowly scoped validation elsewhere.
If a syntax form is not cleaned as expected, do not immediately loosen deletion rules across a large project. First capture it as a minimal reproducible case:
- Keep the smallest possible input source and expected output.
- Determine whether the gap is in AST-node recognition, the reference index, or framework-entry protection.
- Let an AI agent help inspect the syntax tree and existing Language Adapter.
- Have a person confirm the safety boundary of the new rule.
- Make the new case pass, then run the existing regression suites.
- Only then apply the new rule to the target repository.
Start with a small scope
The key to cross-language cleanup is not merely “the syntax parses,” but “the target project’s implicit entry points are understood.” In a new ecosystem, begin with a limited directory, inspect the diff, and use the target project’s own build and tests as the final acceptance criteria.
Full regression commands after changing a Language Adapter
python3 tests/run_tests.pypython3 tests/run_project_tests.pypython3 tests/test_language_matrix.pypython3 tests/test_project_boundary.pyClosing
Dead code cleanup is easy to underestimate.
It is not as visible as a new feature and does not produce comparison numbers as intuitively as a performance optimization. But in a long-lived large project, removing logic that is known never to execute reduces the burden on every later round of development, refactoring, code review, and AI-assisted coding.
dead-code-pruner takes explicit constant facts and uses ASTs plus project-wide indexes to propagate them deterministically through expressions, control flow, methods, fields, and types until the entire project stops changing.
What matters most for a tool like this is not how much code it removes in one run, but determinism, conservatism, testability, and repeatability.