Exorcising a Haunted Codebase: Banishing Code Demons with AI Agents

October 30, 2026
Categories: #AI,  #SoftwareDevelopment
Exorcising a Haunted Codebase: Banishing Code Demons with AI Agents
Barret Blake

Barret Blake, Architect

It’s a special bonus Halloween blog post. Enjoy!!

Every codebase that has been around for more than a couple of years is haunted. You know the signs. There’s a method that nobody can remember calling, but nobody dares delete. There’s a NuGet package from 2019 still shambling along in the .csproj file. There’s a feature flag that was supposed to be temporary, has been set to true in every environment for eighteen months, and still has both branches of code living underneath it. And somewhere, in a file nobody opens, there’s a 40-line block of commented-out code with a note that just says // TODO: fix this later - DO NOT REMOVE.

None of these spirits are actively breaking anything. That’s what makes them so dangerous. They slow down every developer who has to read around them, they confuse new team members, they show up in security scans, and they make every refactor scarier than it should be.

Cleaning them up has always been tedious, which is why it rarely happens. But tedious, repetitive investigation is exactly the kind of work that AI coding agents are good at. This is Halloween. So let’s call in the ghost hunters. 🎃

A Word of Warning Before the Séance

Before we hand our agents a flashlight and send them into the basement, we need to be honest about one thing: an AI agent that doesn’t understand your house will happily knock down a load-bearing wall because it “looked unused.”

So we’ll follow one simple principle for the entire exorcism:

Tools find. Agents investigate. Humans decide.

The .NET compiler, analyzers, and NuGet tooling are fast, deterministic, and good at finding candidates. Agents are good at the slow part: chasing down every reference, checking for reflection and conventions, reading git history, and writing up the evidence. And we, the humans, make the final call on what actually gets removed.

We also need a little preparation:

  • Work on a branch. Obviously. Every banishing goes through a pull request.
  • Get the tests green first. If the test suite is already failing, you won’t be able to tell what the cleanup broke.
  • Add a safety net where coverage is thin. If an area has no tests at all, consider adding characterization tests with snapshot testing before you let anyone, human or AI, start deleting things.

With the circle of salt drawn, let’s meet our spirits.

👻 Ghosts: Dead Code

Ghosts are the code that nobody calls anymore. Private methods left behind after a refactor. Fields that get written but never read. Parameters that haven’t been used since the method’s original purpose was abandoned. Entire classes whose last caller was deleted three sprints ago.

Let the Compiler Find the Easy Ones

.NET already ships with code style analyzers that detect a lot of this. The problem is that, by default, most of them only show up as faint suggestions in the IDE, which is a lot like a ghost: you might notice it out of the corner of your eye, but only if you’re looking.

We can make them visible in the build by turning on EnforceCodeStyleInBuild in our Directory.Build.props file:

<Project>
  <PropertyGroup>
    <EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild>
  </PropertyGroup>
</Project>

And then raising the severity of the rules we care about in .editorconfig:

[*.cs]
# Unused private members
dotnet_diagnostic.IDE0051.severity = warning
# Private members that are assigned but never read
dotnet_diagnostic.IDE0052.severity = warning
# Unused parameters
dotnet_diagnostic.IDE0060.severity = warning

Now a dotnet build will list every one of these ghosts as a warning, with a file and line number.

Let the Agent Hunt the Hard Ones

The analyzers only cover private members, because the compiler can see every possible caller of a private member. Public and internal types are a different story. A public class with zero references in your solution might be dead. Or it might be:

  • A handler or validator that gets registered by assembly scanning (Scrutor, MediatR, FluentValidation’s AddValidatorsFromAssembly, and so on)
  • An options class that is populated by the configuration binder, where the properties are never “referenced”, only bound
  • A type that gets serialized or deserialized, so its properties are read and written by System.Text.Json
  • An EF Core entity property that exists to support a column and a migration
  • A Razor view, page, or endpoint discovered by convention
  • Something used by another repository, or a public API of a NuGet package you publish

