# 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.

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`.

> **Warning**
>
> 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](https://docs.repowise.dev/intelligence/findings#which-finding-types-a-surface-shows)
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.

> **Note**
>
> 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 recorded git history | `0.50` |
| 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 a `scripts/` / `bin/` / `tasks/`
  directory), confidence is capped at `0.40` and 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:

1. The kind is `unused_export`.
2. Confidence is at or above `0.70`.
3. The path carries no runtime-load risk factor.
4. The name does not match a dynamic-dispatch pattern (`*Plugin`,
   `*Handler`, `*Adapter`, `*Middleware`, `*Mixin`, `*Command`,
   `register_*`, `on_*`, `*_view`, `*_endpoint`, `*_route`,
   `*_callback`, `*_signal`, `*_task`).
5. 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_basis` says 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`](https://docs.repowise.dev/mcp/get-dead-code) page, and the flags on
[other CLI commands](https://docs.repowise.dev/cli/other).

## 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](https://docs.repowise.dev/installation/languages), 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`](https://docs.repowise.dev/mcp/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`), JVM `ServiceLoader` and auto-configuration classes, and
  TypeScript `exports` subpaths, MDX-imported components, vitest `include`
  targets 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, MassTransit consumers, NServiceBus handlers, Azure Functions and WCF service contracts, 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 |
| Python | Names a module hands out lazily through an attribute tuple (a lazy-import table) |
| Salesforce | Apex classes the platform also reaches by name: findings are review-only, and a third-party library in a static resource counts as vendored |

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](https://docs.repowise.dev/intelligence/findings#which-finding-types-a-surface-shows).

## 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 note names a package
dependent only when its manifest covers the file's package or a contract link
points at the file. A finding with a note is never safe to delete.

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](https://docs.repowise.dev/multi-repo/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
  `.register` decorator 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__.py` and 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__.py` exemption, 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.55` precisely 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 update` recomputes findings for changed files only.

## See also

- [Code health](https://docs.repowise.dev/intelligence/code-health): the scoring layer that
  shares the same graph and git data.
- [Dependency graph](https://docs.repowise.dev/intelligence/dependency-graph): the graph this
  analysis walks.
- [`get_dead_code`](https://docs.repowise.dev/mcp/get-dead-code): the MCP tool, its
  parameters, and how findings come back tiered.
- [Glossary](https://docs.repowise.dev/reference/glossary): every dead-code term as it
  appears in the database and in tool responses.
