dead-code-pruner: 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 a million-line-scale Android project, 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: cleanup also helps AI write correct code.
When an AI agent understands a project, it infers intent from files, references, call chains, and local context. The more dead code a project contains, the more invalid patterns it reads and the more distracting examples retrieval surfaces.
This is especially noticeable in large projects:
- 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 extra implementation branches and unnecessary defensive code.
- Non-executable nodes in call chains interfere with bug localization and logic completion.
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 | Reading, reasoning about, editing, and validating every file is slow at project scale, and results are less stable |
| 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, or explain why a particular case cannot be removed. They are not a good fit for a direct instruction to “delete every unused legacy feature from this repository.”
That task is not one decision; it is thousands of small decisions. Each one must consistently distinguish code from comments, declarations from calls, ordinary methods from framework entry points, and same-named methods from real references. If even a small fraction of those decisions are unstable, the resulting diff becomes difficult to review.
Throughput is another concern. When agents such as Claude Code, Cursor, or Codex process files, they do more than scan text once: they retrieve context, reason about changes, generate edits, and inspect the result. At repository scale this introduces substantial latency and cost, while deterministic local analysis can process repeated syntax patterns directly.
AI agents are still valuable while developing the tool: they can analyze failing cases, explain ASTs, and generate test fixtures before a person confirms the boundary. But for scanning and rewriting the repository itself, I chose a deterministic AST-based approach. The input is an explicit constant map, cleanup rules live in code, and representative edge cases are fixed in tests. The same input should produce the same output every time.
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 critical boundary provided by the AST
Configured patterns are applied only outside ranges the AST identifies as comments or strings. Java/Kotlin text blocks, Go raw strings, Swift extended strings, Dart raw strings, nested block comments, and interpolation boundaries are therefore not blindly treated as ordinary code.
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 until convergence; Phase 2 builds project-wide indexes and iterates through “delete declarations → simplify changed files → refresh indexes” until stable.
flowchart LR
P1["Phase 1<br/>Source simplification<br/>Per-file convergence"] --> QG1["Inter-phase<br/>quality gate"]
QG1 --> S1["Phase 2 · Step 1<br/>Unified project scan"]
S1 --> S2["Step 2<br/>Clean dead declarations"]
S2 -->|"Changes found"| S3["Step 3<br/>Rerun Phase 1 on changed files"]
S3 --> S4["Step 4<br/>Incrementally refresh indexes"]
S4 --> S2
S2 -->|"No changes; converged"| S5["Step 5<br/>Remove empty classes and files"]
S5 --> QG2["Final<br/>quality gate"]
QG2 --> Done((Done))
Where boolean method inlining belongs
In the full pipeline, Phase 2’s dead declaration cleanup handles constant-return boolean methods. The project still provides a standalone boolean method inliner for focused tests and custom integrations, but it is no longer a separate phase in the main pipeline.
Why loop? Because dead code cleanup is often cascading.
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. That triggers another Phase 1 branch cleanup, while the method definition and a cross-file empty class also lose their final references.
final boolean legacyEnabled = FeatureFlags.LEGACY_MODE;if (!legacyEnabled && (LegacyFeatureController.RETIRED_DEFAULT || isLegacyMode())) { RetiredFeature.renderLegacy();} else { renderCurrent();}renderCurrent();A single pass leaves the tail behind. This kind of cleanup needs to 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.
Step 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();}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 declaring scope and never crosses method or class boundaries.
Step 3: Simplify Basic Boolean Expressions
| 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
At this point the language adapter performs language-specific expression cleanup. A Kotlin if expression, for example, is simplified before ordinary dead-branch elimination:
val title = if (false) legacyTitle else currentTitleval title = currentTitleStep 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
Remove statements after return, throw, break, or continue, as well as statements after an if-else whose every branch terminates:
if (isLegacyMode()) { RetiredFeature.renderLegacy(); return; unreachableLegacyCleanup();}Step 8: Remove Unused Boolean Variables
Remove final boolean declarations that have no remaining uses after propagation.
final boolean legacyEnabled = false;if (isLegacyMode()) { RetiredFeature.renderLegacy();}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 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 reads the source once and collects:
- Method candidates: method name, return type, parameter count, modifiers, owning class (including start/end lines as a unique identifier), and declaration range.
- Reference index: method names mapped to files that may contain calls, including Kotlin property access forms.
- Per-file contract facts: abstract contracts, inheritance, explicit conformance, Go structural interfaces, Swift protocols, and Dart abstract/implements relationships.
- Field candidates: immutable declarations, compiler-generated accessor aliases, visibility, module boundary, and complete declaration range.
The reference index looks beyond method() calls in source. It also includes Swift #selector, Storyboard/XIB actions, Android XML callbacks, static imports, member receivers, and identifier values in XML/JSON/plist files. Contract facts are stored per file; an incremental rescan removes old facts before merging new ones, so a deleted interface method cannot remain as a stale safety constraint.
Step 2: Clean Dead Declarations
Using the unified scan, this step applies unique identity, safety decisions, call-site rewriting, definition deletion, and unused-field checks in order. Constant-return boolean method inlining also happens here because it can reuse the same reference index and definition-deletion logic.
Method identity
Each method candidate records two keys during scanning:
semantic_method_key:(module, package, class, name, param_count)— detects variant conflicts in multi-module projects._method_key:(filepath, class, name, param_count, decl_start, decl_end)— tracks methods already processed.
Two mechanisms keep same-named methods in different classes precise:
- Same file:
class_lines(the class’s start and end lines) limits the replacement scope. - Cross-file: only qualified
ClassName.method()calls are replaced.
Safety decisions
The safe_to_inline flag on a dead method candidate determines whether it can be processed safely:
| Declaration type | Mechanism |
|---|---|
Java/Kotlin private, Swift private/fileprivate, Dart _ private, Go unexported | The adapter only makes it eligible; same-file and cross-file indexes must still prove zero references |
| Interface/protocol/abstract/override/annotated members | Kept before call rewriting begins |
| Public instance methods | Processed only if reference, inheritance, dynamic metadata, and contract checks all prove them unreachable |
| Immutable fields | Removal scope follows the open/closed project boundary described below |
Field cleanup follows the same project-boundary model as method cleanup:
| Project boundary | Field deletion rule |
|---|---|
| Open world | Only unreferenced, language-private immutable fields become candidates |
| Closed world | Unreferenced static constants and top-level immutable fields may also be deleted |
| Always kept | Annotated fields and fields still found through source, generated-accessor, or dynamic-reference analysis |
The tool deliberately preserves different rewriting boundaries for different candidate types:
| Candidate type | Call-site handling | Definition handling |
|---|---|---|
| Zero-argument constant-return boolean method | Replace with true or false | Delete after references are cleared |
| Empty void method | Remove side-effect-free call statements | Delete after references are cleared |
String, numeric, or null/nil getter | Keep encapsulation at live calls | May delete after project-wide zero-reference proof |
| Method with parameters | Do not replace, avoiding loss of argument side effects | Delete only when the adapter can prove every reference form |
| Kotlin/JVM property | Include getter/property aliases in the reference index | Keep the source property while any alias remains referenced |
Empty void and constant-return boolean helpers have explicit dead-code rewriting semantics in every supported language, so they can be handled uniformly. Non-boolean constant getters are not expanded into live callers, but their definitions may still be deleted after zero-reference proof. Arbitrary non-constant method bodies require a stronger adapter-level proof: in languages with trailing lambdas, function values, tear-offs, selectors, or framework dispatch, “private with no textual references” is not sufficient. Automatic deletion is currently enabled only for reference-complete cases such as Java static helpers. Kotlin, Go, Swift, and Dart retain arbitrary bodies whenever the adapter cannot prove every reference form. Kotlin’s reference index also recognizes parenthesis-free trailing-lambda calls such as withLegacyFeature { ... }.
Call-site handling + definition deletion
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, boolean helper calls first become literals; the definition and now-meaningless empty void 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 shared declaration range such as int used, unused;, the whole line is deleted only if every declarator is removable.
private static final boolean RETIRED_DEFAULT = false;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.
The if (false) left by the previous step is removed here:
void render() { if (false) { RetiredFeature.renderLegacy(); } else { renderCurrent(); } renderCurrent();}Step 4: Refresh Changed Files
Before the next round, the unified scan refreshes only the files changed in this round. This exposes new cleanup candidates without rescanning the entire project.
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"]
Step 5: Remove Empty Classes and Files
After project cleanup converges, the tool detects classes left empty after all members are removed. It deletes a class declaration only when cross-file references are also empty. If the file then contains only non-declaration content such as package and import statements, the entire source file is deleted.
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, boolean helper inlining, empty void cleanup, unused field deletion, and finally empty class and file removal.
Capabilities Shared Across Both Phases
The two phases describe execution order. Safety boundaries, language adapters, and multi-module analysis are not extra cleanup stages; they provide constraints and context throughout the pipeline. Per-transformation checks protect Phase 1, adapter rules participate in both phases, and module and project boundaries primarily constrain Phase 2 deletion decisions.
Safety Boundaries
The tool is deliberately conservative.
Four Layers of Safety Gates
| Layer | When | Mechanism |
|---|---|---|
| Per-transformation | 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 |
| Dangling 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 are kept because
@Override, DI, routing, AOP, and serialization entry points may be invoked at runtime. abstract,open,override, andnativemethods are kept.- Methods on interfaces, protocols, and abstract classes are retained as contracts.
- Parameter counts must match:
render()andrender(dialog)are different methods. - Same-named methods in different classes are handled precisely through
class_linesscoping and class-name qualification. - Chained calls are not rewritten blindly;
method().subscribe()is not an ordinary standalone call. - Dynamic entry points such as Swift selectors and Storyboard/XIB actions enter the reference index.
- Calls to methods with parameters are not replaced because arguments may have side effects, though definitions may still be deleted.
- Same-named methods in multi-module projects are analyzed in module-isolated scopes and cannot conflict across modules.
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
Each language has a dedicated adapter:
| Adapter | Language-specific boundaries |
|---|---|
| Java | Android/JVM callbacks, annotations, overload arity, interfaces/abstract classes, static imports, XML/JSON metadata, and switch scope |
| Kotlin | Android/JVM callbacks, top-level main, annotations/default parameters, expression-bodied functions, if expressions, JVM-generated property accessors, companion/private fields, and colon-style contracts |
| Go | Grouped parameters, receiver ownership, exported/test/init/main entry points, structural interfaces, unexported methods and constants, and raw strings |
| Swift | External/internal parameter labels, implicit constant returns, protocols, private/fileprivate rules, extended and multiline strings, selectors, and Storyboard/XIB/plist references |
| Dart | Adjacent signature/body nodes, arrow bodies, optional parameters, abstract/implements contracts, metadata annotations, underscore-private methods and fields, and raw strings |
Multi-Module Project Support
The tool automatically detects the project’s build system and module structure:
| 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 plus nested modules |
| Dart/Flutter | pubspec.yaml | Root package plus sub-packages under packages/ |
| Xcode | .xcworkspace / .xcodeproj | A single workspace scope |
Module awareness means same-named methods such as Utils.isEnabled() in different modules are tracked and analyzed independently, without conflicts.
The same module map drives an explicit project-boundary model:
- Applications, executables, services, Flutter apps, root Go executables, Xcode apps, and deployment metadata count as closed-world evidence.
- Unpublished library modules inside an application or service Gradle build inherit the host’s closed boundary; the library plugin describes compilation shape, not external publication.
- Publishing configuration and standalone library products count as open-world evidence and take precedence when signals conflict.
- Standalone projects whose boundary cannot be determined default to open, preserving APIs that may be called from outside the repository.
This distinction matters. In an application, zero references means every source consumer is unreachable, so unreferenced public static or top-level declarations can be removed. In an SDK, callers may exist outside the repository, so public APIs and externally visible empty types must be preserved.
Performance and Parallel Processing
Performance work is primarily about avoiding repeated project-wide operations:
- Per-file convergence lets Phase 1 avoid repeated full-project traversal.
- Phase 2 builds methods, fields, references, contracts, and class hierarchies together, replacing multiple independent repository-wide indexing passes with one unified scan.
- Once a project is large enough to benefit, CPU-intensive file transformations, project scanning, and field-reference analysis are distributed across worker processes. Small projects remain sequential to avoid process startup overhead.
- Incremental convergence reuses analysis data for unchanged files, so later rounds are driven mainly by the changed-file set.
- Safety snapshots are read once and reused throughout the pipeline instead of rereading original content for every phase.
- Field-reference scanning extracts identifiers once and matches them with set lookups, making its cost grow mainly with source size rather than multiplying by the number of candidate fields.
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 a configuration-file argument:
python3 -m pruner /path/to/your/projectI recommend running it on a separate branch and reviewing the diff directly afterward.
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 a fork and a narrowly scoped validation are the safer place to begin 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 a minimal input source file and its expected output.
- Determine whether the gap is in AST node recognition, the reference index, or framework entry-point protection.
- Let an AI agent help analyze the syntax tree and existing adapter, then have a person confirm the safety boundary of the new rule.
- Make the new case pass, run the full regression suite, and only then apply it to the target repository.
Start with a small diff
The key to cross-language cleanup is not merely “the syntax parses,” but “every implicit entry point in the project is 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 an 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 less visible than a new feature and rarely comes with a polished 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.
There is nothing mysterious about dead-code-pruner. Given explicit constant facts, it uses ASTs and project-wide indexes to propagate those facts consistently until the code converges.
For this kind of tool, the most important qualities are not cleverness, but determinism, conservatism, testability, and repeatability.