This is where an agent earns its keep. “Find all references” is easy. “Find all references, then check for each of those six ways it might be used invisibly, then look at the git history to see when its last caller disappeared” is tedious for a human and right in an agent’s wheelhouse. It’s also exactly where an unguided agent will get it wrong, which is why we’ll give it a skill file in a moment.

🧟 Zombies: Dead Dependencies That Are Still Walking

Zombie dependencies are packages that are technically still in your project, but are dead in every way that matters. They come in a few different flavors:

  • Vulnerable: packages with known security advisories
  • Deprecated: packages the author has officially abandoned, often with a recommended replacement
  • Hopelessly outdated: several major versions behind, like the Microsoft.AspNetCore.* 2.x packages still hanging around in a project that targets .NET 10
  • Unused: packages that nothing in the code actually uses anymore. My favorite is Newtonsoft.Json sitting quietly next to System.Text.Json long after the migration “finished.”

The first three are easy to find, because the dotnet CLI will tell us:

dotnet list package --vulnerable --include-transitive
dotnet list package --deprecated
dotnet list package --outdated

In the .NET 10 SDK, these commands are also available in the newer noun-first form, such as dotnet package list --vulnerable. Either form works.

On top of that, NuGet Audit runs during every restore and raises NU1901 through NU1904 warnings (low through critical severity) for vulnerable packages, including transitive ones.

The unused zombies are the tricky ones. There’s no built-in command for “which of my package references does nothing?” But an agent can figure it out by taking each direct PackageReference, working out the namespaces that package provides, and searching the project for any using directives, extension method calls, or registrations that rely on it. Then it can try removing the reference and running a build to confirm.

If you aren’t already using Central Package Management, this is also a great time to have the agent consolidate your versions into a single Directory.Packages.props file. Zombies are much easier to spot when they’re all in one place.

🕯️ Ghost Flags: Feature Flags That Never Moved On

I’ve written before about how feature flags are a secret weapon for .NET developers. But every feature flag carries a promise: someday, I will be removed. When that promise is broken, the flag lingers in the codebase like a spirit with unfinished business.

Here’s what a ghost flag looks like:

app.MapGet("/orders/{id}", async (int id, IFeatureManager features, IOrderService orders) =>
{
    if (await features.IsEnabledAsync("NewPricingEngine"))
    {
        return Results.Ok(await orders.GetWithNewPricingAsync(id));
    }

    return Results.Ok(await orders.GetWithLegacyPricingAsync(id));
});

The NewPricingEngine flag has been true in Development, UAT, and Production since last spring. That means GetWithLegacyPricingAsync, and everything underneath it, is dead code that the compiler can’t detect. Because the flag could be false, the legacy path still looks reachable.

Finding these is a great job for an agent, because it requires correlating information across several places:

  1. Find every flag in configuration (appsettings.*.json, Azure App Configuration exports, environment variable files, and so on).
  2. Find every usage in code: IsEnabledAsync, [FeatureGate] attributes, <feature> tag helpers, and WithFeatureGate on endpoints.
  3. Compare the values across every environment. A flag that’s true everywhere (or false everywhere) is a ghost candidate.
  4. Check the git history to see how long the value has been unchanged.

Once a human confirms that the flag is ready to move on, the removal is straightforward:

app.MapGet("/orders/{id}", async (int id, IOrderService orders) =>
{
    return Results.Ok(await orders.GetWithPricingAsync(id));
});

The flag check is gone, the legacy method and everything that only it used are removed, the new method gets a less temporary-sounding name, and the flag is removed from every configuration file. That last step is the one people most often forget. If the flag is left behind in configuration, the next person to come along may assume it still does something.

🪑 Poltergeists: Commented-Out Code and Ancient TODOs

One last spirit before we get to the ritual itself. Poltergeists are the noisy ones: commented-out blocks of code and TODO comments that have outlived several of the developers who wrote them.

Commented-out code is never the right way to save something for later. That’s what source control is for. An agent can find these blocks, then run git blame or git log -L against them to report when each one was commented out and by whom. A // TODO from last week is a reminder. A // TODO from 2021 is a poltergeist, and it either deserves to be a real work item in your backlog or to be deleted.

