DEV Community

yureki_lab
yureki_lab

Posted on

How I Cleaned Up 247 Stale Feature Flags With Claude Code

TL;DR

Our codebase had accumulated 247 stale feature flags over four years — flags that were 100% rolled out, 0% rolled out, or referenced code paths nobody could explain. I used Claude Code to inventory, classify, and delete them over two sprints. 231 came out cleanly, 14 needed human judgment, and 2 bit back in interesting ways. Here's the playbook, the code, and the lessons. 🚀

The Problem

Feature flags are borrowed complexity. You take them out with every launch, and the interest compounds quietly until your codebase looks like this:

if (flags.isEnabled("new-checkout-v2")) {
  if (flags.isEnabled("checkout-address-autocomplete")) {
    // ...which internally checks "checkout-intl-rollout"
  }
} else {
  // "old" checkout, which still has its own flags inside
}
Enter fullscreen mode Exit fullscreen mode

When I actually counted the flags in our mid-sized product codebase (~180k lines of TypeScript, Node.js 22.x, React 18), the number was 247. Our flag dashboard said we were "actively using" about 30.

The other 200+ were zombies:

  • Fully rolled out — enabled for 100% of users for over a year, dead else branch still shipped in every bundle
  • Fully rolled back — the experiment failed, the if branch was dead weight
  • ⚠️ Orphaned — defined in the flag service, referenced nowhere in code (or worse: referenced in code, deleted from the service, silently falling through to a default)

Why care? Three concrete costs:

  1. Every zombie flag doubles the theoretical test surface of the code it touches. 247 flags is 2^247 theoretical states. Nobody tests that. Nobody can.
  2. Dead branches confused every new engineer and, notably, confused AI tools too — Claude Code kept "helpfully" maintaining both sides of dead conditionals during unrelated refactors.
  3. The orphaned flags were live incidents waiting to happen: a flag falling back to a hardcoded default is a behavior change nobody decided on.

Manual cleanup had been "on the backlog" for two years. It's the perfect job nobody does by hand: wide, shallow, boring, and slightly dangerous. In other words, a great job for an AI coding agent with a human reviewing the diffs.

How I Solved It

