Why didn't the cleanup Action remove my flag?

Last updated:

Almost always because your code reads the flag through your own wrapper function instead of calling the SDK directly. Matching is by method name plus key string, so a wrapper is invisible to the rules and the run reports no changes. The other common causes are a refusal it prints explicitly, such as a mock stub holding the flag read, or a key that is not a string literal at the read site.

A run that finishes cleanly and reports no-changes for a flag you can grep for in forty files looks like a bug. It usually is not. There are three different reasons a flag survives a cleanup run, and they are easy to tell apart once you know the run prints all of them.

The wrapper case, which is most of them

The rules match a flag read by callee name plus key string. A direct SDK call is recognized:

if (client.boolVariation('checkout-v2', ctx, false)) {

Your own helper is not:

if (useFlag('checkout-v2')) {

Nothing about useFlag says it is a flag read. It could add logging, supply a default, or read from somewhere else entirely, and rewriting an unknown function because its first argument happened to be a flag key is how a tool deletes a real call. So it does nothing, and reports no-changes.

Most production codebases wrap the SDK, so this is the ordinary outcome and not an edge case. You can call the SDK directly at the read site, or add the key to ignore so it stops appearing in the queue. If your wrapper is stable and widely used, name it in accessors and the discarded-read handling will cover it.

When the run refuses outright

Some shapes are not skipped quietly. They stop the flag and exit non-zero, naming the file:

when(client.boolVariation("checkout-v2", ctx, false)).thenReturn(true);

Folding that read produces when(true).thenReturn(true), which compiles while quietly no longer stubbing anything. Mockito, MockK, ts-mockito, package:mockito and gomock expectations are all refused for the same reason. A Python read bound at module level is refused too, because the name is public API and the importing modules are never in the candidate set.

Look for [unsafe-rewrite] or [piranha-error] in the output. A refusal is always printed, so a flag that fails this way is never a silent one. The reasoning behind each refusal is covered in what an automated flag removal has to decline.

When the key is not a literal

If the key is not a string literal at the read site, the rules cannot tell which flag the call belongs to:

const on = client.boolVariation(keyFromConfig, ctx, false);

The one exception is a constant declared in the same file, which is resolved and removed with the read. A constant in a different file is out of reach, because the tool parses one file at a time.

Confirming which one you hit

Run with dry-run: true. Every refusal a real run can produce is reachable in a dry run and reported identically, so the preview tells you whether you are looking at a quiet no-changes or a printed refusal, without opening a pull request. The two outcomes have different fixes, and the output names which one you have.

Still stuck?

The docs cover every SDK, and the free Solo plan is enough to reproduce most of these locally.