Skip to content

migration

5 posts with the tag “migration”

Feature Flag Technical Debt in TypeScript: Find, Measure, and Clear It

TypeScript’s type system enforces interface contracts and catches argument-type mismatches at compile time — but it cannot see which of your modules still depend on the LaunchDarkly SDK, which of those call sites can be automatically rewritten, or how many engineer-hours the migration backlog represents.

Feature flag technical debt in TypeScript codebases compounds quietly. A team ships a boolean flag behind ldClient.boolVariation, the rollout succeeds, and the code moves on. Six months later the flag is still evaluated on every request. The surrounding code has grown around it. The LaunchDarkly SDK is a locked-in transitive dependency for the entire module that contains it. And no one has a reliable count of how many of these exist, because the only tool most teams reach for is a grep that conflates static flag keys, wrapper functions, and bulk calls into one undifferentiated list.

FlagLint is a free open-source CLI that parses TypeScript source files using an AST scanner to enumerate every LaunchDarkly SDK call site, classify each one by call type and risk, compute a readiness score, and output a migration plan to OpenFeature. No LaunchDarkly API key required.

Why grep misses TypeScript feature flag technical debt

Section titled “Why grep misses TypeScript feature flag technical debt”

Running grep -r "ldClient" ./src gives you a count. It does not give you a classification. Every result looks equivalent in grep output, but three structurally different situations hide behind the same pattern:

  1. Static flag key, direct call — the flag key is a string literal; the call type is boolVariation, stringVariation, or numberVariation; the return type is known. FlagLint can generate a safe rewrite for this automatically.
  2. Wrapper function with a dynamic key — the function accepts a flagKey parameter and calls the LaunchDarkly SDK internally. FlagLint cannot statically determine which flag is being evaluated, verify the call type, or confirm the return type. This is a high-risk call type.
  3. Detail evaluation or bulk callboolVariationDetail and allFlagsState have no direct OpenFeature provider equivalent and cannot be safely transformed by a static rewriter.

All three groups require completely different migration approaches — but grep cannot distinguish them.

Run flaglint scan against your source directory to get the AST-based inventory:

Terminal window
npx flaglint scan ./src

Real output from the enterprise checkout service included with FlagLint (5 source files):

- Scanning examples/enterprise-checkout-service/src/...
✓ 19 flag usages found across 11 unique flags (65ms)
ℹ 1 dynamic flag key(s) require manual review
# FlagLint Scan Report
**Scanned:** 5 files in 65ms
**Flag usages:** 19 across 11 unique flags
**Stale candidates:** 0 flags flagged for review
## Flag Inventory
| Flag Key | Usages | Files | Call Types | Status |
|-----------------------|--------|-------|------------------------------------------------------------|----------|
| (dynamic key) | 7 | 3 | variationDetail, boolVariation, stringVariation, ... | ✓ Active |
| checkout-experiment | 1 | 1 | boolVariationDetail | ✓ Active |
| (dynamic key) | 1 | 1 | allFlagsState | ✓ Active |
| checkout-v2 | 1 | 1 | boolVariation | ✓ Active |
| payment-provider | 1 | 1 | stringVariation | ✓ Active |
| one-click-checkout | 1 | 1 | boolVariation | ✓ Active |
| checkout-currency | 1 | 1 | stringVariation | ✓ Active |
| discount-percentage | 1 | 1 | numberVariation | ✓ Active |
| max-discount-amount | 1 | 1 | numberVariation | ✓ Active |
| discount-config | 1 | 1 | jsonVariation | ✓ Active |
| pricing-tier-config | 1 | 1 | jsonVariation | ✓ Active |
| recommendations-variant | 1 | 1 | stringVariation | ✓ Active |
| bulk-discount-enabled | 1 | 1 | boolVariation | ✓ Active |

Seven of the nineteen usages resolve to a dynamic flag key. All seven originate from flags-wrapper.ts, which accepts flagKey as a parameter and proxies calls to the LaunchDarkly SDK. Grep would list those seven as equivalent entries alongside the statically-keyed calls in checkout.ts and pricing.ts. The AST scanner surfaces the wrapper boundary.

flaglint scan gives you the inventory. flaglint audit adds risk classification, a readiness score, and an optional effort estimate in engineer-hours:

