Identifying Dead Code and Deprecated Features in a Repository
Five types of dead code each need their own detection method to safely remove.

Dead code is any code that still lives in a repository, still compiles, still passes the test suite, and yet no longer does anything to affect the program's behavior. Dead code is neither a bug nor broken; it just sits there. It just sits there. The central claim here is straightforward: dead code needs a different detection technique for each of its forms, and in a codebase spread across dozens of repositories, the hard part is coordinating the cleanup once detection has already happened. The hard part is coordinating the cleanup once detection has already happened, and most teams get that sequencing backwards.
Start with a distinction most engineers blur without meaning to: dead code is not unreachable code. Unreachable code sits after a return, a throw, or a conditional that can never evaluate true, and compilers catch it automatically, often with a warning at build time. Dead code is different. It's technically executable, and the compiler has no objection to it. Its results simply never get used, or the path that would trigger it never fires in practice. That difference matters operationally: unreachable code can't be reactivated because it structurally can't run, while dead code can spring back to life the moment something reconnects it to the control flow. That single property, the chance of reactivation, is what makes dead code dangerous in a way that unreachable code never is.
Redundant code, obsolete logic, commented-out code, empty control structures, and unused exports each carry their own signature and their own risk profile once someone finally decides to delete them. Redundant code is the duplicate function doing the same job as another one somewhere else in the repo, usually the result of two engineers solving the same problem in parallel, or a copy-paste that never got consolidated. Obsolete logic is the old implementation left behind after an API upgrade or a platform migration that nobody circled back to finish. Commented-out code loses its context within weeks of being written, and it's the most visible of the group and the least useful. Empty control structures, stubbed if, for, and while blocks that never got filled in, inflate a codebase's cyclomatic complexity without adding any behavior. Unused exports, functions or modules exported from a file but never imported anywhere downstream, are the quiet ones: they don't clutter a diff or trip a linter by default, so they just accumulate.
The boundary around all five categories is fuzzier than any of them sound in isolation. Reflection, dynamic dispatch, plugin architectures, and dependency-injection frameworks can all invoke code through a call site that never appears as a literal reference anywhere in the source. Static analysis cannot see a function name that only exists as a string at runtime, and that limitation is visible again in the section on feature flags below. No tool built on parsing source text alone will ever fully close that gap, and pretending otherwise is the single most common mistake teams make when they buy a scanner and assume it settled the question.
How dead code accumulates and what it costs teams
Dead code doesn't pile up because engineers are careless. It piles up because of how software actually gets built. Requirements change, a replacement ships, and the old path just stays where it was because removing it wasn't part of anyone's ticket. Refactors get to 90% complete, with the new callers wired up and the tests green, while the old callee that nobody points to anymore just sits there, technically alive.
AI-assisted coding tools have added a new accelerant, and this is where most teams get the risk backwards. The instinct is to treat Cursor and Copilot as productivity wins with no downside, but these tools speed up how fast dead code accumulates simply because they generate code faster than teams clean it up. A new engineer, dropped into a codebase they don't know yet, will duplicate logic that already exists three files over, because finding it would have taken longer than writing it again. Faster code generation without a matching cleanup habit is a net loss dressed up as a productivity gain.
The hit to output is measurable. Azul's State of Java Survey found that 63% of respondents said dead and unused code was hurting their team's productivity, and only 6% said it had no impact. That's the substantial majority of a surveyed engineering population saying the code nobody wrote this sprint is slowing down the code someone needs to ship this sprint.
The tools adopted to move faster are colliding with the debt those same tools help create. IBM Institute for Business Value research found that enterprises accounting for technical debt cost in their AI business cases project 29% higher ROI than those that don't. Ignoring the debt makes it invisible on the spreadsheet until the quarter it isn't, not free. It just makes it invisible on the spreadsheet until the quarter it isn't.
Security gets skipped in this conversation more often than it should. Obsolete code doesn't stop importing its dependencies just because nobody calls it anymore, and those dependencies can carry known CVEs that a scanner will still flag, correctly, as live risk. A dead path is still attack surface even when no active feature route ever calls it. Performance carries its own bill too: research into unused JavaScript found that removing it cut payload sizes by up to 60%. That number scales directly into build times in large repositories, where every unnecessary file still gets compiled, linted, and shipped through CI on every single commit.
Then there's Knight Capital, which is the whole argument in one sentence. On August 1, 2012, a forgotten feature flag reactivated code that had been dead for eight years, and Knight Capital lost $440 million in roughly 45 minutes. Nobody decided to lose $440 million; nobody decided anything. The code was just there, waiting for a flag to flip, which is exactly the deferral pattern described above run out to its worst possible ending.
Why dead code resists any single detection method
Different kinds of dead code hide in different places, and each one demands a different kind of evidence to surface. Unreachable files, ones never imported or required from anywhere, get found by tracing the import graph out from known entry points. Unused exports need cross-file, and in larger systems cross-repo, graph traversal, because the export might be consumed three directories away or never consumed. Zombie packages, listed in a dependency manifest but never actually invoked anywhere in the code, need someone to match the install list against the import graph and flag the gap.
Then there's the category no static tool can fully reach: code invoked through reflection or dynamic dispatch, where the call site is a string built at runtime rather than a literal reference a parser can follow. Static analysis will mark that code dead, confidently and incorrectly, because it's invisible to the method being used to look for it.
The correct conclusion here isn't that static analysis is weak, it's that a clean scan result proves less than most teams assume it does. Inactive logic hides behind business rules, feature flags, and runtime dispatch that a parser simply can't see into, and static analysis only ever covers a bounded, provable subset of the actual problem.
Confidence scoring has become a real part of the answer to that limit. Modern detectors increasingly attach a score or a short rationale to each finding, which lets a team triage the obvious, safe deletions separately from the ones that need a senior engineer's judgment before anyone touches them. Without that signal, every finding demands the same amount of manual review, whether it's a genuinely dead helper function or a reflection target masquerading as one.
Static and dynamic analysis are complementary layers. They're complementary layers, and treating them as substitutes for each other is the second-most common mistake in this space. Static analysis is fast, needs no production data, and catches structurally dead code reliably. Runtime and APM coverage, using platforms like New Relic, Datadog, or Dynatrace, catches the code that only gets invoked through reflection or through edge cases rare enough that static analysis never flags them as live, but that requires actual production instrumentation running for weeks, not a one-time scan.
Git history adds a third signal that's easy to overlook because it's so simple. Files untouched for six months or more are reasonable candidates for review, and that's a query anyone can script with git log --follow or run through a visualization tool. Cross-referencing that list against recent deployments helps flag components that have quietly fallen out of the production build. A mature cleanup workflow layers all three signals, static structure, runtime behavior, and commit history, and reconciles what each one says before anyone deletes a line.
Static analysis tools and what each one is built to find
There's no universal tool here, and there probably won't be one, because coverage is inherently language-specific and signal-specific. The right move is picking tools that match the specific kind of dead code being hunted, not reaching for whichever one appears first in a search.
For JavaScript and TypeScript, Knip is described by cleanai.pro's comparison as the most comprehensive open-source tool for dead code detection in those languages. It covers unused files, unused exports, and zombie packages in a single pass, a meaningfully broader scope than most competitors attempt. ts-prune narrows the focus to TypeScript's unused exports specifically: a smaller net, but a precise one. Vulture does the equivalent job for Python and attaches a confidence level to each finding, which helps a reviewer separate the obvious wins from the removals that need a second look. Periphery covers unused code detection for Swift.
A newer entrant, fallow (the fallow-rs/fallow project, with a modest but notable following of GitHub stars, as GitHub's topic listings show), positions itself as codebase intelligence for TypeScript and JavaScript rather than a narrow dead-code scanner. The free static layer, written in Rust, covers unused code, duplication, circular dependencies, complexity hotspots, architecture boundaries, and drift in design systems. An optional paid layer, Fallow Runtime, adds hot-path review and cold-path deletion evidence pulled from actual production traffic, which starts to close the static-versus-dynamic gap described above.
CleanAI, a VS Code and Cursor extension, bundles several analysis tools into a single scan and adds a feature called Auto Clean (Safe): it comments out each finding, runs the build, and confirms the removal only if the build still passes. That design converts "the tool thinks this is dead" into "the tool proved the build survives without it" before anything gets deleted for good, which is the correct order of operations and one a lot of manual cleanups skip.
SonarQube works across languages and flags unused imports, unused variables, and unreachable branches, and it plugs directly into CI/CD pipelines so the checks run on every commit rather than only when someone remembers to run a scan. The community edition is free; paid tiers are available for teams needing additional language and CI/CD support.
Anvien (tamnguyendinh/Anvien, last updated September 14, 2026 per GitHub's topic pages) takes a different shape. It's an MCP-integrated code intelligence tool built with agent-facing commands, covering dead code detection alongside a knowledge graph, symbol navigation, and impact analysis, aimed specifically at AI agents operating across large repositories rather than a human running a manual scan.
A handful of newer, more specialized tools surfaced on GitHub through mid-2026, based on the platform's topic research: an MCP-server-integrated TypeScript tool, a hybrid dead-code gem for Ruby combining call-graph analysis with tracepoint instrumentation, a PHP static analyzer producing dependency graphs with attached confidence levels, and an AI-powered forensic scanner for Python built around framework-aware analysis.
What separates the stronger tools from the weaker ones has nothing to do with the language they target. It comes down to whether they build an actual dependency graph or just grep for import statements. Graph traversal is what modern detectors do differently from simple pattern matching, and that's the reason graph-based tools catch more zombie packages and unused exports than pattern-matching ones ever will. On the commercial side, Repowise's own tool, getdeadcode, listed first in that same May 2026 comparison, pairs static analysis with MCP-based agent tooling.
None of this touches what reflection, dynamic dispatch, plugin hooks, or framework magic hide from a parser. That gap is real regardless of which tool from this list gets chosen, and closing it requires the runtime layer described earlier, not a sharper static scanner.
Feature flags: the category of dead code that static analyzers consistently miss
A feature flag branch doesn't look dead to any tool listed above, and that's exactly the problem. It's syntactically valid, it passes every test in the suite, and it isn't an unused export, because something does call it: the flag evaluation itself. Static analyzers have no mechanism to know a flag has been permanently resolved to one variant unless they can query the flag management platform directly, and most of them simply can't.
That's the platform gap in practice. LaunchDarkly, used by over 4,000 companies according to flagshark.com, will tell a team exactly which flags exist and how each one is targeted across environments. What it won't do is reach into a service's codebase and remove the dead conditional branch, clean up the now-unused imports in a TypeScript component, or coordinate that same flag's removal across the fifteen other repositories that also reference its key.
Detecting a dead flag means watching for one specific signal: a flag receiving 0% of traffic across every one of its variants. That instrumentation has to sit at the flag evaluation level, not the deployment level, because a flag can be fully deployed everywhere and still be evaluating to the same static value on every single request.
A few tools exist specifically to close this gap. FlagShark recognizes LaunchDarkly SDK signatures across 13 supported languages, using tree-sitter AST parsing for syntax-aware detection in TypeScript, JavaScript, Go, Python, Java, C#, PHP, and Rust, and falls back to precise regex matching for the rest. That AST-level awareness matters because it keeps comments and string literals from triggering false positives, something a naive text search trips over constantly. Piranha takes a similar mission: find stale flags in source and automate their removal, rather than leaving that step to a human with a search bar. The LaunchDarkly Claude Code skill, goes further still: it helps teams work with LaunchDarkly's API and manage flag references across a codebase and files left behind, hardcoding the winning variation while preserving whatever the production behavior already was.
Knight Capital belongs in this section as much as the last one, for what actually failed. The 2012 incident was, specifically, a stale flag reactivating eight-year-old dead code. Static analysis wouldn't have caught it, because the code passed every test and every review ever run against it. The failure was a manual deployment step where one server out of several got missed and kept running the old code path, and only a dedicated flag lifecycle process, one treating flag retirement as a tracked, mandatory step rather than an afterthought, would have surfaced that mismatch before it went live. Generic static analyzers were never built to catch that kind of failure. Flag cleanup needs a tool built specifically to bridge the flag platform and the actual source code, because neither side of that bridge can see the other on its own.
Finding deprecated API usage before it finds you
Deprecated API usage is a related problem, but it's a different discipline from dead code detection, and treating the two as one job is a mistake that costs teams real remediation time. Dead code is about paths inside a codebase that nothing calls anymore. Deprecated API usage is about calls to interfaces, internal or external, that still work today but are scheduled to stop working on a known date. Both carry real risk, but the timeline for fixing each is different, and so is the signal that reveals the problem.
Human awareness doesn't scale here, and expecting it to is the mistake most engineering orgs make by default. In any organization large enough to be hiring regularly, no engineer is realistically going to read every release note or migration guide the moment it's published. A deprecation notice sitting in a changelog does nothing for the developer already mid-keystroke, calling the exact function it warns against.
So enforcement has to move out of documentation and into the toolchain itself. ESLint rules and custom SonarQube rules can flag deprecated API calls directly in CI, turning what used to be a documentation footnote into a build warning or an outright build failure. Where the language supports it, compiler warnings can flag deprecated annotations the same way. Deprecation HTTP response headers pointing to a migration guide put the warning directly in a developer's runtime logs, catching people who never read the docs. API gateway usage metrics make it possible to see, concretely, which deprecated endpoints are still receiving live traffic, which turns a vague "we should probably migrate that" into a prioritized list ranked by actual usage volume.
Supply chain monitoring belongs in the same conversation. Platforms like Tidelift or Snyk alert teams the moment a package somewhere in their dependency tree gets marked deprecated upstream, tying dependency health directly into the same workflow already tracking dead code and internal API deprecation.
Large language models train on a snapshot of the world, and that snapshot goes stale the day after training ends. An AI coding assistant can generate a call to a deprecated API with complete confidence, simply because the deprecation happened after its training cutoff and it has no way of knowing that. Context7, addresses this directly by fetching current library documentation and injecting it into the model's context window at generation time, closing the gap between what the model learned once and what's actually current now.
Static analysis, flag lifecycle tracking, and deprecated API monitoring are all manageable inside a single repository with the tools already described here. The difficulty stops scaling in a straight line the moment the codebase spans more than a handful of repositories, and that's where the real operational problem starts.
Multi-repo codebases and the hard problem of dead code coordination
Past a modest number of repositories, grep -r stops working as a viable strategy, because teams relying on it are solving the wrong problem. Finding every caller of a deprecated function across fifty repositories is an indexing problem. It's an indexing problem: the entire codebase needs to sit somewhere queryable, because no engineer is going to manually check fifty repos before deleting a function, and pretending they will is how the same class of deferral risk plays out at smaller scale.
Several things break specifically at this scale that don't break inside a single repo. A function can look completely unused inside repo A, because its only callers live in repos B and C, and single-repo static analysis has no visibility into that and will mark it dead incorrectly. A deprecated internal API can get removed from the service that provides it before every consuming service finishes migrating off it, and that failure occurs at deployment time in a completely different repository, not at the editor of the person who removed it. A stale flag can get cleanly removed from one service while three other services, sharing that same flag key, still carry the conditional branch that references it. Ownership itself gets murky too: a module four different teams used to call, back when it mattered, can end up orphaned with nobody quite sure whose backlog it belongs on now.
The coordination gap is structural, and no amount of buying a better scanner fixes it. Knip, Vulture, and tools like them run per repository and produce findings per repository, which is exactly the right scope for a single-repo scan. Stitching those separate outputs into one prioritized, cross-repo cleanup backlog is a different task entirely, one that needs either a platform layer built to aggregate findings across repo boundaries, or a coordinated process that someone actually owns end to end. No single-repo tool was ever designed to answer a question that spans fifty repos at once, and expecting one to is the last mistake left in this whole chain.