The Exorcism Ritual

Now let’s put it all together. If you’ve read through our Next Level AI Coding Agents series, you know that the best way to teach an agent a repeatable, multi-step process is with a skills file. So let’s write one.

The Ghost Hunt Skill

Here’s a skills/ghost-hunt/SKILL.md file that encodes the process we’ve walked through:

---
name: ghost-hunt
description: Survey the solution for dead code, zombie dependencies, stale feature flags, and commented-out code, and produce a haunting report. Use when asked to find or clean up dead code, unused packages, or old feature flags.
---

# Ghost Hunt

## Rules

- The survey is READ-ONLY. Do not modify, move, or delete any file while surveying.
  The only file you may create is `haunting-report.md` in the repository root.
- Never recommend removing anything that is public in a project with
  `<IsPackable>true</IsPackable>`. Other teams may depend on it.
- "No references found" is NOT proof that code is dead. Before marking any
  public or internal type or member as dead, check for and document:
  - Assembly scanning registrations (Scrutor, MediatR, FluentValidation, AddControllers, etc.)
  - Configuration binding (`Bind`, `Configure<T>`, `AddOptions<T>`)
  - Serialization (System.Text.Json, Newtonsoft.Json, EF Core entities and owned types)
  - Convention-based discovery (Razor views/pages, controllers, endpoints, NServiceBus handlers)
  - `InternalsVisibleTo` attributes and reflection (`typeof`, `GetType`, `Activator`)
- Every finding must include evidence. If you can't explain why something is
  dead, it's not dead. Mark it Low confidence.

## Step 1: Ghosts (dead code)

1. Run `dotnet build` and collect all IDE0051, IDE0052, and IDE0060 warnings.
2. For public and internal types with no references, apply the checks in Rules.
3. Use `git log` to find when the last caller was removed, if possible.

## Step 2: Zombies (dependencies)

1. Run `dotnet list package --vulnerable --include-transitive`.
2. Run `dotnet list package --deprecated` and `dotnet list package --outdated`.
3. For each direct PackageReference, search for usages of its namespaces,
   extension methods, and registrations. List any with no usages as Unused candidates.

## Step 3: Ghost flags

1. List every feature flag in every appsettings and environment configuration file.
2. List every usage in code (`IsEnabledAsync`, `[FeatureGate]`, `<feature>`, `WithFeatureGate`).
3. Flag any whose value is identical in every environment, and use git history to
   report how long it has been unchanged.
4. Flag any that appear in configuration but not in code, or in code but not in configuration.

## Step 4: Poltergeists

1. Find commented-out code blocks of 3 or more lines and TODO/HACK comments.
2. Use `git blame` to report the date each was introduced.

## Output

Write `haunting-report.md` with one table per category using these columns:

| Item | Location | Evidence | Confidence (High/Medium/Low) | Recommended Action |

End the report with a summary count per category and confidence level.

A few things are worth calling out in this skill. The survey is read-only. The agent doesn’t get to delete anything in this phase; it only gets to report. The Rules section is mostly a list of ways code can be alive without being referenced. That’s where all of the hard-won knowledge about your codebase goes, so add to it whenever your team finds a new way code gets used. And every finding needs evidence and a confidence level, which is what makes the next step fast.

Survey, Review, Banish, Verify

With the skill in place, the exorcism follows four steps.

1. Survey. Ask the agent to run the ghost hunt. You’ll end up with a haunting-report.md that looks something like this:

ItemLocationEvidenceConfidenceRecommended Action
LegacyPricingAsyncOrders/OrderService.cs:142Only caller is behind NewPricingEngine flag, which is true in all environments since 2026-03-04HighRemove with flag
LegacyCsvExporterReports/LegacyCsvExporter.csNo references; not registered via scanning; last caller removed in commit a1b2c3d (2025-11-12)HighRemove
SmtpSettings.UseSslNotifications/SmtpSettings.cs:9No code references, but class is bound via Configure<SmtpSettings>LowKeep. Bound from configuration

That last row is the agent doing exactly what we want: noticing that “no references” isn’t the whole story.

