The export engine that exported nothing
A stub is not an empty function. It returns plausible values, satisfies its interface and does no work, which is why emptiness checks never find it.
A class named for the thing it does, implementing the right interface, with a body that is not empty. It compiles, it is injected, it is called, and it returns successfully.
It does nothing. Not as a bug — by construction, because somebody wrote it as a placeholder and nobody came back.
The case this post is about
One handler with a non-empty body:
fun onDeleteModel(id: String) {
analytics.log("model_delete_tapped", mapOf("id" to id))
snackbar.show("Model deleted")
}
Two statements, both real, neither of which deletes anything. The button works, the toast appears, the event is recorded, and the model is still there after a restart.
Every check that looks for emptiness passes this. The body is not { }, not a not-implemented
throw, not a bare Result.success(Unit) — it has real calls in it, and those calls touch the two
things that make it look like a feature while touching nothing the feature is about.
This post follows that one handler, because what separates it from an implementation is not length or syntax. It is whether it touches the thing it declared it would.
The symptom
A feature is reported as working and produces no effect. The export runs, reports success, and no file appears. The visible absence is at the end of a chain where every link reported fine, which is why the investigation starts at the wrong end.
The second thing is that it survives the checks you would expect to catch it. A scan for empty method bodies finds nothing, because the body is not empty — it builds a result object, it logs, it returns a success value. A test that asserts the call does not throw passes. Static analysis sees a class that satisfies its interface, which it does.
The third is the naming, which is honest and therefore invisible. These classes are usually called exactly what they are — a no-op, a stub, a placeholder — and that name was accurate and intended when it was written. Nothing degrades about the name over time; what degrades is anybody's memory that the file exists, and the name is only a warning to somebody who happens to open it.
The fourth is where they come from, which is never carelessness. A real implementation is blocked on a decision, an API, a credential, or a platform capability, and the rest of the feature can be built and tested against a placeholder in the meantime. That is good practice. The defect is not writing the stub; it is that nothing in the system knows the difference between a stub and the thing it is standing in for.
That gap has a shape worth naming. The author holds the distinction in their head, the code holds no trace of it, and the moment of handover — to a colleague, to a future self, to an automated process that reports the feature complete — is where the information is lost. Every remedy below is a way of writing the distinction down somewhere other than a person.
Why the obvious fix failed
Search for deferred-work comments. Catches the stubs that were marked and misses the ones written as complete-looking code, which are the dangerous ones. A stub carrying a to-do marker is a stub somebody intends to find; a stub without one has already blended in.
Check for empty bodies. The check I wrote first, and it is answering the wrong question. The distinguishing property of a stub is not that it contains no statements — it is that none of its statements touch anything outside itself. A function that constructs a result and returns it is full of statements and reaches nothing.

Code review. Works at the moment the stub is introduced, when everybody knows what it is for, and that is the moment it is least dangerous. The risk accrues over the following months, and there is no review event at the point where it becomes a problem.
Integration tests. The right instrument and they tend to be written against the same placeholder. A test that exercises the export path and asserts it reports success is satisfied by the stub, because the stub was built to make exactly that path work.
That is not a flaw in how the test was written; it is what the stub is for. A placeholder exists precisely so that the surrounding code can be developed and exercised, which means the tests written during that period are, by design, tests the placeholder satisfies. They do not become wrong when the real implementation is meant to arrive — they just stop being evidence.
The actual mechanism
Distinguish a stub from an implementation by whether it touches the thing it claims to use, and require any genuine placeholder to say so in a form a machine can read.
declare a placeholder carries an explicit annotation and a stated reason
detect a class named for a no-op in production source, without that annotation
detect an implementation of a platform-backed interface with zero platform references
assert a handler whose contract names a target actually touches that target
The third line is the one that catches the quiet cases. An implementation whose entire job is to reach something — a file system, a network, a database, a platform API — and which contains no reference to any of them is not doing that job, whatever its body contains. That is a mechanical property, it needs no judgement, and it does not depend on naming.
The fourth line generalises it, and it is the version I would build first if starting again. When a component declares what it affects — this handler writes to that store, this action deletes that record — the check becomes "does the code reach the thing it declared". A non-empty body that touches the wrong target fails this and passes everything else, which is a defect class in its own right: the handler that does something, successfully, to something else.
The sub-case that keeps the rule usable is the sanctioned placeholder. Stubs are legitimate and necessary, so the check cannot simply forbid them; it requires them to be declared. An annotation plus a one-line reason costs the author nothing at the moment they are already writing a placeholder, and it converts an invisible condition into a greppable list.

