Find Unused Feature Flags (and Dangling References) in Your Codebase
TL;DR
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]Read config. Project ID, key-match patterns, and aliases come from CLI flags, an optional config file, and environment variables (in that precedence order).
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.Scan the tree. It greps each flag key across your configured file globs, respecting
.gitignoreviagit ls-files(and falling back to a directory walk outside a git repo). Every hit is recorded as afile:linereference.Classify. Each flag/key is sorted into orphan, zombie, or active.
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 |
|---|---|
| Optimizely project ID |
| Directory to scan (default |
| Glob patterns to scan (default: common source extensions) |
| Extra excludes, on top of |
|
|
| Reference regexes with a |
| Which categories fail the run: |
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 |
|---|---|---|
| — | Optimizely project ID (or set |
| — (required) | Optimizely read API token; always pass a secret |
| — | Write token, only needed with |
|
| Directory to scan |
|
| Categories that fail the check: |
|
| Archive orphan flags (requires |
|
| Actually perform |
|
| 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
--confirmas well; without it,--fixprints 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 |
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 withaliasesandpatternsin 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
failOnaccordingly.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
patternsto 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.