How to Find and Remove Dead Code in an AI-Assisted Codebase Without Breaking Wire Contracts
The ET Ducky cloud API is written by one person with heavy AI assistance, which produces working code quickly and leaves a specific kind of residue behind it. Issues range from helper frameworks that are incomplete and unused to services injected into constructors just because the patterns look like they should be, and everything in between. None of it breaks a build or fails a test, so it accumulates quietly until something goes looking for it.
What follows is the strategy we settled on for finding that residue and removing it safely, with the reasoning behind each step, illustrated by a cleanup that closed twenty-six findings in the API. The short version is that static analysis handles everything inside the assembly and contract tests handle everything that crosses out of it, because the second category is where this work actually goes wrong.
Why static analysis is the right tool for this particular problem
The instinct with generated code is to review it harder, but review is sampling and this problem is exhaustive by nature. Unused members are invisible precisely because each one looks correct in isolation, since a constructor that takes a service it never uses reads exactly like a constructor that uses it, and the difference only appears when you check every reference in the project. That is mechanical work at a scale that rewards a tool and punishes attention.
The reason it matters more on an AI-assisted codebase is that generating a plausible constructor, helper or allowlist is cheap while generating the code that consumes it is a separate step that sometimes does not happen. The residue is therefore a predictable byproduct of the working method rather than evidence of sloppiness, which means it should be handled by something that runs on every build rather than by resolving to be more careful.
Choosing rules that carry intent signal rather than formatting signal
Most analyzer output is mechanical enough to fix in bulk, since redundant initializers, culture arguments and .Any() versus .Count > 0 take the same fix every time regardless of where they appear, and reading those findings individually is wasted effort. The rules worth selecting for this strategy are the small number that report on intent instead of form.
A private field that nothing reads is the clearest example, because somebody decided the class needed that dependency, and if nothing reads it then either the decision was wrong and the member is noise, or the decision was right and the code that was supposed to use it was never written. That second possibility is what makes the class valuable, and in our experience it is the only lint output that regularly points at missing behavior instead of untidy behavior.
In .NET the four rules that carry this signal are already present and usually configured as suggestions, covering an unused private member, a private field that is written but never read, unreachable code, and a variable that is assigned and never used. Turning them into build errors is a one-line change:
<WarningsAsErrors>CS0162;CS0219;IDE0051;IDE0052</WarningsAsErrors>
The equivalent exists in most ecosystems, and the selection principle transfers even where the rule names do not. You are looking for the checks that answer "did somebody intend something here that never arrived" rather than the checks that answer "is this written the conventional way."
Driving the backlog to zero before turning on the escalation
The sequencing is the part most likely to be done backwards, and getting it wrong is what turns this kind of initiative into a rule that gets suppressed six weeks later.
Enabling the escalation while a backlog exists produces a build that cannot succeed, which creates immediate pressure to suppress the rule, add a blanket exclusion or revert the change, and the usual outcome is that the rule ends up disabled with a comment explaining that it was too noisy. Enabling it once the count is already zero produces the opposite dynamic, because the next unused private member fails the build on the day it is written, when the person who wrote it still remembers what it was for and the fix costs a minute.
That ordering is what converts a cleanup into a ratchet. The work of clearing the backlog is paid once, and the escalation is what stops you from paying it again in a year. Any strategy here that ends without an enforcement step is a sweep rather than a fix, and sweeps do not survive contact with a shipping schedule.
Triaging findings by why the code is dead
The temptation once the list exists is to delete everything on it, which is wrong because the deletion is usually correct but the follow-up action differs. Sorting each finding by cause is what tells you whether you are finished after removing it, and in our twenty-six the causes fell into four groups.
Dead by design and documented. A controller had an injected approval service it never called, and the file already explained why, since approval is checked inline in that endpoint rather than routed through the service. The injection survived a design change that made it unnecessary, so removal was the whole of the work.
Superseded rather than missing. A controller helper carried a docstring reading "Copied from SoftwareCatalogController", and every privilege endpoint in that controller already gated on an owner check stricter than the role the helper tested. A newer authorization model had replaced it and the copy survived. This category carries a trap worth planning for, because identically named helpers elsewhere in the same project were live and heavily used, and several controllers keep private per-controller copies of the same helper. A name being dead in one file says nothing about the same name elsewhere, so a project-wide search for the name is exactly the wrong verification step.
Scaffolded and never wired. A controller had helpers to resolve organization ids and the current user, but all eight of its endpoints read built-in reference data and the underlying service takes no organization parameter, so there was no tenant data to scope. The helpers existed because a controller usually needs them, which is the most common shape this residue takes in generated code.
Redundant one layer down. A service injected a validation dependency and never called it, but the class is a thin wrapper over another service that injects the same dependency and does perform the check. This is the category that requires the most care, because telling it apart from a genuinely skipped validation decides whether you delete the field or write the missing call, and the two look identical until you read the layer below.
The reason this triage earns its time is that some findings are the visible end of something larger. Two of ours were, including a constant allowlist of supported provider types that nothing referenced, where validation was instead happening implicitly in the default branch of a switch statement, so an unrecognized value was accepted at create time and failed later at use time as a server error rather than a validation error. Deleting the constant was correct, and wiring it into the create path was a behavior change that needed its own decision. The other was an ORM entity whose table was never queried, added to or updated because all access went through an in-memory catalog, and which was nonetheless listed in the tenant-filter entity list despite having no organization column for the filter to apply to. An allowlist that nothing applies and an entity that nothing queries are both descriptions of a decision that was never finished, and a bulk delete removes the evidence while leaving the gap in place.
Planning for the blast radius before deleting
Analyzer output is a list of symptoms rather than a list of edits, and treating it as the latter is how a cleanup introduces the bugs it was supposed to prevent. Deleting a private member routinely orphans things no analyzer points at, and across our removals that meant two source-generated logging methods losing their only call site, five imports becoming unused, and four constructor signatures changing.
The logging methods needed formal retirement along with a correction to the event-id range comments at the top of their files, because the project holds an invariant that no logging method exists without a caller, and a cleanup that quietly breaks a stated invariant is worse than the residue it removed.
The constructors are the ones that deserve a deliberate check rather than a compile. All four classes are resolved through dependency injection, where a changed signature fails at runtime rather than at build time if anything constructs them manually, so each was verified against every manual construction site in the project and every reference from the test project before the parameter came out. The general form of this rule is to ask what the deleted thing was holding up, and to be most careful wherever the language's own guarantees are weakest, which in practice means reflection, dependency injection, serialization and anything else resolved by name at runtime.
Knowing where static analysis stops
This is the part of the strategy that matters most, because the compiler's guarantee ends at the assembly boundary and the code that AI generates most confidently is the code that crosses one.
A naming rule in our cleanup wanted a property called Guid renamed, since the name collides with a type. That property sits on a type nested inside the request body for the endpoint every agent posts its provider inventory to, and the same shape exists in the agent codebase, where every deployed agent sends a JSON field named guid. Renaming it compiles cleanly and passes every test before binding an empty value on every inventory upload from every agent in the field, because the break lives in JSON name matching rather than in any type, and nothing in the build or the test suite was looking at the name that goes on the wire.
The specific fix is one attribute pinning the serialized name:
[property: JsonPropertyName("guid")]
The strategic fix is a test that treats the wire format as a contract with its own verification, declaring the types that cross the boundary as roots and walking everything reachable from them, unwrapping nullables, arrays and generic collections along the way. For each property it computes the name that actually appears on the wire, which is the explicit serialization attribute where one is present and the default casing otherwise, then diffs the result against a frozen map of the types it covers.
Transitive reachability is the reason to walk rather than to list, since the property that nearly broke sits three levels below anything a reviewer would think to open, which is precisely why the rename looked safe. Any boundary you do not deploy atomically deserves this treatment, and the asymmetry to keep in mind is that deletion is comparatively safe because the compiler tells you when you are wrong, while renaming is the operation that compiles, passes every test, and then fails in the field.
Making a guard prove it examined something
The last piece is small and easy to skip. A contract test that walks a type graph can stop finding types for reasons that have nothing to do with correctness, and when it does it reports success, because a check that examined nothing looks identical to a check that found no problems.
We added a second test asserting that the walk actually reaches what it claims to reach, so a silently empty run fails instead of passing. The general principle applies to any guard whose coverage is computed rather than enumerated, including linters driven by glob patterns, scanners pointed at a directory and test suites gated on discovery. If the guard decides at runtime how much to inspect, something needs to assert that the answer was not zero.
The strategy in order
Select the analyzer rules that report on intent rather than formatting, since those are the ones worth reading individually. Drive the backlog to zero and only then turn them into build errors, because that ordering is what makes the result permanent. Triage each finding by why the code is dead, since the deletion is usually right but the follow-up differs and some findings are marking unfinished decisions. Verify the blast radius of every deletion, paying attention wherever the compiler's guarantees are weakest. Then write contract tests for every boundary the compiler cannot see across, and make those tests prove they inspected something.