The fix
annotate every intentional placeholder, with a reason, at the moment it is written
fail an unannotated no-op in production source
fail a platform-backed implementation containing no platform reference
list the annotated ones, visibly, so the list is something somebody reads
The two failures are different shapes, so the detector checks two different things — an unannotated no-op, and an implementation that claims a platform and references none:
private val NOOP_BODY = Regex("""\{\s*(//[^\n]*\s*)?\}|=\s*Unit\b|Result\.success\(Unit\)""")
private val PLATFORM_HINT = Regex("""android\.|platform\.|NSObject|UIKit|java\.io|Context""")
fun findStubs(src: File): List<String> = src.walkTopDown()
.filter { it.extension == "kt" && "/test/" !in it.path }
.flatMap { f ->
val text = f.readText()
val findings = mutableListOf<String>()
// 1. A no-op body with no @Stub annotation and no stated reason.
FUNCTIONS.findAll(text).forEach { m ->
val (name, body) = m.destructured
if (!NOOP_BODY.containsMatchIn(body)) return@forEach
val above = text.take(m.range.first).takeLast(220)
if ("@Stub" !in above || "intentional-noop:" !in above) {
findings += "${f.name}:$name — empty body, unannotated"
}
}
// 2. An *Impl claiming a platform, containing no platform reference at all.
if (f.name.endsWith("Impl.kt") && !PLATFORM_HINT.containsMatchIn(text)) {
findings += "${f.name} — platform implementation with zero platform API references"
}
findings
}.toList()
Both classes on one module:
$ ./gradlew verifyNoStubs
ExportEngineImpl.kt — platform implementation with zero platform API references
ProfileScreen.kt:onDelete — empty body, unannotated
(2 annotated stubs, listed in build/stubs.txt)
FAILED
The first line is the one worth the detector. ExportEngineImpl compiled, was injected, was called,
and returned successfully — it simply never touched the file system it was named for.
The fourth line is what stops the annotation becoming a way to launder the problem. A declared stub is fine; a declared stub nobody has looked at in eight months is the original bug with better paperwork. The list has to surface somewhere a human sees it on a cadence, or the mechanism just makes the stubs legal.
What makes this cheap is that all three detections are structural. None of them needs to understand what the code means — they ask whether a name matches a pattern, whether a file references a package, whether a declared target appears. Those are greps with good questions behind them, which is the most reliable kind of check there is.
The corollary is that they are also cheap to run everywhere, every time, which matters more than it sounds. A check that needs a build, a device or a network gets run at a gate; a check that is a grep over source can run on every save, and the difference in how early a stub is caught is the difference between a correction and an archaeology project.
How we proved it
One audit found fourteen violations in a single codebase, and the composition is the instructive part: a no-op export engine standing in for a real one, duplicate feature scaffolding where one copy was dead, and nine routes that nothing navigated to. Every one of them compiled, and the project was reported complete.
The empty-body check demonstrably misses it. Write a class that constructs a result object and returns it, with no external reference of any kind, and run any emptiness scan over it. The scan passes. That takes two minutes and it is the whole argument for checking reach rather than size.

What I cannot tell you is how long the export engine had been in place. The audit reports the current state, not when each violation entered, and nothing was recording that. My honest estimate is months rather than weeks, based on when the surrounding feature was built, and an estimate is what it is.
What I have not solved: the stub that genuinely touches something and still does nothing useful. An implementation that opens a file, writes zero bytes and closes it has platform references, a non-empty body and a declared target it touches — and it satisfies every check above. Catching that requires knowing what the operation was supposed to achieve, which is a behaviour question, not a structural one.
The boundary between the two is worth locating rather than blurring, because it decides what you should stop trying to automate. Structure tells you whether a component is wired into anything; behaviour tells you whether what it does is right. Checks of the first kind are cheap, exact and worth running constantly. Checks of the second kind are tests, and no amount of cleverness about the first substitutes for writing them.
And a limitation of the annotation approach: it depends on the author being honest at the moment of writing, and the failure mode it does not cover is somebody who intends the placeholder to be temporary and does not think of it as a stub at all. "I will fill this in before merging" is a thought, not an annotation, and there is no check for a thought.
What partially covers that gap is making the annotation the path of least resistance. If the placeholder is easier to write annotated than unannotated — because an unannotated one fails the build immediately and the fix is one line — then the person who never thought of it as a stub is told, at the moment they are still holding the context, that it is one.
The thing that reframed it for me was realising I had been looking for absence and the defect is a presence. Everything I built first — empty-body scans, marker greps, coverage thresholds — was searching for something missing, and a stub is not missing. It is there, it is complete, it satisfies its contract, and the only thing wrong with it is that the work happens somewhere it never goes.
What this generalizes to
A component can satisfy its interface completely and participate in nothing, and type systems are specifically unable to notice.
The pattern recurs wherever a contract describes a shape rather than an effect. A mock left in production wiring. A feature flag that gates a code path nobody wrote. A migration that runs and changes nothing. A retry policy with a maximum of zero. A cache invalidation that invalidates an empty set. Each is correct against its declaration, and the declaration never said anything about having an effect.
So the question worth asking of any component you are trusting is what would be different if this were replaced by a successful no-op — and if the honest answer is "nothing I currently check", then you do not yet know which of the two you have.
One post a week, no digest
New writing when it lands. No roundups, no course pitch, no "10 tips".