2. Review. A human reads the report. This is the step where you strike out anything you know is alive for reasons the agent couldn’t see, like the class that a different team’s service calls through a shared library. Because every row has evidence, this goes quickly. You’re reviewing reasoning, not repeating the investigation.

3. Banish. Now, and only now, the agent gets to delete things. Keep the scope small and the commits smaller:

Using haunting-report.md, remove only the items marked High confidence in the
Ghosts table. Make one commit per item. After each removal, run `dotnet build`
and `dotnet test`. If anything fails, revert that removal and mark the item
"Fought back" in the report with the failure details. Do not touch any other
table in the report.

One category per branch, one item per commit. When something does fight back, and something always does, a single revert gets rid of it.

4. Verify. The build and tests passing is necessary, but it isn’t sufficient. Code that’s discovered by reflection or convention tends to fail at runtime, not at compile time. So run the application and exercise it. If you’re using Aspire, spin up the whole thing and keep an eye on the dashboard for new exceptions. Pay extra attention to anything the report rated as Medium confidence. Another great use-case for using Aspire. Are you using it?

Keeping the House Clean

An exorcism is satisfying, but it doesn’t help much if the house is haunted again by next Halloween. A few small guardrails go a long way.

Make the analyzers and NuGet Audit fail the build. We already turned on EnforceCodeStyleInBuild. We can also make high and critical vulnerabilities break the build instead of just warning about them:

<Project>
  <PropertyGroup>
    <EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild>
    <NuGetAudit>true</NuGetAudit>
    <NuGetAuditMode>all</NuGetAuditMode>
    <WarningsAsErrors>$(WarningsAsErrors);NU1903;NU1904</WarningsAsErrors>
  </PropertyGroup>
</Project>

Give every feature flag an expiration date. The best way to stop ghost flags is to make it impossible to create a flag without deciding when it should go away. One simple approach is to keep flag names as constants and tag each one with an expiration date:

[AttributeUsage(AttributeTargets.Field)]
public sealed class FlagExpiresAttribute(string date) : Attribute
{
    public DateOnly Expires { get; } = DateOnly.Parse(date);
}

public static class FeatureFlags
{
    [FlagExpires("2027-01-31")]
    public const string NewPricingEngine = "NewPricingEngine";
}

Then add a test that fails when any flag has no expiration date, or has outlived its welcome:

public class FeatureFlagTests
{
    [Fact]
    public void No_feature_flag_outlives_its_welcome()
    {
        var today = DateOnly.FromDateTime(DateTime.UtcNow);

        var expiredFlags = typeof(FeatureFlags)
            .GetFields(BindingFlags.Public | BindingFlags.Static)
            .Select(field => (field.Name, Attribute: field.GetCustomAttribute<FlagExpiresAttribute>()))
            .Where(flag => flag.Attribute is null || flag.Attribute.Expires < today)
            .Select(flag => flag.Name)
            .ToList();

        expiredFlags.ShouldBeEmpty();
    }
}

When the test fails, the team has a decision to make: remove the flag, or consciously extend the date. Either way, the flag can’t quietly become a ghost. If you want to go further with tests that enforce the structure of your code, take a look at Getting Started with ArchUnitNET.

Schedule a regular ghost sweep. Because the survey phase is read-only, it’s safe to run on a schedule. Set up a monthly job, using whatever agent-driven workflow tooling your team already has, that runs the ghost hunt skill and opens an issue with the report attached. A small report every month is a lot less frightening than a giant one every five years.

Conclusion

Haunted codebases don’t get that way because developers are careless. They get that way because cleanup is tedious, investigation is slow, and deleting code always feels riskier than leaving it alone. AI coding agents change that math. They’re willing to chase down every reference, read every commit, and write up every piece of evidence, without getting bored halfway through.

But the agent is the ghost hunter, not the homeowner. Let the tools find the spirits, let the agent investigate them, and keep a human in charge of deciding which ones get banished. Do that, and you’ll end up with a codebase that’s a lot less spooky to work in, all year round.

Happy Halloween! Go and banish some demons… 🎃👻

Resources