Terminal window
npx flaglint audit examples/enterprise-checkout-service/

Real output:

- Auditing examples/enterprise-checkout-service/...
# FlagLint Audit Report
**Files scanned:** 16
**Duration:** 97ms
## Summary
| Total Flags | High Risk | Medium Risk | Total Usages |
|-------------|-----------|-------------|--------------|
| 13 | 3 | 10 | 27 |
| Dynamic Keys | Detail Evals | Bulk Calls | Stale Signals | Safely Automatable | Manual Review |
|--------------|--------------|------------|---------------|--------------------|---------------|
| 7 | 1 | 1 | 0 | 18 | 9 |
> **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: **67/100** · moderate
[█████████████████░░░░░░░░] 67%
18 safely automatable · 9 require manual review

The readiness score is the fraction of direct LaunchDarkly SDK call sites that FlagLint can rewrite automatically. A score of 67 means 18 of the 27 call sites are safely transformable. The remaining 9 require a human to resolve before an automated pass can run on those files.

The staleness signal column surfaces flag keys whose names carry heuristic staleness signal — keywords like old, deprecated, legacy, or tmp in the flag key itself. Zero here means no staleness signal at the source level. Staleness detection does not require a LaunchDarkly API key or runtime data.

Add --effort-estimate to convert the count into a planning number:

Terminal window
npx flaglint audit ./src --effort-estimate

This appends a three-phase estimate: automatable call sites at approximately 0.25 engineer-hours each, manual review call sites at 1.5–3 hours each, plus 30% overhead for validation and testing. Supplying --hourly-rate 150 appends a dollar range to the summary. The estimate is a planning heuristic calibrated to call-site complexity, not a billing projection.

The three risk tiers in the flag debt inventory

Section titled “The three risk tiers in the flag debt inventory”

Every flag key in the audit report lands in one of three tiers:

High risk — cannot be automated:

<dynamic key> (7 usages across 3 files) — the flag key is a runtime variable, not a string literal. FlagLint marks every dynamic flag key as high risk because it cannot statically determine which flag is being evaluated, verify the call type, or confirm the return type. The resolution is to trace back to the call sites that supply the key parameter, then extract each unique flag key to a named constant. Re-running flaglint audit after that change will reclassify the previously-dynamic entries as automatable.

checkout-experiment (1 usage) — boolVariationDetail is a detail evaluation call type. OpenFeature has a getBooleanDetails equivalent, but the reason vocabulary differs: the LaunchDarkly SDK returns TARGETING_MATCH and RULE_MATCH; OpenFeature uses its own reason strings. Code that inspects reason.kind or reason.ruleId must be updated by hand alongside the call site.

* (1 usage) — allFlagsState is a bulk call with no OpenFeature provider equivalent. The resolution is to enumerate the specific flag keys the bulk call feeds and replace them with individual named-key calls. If full flag state at application startup is genuinely required, retain the LaunchDarkly SDK client for that bootstrap path while migrating all other call sites.

Medium risk — automatable with review:

discount-config and pricing-tier-config are jsonVariation call types. They are safely automatable, but OpenFeature’s object value API returns unknown. After the rewrite, confirm that any code that casts or destructures the return value still compiles and behaves correctly.

Automatable — safe to transform:

Eight flag keys — checkout-v2, payment-provider, one-click-checkout, checkout-currency, discount-percentage, max-discount-amount, recommendations-variant, and bulk-discount-enabled — are called with boolVariation, stringVariation, or numberVariation using static string literal flag keys. FlagLint can rewrite all of these.

The automatable rewrite is not a text substitution. The LaunchDarkly SDK and OpenFeature provider place the fallback value and evaluation context in different argument positions:

// LaunchDarkly SDK — (flagKey, context, fallback)
const enabled = await ldClient.boolVariation("checkout-v2", ctx, false);
// OpenFeature provider — (flagKey, fallback, context)
const enabled = await openFeatureClient.getBooleanValue("checkout-v2", false, ctx);

The flag key is identical. The fallback value and evaluation context swap positions. A naive find-and-replace migration that does not track argument order evaluates every flag with the wrong context on the first request and returns the wrong result silently. FlagLint’s AST rewriter moves all three arguments to the correct positions for each automatable call type.