Step 1: Build the inventory (don't trust grep alone)

First instinct: grep -r "isEnabled". That found ~600 call sites but missed dynamic lookups and aliased imports. I had Claude Code write a small ts-morph script instead, which walks the AST and resolves what string actually reaches the flag client:

// find-flags.ts (ts-morph 21.x)
import { Project, SyntaxKind } from "ts-morph";

const project = new Project({ tsConfigFilePath: "tsconfig.json" });
const flagCalls: Record<string, string[]> = {};

for (const source of project.getSourceFiles("src/**/*.ts{,x}")) {
  for (const call of source.getDescendantsOfKind(SyntaxKind.CallExpression)) {
    const name = call.getExpression().getText();
    if (!/\b(isEnabled|getVariant|getFlag)$/.test(name)) continue;
    const arg = call.getArguments()[0];
    const key = arg?.asKind(SyntaxKind.StringLiteral)?.getLiteralValue()
      ?? `DYNAMIC:${arg?.getText() ?? "?"}`;
    (flagCalls[key] ??= []).push(
      `${source.getFilePath()}:${call.getStartLineNumber()}`
    );
  }
}
console.log(JSON.stringify(flagCalls, null, 2));
Enter fullscreen mode Exit fullscreen mode

The DYNAMIC: bucket was the important part. 31 call sites passed a variable instead of a literal. Those you cannot bulk-delete safely, and knowing where they are up front saved me from a very bad afternoon.

Then I joined that against an export from our flag service (last-evaluated timestamps, rollout percentages) into one CSV: flag key, rollout state, call sites, last evaluation date.

Step 2: Classify with the boring rules first

Claude Code classified each flag using rules we agreed on before it saw the data:

  • 100% for >90 days + literal-only call sites → DELETE, keep the enabled branch
  • 0% for >90 daysDELETE, keep the disabled branch
  • Referenced in code, missing from flag service → INCIDENT-REVIEW (a human looks today)
  • Any DYNAMIC: call site → MANUAL
  • Everything else → KEEP

Result: 231 DELETE, 9 MANUAL, 5 INCIDENT-REVIEW, 2 of which turned out to be genuinely misconfigured and silently serving defaults to real users. Finding those two flags alone justified the whole project.

Step 3: Delete in batches, one flag = one commit

The deletion prompt matters. Mine boiled down to:

Remove flag checkout-address-autocomplete. It is permanently ON. Inline the enabled branch, delete the disabled branch, then remove any variables, imports, tests, and mocks that became unused because of this change only. Do not refactor anything else. Do not "improve" adjacent code.

That last sentence is load-bearing. Without it, the agent treats every file it opens as an invitation.

flowchart LR
    A[Inventory<br/>ts-morph scan] --> B[Classify<br/>rules + rollout data]
    B --> C[Batch of 10 flags]
    C --> D[Agent: 1 flag = 1 commit]
    D --> E[typecheck + tests + snapshots]
    E -->|green| F[Human reviews diff]
    E -->|red| G[Revert single commit]
    F --> C

I ran batches of ten flags, each flag its own commit, full typecheck + test suite between batches. Small batches meant a failed check pointed at exactly one flag, and git revert was surgical.

Two weeks of this (maybe 45 minutes a day of my attention, mostly reviewing diffs) and 231 flags were gone: -11,800 lines net, including 63 test files that existed only to test both sides of dead conditionals.

Step 4: The MANUAL pile — where the humans earn their keep

The 9 flags with dynamic call sites deserved their own approach, because a variable flag key defeats static analysis by definition. For each one, I had Claude Code trace where the variable came from instead of what it contained:

// The pattern that produced most of our DYNAMIC bucket:
const flagKey = `${experimentPrefix}-${cohort}`; // 😱
if (flags.isEnabled(flagKey)) { ... }
Enter fullscreen mode Exit fullscreen mode

For six of the nine, the set of possible values was actually enumerable once you followed the data flow — cohort came from a config file with four entries, so the "dynamic" lookup was really four static flags wearing a trench coat. Claude Code enumerated the possibilities, I verified them against the flag service export, and they joined the normal DELETE pipeline.

The remaining three were genuinely dynamic (keys arriving from a database), and those we left alone — but we wrapped them in a typed helper so the next inventory scan will at least know where they live:

// Every intentionally-dynamic lookup now goes through this,
// so static analysis has one choke point to find them all.
export function isEnabledDynamic(key: string): boolean {
  return flags.isEnabled(key);
}
Enter fullscreen mode Exit fullscreen mode

That's a small change with an outsized payoff: the unmeasurable category shrank from "31 mystery call sites" to "3 known, fenced ones."

The two that bit back

Honesty section. Two deletions were wrong:

  1. A "fully rolled out" flag was also read by a batch job under 0.1% of traffic that evaluated it with different targeting rules. The inventory was code-complete but my rollout data wasn't context-complete. Caught in staging by a snapshot diff.
  2. One deleted else branch contained the only remaining call to a cleanup function. The function became dead code, a later automated dead-code pass deleted it, and that surfaced a leaking temp-file path three weeks later. Flags can be load-bearing in ways the flag has no idea about.

Both were caught before customer impact, but only because of the one-flag-one-commit discipline. A big-bang cleanup PR would have shipped both.

Lessons Learned

  1. Zombie flags are a perfect AI-agent workload. Wide, shallow, pattern-heavy, individually low-risk. The agent did the 95% that's mechanical; I spent my attention on the 5% that wasn't. Trying to do this by hand is why it sat in the backlog for two years.

  2. Classification rules must exist before the agent sees the data. When I experimented with "look at this flag and decide if it's safe to remove," Claude Code produced confident, plausible, occasionally wrong reasoning. Rules first, judgment second, agent third.

  3. "Do not improve adjacent code" belongs in every mechanical-refactor prompt. Scope discipline is the difference between 231 reviewable commits and 231 arguments with a diff.

  4. The flag service and the codebase will disagree, and the gap is where incidents live. Five flags existed on only one side of that boundary. Two were silently changing behavior. Audit the join, not each side separately.

  5. One flag, one commit is non-negotiable. Both of my failures were recoverable in minutes because of it. The batch size of your cleanup is the blast radius of your mistake.

What's Next

The cleanup is done; the interesting problem is keeping it that way. I'm wiring the ts-morph inventory into CI so every PR that adds a flag also files an expiry date, and a weekly job flags anything past its date — the goal is that "flag debt" becomes visible within days, not years. I'll write that up once it has survived a full quarter.

Wrap-up

247 flags in, 16 survivors out, two near-misses, and a codebase that's 11,800 lines lighter. If your flag dashboard and your codebase have never been formally introduced to each other, I'd bet money you have zombies too.

If this was useful: follow me here on Dev.to — I write weekly about putting AI coding agents to work on real, unglamorous engineering problems. And if you've had a feature flag bite you months after everyone forgot it existed, tell me the story in the comments. Those are my favorite kind. 💡

Top comments (0)