Find Unused Feature Flags (and Dangling References) in Your Codebase

Loading...·7 min read

Feature flags accumulate. A flag ships, the experiment concludes, the winning variation is rolled out — and the flag stays live in Optimizely long after the last line of code that reads it was deleted. Over months, a project fills with flags nobody references and code paths that call flags nobody kept. optipilot-find-code-refs is a command-line tool (and GitHub Action) that reconciles the flag keys used in your source code against the live flag inventory in Optimizely Feature Experimentation, so you can find and clean up both sides of that drift. It is the OptiPilot counterpart to LaunchDarkly's ld-find-code-refs.

The tool runs wherever your source is checked out — a developer machine or CI — is read-only by default, and has zero runtime dependencies (just Node.js 20+).

What it detects

The scanner pulls every flag in your Optimizely project and greps your working tree for references to each key, then sorts the result into three categories:

Category

Definition

What to do

Orphan

Live (non-archived) in Optimizely, but zero references in the scanned code

Safe-to-archive candidate — the flag is dead weight

Zombie

Referenced in code, but archived or absent in Optimizely

A dangling reference — the code calls a flag that no longer exists

Active

Live in Optimizely and referenced in code

Healthy — no action needed

Orphans and zombies are the two failure modes of flag hygiene. Orphans are flags you forgot to remove; zombies are code you forgot to remove. Catching them early keeps your flag list honest and prevents a decide() call from silently falling back to a default because the flag was archived out from under it.

How it works

flowchart TD
  A[Read config] --> B[Fetch live flags]
  B --> C[Fetch archived flags]
  C --> D[Scan working tree]
  D --> E[Classify each key]
  E --> F[Orphan / Zombie / Active]
  F --> G[Print report + JSON]
  G --> H{Policy violated?}
  H -->|yes| I[Exit non-zero]
  H -->|no| J[Exit 0]
  1. Read config. Project ID, key-match patterns, and aliases come from CLI flags, an optional config file, and environment variables (in that precedence order).

  2. Pull the inventory. The tool calls the Optimizely Flags v1 API for the live inventory (archived=false) and the archived inventory (archived=true), following cursor pagination until every page is fetched.

  3. Scan the tree. It greps each flag key across your configured file globs, respecting .gitignore via git ls-files (and falling back to a directory walk outside a git repo). Every hit is recorded as a file:line reference.

  4. Classify. Each flag/key is sorted into orphan, zombie, or active.

  5. Report and exit. It emits a human-readable report plus an optional JSON file, and exits non-zero when a policy category is violated.

Install and run

The tool is distributed as a small Node package. Clone it, build once, and run the CLI:

git clone https://github.com/sobrienti/optipilot-find-code-refs.git
cd optipilot-find-code-refs
npm install
npm run build

node dist/cli.js --project 1234567890

You can also run it without building via tsx:

npx tsx src/cli.ts --project 1234567890

Provide a read API token in the environment. Generate one in Optimizely under Account Settings > API Tokens:

export OPTIMIZELY_API_TOKEN="<read token>"
node dist/cli.js --project 1234567890 --json report.json

The --root flag points the scan at the repository you want to check (default is the current directory), which does not have to be the same directory as the tool itself:

node dist/cli.js --project 1234567890 --root ../my-app

Example output

OptiPilot code-references scan — project 1234567890
  live flags: 42  archived: 7  referenced keys: 39

Orphans (live in FX, 0 code refs) — 3:
  - beta_banner  "Beta Banner"
  - old_pricing_test
  - dark_mode_v3

Zombies (referenced in code, archived/absent in FX) — 1:
  - old_checkout  [archived]  (2 refs)
      src/checkout/legacy.ts:88
      src/checkout/legacy.ts:141

POLICY VIOLATION: zombies (exit 1)

The header line summarises the inventory. Orphans and zombies are listed with their keys; zombies also print each file:line reference so you can jump straight to the dangling call. The final line reports the exit status.

Configuration

CLI flags override a config file, which overrides defaults. The tool auto-detects .optipilot-find-code-refs.json or .optipilot-flags.json in the scanned repo's root.

Field

Meaning

projectId

Optimizely project ID

root

Directory to scan (default .)

include

Glob patterns to scan (default: common source extensions)

exclude

Extra excludes, on top of .gitignore, node_modules, dist, etc.

aliases

canonicalKey -> [alternateIdentifiers] for keys whose name in code differs from the Optimizely key

patterns

Reference regexes with a {{key}} placeholder (e.g. decide("{{key}}")); when set, only these count as references

failOn

Which categories fail the run: orphans, zombies (default ["zombies"])

A typical config file looks like this:

{
  "projectId": "1234567890",
  "include": ["src/**/*.{ts,tsx,js,jsx}"],
  "aliases": {
    "checkout_v2": ["CHECKOUT_V2_FLAG", "checkoutV2"]
  },
  "patterns": ["decide\\(\\s*[\"']{{key}}[\"']"],
  "failOn": ["zombies"]
}