Preview every transformation before any file is touched:

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

The dry-run output shows a reviewable diff for each automatable call site alongside the OpenFeature provider setup steps. No files are modified.

Applying the migration plan and enforcing the boundary

Section titled “Applying the migration plan and enforcing the boundary”

Once you have reviewed the dry-run output and set up the OpenFeature provider:

Terminal window
npx flaglint migrate --apply ./src

This applies all safe rewrites in-place. Run your test suite after the apply. Then lock the boundary in CI:

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

flaglint validate exits non-zero when any direct LaunchDarkly SDK call is detected. Add it to your GitHub Actions workflow and direct LaunchDarkly SDK calls become a build failure from that point forward, blocking regressions as the migration lands across multiple PRs.

Clearing the manual review backlog incrementally

Section titled “Clearing the manual review backlog incrementally”

Work through the high-risk items in batches. After each batch, re-run flaglint audit to watch the readiness score climb. At 80 or above, the remaining feature flag technical debt in TypeScript can be handled in a single automated pass — flaglint migrate --apply clears it and flaglint validate --no-direct-launchdarkly confirms the boundary is clean.

The audit plus the CI gate is a closed loop: audit measures what exists, migrate rewrites what is safe, validate blocks regressions, audit confirms progress. You can run the full cycle on a large codebase before writing a single line of migration code. The readiness score tells you up front whether you are looking at a two-sprint effort or a six-month program, and the migration plan tells you exactly which call sites require which kind of attention.

Enforcing Your LaunchDarkly to OpenFeature Migration in GitHub Actions

You started your LaunchDarkly to OpenFeature migration three weeks ago. The first sprint went well—five files converted, OpenFeature provider wired in, existing tests green. Then a teammate opened a PR for a new service. Inside it: two fresh ldClient.boolVariation() calls. Not malicious. They just forgot. You merge it anyway because it is not worth blocking the PR over. Two weeks later there are six more.

This is migration drift. It is the most common reason LaunchDarkly to OpenFeature migration projects stall: there is no gate on new direct LaunchDarkly SDK calls landing in main. Without a CI check that fails on any new call site, every PR can quietly add to the flag debt you are actively paying down.

FlagLint addresses this with two commands—audit to measure the existing flag debt and validate to enforce the boundary—and a one-step GitHub Actions integration that adds the gate with two lines of YAML.

Step 1: Baseline your flag debt before you gate

Section titled “Step 1: Baseline your flag debt before you gate”

Before you block anything in CI, run flaglint audit against your source directory. This produces a readiness score and a per-flag-key inventory—the snapshot you will measure progress against, and the list you need when deciding what to exclude during the transition period.

Terminal window
npx flaglint audit ./src

Real output from the src/ directory of the enterprise checkout service shipped with FlagLint examples:

- Auditing examples/enterprise-checkout-service/src/...
# FlagLint Audit Report
**Scanned at:** 2026-06-24T03:20:27.050Z
**Scan root:** /home/user/flaglint/examples/enterprise-checkout-service/src
**Files scanned:** 5
**Duration:** 63ms
## Summary
| Total Flags | High Risk | Medium Risk | Total Usages |
|-------------|-----------|-------------|--------------|
| 13 | 3 | 10 | 19 |
| Dynamic Keys | Detail Evals | Bulk Calls | Stale Signals | Safely Automatable | Manual Review |
|--------------|--------------|------------|---------------|-------------------|---------------|
| 7 | 1 | 1 | 0 | 10 | 9 |
## Migration Readiness
Migration readiness: **53/100** · moderate
[█████████████░░░░░░░░░░░░] 53%
10 safely automatable · 9 require manual review
## Flag Debt Inventory
| Flag Key | Risk | Usages | Files | Call Types | Reasons |
|----------|------|--------|-------|------------|---------|
| `<dynamic key>` | 🔴 High | 7 | 3 | variationDetail, boolVariation, stringVariation, numberVariation, jsonVariation | dynamic key |
| `checkout-experiment` | 🔴 High | 1 | 1 | boolVariationDetail | detail evaluation |
| `*` | 🔴 High | 1 | 1 | allFlagsState | bulk call |
| `checkout-v2` | 🟢 Automatable | 1 | 1 | boolVariation | safely automatable |
| `payment-provider` | 🟢 Automatable | 1 | 1 | stringVariation | safely automatable |
| `one-click-checkout` | 🟢 Automatable | 1 | 1 | boolVariation | safely automatable |
| `checkout-currency` | 🟢 Automatable | 1 | 1 | stringVariation | safely automatable |
| `discount-percentage` | 🟢 Automatable | 1 | 1 | numberVariation | safely automatable |
| `max-discount-amount` | 🟢 Automatable | 1 | 1 | numberVariation | safely automatable |
| `discount-config` | 🟡 Medium | 1 | 1 | jsonVariation | safely automatable, json variation |
| `pricing-tier-config` | 🟡 Medium | 1 | 1 | jsonVariation | safely automatable, json variation |
| `recommendations-variant` | 🟢 Automatable | 1 | 1 | stringVariation | safely automatable |
| `bulk-discount-enabled` | 🟢 Automatable | 1 | 1 | boolVariation | safely automatable |
✓ Audit complete: 13 flags — 3 high risk, 10 medium risk
Migration readiness: 53/100 · moderate
[█████████████░░░░░░░░░░░░] 53%
10 safely automatable · 9 require manual review

