On this page
- How does dead code detection work in repowise?
- Does it use PageRank?
- How do entry points and runtime loading avoid false positives?
- How does git history set the confidence score?
- What do the confidence tiers mean?
- What does safe_to_delete actually promise?
- What does the output look like on a real repo?
- How do I find unused code in Python and TypeScript?
- How we checked
- FAQ
repowise finds dead code by walking the dependency graph for files and exports that nothing reaches, then scoring each candidate with git history and capping it when a runtime loader could be reaching it. It is deterministic: no LLM, no network calls, and it runs as part of repowise init and repowise update. This post covers how that dead code detection works, what the confidence tiers mean, and what the output looks like on the repowise repository itself.
How does dead code detection work in repowise?
The analyzer takes the dependency graph built at index time and asks one question per candidate: can anything reach this? DeadCodeAnalyzer in analysis/dead_code/analyzer.py runs four detectors in order, and each one reads the same graph.
| Kind | What it means | Shown by default |
|---|---|---|
unreachable_file | No file imports it and it is not an entry point | Yes |
unused_export | A public top-level symbol that no import, call, inheritance or type reference reaches | Yes |
zombie_package | A top-level directory with no inter-package import edges | Yes |
unused_internal | A private symbol with no call and no cross-file importer | No, opt in |
Scroll the table sideways to see every column.
Methods, fields, properties and enum members are never reported as unused exports, because they are reached through their container. unused_internal is opt-in because private symbols carry few signals, so the default report leaves it out. The full reference is in the dead code docs.
The graph underneath is the same one that powers the rest of repowise. If you want to see how it is built across languages, read building a dependency graph for any codebase.
Does it use PageRank?
No. PageRank ranks nodes by how much of the graph points at them, which is useful for finding load-bearing code. We use it elsewhere for exactly that purpose, and the comparison with community detection is in Leiden communities vs PageRank for code hotspots.
Dead code is the opposite question. A file is a candidate when its inbound edge count is zero after entry points and rescues, and an in-degree check answers that exactly. A centrality score adds nothing: a file with zero importers has the lowest possible score whatever the algorithm, and the interesting work is deciding which zero-importer files are actually safe to remove. Searching the dead_code package for pagerank returns no matches.
What the detector uses instead is three layers of evidence.
- Reachability on the graph. Edges of type
imports, calls, inheritance and type references count as use. Aco_changesedge does not, because two files being committed together is not a path any code can take (file_reachability.py). - Git history as the base confidence for whole files.
- Caps and exemptions that encode what static analysis cannot see.
How do entry points and runtime loading avoid false positives?
Static reachability can show that nothing imports a file. It cannot show that nothing loads it. Most of the detector's code exists to close that gap.
is_file_reachable in file_reachability.py is the single answer to "can anything reach this file?". It returns true before the graph is even consulted for conventional entry points, framework-instantiated files, published API contracts, files a language warmup flagged from a build manifest, convention globs (shell scripts, framework file routes, migration scripts), barrel files such as index.ts and __init__.py, and bundler alias targets. Go, JVM, C and C++ files get package-granular reachability instead of file-granular, and C# and Swift use name-granular checks, because those languages import namespaces and packages, not files.
Test files, generated code, vendored trees, build files and CI-referenced files are never flagged at all. The globs live in constants.py.
For code reached by string, the analyzer looks for runtime loaders. If a file contains importlib.import_module, a dynamic import(, Class.forName(, ServiceLoader.load(, plugin.Open( or similar, its neighbours in the same directory are capped at 0.40 confidence. Markers exist for Python, JavaScript and TypeScript, Java, Kotlin, Ruby, PHP, Go, Swift, Scala, Rust, C#, and C and C++ (dynamic_markers.py).
Names that follow dispatch conventions (*Handler, *Plugin, register_*, on_*) and symbols registered by framework decorators such as pytest fixtures, Flask and FastAPI routes and Celery tasks are treated as used.
How does git history set the confidence score?
For an unreachable file, confidence starts from how long it has been quiet. _git_age_confidence in analyzer.py is a short ladder, and _GIT_AGE_RUNGS holds the three old-file rungs:
| Condition | Starting confidence |
|---|---|
| Any commit in the last 90 days | 0.40 |
| No commits for 90+ days | 0.80 |
| No commits for 180+ days | 0.90 |
| No commits for 365+ days | 1.00 |
| File under 30 days old | 0.55 |
| No git row at all | 0.50 |
| Anything else | 0.70 |
Scroll the table sideways to see every column.
The logic is simple. A file nobody imports and nobody has touched in a year is dead with high probability. A file nobody imports that someone edited last week is probably work in progress or a script run by hand. The absence of git data scores 0.50 on purpose, held below the deletion-ready threshold, because the file may simply be untracked.
Unused exports start from graph evidence instead. If at least one importer of the file names specific symbols and none take this one, the absence is evidence and the score is 1.00. When that check is impossible, such as a C #include or a C# using, the start is 0.60. A deprecated symbol (by annotation or a _DEPRECATED, _LEGACY or _COMPAT suffix) starts at 0.30.
After the base score, scores only go down. These caps move a finding to 0.40:
- a runtime-loader marker in the same directory
- a path that looks like config, environment, bootstrap, database, script or served-asset code (
risk_factors.py) - an export whose name is written anywhere else in the repository, including non-code files (
name_occurrences.py) - a name that appears in a source file skipped during indexing
Each cap adds a line to the finding's evidence explaining why.
What do the confidence tiers mean?
The tiers are fixed thresholds shared by the CLI, the dead code dashboard and the MCP tool, so a finding lands in the same tier everywhere (serving.py, mirrored in packages/types/src/dead-code.ts).
| Tier | Confidence | Meaning |
|---|---|---|
| High | 0.7 or more | No references found. A strong cleanup candidate. |
| Medium | 0.4 to 0.7 | Likely unused, but something indirect may reach it. Review first. |
| Low | below 0.4 | Plausibly used at runtime. Investigate first. |
Scroll the table sideways to see every column.
The default min_confidence is 0.4, so the low tier is hidden unless you ask for it.
What does safe_to_delete actually promise?
safe_to_delete promises that removing the symbol breaks nothing, and it is the strictest flag in the output. The rule lives in one place, deletion_gate.py, so the analyzer and every read-time consumer agree. A finding is deletion-ready only when:
- its confidence is at least 0.7 (
SAFE_CONFIDENCE_THRESHOLD) - its path carries no runtime-load risk factor
- it is not in
REVIEW_ONLY_KINDS, which holdsunreachable_file
The gate only withholds. It never raises confidence and never switches the flag on. It also withholds for documentation and example code (a docs build can include a snippet by line range), for an export where only the export is unused and the symbol itself is still used, and for languages whose call resolution in this repo is mostly name matches, where a missing edge says little.
In practice, safe_to_delete means an unused export with strong evidence. Whole files are review candidates by design: build scripts, manifests and runtime loaders read files by path.
What does the output look like on a real repo?
We ran it on the repowise repository (Python and TypeScript, the index at commit 215776c2e). The command and the CLI output:
repowise dead-code --no-workspace
Dead Code (1 findings)
Kind File / Symbol Confidence Ready? Lines Reason
unused_export build_visual_context 40% x 144 Public symbol 'build_visual_context' has no importers
Cleanup-candidate lines: 0 (medium 1 confidence)
One finding. It is a 144-line function in packages/server/src/repowise/server/mcp_server/_graph_utils.py. The MCP tool get_dead_code returns the same finding with more evidence, trimmed here:
{
"kind": "unused_export",
"symbol_name": "build_visual_context",
"symbol_kind": "function",
"start_line": 215,
"end_line": 358,
"reason": "Public symbol 'build_visual_context' has no importers",
"safe_to_delete": false,
"risk_factors": [],
"commit_count_90d": 5,
"age_days": 185,
"primary_owner": "Raghav Chamadiya"
}
The repowise repository has no finding above 0.7, so here is a high-tier run on a local checkout of the FastAPI project, also indexed by repowise (repowise dead-code --no-workspace --min-confidence 0.7, trimmed to two rows):
Kind File / Symbol Confidence Ready? Lines Reason
unreachable_file docs_src/dependencies/tutorial001_py310.py 100% x 17 File has no importers (in_degree=0)
unused_export generate_operation_id 100% yes 13 Public symbol 'generate_operation_id' has no importers
Cleanup-candidate lines: 13 (high 8 confidence)
Both are at 100%, yet only the export is deletion-ready: the whole file stays a review candidate by design, which is the gate described above. The CLI also reported 233 findings hidden below the 0.7 threshold.
The tool call ran in a workspace that also includes the frontend, which adds a cross-repo pass: _adjust_one in serving.py multiplies confidence by 0.5 when the file has cross-repo consumers, and by 0.3 for an unused_export when another repo depends on this one as a package. Here the package-level factor applies: 0.40 x 0.3 = 0.12, which is the low tier, and the finding gains a cross_repo_note naming the consuming repo. That is why the MCP result shows a lower confidence than the CLI. The default report lists what is worth reading.
The MCP response also carries call_resolution_basis per language. On this repo it reports 88,723 resolved Python call edges with a 2% guessed share and 9,256 TypeScript edges at 4%, which tells an agent how much weight a missing edge deserves.
How do I find unused code in Python and TypeScript?
Run the index once, then query it:
repowise init
repowise dead-code # the report
repowise dead-code --safe-only # deletion-ready exports only
repowise dead-code --kind unused_export --format json
repowise dead-code --min-confidence 0.0 # compute live below the 0.4 floor
Python and TypeScript both get symbol-level import tracking, which is why unused exports are reliable in those languages. Python also gets a literal __all__ raising listed names to public, and dotted-path strings in entry-point tables, Celery and Django settings are read as references (module_strings.py). TypeScript treats export { local as alias } as a use and recognises same-file type uses. For a language-by-language comparison with tools like Knip and vulture, see how to find dead code in your repository.
From an agent, call get_dead_code(tier="high", safe_only=True). The result is grouped by tier with per-directory and per-owner rollups.
How we checked
We read the detector source in packages/core/src/repowise/core/analysis/dead_code/ (analyzer.py, file_reachability.py, deletion_gate.py, risk_factors.py, serving.py) and the contributor docs docs/layers/DEAD_CODE.md and docs/architecture/dead-code.md. We searched the dead_code package for pagerank and found no match. The CLI and get_dead_code outputs come from runs against the repowise repository on 15 October 2026 at index commit 215776c2e, which was up to date with HEAD.
FAQ
- Does repowise use PageRank for dead code detection?
- No. Dead code is a reachability question, so the detector checks inbound edges on the dependency graph and scores candidates with git history. PageRank is used elsewhere in repowise to rank important files, not to decide what is unused.
- How does repowise find unused exports in Python and TypeScript?
- It looks for a public top-level symbol with no import edge naming it, and no call, inheritance or type-reference edge reaching it. If another file imports from the module but never takes the symbol, confidence starts at 1.00, and caps then lower it for runtime loaders, config-like paths and names written elsewhere in the repo.
- When is code marked safe to delete?
- Only when confidence is at least 0.7, the path carries no runtime-load risk such as config, bootstrap or database code, and the finding is not a whole file. In practice that means an unused export with strong evidence. Everything else is a review candidate.
- Why is a file that nothing imports not marked safe to delete?
- Build scripts, manifests and runtime loaders read files by path, which no import graph can see. Whole files stay review candidates by design, so
unreachable_filefindings are never deletion-ready whatever their confidence. - Does dead code detection need an LLM or an API key?
- No. It is graph traversal plus git history, makes no network calls, and runs inside
repowise initandrepowise update. - Can I mark a finding as a false positive?
- Yes, in the dashboard. Each finding has a status of open, acknowledged, resolved or false positive, and a finding you have acted on stays out of the open list across later
repowise updateruns.