The patterns field is the most useful control for accuracy: by requiring a real call shape such as decide("{{key}}"), you stop a flag key that only appears in a comment or a string from being counted as a live reference.

CLI flags

--project <id>        Optimizely project ID
--root <dir>          Directory to scan
--config <path>       Config file path
--include <globs>     Comma-separated include globs
--exclude <globs>     Comma-separated extra exclude globs
--fail-on <cats>      orphans,zombies,none (default zombies)
--json <path>         Write the JSON report to a file
--pr-comment [path]   Emit GitHub PR-comment markdown (stdout or a file)
--quiet               Suppress the human report
--fix                 Archive orphan flags (needs --confirm + write token)
--confirm             Actually perform --fix writes (otherwise dry run)

Recognised environment variables: OPTIMIZELY_API_TOKEN (read), OPTIMIZELY_WRITE_TOKEN (for --fix), OPTIMIZELY_PROJECT_ID, and OPTIMIZELY_BASE_URL.

Run it in CI with the GitHub Action

The most valuable place to run the scanner is on every pull request, so dead flags and dangling references are caught before they merge. The tool ships as a composite GitHub Action that runs the scan, posts a PR comment summarising orphans and zombies, and fails the check according to fail-on.

name: Flag hygiene
on: [pull_request]

jobs:
  flags:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: sobrienti/optipilot-find-code-refs@v0
        with:
          project-id: ${{ vars.OPTIMIZELY_PROJECT_ID }}
          read-token: ${{ secrets.OPTIMIZELY_API_TOKEN }}
          fail-on: zombies

Store the read token as a repository secret (OPTIMIZELY_API_TOKEN) and the project ID as a repository variable. Action inputs mirror the CLI:

Input

Default

Meaning

project-id

Optimizely project ID (or set projectId in a config file)

read-token

— (required)

Optimizely read API token; always pass a secret

write-token

Write token, only needed with fix: true

root

.

Directory to scan

fail-on

zombies

Categories that fail the check: orphans, zombies, none

fix

false

Archive orphan flags (requires write-token and confirm)

confirm

false

Actually perform fix writes (otherwise a dry run)

comment

true

Post a PR comment with the report

The action exposes two outputs — exit-code (0 clean, 1 policy violation) and json-report (path to the JSON artifact) — so later workflow steps can react to the result.

Start with fail-on: zombies. Zombies are almost always a genuine bug (code calling a flag that no longer resolves), whereas an orphan can be a false positive when a flag is consumed by a repository this scan does not cover. Once your flags are consolidated into scanned repos, tighten the policy to fail-on: orphans,zombies.

Archiving orphans with --fix

Beyond reporting, the tool can archive orphan flags for you. Archiving is reversible — Optimizely supports unarchive — but because it writes to your account it is guarded twice:

  • It performs no writes unless you pass --confirm as well; without it, --fix prints a dry-run notice of what it would archive.

  • It requires a separate write token in OPTIMIZELY_WRITE_TOKEN, distinct from the read token, so a read-only CI job can never accidentally mutate flags.

OPTIMIZELY_API_TOKEN=<read> OPTIMIZELY_WRITE_TOKEN=<write> \
  node dist/cli.js --project 1234567890 --fix --confirm

Under the hood, --fix archives each orphan through Optimizely's bulk archive endpoint. Run it locally first as a dry run, review the list, and only add --confirm once you are satisfied every listed flag is genuinely dead.

Exit codes

Code

Meaning

0

Clean — no policy violations

1

Policy violation (an orphan or zombie in a fail-on category)

2

Operational error (bad token, unreachable API, misconfiguration)

Because a policy violation exits 1, wiring the tool into CI turns flag drift into a red check rather than a silent accumulation.

Known limitations

The scanner is a text-based grep against a known-key list, which makes it fast and dependency-free but comes with three honest caveats:

  • Dynamically constructed keys evade grep. A key assembled at runtime, such as decide("flag_" + variant), is never matched literally and may be mis-reported as an orphan. Mitigate with aliases and patterns in config; this is an accepted limitation, not solved.

  • Multi-repo flags look like orphans. A flag consumed only by a repository this scan does not cover will appear unused here. Run the scanner across every repo that reads the project's flags, or scope failOn accordingly.

  • Mentions in comments and strings count as references. Grep cannot tell a real call from a comment, so a key named only in a comment still suppresses an orphan classification. Use patterns to require an actual call shape.

These are properties of the approach, not bugs — knowing them lets you configure the tool so its output stays trustworthy.

Where it fits

Pair this tool with disciplined flag lifecycle habits. The moment an experiment concludes and a variation is rolled out, the flag becomes an orphan; the scanner surfaces it so it can be archived and its code path removed. For the broader practices that keep flags from piling up in the first place — naming, ownership, and retirement policies — see Feature Flag Best Practices for Production Systems.