The readiness score of 53 means 10 of the 19 direct LaunchDarkly SDK call sites can be automatically rewritten by flaglint migrate --apply. The remaining 9 require manual work: 7 use a dynamic flag key (a variable, not a string literal), 1 is a detail evaluation returning reason metadata, and 1 is a bulk allFlagsState call with no single-flag OpenFeature equivalent. The staleness signal count of zero means no flag keys carry source-level stale signal—no keys contain old, deprecated, legacy, or tmp.

Save this output as your progress baseline.

flaglint validate --no-direct-launchdarkly exits non-zero when any direct LaunchDarkly SDK call is found in the scanned directory. Before wiring it into CI, run it locally so you know exactly what the gate will report:

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

Real output from the same src/ directory:

- Scanning examples/enterprise-checkout-service/src/...
✗ validate --no-direct-launchdarkly: 19 direct LaunchDarkly evaluation call(s) found.
analytics.ts:51:43 — variationDetail("(dynamic key)")
analytics.ts:76:23 — boolVariationDetail("checkout-experiment")
analytics.ts:104:22 — allFlagsState(bulk inventory)
checkout.ts:40:9 — boolVariation("checkout-v2")
checkout.ts:49:9 — stringVariation("payment-provider")
checkout.ts:58:9 — boolVariation("one-click-checkout")
checkout.ts:67:9 — stringVariation("checkout-currency")
flags-wrapper.ts:48:9 — boolVariation("(dynamic key)")
flags-wrapper.ts:67:11 — boolVariation("(dynamic key)")
flags-wrapper.ts:70:11 — stringVariation("(dynamic key)")
flags-wrapper.ts:73:11 — numberVariation("(dynamic key)")
flags-wrapper.ts:75:9 — jsonVariation("(dynamic key)")
pricing.ts:46:9 — numberVariation("discount-percentage")
pricing.ts:55:9 — numberVariation("max-discount-amount")
pricing.ts:69:9 — jsonVariation("discount-config")
pricing.ts:83:9 — jsonVariation("pricing-tier-config")
product.ts:52:9 — boolVariation("(dynamic key)")
product.ts:61:9 — stringVariation("recommendations-variant")
product.ts:70:9 — boolVariation("bulk-discount-enabled")
These files must migrate to OpenFeature before this rule passes.
Run `flaglint migrate --dry-run` to review the migration plan.

Each finding shows file path, line number, column, call type, and flag key. All call types are tracked: boolVariation, stringVariation, numberVariation, jsonVariation, variationDetail, boolVariationDetail, and allFlagsState. Dynamic flag keys appear as (dynamic key).

The gate exits non-zero the moment any direct LaunchDarkly SDK call is detected, blocking any new flag from bypassing the OpenFeature provider. When validate finds zero violations, it exits cleanly:

✓ validate --no-direct-launchdarkly: no direct LaunchDarkly evaluation calls found.

That line is what you are working toward.

FlagLint ships a composite GitHub Actions action. The minimum setup is two lines:

