Skip to content

FlagLint Blog

OpenFeature Node.js Tutorial: Evaluate Flags, Swap Providers, and Audit Your Flag Debt

Every Node.js codebase that uses feature flags eventually hits the same problem: the evaluation calls are scattered across dozens of files, all coupled to one vendor SDK. Swap the backend and you touch every call site. Retire a vendor contract and you’re doing a codebase-wide search-and-replace that breaks argument order on half the calls you touch.

OpenFeature fixes this by decoupling the evaluation API from the backend provider. Your application code calls a stable, vendor-neutral client. The provider — LaunchDarkly, flagd, Unleash, or anything else — sits behind an interface you swap without touching call sites. This OpenFeature Node.js tutorial walks through the full setup: installing the SDK, wiring a provider, evaluating flags in real code, and then using FlagLint to audit any existing direct LaunchDarkly SDK calls and generate safe migration diffs.

OpenFeature is a CNCF standard for feature flag evaluation. The Node.js server SDK (@openfeature/server-sdk) gives you four typed evaluation methods that map to the four flag types you actually use:

client.getBooleanValue(flagKey, defaultValue, evaluationContext)
client.getStringValue(flagKey, defaultValue, evaluationContext)
client.getNumberValue(flagKey, defaultValue, evaluationContext)
client.getObjectValue(flagKey, defaultValue, evaluationContext)

The argument order is flagKey → defaultValue → context. This differs from the LaunchDarkly SDK where context comes second and the default comes last — the order swap is the most common source of silent bugs during a LaunchDarkly to OpenFeature migration. The OpenFeature standard puts the default before the context, and all providers must match it.

For a new Node.js project, install only the OpenFeature server SDK and whichever provider backs it:

Terminal window
# Core SDK (always required)
npm install @openfeature/server-sdk
# LaunchDarkly as your backend provider (keep your existing LD account)
npm install @launchdarkly/openfeature-node-server @launchdarkly/node-server-sdk

The LaunchDarkly OpenFeature provider wraps the LaunchDarkly Node.js SDK. Your LaunchDarkly account, rules, segments, and flag keys stay exactly as they are. Only the call-site API changes.

Step 2: Initialize the provider once at startup

Section titled “Step 2: Initialize the provider once at startup”

Wire the provider at application bootstrap, before any flag evaluation happens:

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 featureClient = OpenFeature.getClient();

setProviderAndWait blocks until the LaunchDarkly streaming connection is established and the local flag cache is populated — the same guarantee you get from ldClient.waitForInitialization() in the raw LaunchDarkly SDK. Once this resolves, featureClient is ready to use anywhere in your application.

Step 3: Evaluate flags in your application code

Section titled “Step 3: Evaluate flags in your application code”

A checkout module using direct LaunchDarkly SDK calls looks like this:

// Before: coupled to LaunchDarkly SDK
import ldClient from "./ldClient";
export async function getCheckoutConfig(userId: string) {
const user = { key: userId };
const newCheckoutEnabled = await ldClient.boolVariation("new-checkout-flow", user, false);
const discountRate = await ldClient.numberVariation("discount-rate", user, 0);
const checkoutTheme = await ldClient.stringVariation("checkout-theme", user, "default");
const featureConfig = await ldClient.jsonVariation("feature-config", user, {});
return { newCheckoutEnabled, discountRate, checkoutTheme, featureConfig };
}

After wiring OpenFeature, the same module becomes:

// After: vendor-neutral OpenFeature call sites
import { featureClient } from "./openfeature";
export async function getCheckoutConfig(userId: string) {
const ctx = { targetingKey: userId };
const newCheckoutEnabled = await featureClient.getBooleanValue("new-checkout-flow", false, ctx);
const discountRate = await featureClient.getNumberValue("discount-rate", 0, ctx);
const checkoutTheme = await featureClient.getStringValue("checkout-theme", "default", ctx);
const featureConfig = await featureClient.getObjectValue("feature-config", {}, ctx);
return { newCheckoutEnabled, discountRate, checkoutTheme, featureConfig };
}

The evaluation context (ctx) replaces the LaunchDarkly user object. The targetingKey field is the OpenFeature standard for the primary identifier; the LaunchDarkly provider maps it to the LaunchDarkly targeting key automatically. Any additional user attributes you pass — email, plan, country — are forwarded to LaunchDarkly as custom attributes and used in targeting rules unchanged.

Step 4: Audit your existing LaunchDarkly SDK flag debt

Section titled “Step 4: Audit your existing LaunchDarkly SDK flag debt”

