Dead code
Files nothing imports, exports nothing uses, and packages nothing depends on, each with a confidence score and the evidence behind it.
Repowise hands you a ranked list of code you can probably delete: the files nothing imports, the exported symbols nothing uses, and the packages nothing depends on. Every finding carries a confidence score and the evidence that produced it, so you can start at the top of the list and stop wherever you stop trusting it.
It is pure graph traversal and SQL. No LLM calls, no network, and it
finishes in under 10 seconds on any repo size. Findings populate during
repowise init and refresh for changed files on repowise update.
The layer surfaces candidates. You decide. Static reachability can prove that nothing imports a file. It can never prove nothing loads it. Everything below is built around that asymmetry.
The four finding kinds
| Kind | What it means | Base confidence |
|---|---|---|
unreachable_file | No file in the repo imports this one (file-node in-degree of 0 after entry points and the allowlist come out). | Scored from git age, see below |
unused_export | A public symbol nothing imports and no calls / method_implements / reads / extends / implements / type_use edge reaches. | 1.00 when the containing file does have importers (the file is alive, only this symbol is not), 0.70 when it does not, 0.30 when the name ends in _DEPRECATED / _LEGACY / _COMPAT |
unused_internal | A private or underscore-prefixed symbol nothing calls, and no cross-file importer pulls the name (which would mean a dispatch-table lookup). Off by default. | 0.65 |
zombie_package | A whole top-level package no other package imports. Never marked safe to delete. | 0.50 |
unused_internal is opt-in (--include-internals); zombie_package is
on by default and turns off with --no-include-zombie-packages. Passing
--kind overrides both toggles, so --kind unused_internal enables
internals on its own. unused_internal is disabled entirely for Rust,
where the graph does not yet emit intra-file call edges, so a private
Rust helper is never flagged.
The lines count on file and package findings is an estimate: symbol
count times ten, not a real line count. Read the "reclaimable lines"
roll-up as an order of magnitude, not a figure.
The git-age confidence ladder
An orphaned file nobody has touched in a year is a much stronger signal than one added last week, so unreachable-file confidence starts from git activity.
| Condition | Confidence |
|---|---|
| No commits in 90 days, last touched over a year ago | 1.00 |
| No commits in 90 days, last touched over 180 days ago | 0.90 |
| No commits in 90 days, last touched over 90 days ago | 0.80 |
| No commits in 90 days, no other signal | 0.70 |
| No commits in 90 days, but the file is under 30 days old | 0.55 (may be work in progress) |
| Still being committed to | 0.40 |
From there confidence only ever goes down. Two caps apply:
- Dynamic imports nearby. If any file in the same directory uses a
runtime loader, confidence is capped at
0.40. - Runtime-load risk factors. If the path looks like config,
environment, bootstrap, database, or script code (
config,settings,env,bootstrap,startup,entrypoint,database,db,schema,seed,migration, or ascripts//bin//tasks/directory), confidence is capped at0.40and the finding carries an evidence line saying why. These are exactly the files wired up by a config key or a string path rather than an import, so "nothing imports it" is weak evidence.
What safe_to_delete requires
A finding is presented as safe to delete only when all three hold:
- Confidence is at or above
0.70. - The path carries no runtime-load risk factor.
- The name does not match a dynamic-dispatch pattern (
*Plugin,*Handler,*Adapter,*Middleware,*Mixin,*Command,register_*,on_*,*_view,*_endpoint,*_route,*_callback,*_signal,*_task).
Zombie packages are never safe to delete regardless of confidence. The safety re-derivation is monotonic: it only ever downgrades a stored flag, never upgrades it, so findings written by an older version stay honest.
The CLI and MCP tiers differ on purpose
Worth knowing before you compare numbers across the two surfaces.
| Surface | High | Medium | Low | Default floor |
|---|---|---|---|---|
CLI (repowise dead-code) | >= 0.7 | 0.4 to 0.7 | < 0.4 | --min-confidence 0.4 |
MCP (get_dead_code) | >= 0.8 | 0.5 to 0.8 | < 0.5 | min_confidence=0.5 |
The MCP surface is deliberately stricter: an agent acting on a finding is
riskier than a human reading a table. The tool call itself is documented
on the get_dead_code page, and the flags on
other CLI commands.
What is exempt by construction
Before anything is scored, repowise removes what it knows is framework-loaded, generated, or convention-wired. Static reachability is the wrong tool for these, so they are never flagged rather than flagged and down-weighted.
| Group | Examples |
|---|---|
| Entry points | Anything the graph marked is_entry_point, plus __init__.py, __main__.py, conftest.py, manage.py, wsgi.py, asgi.py, setup.py, main.go, build.rs |
| Shell scripts | *.sh, *.bash, *.zsh, invoked by name from CI configs and Makefiles |
| Framework routes | Next.js page.tsx / layout.tsx / route.ts / middleware.ts, SvelteKit +page.svelte, Nuxt pages/*.vue, Remix entry files, ASP.NET minimal-API Apis/ / Endpoints/, Blazor and Razor code-behind |
| Test files | *_test.go, *.test.ts, *.spec.ts, *_test.cc, *Test.java, **/tests/*.rs, src/test/java/, MSTest and xUnit layouts, __tests__/, __mocks__/ |
| Generated code | protoc *.pb.go / *.pb.cs / *.pb.cc, Qt MOC/UIC/RCC, Bison/Flex, SWIG, Cython, stringer, MapStruct *MapperImpl.java, Dagger, AutoValue, Roslyn *.g.cs, Dart *.g.dart / *.freezed.dart, **/generated/** |
| Reflective loading | Alembic versions/*.py, Django migrations, EF entity configurations, COM *ClassFactory.cpp, Win32 *NativeMethods.cs, ETW event classes |
| Vendored trees | vendor/, third_party/, deps/, external/, extern/, contrib/, submodules/ |
| Build artifacts | build/, cmake-build-*/, _deps/, *.min.js, *.bundle.js |
| Non-code languages | Config and infra languages from the language registry, plus anything the parser could not identify |
Symbols decorated by a framework count as live too: pytest fixtures,
Flask and FastAPI routes, Django admin.register and signal receivers,
Celery tasks, Click and Typer commands, and the JVM stereotype and
routing annotations (@Component, @Service, @RestController,
@Entity, @KafkaListener, @GetMapping, @Test, JAX-RS @Path /
@GET). Decorator suffixes match too, so @my_local_group.command and
@api.get register even when the receiver has a project-local name.
Two more targeted rescues: an interface in a file with no incoming
implements edges is capped at 0.40 (implementor detection is
heuristic, and missing evidence is not evidence of absence), and COM
contract methods (QueryInterface, AddRef, Release) are capped the
same way because they dispatch through native vtables. Zombie-package
detection additionally ignores directories that are not packages at all:
.github, .vscode, .devcontainer, docs, scripts, assets,
static, public, tests, benches, fuzz, and their siblings.
Dynamic-import awareness
When a file uses a runtime loader, repowise assumes its neighbours may be
reached through it and caps their confidence at 0.40. Detected markers:
- Python:
importlib.import_module,__import__(,importlib.reload,pkgutil.iter_modules - JS/TS: dynamic
import(,require.context(,import.meta.glob(,React.lazy(,next/dynamic,jest.mock(/vi.mock(, and the'use server'/'use client'boundary directives
This is a text scan over source, grouped by file extension. Languages without markers in that table get no dynamic-import protection, which is one of the honest limits below.
Cross-repo consumers in a workspace
A file that is dead inside its own repo may still be the surface another
repo depends on, so get_dead_code checks every finding against the
cross-repo graph before returning it. If the file cross-changes with
files in other repos, confidence is halved and the finding gains a
cross_repo_note naming those repos (a behavioral signal from git
co-change history, not an import edge, so read it as "something over
there moves when this moves"). If another repo depends on this one as a
package, the finding is an unused_export, and there was no co-change
signal, confidence is cut to 30% of its value with a note saying the
export may be consumed and should be verified.
The adjustment runs after tiering, so it lowers displayed confidence
without moving a finding between tiers. The CLI has no cross-repo pass
yet: repowise dead-code --repo <alias> analyzes that one repo in
isolation. See workspace setup for
how the cross-repo graph gets built.
Known false-positive sources
The layer is conservative, but it is still a static analysis over a static graph. These are where a finding is most likely wrong:
- Reflection and string-keyed dispatch. A class instantiated from a
name in a config file, a handler looked up in a registry dict, a Java
class loaded by
Class.forName. The dynamic-pattern name list and the.registerdecorator suffix catch the common shapes. Nothing catches all of them. - Dynamic imports in unmodelled languages. The marker table covers Python and JS/TS. Go, Ruby, PHP, Kotlin, Swift, and Scala runtime loading is not detected yet, so an orphan in those languages carries no dynamic-import cap.
- Entry points the graph did not mark. A binary target, a CLI script, or a serverless handler that neither the allowlist nor the entry-point pass recognized reads as unreachable every time. If a whole directory lights up, that is usually the cause.
- Barrel re-exports.
__init__.pyand index barrels are exempt from being flagged as unreachable files, but deliberately not exempt in the unused-export pass: a symbol defined in a barrel that nobody imports should still be reported. A symbol re-exported through a barrel to external callers can therefore surface as an unused export. - Python
__all__is not read. Declaring a public API there does not by itself rescue a symbol. The rescues that do apply are the__init__.pyexemption, the dunder-name skip, and intra-module reference tracking. - Test-only usage reads as usage. A test file's import produces a real graph edge, so a symbol only its tests touch is not flagged. That is deliberate, but it also means repowise will not tell you a symbol is exclusively exercised by tests. There is no "used only in tests" classification.
- Recently added code. A file under 30 days old with no importers is
capped at
0.55precisely because it is often unfinished, not dead. - Interfaces and abstract bases. Reached only through implementors, which is heuristic detection. Capped, not suppressed.
The evidence list on every finding tells you which of these applied. Read it before deleting anything.
Where else it shows up
- The generated
CLAUDE.mdlists dead-code candidates alongside hotspots and decisions. - Contributor profiles carry a dead-code burden per author.
- Module health folds dead-code percentage into its 0-100 composite.
repowise updaterecomputes findings for changed files only.
See also
- Code health: the scoring layer that shares the same graph and git data.
- Dependency graph: the graph this analysis walks.
get_dead_code: the MCP tool, its parameters, and how findings come back tiered.- Glossary: every dead-code term as it appears in the database and in tool responses.
Test intelligence
Ingest a coverage report and repowise answers two questions your CI cannot: which files are risky and untested, and which tests a given diff actually exercises.
Refactoring intelligence
A health score tells you a file is in trouble; refactoring intelligence names the specific fix. Five deterministic detectors (Extract Class, Extract Helper, Move Method, Break Cycle, Split File) emit one structured, graph-aware plan per opportunity, ranked by impact and effort. No LLM in the hot path; code generation runs only on demand, on by default and opt-out.