name: FlagLint
on: [pull_request]
jobs:
validate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: flaglint/flaglint@main
with:
directory: ./src

The action runs flaglint validate ./src --no-direct-launchdarkly and exits 1 when any direct call is found. Do not set continue-on-error: true on the FlagLint step. The job failing is the mechanism—that is what blocks the PR.

Your OpenFeature provider setup module legitimately imports from the LaunchDarkly SDK to instantiate the provider. Exclude it with --bootstrap-exclude so the gate does not fire on it:

- uses: flaglint/flaglint@main
with:
directory: ./src
extra-args: '--bootstrap-exclude "src/provider/setup.ts"'

You can pass multiple exclusion patterns:

extra-args: >-
--bootstrap-exclude "src/provider/setup.ts"
--bootstrap-exclude "src/bootstrap/**"

The excluded files can call the LaunchDarkly SDK directly. Everything else cannot. The --bootstrap-exclude flag accepts glob patterns, so a single "src/provider/**" covers a provider directory with multiple files.

Step 4: Add SARIF annotations for inline PR diff visibility

Section titled “Step 4: Add SARIF annotations for inline PR diff visibility”

Job-level failure tells engineers something is wrong. SARIF annotations tell them exactly which line is wrong, directly in the PR diff. Add the SARIF upload step alongside the gate:

name: FlagLint Policy
on: [pull_request]
jobs:
validate:
runs-on: ubuntu-latest
permissions:
contents: read
security-events: write
steps:
- uses: actions/checkout@v4
- name: Validate no direct LaunchDarkly calls
id: flaglint
uses: flaglint/flaglint@main
with:
directory: ./src
extra-args: >-
--bootstrap-exclude "src/provider/setup.ts"
--format sarif
--output flaglint-validation.sarif
- name: Upload validation SARIF
if: always()
uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: flaglint-validation.sarif

SARIF findings use rule id flaglint.direct-launchdarkly. With security-events: write, GitHub annotates each violation inline on the relevant PR diff line as a code scanning alert. Set if: always() on the upload step—not on the validate step—so GitHub receives the SARIF file even after the job fails, and annotations appear regardless of whether the PR passes.

If your service already has 19 direct LaunchDarkly SDK calls when you add the gate, CI will immediately fail. Two approaches handle the transition:

Start with SARIF-only, then harden. Set continue-on-error: true temporarily on the validate step so violations surface as code scanning alerts without blocking merges. Remove continue-on-error once you have migrated the bulk of existing call sites.

Exclude directories that are mid-migration. Use --bootstrap-exclude patterns to allow files already in the migration queue through the gate while blocking any new file from adding a direct LaunchDarkly SDK call. Remove each exclusion as you migrate that directory.

Re-run the audit after each sprint to track how the readiness score moves. The goal is a validate run that exits cleanly:

✓ validate --no-direct-launchdarkly: no direct LaunchDarkly evaluation calls found.

When that is the consistent CI result, the LaunchDarkly to OpenFeature migration is structurally complete. No new direct call sites can land, and the codebase no longer carries flag debt pointing at the LaunchDarkly SDK.

LaunchDarkly Flag Debt: Audit, Estimate, and Prioritize Your Migration

LaunchDarkly flag debt accumulates quietly. A team ships a feature behind a flag, verifies the rollout, and moves on. The flag stays. Six months later it is still being evaluated on every request — carrying the original business logic, an implicit dependency on the LaunchDarkly SDK, and a refactoring cost that compounds with every passing sprint.

At scale, the problem becomes a planning question as much as a technical one. Grepping for ldClient gives you a count, but it misses wrappers, misclassifies risk levels, and gives no indication of how long cleanup will actually take. Before you can schedule the work or make the case to your engineering manager, you need a measurement: how many direct LaunchDarkly SDK calls exist, which ones are safe to automate, and how many engineer-hours does this represent?

FlagLint produces that measurement from static source analysis alone. No LaunchDarkly API key required.

Run flaglint audit against your source directory:

Terminal window
npx flaglint audit ./src

Real output from the enterprise checkout service shipped with FlagLint (5 source files, run from examples/enterprise-checkout-service/):