If you are adding OpenFeature to a codebase that already has direct LaunchDarkly SDK calls, start with an audit before touching any call site. FlagLint’s audit command scans your source with AST analysis (not regex) and produces a complete flag debt inventory with risk classification and a readiness score.

Terminal window
npx flaglint@latest audit ./src

Real output from a Node.js checkout service with six direct LaunchDarkly SDK call sites:

- Auditing ./src...
# FlagLint Audit Report
**Scanned at:** 2026-08-27T03:03:30.875Z
**Scan root:** /tmp/.../example-svc/src
**Files scanned:** 1
**Duration:** 41ms
## Summary
| Total Flags | High Risk | Medium Risk | Total Usages |
|-------------|-----------|-------------|--------------|
| 6 | 2 | 4 | 6 |
| Dynamic Keys | Detail Evals | Bulk Calls | Stale Signals | Safely Automatable | Manual Review |
|--------------|--------------|------------|---------------|-------------------|---------------|
| 1 | 1 | 0 | 0 | 4 | 2 |
## Migration Readiness
Migration readiness: **67/100** · moderate
[█████████████████░░░░░░░░] 67%
4 safely automatable · 2 require manual review
## Flag Debt Inventory
| Flag Key | Risk | Usages | Files | Call Types | Reasons |
|----------|------|--------|-------|------------|---------|
| `<dynamic key>` | 🔴 High | 1 | 1 | boolVariation | dynamic key |
| `premium-pricing` | 🔴 High | 1 | 1 | boolVariationDetail | detail evaluation |
| `new-checkout-flow` | 🟢 Automatable | 1 | 1 | boolVariation | safely automatable |
| `discount-rate` | 🟢 Automatable | 1 | 1 | numberVariation | safely automatable |
| `checkout-theme` | 🟢 Automatable | 1 | 1 | stringVariation | safely automatable |
| `feature-config` | 🟡 Medium | 1 | 1 | jsonVariation | safely automatable, json variation |
✓ Audit complete: 6 flags — 2 high risk, 4 medium risk (41ms, 1 files)
Migration readiness: 67/100 · moderate

The readiness score (67/100) tells you what fraction of flag debt can be safely automated. The two high-risk call types flagged here — a dynamic flag key and a detail evaluation — are the two call types that require manual review before any rewrite; both are documented in the five patterns that block automatic migration.

For the four automatable call sites, run flaglint migrate --dry-run to preview the exact rewrites:

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

Real output:

- Scanning ./src...
LaunchDarkly usages found: 6
Safely automatable: 4 · Manual review: 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('new-checkout-flow', user, false);
+ const newCheckoutEnabled = await openFeatureClient.getBooleanValue('new-checkout-flow', false, user);
@@ -9,1 +9,1 @@
- const discountRate = await ldClient.numberVariation('discount-rate', user, 0);
+ const discountRate = await openFeatureClient.getNumberValue('discount-rate', 0, user);
@@ -10,1 +10,1 @@
- const checkoutTheme = await ldClient.stringVariation('checkout-theme', user, 'default');
+ const checkoutTheme = await openFeatureClient.getStringValue('checkout-theme', 'default', user);
@@ -11,1 +11,1 @@
- const featureConfig = await ldClient.jsonVariation('feature-config', user, {});
+ const featureConfig = await openFeatureClient.getObjectValue('feature-config', {}, user);
## Skipped Usages
- checkout.ts:19:24 — `flagKey` via `boolVariation`: dynamic key requires manual review
- checkout.ts:25:29 — `premium-pricing` via `boolVariationDetail`: detail methods skipped

The diffs show the argument-order swap explicitly: numberVariation('discount-rate', user, 0) becomes getNumberValue('discount-rate', 0, user). The default value and context swap positions. FlagLint applies this swap mechanically and correctly on every automatable call site; doing it by hand at scale is where the argument-order bug most commonly appears.

To apply the rewrites:

Terminal window
npx flaglint@latest migrate ./src

Once you have migrated a module, lock it with a CI check that fails on any new direct LaunchDarkly SDK call:

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

On an unmigrated codebase, this exits non-zero and lists every violation:

✗ validate --no-direct-launchdarkly: 6 direct LaunchDarkly evaluation call(s) found.
checkout.ts:8:35 — boolVariation("new-checkout-flow")
checkout.ts:9:29 — numberVariation("discount-rate")
checkout.ts:10:30 — stringVariation("checkout-theme")
checkout.ts:11:30 — jsonVariation("feature-config")
checkout.ts:19:24 — boolVariation("(dynamic key)")
checkout.ts:25:29 — boolVariationDetail("premium-pricing")
These files must migrate to OpenFeature before this rule passes.

