A LaunchDarkly React SDK migration is a different problem from migrating a Node.js service. When
you run flaglint migrate --apply on a server-side codebase, it rewrites ldClient.boolVariation
call sites automatically. React is different. The LaunchDarkly React SDK ships a hooks-based
evaluation API — useFlags, useVariation, useLDClient — that is structurally coupled to
component trees and context providers. FlagLint detects every call site and surfaces them in the
migration plan, but the rewrites are flagged as manual review items, not auto-applied.
This article is for engineers who have run FlagLint on a React codebase and are looking at a list
of manual review items. It covers what each hook maps to in the OpenFeature React SDK, shows
real before/after diffs for each conversion, and explains how to lock the boundary in CI once
migration is complete.
It’s the Go-native counterpart to flaglint-js, and it shares the same non-negotiable rule: a variable is only ever treated as a LaunchDarkly client when its identity can be proven — never by matching a method name in isolation. That rule is why flaglint-js earned trust in the first place, and flaglint-go inherited it from day one.
Before shipping, we validated flaglint-go the way you’d hope any static analysis tool gets validated: not just against synthetic fixtures we wrote ourselves, but against real, unmodified, open-source Go repositories known to use the LaunchDarkly SDK — including the official launchdarkly-labs/ld-sample-app-go, plus weaviate/weaviate, CMS-Enterprise/mint-app, and e2b-dev/infra.
The result: zero false positives, but also zero recall on every single repo with genuine usage.
That’s a striking failure. Not “missed a few edge cases” — missed all of it, on the official sample app included. The scanner wasn’t broken; it was doing exactly what it was designed to do (prove identity syntactically, never guess by name) — it just turned out real Go code almost never wires up the LaunchDarkly client the simple way our synthetic fixtures assumed.
mint-app passes an already-constructed client into a struct’s constructor as a plain function parameter — there’s no assignment to trace at all; the parameter’s declared type is the only place identity is ever established.
None of these require real type-checking to resolve. In every case, the proof of identity is available directly from the AST — a struct’s declared field type, a function’s declared parameter or return type — the same “trust the syntax, no build required” spirit as the rest of the scanner. What they do require is looking at more than one file at a time, since the code that constructs the client and the code that uses it are routinely in a different file, sometimes a different package, from wherever the binding was first established.
We rearchitected the scanner from a per-file model (read, parse, detect, discard — one file at a time) into a whole-scan pass: parse every file up front, then resolve identity across the entire scan before any detection runs. That closed all three gaps:
Composite-literal struct-field binding — &LDIntegration{ldClient: client} binds the field when client is itself already bound.
Multi-level field-selector chains, including through generics — a struct’s fields can be declared in a different file than where they’re used.
Cross-package factory/getter functions, resolved via real go.mod-derived import paths — never a name-based guess. (We explicitly considered and rejected matching by “last segment of the import path” as a shortcut — that’s a name heuristic wearing an import-path costume, exactly the kind of thing our non-negotiable identity rule exists to prevent.)
Parameter-typed client bindings — a parameter declared *ld.LDClient is bound from its type alone, no assignment to trace.
We also found and fixed a bug that had nothing to do with the original plan: Go generics. weaviate’s FeatureFlag[T] broke a piece of code that had only ever been tested against non-generic structs — a method receiver on a generic type has a different AST shape (*ast.IndexExpr) than a plain one, and the scanner silently failed to resolve it at all. Found only by testing against real code that happened to use generics.
Before merging any of this, we ran an independent review pass — a fresh reviewer with no context on the implementation, adversarially checking the diff. It found something real: two of the new whole-scan indices (struct field types, and package-level/struct-field bindings) were keyed by bare name across the entire scan, not scoped to a package. Go allows two completely unrelated packages to each declare their own Service struct with their own Client field — and a genuinely-bound client in one package would have incorrectly matched a same-named, unconnected field in a totally different package.
That’s exactly the class of false positive our whole identity model exists to prevent, and it slipped through the first pass. We fixed it by partitioning every whole-scan index by package — matching how an unqualified identifier is only ever visible within its own package in real Go anyway — added regression fixtures reproducing the exact collision, and re-verified against all three real repos to confirm detection was unaffected by the fix.
weaviate’s four call sites all show as high-risk dynamic keys — correctly so, since the flag key there (f.key) is a runtime struct field, not a string literal. That’s the honest answer, not a false claim of full static resolution.
We’re not going to pretend this closes every gap. e2b-dev/infra’s usage is still undetected — it takes a bound client’s method value and passes it through a generic helper function, which is a genuinely harder problem (interprocedural data-flow, not just “look at more files”). That, along with a handful of narrower gaps found along the way, is filed as tracked, public issues rather than silently swept under the rug — see the Supported Scope and Limitations pages for the full, honest list.
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.
- 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.
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.
- 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 jsonVariation → getObjectValue 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
npminstall@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 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.
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.
# In CI: fail only on findings not present in the baseline
npxflaglintvalidate./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:
flaglint audit ./src — inventory your flag debt and get a readiness score
flaglint migrate ./src --dry-run — review the migration plan before touching files
flaglint migrate ./src --apply — apply safe rewrites; fix the remaining two manually
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.
Until now, the only install paths were npm install -g flaglint or npx flaglint@latest. Both work fine — but both require Node.js 20 or newer. That’s a reasonable assumption for application developers, but it’s a friction point in a few common situations.
DevOps and platform engineers often run tooling audits across repos without a Node.js environment set up. Installing Node just to run one CLI is the kind of thing that gets a tool skipped in favour of whatever’s already available.
Docker-based CI is the bigger one. A lot of teams run their CI in minimal images — no Node, no npm. Adding a Node install step purely to run npx flaglint adds 30-60 seconds to every pipeline run and pulls in a dependency that has nothing to do with the actual application. With Homebrew, a Linux CI job can install FlagLint in a single brew install call with no Node dependency.
Mac developers who use Homebrew for CLI tools now get brew upgrade flaglint like any other tool, without thinking about npm.
The tap lives at github.com/flaglint/homebrew-tap. The formula fetches the published npm tarball directly from the npm registry and wires up the CLI binary.
The formula updates automatically on every release — a GitHub Actions job in the main repo runs after each npm publish, computes the new tarball SHA256, and commits an updated formula to the tap. So brew upgrade flaglint will always pull the current release without any manual intervention.
The OpenFeature ecosystem is a directory maintained by the OpenFeature project (a CNCF incubating standard) where providers, SDKs, hooks, and integrations can be listed. It is not an award or a certification. It is a discovery page. Teams that are already evaluating OpenFeature — researching providers, looking for tooling, trying to understand the ecosystem — land there.
Being listed means that those teams will now find FlagLint when they filter for integrations. That matters because the people who need FlagLint most are exactly the people who are actively thinking about OpenFeature.
The OpenFeature standard is the reason FlagLint exists. The whole problem FlagLint solves — the argument-order inversion between LaunchDarkly’s boolVariation(key, ctx, default) and OpenFeature’s getBooleanValue(key, default, ctx) — only surfaces when you are trying to move to OpenFeature. If teams weren’t adopting OpenFeature, there would be no migration to get wrong.
So it made sense to be in the directory where those teams are looking. Not to market FlagLint as a product, but to be findable at the point in the journey where someone is asking “what tooling exists around OpenFeature for LaunchDarkly migration?”
FlagLint is not an OpenFeature SDK or provider. It doesn’t evaluate flags. What it does is sit at the boundary between your existing LaunchDarkly codebase and the OpenFeature world you’re moving toward.
Specifically:
Audit — inventory every direct LaunchDarkly SDK call, classify each one by migration risk, produce a readiness score
Migrate — preview and apply proven-safe call-site rewrites that transpose arguments correctly and rename methods atomically
Validate — enforce in CI that no new direct LaunchDarkly calls land once you’ve drawn the boundary
None of that requires a network connection, an API key, or access to your LaunchDarkly environment. It’s all static analysis on your source code, running locally.
If you’re in the process of moving to OpenFeature and want to understand your current exposure before touching any code, the audit command is the right starting point.
Terminal window
npxflaglint@latestaudit./src
It runs in under a minute on most codebases and doesn’t touch any files.