- Auditing src/...
# FlagLint Audit Report
**Files scanned:** 5
**Duration:** 64ms
## Summary
| Total Flags | High Risk | Medium Risk | Total Usages |
|-------------|-----------|-------------|--------------|
| 13 | 3 | 10 | 20 |
| Dynamic Keys | Detail Evals | Bulk Calls | Stale Signals | Safely Automatable | Manual Review |
|--------------|--------------|------------|---------------|-------------------|---------------|
| 8 | 1 | 1 | 0 | 10 | 10 |
## Migration Readiness
Migration readiness: **50/100** · moderate
[█████████████░░░░░░░░░░░░] 50%
10 safely automatable · 10 require manual review

The readiness score answers the foundational question before any migration begins: what fraction of your direct LaunchDarkly SDK call sites can a tool rewrite automatically? A score of 50 — moderate — means exactly half require a human to review before any automated step runs. A score below 50 is graded complex; 80 or above is ready, meaning the migration can proceed with minimal manual effort.

The stale signals column surfaces flag keys that carry staleness signal — flag keys containing keywords like old, deprecated, legacy, or tmp. Zero here means no flag key names carry obvious staleness signal at the source level. Git-based staleness detection, which checks last-evaluation date against git history, is outside the scope of a static scan.

Step 2: Measure the LaunchDarkly flag debt in engineer-hours

Section titled “Step 2: Measure the LaunchDarkly flag debt in engineer-hours”

A risk count tells you what you have. It does not tell you what it will cost to fix. Add --effort-estimate to get a directional planning number:

Terminal window
npx flaglint audit ./src --effort-estimate

Real output:

## Estimated Migration Effort
| | Low | High |
|---|---|---|
| Automatable calls (10 calls) | 2.5h | 3.8h |
| Manual review calls (10 calls) | 15h | 30h |
| Validation & testing | 5.3h | 10.1h |
| **Total** | **22.8h** | **43.9h** |
> Estimates are directional planning guides based on call-site complexity. Actual effort
> depends on test coverage, team familiarity, and provider setup. FlagLint does not access
> runtime data or LaunchDarkly billing.
Migration readiness: 50/100 · moderate
[█████████████░░░░░░░░░░░░] 50%
10 safely automatable · 10 require manual review
Estimated migration effort: 22.8h – 43.9h
Estimates are directional. See the report for assumptions.

The estimate breaks into three phases. Automation covers running flaglint migrate --apply, reviewing the generated diffs, and merging — roughly 0.25 engineer-hours per automatable call site. Manual review is where the range widens: each call site that requires human inspection is estimated at 1.5–3h, because the effort depends on what the surrounding code does with the evaluated value and how complex the flag key resolution is. Validation adds 30% of the combined automation and manual total for test runs, CI, and integration checks.

Supplying --hourly-rate converts the estimate to an engineering cost range:

Terminal window
npx flaglint audit ./src --effort-estimate --hourly-rate 150

This appends Estimated cost: $3,420 – $6,585 to the summary output. It is a planning heuristic calibrated to call-site complexity, not a billing projection.

The audit report includes a per-flag breakdown. This is where you translate the summary numbers into a concrete migration plan:

| Flag Key | Risk | Usages | Call Types | Reasons |
|-----------------------|----------------|--------|--------------------------------------|-----------------------------|
| `<dynamic key>` | 🔴 High | 8 | boolVariation, stringVariation, ... | dynamic key, wrapper usage |
| `checkout-experiment` | 🔴 High | 1 | boolVariationDetail | detail evaluation |
| `*` | 🔴 High | 1 | allFlagsState | bulk call |
| `checkout-v2` | 🟢 Automatable | 1 | boolVariation | safely automatable |
| `payment-provider` | 🟢 Automatable | 1 | stringVariation | safely automatable |
| `discount-config` | 🟡 Medium | 1 | jsonVariation | safely automatable, json variation |

Three call types drive the high-risk category in this service:

Dynamic flag key (8 usages across 3 files) — the flag key is a variable or template literal rather than a string literal. In this service, flags-wrapper.ts is the source: it accepts flagKey as a parameter and calls the LaunchDarkly SDK internally. FlagLint classifies it as a wrapper and marks every call through it as high risk because it cannot statically determine which flag is being evaluated, verify the call type, or confirm the return type. The resolution is to extract each dynamic key path to a named constant per call site so subsequent flaglint audit runs can classify them as automatable.

