A screen nothing navigates to still compiles
A route with no caller compiles, registers and never runs. Reachability is a graph property, so no per-file check can see it and nothing fails.
A screen is implemented. Its route is defined, registered in the navigation graph, and compiles cleanly. Everything about it is correct.
Nothing in the application navigates to it, so it has never once been displayed.
The case this post is about
Nine screens in one app: fully implemented, registered in the navigation graph, and reachable from nothing.
Each one had a *Route.kt, a composable, a ViewModel, tests. Each was wired into the graph with a
route string. No screen anywhere called navigate with any of those nine strings, and no deep link
named them.
Nothing about that is visible in a file. Every one of the nine is correct in isolation, and reachability is a property of the graph:
nodes every declared route
edges every navigation call, from any screen to a route
start the entry points: launch destination, deep links, notifications
dead any node not reachable from any start
This post follows those nine, because they passed every per-file check that existed and the question that finds them cannot be asked of one file at a time.
The symptom
There is no symptom, which is the recurring theme of every defect that lives in a relationship rather than in a file. The screen exists, the route exists, the graph accepts it. A reviewer reading any of those files finds nothing wrong, because nothing is wrong with any of them individually.

The second thing is how it happens, which is almost always reasonable. A screen is built ahead of the entry point that will reach it — the feature is being developed in pieces, or the navigation change is waiting on a design decision, or the entry point was in a branch that got reworked. The screen is finished and the thing that would have called it is not, and then attention moves on.
The third is that it survives every check that exists. It compiles, so the compiler is satisfied. It is registered, so any check that asks "is this route in the graph" passes. Its tests pass, because a screen test constructs the screen directly rather than navigating to it. Every gate in the pipeline is answering a question that this defect does not fail.
The fourth is the cost, which is not that the code is there — dead code is cheap — but that nobody knows it is dead. It appears in search results, gets updated during refactors, gets migrated when APIs change, and is maintained indefinitely by people who reasonably assume a registered screen is a reachable one.
That last part is what turns a tidiness problem into a real one. A dead screen is not inert: it has dependencies, so it keeps a repository or a use-case alive that nothing else needs; it has strings, so it enters the translation inventory and six locales pay to translate a screen nobody sees; it compiles, so it adds to build time on every machine for ever. None of those costs is large and all of them are permanent, and each is paid by somebody who had no way of knowing the screen was never displayed.
Why the obvious fix failed
Delete unused code during cleanup. Requires knowing it is unused, which is the thing in question. A route with no caller looks exactly like a route whose caller you have not found yet, and the difference takes a search to establish — per route, by hand.
Rely on the IDE's unused-symbol detection. It finds a symbol nothing references, and a registered route is referenced: the graph registration references it. The tool is answering a question about symbol usage correctly and the question that matters is about graph reachability, which is a different and larger analysis.
Coverage from instrumented runs. A screen never displayed shows as uncovered, which is real signal and arrives mixed with every other uncovered path — error states, edge cases, screens the test run simply did not visit. Zero coverage does not distinguish unreachable from untested.
It also answers the wrong question in a subtler way: coverage tells you what a particular run did, which depends on what the run happened to exercise. Reachability is about what any run could do, and no number of observed runs establishes it — a screen could be reachable by a path nothing has exercised in a year, and that is a different situation from one nothing can reach at all.
Remember to wire it up. The convention that failed. The entry point is in a different file, often a different module, written at a different time, and the screen is complete without it.
The pattern across all four is that each check asks about a thing rather than about a relationship. That is not a criticism of the tools — a compiler that tried to reason about which screens users can get to would be answering a question it has no business answering. It is an observation about where the gap is, and gaps of this shape are only ever closed deliberately.
The actual mechanism
Reachability is a graph property, so compute it over the graph.
nodes every declared route
edges every navigation call, from any screen to a route
start the entry points: launch destination, deep links, notifications
dead any node not reachable from any start
That is a standard traversal and it answers exactly the question no per-file check can, because the information is distributed: the route is in one file, its callers in several others, and the property is a function of all of them together.
The important part is the start set, and it is where a naive implementation goes wrong. A screen reached only by a deep link has no in-app caller and is perfectly reachable; so does one launched from a notification, or from a widget, or as the app's start destination. Treating only in-app navigation as edges reports every one of those as dead, which produces exactly enough false positives to get the check switched off.

The sub-case worth deciding deliberately: routes that are intentionally unreachable right now. A screen built ahead of its entry point is a legitimate state during development, and the check will flag it correctly. The answer is an explicit marker — this route is not yet wired, on purpose, as of this date — which keeps the check honest and makes the pending work visible rather than silent. The marker is the point: it converts "we forgot" into "we know", and those are different situations that look identical without it.

