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.
The dead code view leads with what is safe to delete, with the full
flagged count as a secondary line; the summary endpoint carries
safe_to_delete_count. repowise dead-code --safe-only and
get_dead_code(safe_only=True) give the same pile.
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 / extends / implements / method_implements / dispatches_to / framework_binds / references / type_use edge reaches. | 1.00 only when an importer names one of the file's symbols and none names this one; 0.60 when that check is impossible (no importer names individual symbols, or the language reaches symbols through a preprocessor); 0.30 for an export only its own file uses, or a name ending in _DEPRECATED / _LEGACY / _COMPAT. C, C++ and Objective-C exports never reach 1.00 |
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) in the CLI, and hidden on
every reading surface (MCP, REST, dashboard) by the
finding-type registry
after it measured at 0.5% precision; 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 a finding is the symbol's real inclusive line span
(end_line - start_line + 1), not an estimate. When the span is only
half known the count is omitted, never guessed, so a missing
lines value means the source was not read, not zero.
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
One gate decides safe_to_delete. A finding is presented as safe to
delete only when all of these hold:
- The kind is
unused_export. - 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). - The file is not docs or example code, the finding is not export-only,
and the language's imports and calls are not weakly resolved in this
repository (
summary.call_resolution_basissays which are).
unused_internal and unreachable_file are never deletion-ready.
unreachable_file is review-only by construction, whatever its
confidence: a whole file with no importers is measured well below the
deletion-ready bar (files read by build scripts, manifests, and runtime
loaders by path), so it stays a review candidate until path mentions and
entry-shape evidence exist (below). Zombie packages are the same, 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.
Shapes a runtime loads by path
Even after risk factors, an unreachable_file finding whose shape says
something loads it by path is capped at the review tier: a TS/JS module
whose only export is default (what a framework's file router or plugin
loader picks up), a file that runs on load (bare top-level statements;
a shebang or a __main__ guard makes the file a root, below), or one of three or more
unreachable files in the same directory sharing an export shape (a
convention-loaded set: pages, handlers, migrations). Shape is not proof
of use, so the file stays listed as a candidate to check, never
dropped or marked safe.
CLI, REST, MCP, and web share one tiering
CLI, REST, the web dashboard, and MCP (get_dead_code) all tier off the
same two engine constants, so a finding lands in the same band everywhere
it is read.
| 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.7 | 0.4 to 0.7 | < 0.4 | min_confidence=0.4 |
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 |
|---|---|
| Reachability roots | Anything the graph marked is_reachability_root (every entry point is one), plus __init__.py, __main__.py, conftest.py, manage.py, wsgi.py, asgi.py, setup.py, main.go, build.rs |
| Tool-loaded config | *.config.*, .<tool>rc.*, pnpm's .pnpmfile.cjs, and Python's sitecustomize.py / usercustomize.py (loaded on interpreter start when they sit on sys.path) |
| 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 |
Entry points and reachability roots
Two flags on each file answer two different questions:
| Flag | Question | Who reads it |
|---|---|---|
is_entry_point | Where does a reader enter the system? | The entry-point list in get_overview, execution flows, the documentation's entry points and guided tour, and the performance pass that walks what is reachable from entry |
is_reachability_root | Does something outside the import graph start this file, so dead-code analysis must never flag it? | Dead code |
Every entry point is also a root. The reverse does not hold. Files that are roots and not entry points:
- Test files. A runner loads them, but nobody enters the system through
tests/app.py, even when its name looks like an entry. - Files kept alive by a loader or a build tool: gtest, benchmark and fuzzer
translation units, C++ files with registration macros (
Q_OBJECT,PYBIND11_MODULE), JVMServiceLoaderand auto-configuration classes, and TypeScriptexportssubpaths, MDX-imported components, vitestincludetargets and npm-script targets.
Entry points rank manifest evidence first: a package.json bin, main or
exports["."] target, a [project.scripts] name, or a distribution's package
__init__.py outranks a name- or path-derived guess. Only exports["."]
among the exports map is an entry point; the other subpaths are roots.
Keeping the two flags apart keeps entry-point lists short: on a large TypeScript monorepo, 25 files are entry points (none of them tests), while every test file is still a root.
More files are roots because a tool, manifest or framework starts them:
| Ecosystem | Roots |
|---|---|
| Any | Files with a shebang or a __main__ block, a module with a program main, files a CI workflow, Makefile or manifest names by path, scripts whose own usage line runs them, files a tool config passes to a plugin factory |
| Serverless | Handlers named only as a string in serverless, SAM or Terraform config (handler: src/users.create) |
| Python and PHP | Django installed-app modules, functions stored by their own registering decorator, composer autoload.files |
| JS/TS | Storybook config, Grafana plugin modules, .vscode-test.*, Astro pages and endpoints under src/pages |
| JVM | JMH benchmarks, build-named classes, Kotlin expect/actual pairs and operator functions, Compose previews, Dagger and Hilt modules, Gradle and Android convention plugins, test modules and manifests, Java classes named in resources |
| .NET | C# polyfills, classes found by assembly scanning or MEF, VB.NET Modules whose members are used, a C# type its project or a referencing project names |
| Swift | Symbols used from another file in the module |
A co-change edge is never treated as an importer in JVM, Go and C++ reachability, and generated files read as unindexed users of the names they mention.
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. The marker table
covers Python, JavaScript/TypeScript, Java, Kotlin, Ruby, PHP, Go, Swift,
Scala, Rust, C#, and C/C++, for example:
- 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 - Java/Kotlin:
Class.forName(,ServiceLoader.load(,.invoke(, Spring/DI annotations (@Autowired,@Bean,.getBean() - Go:
plugin.Open(,reflect.New(/reflect.ValueOf(,//go:generate,//go:embed,//go:linkname - Rust:
Box<dyn,#[proc_macro],inventory::submit! - C#:
Activator.CreateInstance(,Assembly.Load(,AddScoped</AddSingleton</AddTransient<
This is a text scan over source, grouped by file extension. A language missing a marker set gets no dynamic-import protection for that shape.
Preview languages
Dead code on Java, C#, C, C++, Kotlin and Swift is served with a Preview label carrying its latest hand-judged precision, because a sample there measured below the bar (for example, Android manifest and layout uses, custom MEF export attributes and DI-bound Kotlin classes are not seen). The rows are listed and counted like any other; read the label before deleting. See Findings and triage.
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:
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 (above) covers twelve languages, but a shape none of its entries name is invisible the same way an unmodelled call site is: no cap, no evidence line.
- 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 a bounded signal. Declaring a public API there is read as visibility, not as proof of use. The other rescues 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
- 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.