Detail evaluation (1 usage) — boolVariationDetail returns an evaluation reason object alongside the flag value. OpenFeature has a getBooleanDetails equivalent, but the reason vocabulary differs from the LaunchDarkly SDK (TARGETING_MATCH vs RULE_MATCH). Code that inspects reason.kind or reason.ruleId must be updated alongside the call site. FlagLint cannot safely generate that transformation.

Bulk call (1 usage) — allFlagsState has no OpenFeature provider equivalent. This call type requires an architecture decision before the migration can proceed: enumerate the specific flag keys needed explicitly, or retain the LaunchDarkly SDK client for the bootstrap path while migrating all other call sites to OpenFeature.

The call-site difference between a high-risk and an automatable entry is visible in source:

// High risk — dynamic flag key, cannot be rewritten automatically
const result = await ldClient.boolVariation(flagKey, ctx, false);
// Automatable — static flag key, safely rewritable
const enabled = await ldClient.boolVariation("checkout-v2", ctx, false);
// becomes:
const enabled = await openFeatureClient.getBooleanValue("checkout-v2", false, ctx);

The only structural difference in the automatable rewrite is argument order: the OpenFeature provider convention places the fallback value at position two and the evaluation context at position three. The flag key is preserved exactly. No flag evaluation logic at LaunchDarkly changes.

For teams that need to share the findings with engineering leads or allocate sprint capacity, --format html produces a self-contained file with no external dependencies:

Terminal window
npx flaglint audit ./src --effort-estimate --format html --output flag-debt.html

The file includes the summary card row, the effort estimate table, and the sortable flag debt inventory. It can be attached to a JIRA ticket, linked in a PR description, or opened locally. No LaunchDarkly credentials appear in the output — the report contains only what the static scan detected.

Step 5: Track progress toward zero flag debt

Section titled “Step 5: Track progress toward zero flag debt”

After migrating a batch of call sites, run flaglint validate to confirm the OpenFeature boundary holds:

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

Real output before migration begins:

✗ validate --no-direct-launchdarkly: 20 direct LaunchDarkly evaluation call(s) found.
analytics.ts:51:43 — variationDetail("(dynamic key)")
analytics.ts:76:23 — boolVariationDetail("checkout-experiment")
analytics.ts:104:22 — allFlagsState(bulk inventory)
checkout.ts:40:9 — boolVariation("checkout-v2")
checkout.ts:49:9 — stringVariation("payment-provider")
checkout.ts:58:9 — boolVariation("one-click-checkout")
checkout.ts:67:9 — stringVariation("checkout-currency")
...
These files must migrate to OpenFeature before this rule passes.
Run `flaglint migrate --dry-run` to review the migration plan.

Add this command to your CI pipeline. flaglint validate --no-direct-launchdarkly exits non-zero when any direct LaunchDarkly SDK call is detected, blocking regressions as the migration lands across multiple PRs. The validate gate is the mechanism that turns a migration plan into a contract.

As you resolve manual-review call sites — extracting dynamic flag keys to named constants, migrating detail evaluations by hand, replacing bulk calls with enumerated evaluations — re-run the audit to watch the readiness score climb. At 80 or above, flaglint migrate --apply can handle the remaining LaunchDarkly flag debt in a single automated pass, and the CI validate gate will confirm the boundary is clean.

Five LaunchDarkly SDK Patterns That Block Automatic Migration to OpenFeature

Run flaglint migrate ./src --dry-run and you will see two kinds of results: call sites with a generated diff and call sites marked skip — manual review required. The skipped calls are not bugs in the tool. They are patterns where a mechanical rewrite would change runtime behavior in ways the tool cannot prove are safe.

This article covers the five patterns that produce skips and what you need to do for each.

Why LaunchDarkly → OpenFeature Migrations Break in Production

LaunchDarkly and OpenFeature both evaluate flags with three arguments, but the fallback and context positions are reversed. A naive codemod can produce valid-looking code that silently changes runtime behavior.

This article shows the argument-order trap and why FlagLint uses AST analysis before rewriting any call site.