Skip to content

LaunchDarkly Feature Flag Cleanup: Audit, Rewrite, and Enforce in TypeScript

Your codebase has been accumulating direct LaunchDarkly SDK calls for years. The team knows a cleanup is overdue, but nobody has a clear picture of how many flag keys exist, which ones are safely automatable, and which ones will bite you if touched carelessly. LaunchDarkly’s built-in cleanup tooling — Vega — requires an Enterprise subscription. Grep finds string literals but misses dynamic keys, detail evaluations, and bulk calls. You end up either doing nothing or hand-editing files one at a time and hoping the argument order is correct.

FlagLint is a free, open-source CLI that automates LaunchDarkly feature flag cleanup in TypeScript and JavaScript codebases using AST-based static analysis — not regex — to classify every direct LaunchDarkly SDK call by risk, generate a readable flag debt inventory, rewrite safe call sites to the OpenFeature standard, and enforce the resulting boundary in CI. No LaunchDarkly API key required.

This guide walks through a complete cleanup cycle: audit → rewrite → enforce.

The first step of any LaunchDarkly feature flag cleanup is understanding what you are dealing with. Run flaglint audit against your source directory:

Terminal window
npx flaglint audit ./src

Here is the real output for a two-file Node.js service with seven flag evaluations across checkout and pricing modules:

- Auditing ./src...
# FlagLint Audit Report
**Scanned at:** 2026-07-06T04:47:44.977Z
**Scan root:** ./src
**Files scanned:** 2
**Duration:** 35ms
## Summary
| Total Flags | High Risk | Medium Risk | Total Usages |
|-------------|-----------|-------------|--------------|
| 7 | 2 | 5 | 7 |
| Dynamic Keys | Detail Evals | Bulk Calls | Stale Signals | Safely Automatable | Manual Review |
|--------------|--------------|------------|---------------|-------------------|---------------|
| 1 | 1 | 0 | 0 | 5 | 2 |
> **Staleness:** No staleness signals detected. Heuristics checked: keyword match
> (flag key contains old/deprecated/legacy/temp/tmp/test/demo), path pattern
> (test/spec/mock files, deprecated/old/legacy directories), and minFileCount
> threshold. Git-history-based staleness (last evaluation date) requires git
> metadata and is not available in a pure static scan.
## Migration Readiness
Migration readiness: **71/100** · moderate
[██████████████████░░░░░░░] 71%
5 safely automatable · 2 require manual review
## Flag Debt Inventory
| Flag Key | Risk | Usages | Files | Call Types | Reasons |
|----------|------|--------|-------|------------|---------|
| `<dynamic key>` | 🔴 High | 1 | 1 | numberVariation | dynamic key |
| `beta-pricing` | 🔴 High | 1 | 1 | boolVariationDetail | detail evaluation |
| `checkout-v2` | 🟢 Automatable | 1 | 1 | boolVariation | safely automatable |
| `discount-percentage` | 🟢 Automatable | 1 | 1 | numberVariation | safely automatable |
| `ui-theme` | 🟢 Automatable | 1 | 1 | stringVariation | safely automatable |
| `checkout-config` | 🟡 Medium | 1 | 1 | jsonVariation | safely automatable, json variation |
| `promo-banner` | 🟢 Automatable | 1 | 1 | boolVariation | safely automatable |
## Next Steps
- Run `flaglint migrate --dry-run` to preview safe OpenFeature rewrites
- Run `flaglint validate --no-direct-launchdarkly` to enforce OF boundary in CI
- Review HIGH risk flags manually before any automated migration
✓ Audit complete: 7 flags — 2 high risk, 5 medium risk (35ms, 2 files)
Migration readiness: 71/100 · moderate
[██████████████████░░░░░░░] 71%
5 safely automatable · 2 require manual review

The readiness score of 71/100 means 5 of 7 call sites can be rewritten automatically. The two high-risk entries need manual attention before anything else moves.

Add --format html --output flag-debt.html to produce a shareable report to attach to a migration planning ticket. The flag debt blog post covers the full range of audit options including effort estimates.

What the flag debt inventory is telling you

Section titled “What the flag debt inventory is telling you”

Two call types are classified as high risk:

Dynamic key (<dynamic key>) — the flag key is constructed at runtime from a variable or template literal (e.g., const key = `pricing-${plan}`). FlagLint cannot resolve which flag is actually evaluated at any given call site. Any automated rewrite here would silently touch the wrong call. These require a human decision: extract the dynamic key into a lookup table, split into separate static flag keys, or handle manually per call type.

Detail evaluation (beta-pricing via boolVariationDetail) — variationDetail returns a reason object with no direct OpenFeature equivalent. FlagLint skips these by design. You need to decide whether that reason metadata is still needed after migration, and if so, which OpenFeature detail API maps to your use case.

The five remaining call sites — boolVariation, numberVariation, stringVariation, jsonVariation, and a second boolVariation — are all safely automatable. FlagLint can rewrite every one of them without you touching a line.

Run flaglint migrate with --dry-run to see exactly what changes before any file is modified:

Terminal window
npx flaglint migrate ./src --dry-run

Real output (provider setup guidance section omitted; covered in Step 3 below):