The fix
enumerate routes from their declarations
enumerate navigation calls as edges
declare entry points explicitly: launch, deep links, external triggers
traverse report unreachable routes by name
mark intentionally-unwired routes, with a reason
A reachability walk, which is all it is — the routes are nodes, the navigation calls are edges, and anything the walk does not visit is unreachable:
fun unreachableRoutes(src: File): List<String> {
val routes = mutableSetOf<String>()
val edges = mutableMapOf<String, MutableSet<String>>()
val entries = mutableSetOf<String>()
val waived = mutableMapOf<String, String>()
src.walkTopDown().filter { it.extension == "kt" }.forEach { f ->
val text = f.readText()
ROUTE_DECL.findAll(text).forEach { routes += it.groupValues[1] }
ENTRY_ANN.findAll(text).forEach { entries += it.groupValues[1] } // @StartDestination, deep links
WAIVER.findAll(text).forEach { waived[it.groupValues[1]] = it.groupValues[2] }
val from = ROUTE_OF_FILE.find(text)?.groupValues?.get(1) ?: return@forEach
NAV_CALL.findAll(text).forEach {
edges.getOrPut(from) { mutableSetOf() } += it.groupValues[1]
}
}
val seen = mutableSetOf<String>()
val queue = ArrayDeque(entries) // entry points are DECLARED, not guessed
while (queue.isNotEmpty()) {
val r = queue.removeFirst()
if (!seen.add(r)) continue
edges[r].orEmpty().forEach { if (it !in seen) queue += it }
}
return (routes - seen - waived.keys).sorted()
}
On the module that prompted this:
$ ./gradlew verifyNavigation
routes 34 · entry points 3 · reachable 25 · waived 1
✗ unreachable: ArchiveRoute, ExportRoute, LegacyOnboardingRoute,
PaywallRoute, ReceiptDetailRoute, … (9 total)
FAILED
Nine screens compiled, were tested, and shipped in the APK with nothing navigating to them. The compiler had no opinion because every one of them is a perfectly valid composable.
The third line is the one that needs maintaining, and it is worth it because it is small and because the alternative is a check nobody trusts. Entry points are few, they change rarely, and writing them down has the side benefit of documenting how the app can be entered — which is usually knowledge that exists only in people's heads.
There is a decision about what the check does when it finds something, and the obvious answer is wrong. Failing the build on an unreachable route sounds right and makes the normal development sequence — build the screen, then wire the entry point — impossible without first writing a marker, which turns a reasonable workflow into a dance with the gate. Reporting it, visibly, per build, with the route named, gets the same information in front of the same people and costs nobody anything.
The marker then earns its place for the case that genuinely needs it: a route that will be unreachable for a known period, where somebody wants the report to stay quiet and is prepared to say why and until when. That is a different thing from a build failure and it is the one worth having, because what you actually want from this check is an accurate list, not an obstacle.
How we proved it
Nine routes with no caller, in one application, found the first time the traversal was run. That number is the finding: not one forgotten screen, but nine, each individually explicable and collectively evidence that nothing in the pipeline was asking the question.
The traversal is exact rather than heuristic. Reachability over a finite directed graph has a definite answer; there is no threshold, no scoring and no tuning. Either a path exists from a declared entry point or it does not, and both the nodes and the edges are enumerable from source.

What I cannot tell you is how long each had been dead. The check reports the current state, not when it became true, and I did not go back through the history to date them. Some were probably weeks old and some considerably more — and the fact that I cannot tell is itself the point, because nothing was recording when a route stopped being reached.
What I have not solved: conditional reachability. A route reached only behind a feature flag that is permanently off is reachable to the traversal and dead in practice. The graph sees an edge and cannot evaluate whether that edge is ever taken, so the analysis is structurally optimistic — it finds routes with no caller and not routes whose only caller never runs.
And a boundary on the whole thing: this finds unreachable screens and says nothing about unreachable states within a reachable screen. An error branch nothing can trigger, a dialog never shown, a variant behind a condition that is always false — all of those are the same class of defect at a finer grain, and the graph does not go there.
The nine routes are worth one more sentence because of what happened to them, which was not deletion. Two were genuinely forgotten and were removed. Four were waiting on entry points that still made sense, and got them within the week — the report was the reminder, which is exactly what it is for. Three had been overtaken: the feature had been reconsidered and the screen was a leftover from a design that no longer existed, which nobody had realised because nothing had surfaced them.
That distribution is the reason I would run this on any application of reasonable size. The interesting output was not the dead code; it was three cases where the code and the current intention had quietly diverged, and the only evidence was that nothing could get to them.
What this generalizes to
Some properties do not live in any file, and the checks most teams have are all per-file.
Compilers, linters and reviewers all operate on units — this function, this class, this diff — and they are extremely good at it. Reachability, cycle-freedom, completeness of a mapping, orphaned records, unreferenced resources: every one of these is a statement about a structure that no single file contains, so a pipeline made entirely of per-file checks has a blind spot shaped exactly like the whole-system properties.
The way in is usually to notice that you have a graph and have not been treating it as one. Routes and navigation calls, modules and dependencies, events and handlers, tables and foreign keys — each is a set of nodes and edges, both enumerable from source, with standard questions that have exact answers. The analysis is rarely hard; what is hard is noticing that the question was never being asked.
One post a week, no digest
New writing when it lands. No roundups, no course pitch, no "10 tips".