Add this to your CI pipeline as a required check. Any PR that introduces a new direct LaunchDarkly SDK call will fail. For a step-by-step GitHub Actions setup, see Enforcing Your LaunchDarkly to OpenFeature Migration in GitHub Actions.

The value of this setup is that once your call sites are on the OpenFeature standard, swapping the backend is a one-line change in your initialization code. Remove LaunchDarklyProvider and substitute any other OpenFeature-compatible provider — flagd, Unleash, Harness, or a local in-memory provider for testing:

import { InMemoryProvider } from "@openfeature/server-sdk";
const flags = {
"new-checkout-flow": { defaultVariant: "on", variants: { on: true, off: false } },
"discount-rate": { defaultVariant: "default", variants: { default: 0, sale: 0.15 } },
};
await OpenFeature.setProviderAndWait(new InMemoryProvider(flags));

Your application code does not change. The in-memory provider is especially useful for unit tests — you set the flag state before each test, run the code, assert on the outcome, no LaunchDarkly connection required.

This OpenFeature Node.js tutorial covered the core setup: provider initialization, flag evaluation, AST-based audit of existing LaunchDarkly SDK flag debt, dry-run migration diffs, and CI enforcement.

If your codebase has significant flag debt, read the flag debt guide for a planning-level view of how to estimate hours and prioritize across multiple services. If some of your call sites show up as high-risk in the audit, the five patterns guide covers each blocker and how to resolve it before applying the automated rewrite.

FlagLint is free and open-source. No LaunchDarkly API key is required for any of the commands above — the audit and migrate commands work from static source analysis alone.

Terminal window
npx flaglint@latest --help

flaglint-go: We Field-Tested It Against Real Repos and Found Zero Recall

flaglint-go is a native Go binary for auditing LaunchDarkly Go server SDK usage — no Node.js required.

Terminal window
brew install flaglint/tap/flaglint-go
flaglint-go audit ./services

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.

Three different repos, three different indirection patterns, none of them exotic:

The official sample app wires the client through a package-level singleton getter, called from a different package entirely:

// package ldclient
func GetLdClient() *ld.LDClient { /* ... */ }
// package api, a different file, a different package
client := ldclient.GetLdClient()
client.BoolVariation("test-flag", ctx, false)

weaviate stores the client into a wrapper struct via composite literal, then reaches it through a two-level field chain — on a generic struct:

type LDIntegration struct {
ldClient *ld.LDClient
}
type FeatureFlag[T SupportedTypes] struct {
ldInteg *LDIntegration
}
// inside one of FeatureFlag[T]'s methods:
flag, err := f.ldInteg.ldClient.StringVariation(f.key, f.ldInteg.ldContext, v)

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.

After the fix, we re-cloned and re-scanned the same repos:

$ flaglint-go audit ./ld-sample-app-go
Scan complete — 1 unique flag(s) across 1 call site(s) (3 file(s))
Migration readiness: 100/100 · ready
1 low risk · 0 medium risk · 0 high risk
$ flaglint-go audit ./weaviate
Scan complete — 0 unique flag(s) across 4 call site(s) (4512 file(s))
Migration readiness: 0/100 · complex
0 low risk · 0 medium risk · 4 high risk

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.

Terminal window
brew install flaglint/tap/flaglint-go
flaglint-go audit ./services

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 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
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.

FlagLint Is Now Installable via Homebrew

Starting with v1.1.0, FlagLint is installable via Homebrew — no Node.js required.

Terminal window
brew tap flaglint/tap
brew install flaglint

That’s it. The full CLI is available immediately, including audit, scan, migrate, and validate.

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.

Terminal window
brew tap flaglint/tap
brew install flaglint
flaglint --version

Or if you already have Node.js, npx flaglint@latest still works exactly as before. The Homebrew path is an addition, not a replacement.

Once installed, the quickstart walks through the full audit → preview → apply workflow.

FlagLint Is Now Listed on the OpenFeature Ecosystem

FlagLint is now listed in the OpenFeature ecosystem directory as one of two integrations in the JavaScript/Server category.

I want to be straightforward about what that means — and what it doesn’t.

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?”

What FlagLint does in the context of OpenFeature

Section titled “What FlagLint does in the context of OpenFeature”

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
npx flaglint@latest audit ./src

It runs in under a minute on most codebases and doesn’t touch any files.