Skip to content

open-source

2 posts with the tag “open-source”

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

Flipt's LaunchDarkly Migration Guide Recommends FlagLint

Flipt is an open-source feature flag platform — one of the most popular self-hosted alternatives to LaunchDarkly. Their official documentation includes a guide for migrating a Node.js application from the LaunchDarkly SDK to OpenFeature, and that guide now recommends FlagLint for the codebase analysis step.

Here’s what that looks like in practice and why it makes sense.

Flipt supports OpenFeature through its official provider — so a team moving away from LaunchDarkly can route flag evaluation through the OpenFeature API to Flipt’s provider instead. The flag evaluation backend changes, but the application code uses the same vendor-neutral API regardless of which provider is behind it.

The challenge isn’t the provider setup. It’s the application code. A typical Node.js service has dozens of direct LaunchDarkly SDK calls — boolVariation, stringVariation, jsonVariation — all using LaunchDarkly’s argument order. OpenFeature’s equivalent methods take arguments in a different order, and a naive find-and-replace will silently swap your fallback values and context in every call site.

That’s the problem FlagLint solves before you write a single line of migration code.

Flipt’s guide recommends running three FlagLint commands as an optional but practical first step for larger codebases:

Terminal window
# See every direct LaunchDarkly call site in your codebase
npx flaglint scan ./src
# Get a migration readiness score and per-flag risk classification
npx flaglint audit ./src
# Preview the exact rewrites FlagLint will make, without touching any files
npx flaglint migrate ./src --dry-run

The dry-run output shows you exactly which call sites will be rewritten and what the rewritten code looks like — before anything changes. Call sites that FlagLint cannot safely rewrite (dynamic flag keys, detail methods, bulk evaluation) are reported for manual review and left untouched.

After reviewing the diff, --apply writes the changes. Then you wire in the Flipt OpenFeature provider, run your tests, and enforce the boundary in CI with flaglint validate.

FlagLint and Flipt don’t overlap. FlagLint analyzes and rewrites your application’s call sites. Flipt evaluates your flags at runtime through an OpenFeature provider. They’re doing completely different jobs at different layers of the stack.

What they share is a user: someone who has decided to move away from vendor lock-in and is doing the actual migration work. FlagLint handles the static analysis half; Flipt handles the runtime half.

If you’re in that position — evaluating Flipt as a LaunchDarkly replacement and trying to understand the migration scope — flaglint audit is a good first step. It gives you a concrete inventory and readiness score before you commit to any approach.

Terminal window
npx flaglint@latest audit ./src

No API key, no source upload, runs locally in under a minute. See the full migration guide for everything that comes after the audit.