- Scanning ./src...
LaunchDarkly usages found: 7
Safely automatable: 5 · Manual review: 2
Reviewable diffs: 5
Diffs requiring provider setup: 5
Skipped usages: 2
## Diffs
diff --git a/checkout.ts b/checkout.ts
--- a/checkout.ts
+++ b/checkout.ts
@@ -8,1 +8,1 @@
- const newCheckoutEnabled = await ldClient.boolVariation("checkout-v2", ctx, false);
+ const newCheckoutEnabled = await openFeatureClient.getBooleanValue("checkout-v2", false, ctx);
@@ -9,1 +9,1 @@
- const discountPct = await ldClient.numberVariation("discount-percentage", ctx, 0);
+ const discountPct = await openFeatureClient.getNumberValue("discount-percentage", 0, ctx);
@@ -10,1 +10,1 @@
- const theme = await ldClient.stringVariation("ui-theme", ctx, "default");
+ const theme = await openFeatureClient.getStringValue("ui-theme", "default", ctx);
@@ -11,1 +11,1 @@
- const config = await ldClient.jsonVariation("checkout-config", ctx, {});
+ const config = await openFeatureClient.getObjectValue("checkout-config", {}, ctx);
diff --git a/pricing.ts b/pricing.ts
--- a/pricing.ts
+++ b/pricing.ts
@@ -10,1 +10,1 @@
- const promoEnabled = await ldClient.boolVariation("promo-banner", ctx, false);
+ const promoEnabled = await openFeatureClient.getBooleanValue("promo-banner", false, ctx);
## Skipped Usages
- pricing.ts:9:26 — `dynamicKey` via `numberVariation`: dynamic key requires manual review
- pricing.ts:11:23 — `beta-pricing` via `boolVariationDetail`: detail methods skipped:
OpenFeature detail APIs exist, but LaunchDarkly/OpenFeature detail result parity requires
manual review

Notice the argument order flip: boolVariation("checkout-v2", ctx, false) becomes getBooleanValue("checkout-v2", false, ctx). The LaunchDarkly SDK puts context second and default last; OpenFeature reverses that. This reversed argument order is the most common source of silent production bugs in manual migrations — FlagLint handles it correctly for every safe call type.

The jsonVariationgetObjectValue rewrite is flagged json variation in the audit because OpenFeature’s JSON type is object. If your LaunchDarkly flag ever returns a primitive JSON value (number, string, boolean, null), call semantics differ. Review before applying.

The two skipped usages are left exactly as-is in source.

The dry-run output marks all five diffs as requiring provider setup. The LaunchDarkly SDK stays as your evaluation backend — you are changing the API your application code calls, not where flags are stored or evaluated.

Install once:

Terminal window
npm install @openfeature/server-sdk \
@launchdarkly/node-server-sdk \
@launchdarkly/openfeature-node-server

Add a bootstrap file at application startup. Do not remove existing LaunchDarkly packages — the OpenFeature provider depends on them at runtime:

import { OpenFeature } from "@openfeature/server-sdk";
import { LaunchDarklyProvider } from "@launchdarkly/openfeature-node-server";
const ldProvider = new LaunchDarklyProvider(process.env.LD_SDK_KEY!);
await OpenFeature.setProviderAndWait(ldProvider);
export const openFeatureClient = OpenFeature.getClient();

Import openFeatureClient in every module that has call sites in the migration plan, or configure openFeatureClientBindings in your .flaglintrc so FlagLint locates the client binding automatically. The add OpenFeature provider tutorial covers both approaches with full examples.

Once the OpenFeature provider is wired:

Terminal window
npx flaglint migrate ./src --apply

FlagLint rewrites only the five safely automatable call sites and leaves the two high-risk ones untouched. What you get is an ordinary git diff: five function-call replacements across two files, reviewable like any other PR. The dynamic key and detail evaluation remain as direct LaunchDarkly SDK calls until you handle them manually.

After --apply and manual resolution of the remaining two call sites, lock the boundary so no new direct LaunchDarkly SDK calls can reach main:

Terminal window
npx flaglint validate ./src --no-direct-launchdarkly

If you are mid-cleanup and cannot enforce a hard block yet, use baseline mode: it freezes existing flag debt and fails on any net-new addition.

Terminal window
# Write current findings as the accepted baseline
npx flaglint audit ./src --write-baseline .flaglint-baseline.json
# In CI: fail only on findings not present in the baseline
npx flaglint validate ./src \
--no-direct-launchdarkly \
--baseline .flaglint-baseline.json \
--fail-on-new

Commit .flaglint-baseline.json to source control. Each time you resolve a flag through migrate --apply or a manual cleanup, re-run --write-baseline to shrink the accepted set. The GitHub Actions integration guide shows the full CI step configuration, including SARIF upload for GitHub Code Scanning annotations.

If your LaunchDarkly SDK calls are spread across multiple packages, run the three commands per package rather than at the repo root. Each package can have its own .flaglintrc pointing to its local OpenFeature client binding. The monorepo guide covers per-package configuration and how to sequence the cleanup when the same flag key is evaluated in shared libraries and consumer apps simultaneously.

LaunchDarkly feature flag cleanup at the code level breaks into four repeatable steps:

  1. flaglint audit ./src — inventory your flag debt and get a readiness score
  2. flaglint migrate ./src --dry-run — review the migration plan before touching files
  3. flaglint migrate ./src --apply — apply safe rewrites; fix the remaining two manually
  4. flaglint validate ./src --no-direct-launchdarkly — gate the boundary in CI

No Enterprise subscription, no API key, no manual grep. The complete six-step migration walkthrough picks up from here if you want to see the full picture across a production Node.js service.