From 032b95777b4fea417441665a79f3867e23e052d1 Mon Sep 17 00:00:00 2001 From: David Brackbill Date: Tue, 14 Jul 2026 11:41:18 -0700 Subject: [PATCH] Port flagpls's flag-usage scanner into ldcli as `flags usage` Copies flagpls's AST scanner, LD API enrichment, and table renderer (internal/flagusage/{scanner,enrich,render}) with import paths and config/cache locations rewritten to ldcli's conventions (xdg cache/config under the ldcli namespace instead of flagpls's own). Wires them up as a new `ldcli flags usage` subcommand reusing ldcli's existing --access-token/--base-uri/--project flags instead of flagpls's standalone token/config-file flow. --- cmd/flags/usage.go | 154 ++++ cmd/root.go | 3 +- go.mod | 1 + go.sum | 2 + internal/flagusage/enrich/api_test.go | 278 +++++++ internal/flagusage/enrich/cache.go | 71 ++ internal/flagusage/enrich/cache_test.go | 107 +++ internal/flagusage/enrich/client.go | 104 +++ internal/flagusage/enrich/client_test.go | 46 ++ internal/flagusage/enrich/enrich.go | 645 +++++++++++++++ internal/flagusage/enrich/enrich_test.go | 287 +++++++ internal/flagusage/enrich/helpers_test.go | 85 ++ internal/flagusage/enrich/types.go | 248 ++++++ internal/flagusage/render/aliases.go | 51 ++ internal/flagusage/render/aliases.json | 5 + internal/flagusage/render/render.go | 375 +++++++++ internal/flagusage/render/render_test.go | 181 ++++ internal/flagusage/render/termsize_darwin.go | 6 + internal/flagusage/render/termsize_linux.go | 6 + internal/flagusage/render/termsize_other.go | 6 + internal/flagusage/render/termsize_unix.go | 30 + internal/flagusage/scanner/imports.go | 115 +++ internal/flagusage/scanner/lang_go.go | 370 +++++++++ internal/flagusage/scanner/lang_ts.go | 242 ++++++ internal/flagusage/scanner/patterns.go | 43 + internal/flagusage/scanner/resolve.go | 230 ++++++ internal/flagusage/scanner/scanner.go | 432 ++++++++++ .../scanner/scanner_characterization_test.go | 199 +++++ internal/flagusage/scanner/scanner_go_test.go | 336 ++++++++ internal/flagusage/scanner/scanner_test.go | 780 ++++++++++++++++++ internal/flagusage/scanner/types.go | 45 + 31 files changed, 5482 insertions(+), 1 deletion(-) create mode 100644 cmd/flags/usage.go create mode 100644 internal/flagusage/enrich/api_test.go create mode 100644 internal/flagusage/enrich/cache.go create mode 100644 internal/flagusage/enrich/cache_test.go create mode 100644 internal/flagusage/enrich/client.go create mode 100644 internal/flagusage/enrich/client_test.go create mode 100644 internal/flagusage/enrich/enrich.go create mode 100644 internal/flagusage/enrich/enrich_test.go create mode 100644 internal/flagusage/enrich/helpers_test.go create mode 100644 internal/flagusage/enrich/types.go create mode 100644 internal/flagusage/render/aliases.go create mode 100644 internal/flagusage/render/aliases.json create mode 100644 internal/flagusage/render/render.go create mode 100644 internal/flagusage/render/render_test.go create mode 100644 internal/flagusage/render/termsize_darwin.go create mode 100644 internal/flagusage/render/termsize_linux.go create mode 100644 internal/flagusage/render/termsize_other.go create mode 100644 internal/flagusage/render/termsize_unix.go create mode 100644 internal/flagusage/scanner/imports.go create mode 100644 internal/flagusage/scanner/lang_go.go create mode 100644 internal/flagusage/scanner/lang_ts.go create mode 100644 internal/flagusage/scanner/patterns.go create mode 100644 internal/flagusage/scanner/resolve.go create mode 100644 internal/flagusage/scanner/scanner.go create mode 100644 internal/flagusage/scanner/scanner_characterization_test.go create mode 100644 internal/flagusage/scanner/scanner_go_test.go create mode 100644 internal/flagusage/scanner/scanner_test.go create mode 100644 internal/flagusage/scanner/types.go diff --git a/cmd/flags/usage.go b/cmd/flags/usage.go new file mode 100644 index 00000000..b206969e --- /dev/null +++ b/cmd/flags/usage.go @@ -0,0 +1,154 @@ +package flags + +import ( + "encoding/json" + "fmt" + "os" + "strings" + "time" + + "github.com/spf13/cobra" + "github.com/spf13/viper" + + "github.com/launchdarkly/ldcli/cmd/cliflags" + resourcescmd "github.com/launchdarkly/ldcli/cmd/resources" + "github.com/launchdarkly/ldcli/internal/flagusage/enrich" + "github.com/launchdarkly/ldcli/internal/flagusage/render" + "github.com/launchdarkly/ldcli/internal/flagusage/scanner" +) + +const ( + usageDirFlag = "dir" + usageFormatFlag = "format" + usageWrapperModulesFlag = "wrapper-modules" + usageDefinitionsFlag = "definitions" + usageEnvsFlag = "envs" + usageEvalWindowFlag = "eval-window" + usageEvalSeriesFlag = "eval-series" + usageExposuresFlag = "exposures" + usageContextKindsFlag = "context-kinds" + usageWidthFlag = "width" +) + +// NewUsageCmd reports where a project's feature flags are evaluated in code, +// enriched with each flag's per-environment status pulled from the LD API. +// It is a port of the standalone `flagpls` tool (github.com/launchdarkly-labs/flagpls). +func NewUsageCmd() *cobra.Command { + cmd := &cobra.Command{ + Long: "Scan a source directory for feature flag evaluation call sites and report each flag's per-environment status, targeting, and (optionally) evaluation/exposure counts from the LaunchDarkly API.", + RunE: runUsage, + Short: "Report feature flag usage in code, enriched with live flag status", + Use: "usage", + } + + cmd.SetUsageTemplate(resourcescmd.SubcommandUsageTemplate()) + initUsageFlags(cmd) + + return cmd +} + +func initUsageFlags(cmd *cobra.Command) { + cmd.Flags().String(usageDirFlag, ".", "Directory to scan") + cmd.Flags().String(usageFormatFlag, "text", "Output format: text, json") + cmd.Flags().String(usageWrapperModulesFlag, "", "OVERRIDE (rarely needed): comma-separated wrapper module paths to force-track; modules are auto-discovered via node_modules") + cmd.Flags().String(usageDefinitionsFlag, "", "OVERRIDE (rarely needed): dir of wrapper definition files; definitions are auto-discovered via node_modules — pass this only if deps aren't installed") + cmd.Flags().String(usageEnvsFlag, "", "Comma-separated environment keys to check (default: all)") + cmd.Flags().String(usageEvalWindowFlag, "", "Evaluation lookback window (e.g. 24h, 6h); default 168h (7d)") + cmd.Flags().Bool(usageEvalSeriesFlag, false, "Fetch the per-bucket evaluation timeseries (daily, or hourly for windows <24h); default fetches window totals only via a single batched call per flag") + cmd.Flags().Bool(usageExposuresFlag, false, "Fetch true unique-context counts per context kind (the figure a guarded release gates on), not just total evaluations") + cmd.Flags().String(usageContextKindsFlag, "", "With --exposures, restrict to these comma-separated context kinds (default: auto-discover the flag's kinds)") + cmd.Flags().String(cliflags.FlagFlag, "", "Report only this flag — matches its key or a wrapper accessor name (for editor single-flag lookup)") + cmd.Flags().Int(usageWidthFlag, 0, "Table width in columns; 0 = auto-detect the terminal") + + cmd.Flags().String(cliflags.ProjectFlag, "", "The project key") + _ = cmd.MarkFlagRequired(cliflags.ProjectFlag) + _ = viper.BindPFlag(cliflags.ProjectFlag, cmd.Flags().Lookup(cliflags.ProjectFlag)) +} + +func runUsage(cmd *cobra.Command, args []string) error { + dir, _ := cmd.Flags().GetString(usageDirFlag) + format, _ := cmd.Flags().GetString(usageFormatFlag) + wrapperModules, _ := cmd.Flags().GetString(usageWrapperModulesFlag) + definitionsDir, _ := cmd.Flags().GetString(usageDefinitionsFlag) + envsRaw, _ := cmd.Flags().GetString(usageEnvsFlag) + evalWindowRaw, _ := cmd.Flags().GetString(usageEvalWindowFlag) + evalSeries, _ := cmd.Flags().GetBool(usageEvalSeriesFlag) + exposures, _ := cmd.Flags().GetBool(usageExposuresFlag) + contextKindsRaw, _ := cmd.Flags().GetString(usageContextKindsFlag) + flagFilter, _ := cmd.Flags().GetString(cliflags.FlagFlag) + width, _ := cmd.Flags().GetInt(usageWidthFlag) + + project := viper.GetString(cliflags.ProjectFlag) + token := viper.GetString(cliflags.AccessTokenFlag) + baseURL := viper.GetString(cliflags.BaseURIFlag) + + var evalWindow time.Duration + if evalWindowRaw != "" { + d, err := time.ParseDuration(evalWindowRaw) + if err != nil { + return fmt.Errorf("invalid --%s: %w", usageEvalWindowFlag, err) + } + evalWindow = d + } + + scanResult, err := scanner.Scan(dir, scanner.ScanOptions{ + WrapperModules: splitNonEmpty(wrapperModules), + DefinitionsDir: definitionsDir, + }) + if err != nil { + return fmt.Errorf("scanning %s: %w", dir, err) + } + + if flagFilter != "" { + filtered := *scanResult + refs := make([]scanner.FlagReference, 0, len(scanResult.References)) + for _, ref := range scanResult.References { + if ref.FlagKey == flagFilter || ref.WrapperName == flagFilter { + refs = append(refs, ref) + } + } + filtered.References = refs + scanResult = &filtered + } + + client := enrich.NewClient(token, baseURL) + details, err := enrich.Enrich(client, scanResult, enrich.Options{ + ProjectKey: project, + Environments: splitNonEmpty(envsRaw), + EvalWindow: evalWindow, + Series: evalSeries, + Exposures: exposures, + ContextKinds: splitNonEmpty(contextKindsRaw), + }) + if err != nil { + return fmt.Errorf("enriching flag usage: %w", err) + } + + if format == "json" { + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + return enc.Encode(details) + } + + windowLabel := evalWindowRaw + if windowLabel == "" { + windowLabel = "7d" + } + render.Enriched(os.Stdout, details, windowLabel, splitNonEmpty(envsRaw), width) + + return nil +} + +func splitNonEmpty(s string) []string { + if s == "" { + return nil + } + parts := strings.Split(s, ",") + out := make([]string, 0, len(parts)) + for _, p := range parts { + if p = strings.TrimSpace(p); p != "" { + out = append(out, p) + } + } + return out +} diff --git a/cmd/root.go b/cmd/root.go index 0bb93cce..61d4c99a 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -22,8 +22,8 @@ import ( flagscmd "github.com/launchdarkly/ldcli/cmd/flags" logincmd "github.com/launchdarkly/ldcli/cmd/login" memberscmd "github.com/launchdarkly/ldcli/cmd/members" - sdkactivecmd "github.com/launchdarkly/ldcli/cmd/sdk_active" resourcecmd "github.com/launchdarkly/ldcli/cmd/resources" + sdkactivecmd "github.com/launchdarkly/ldcli/cmd/sdk_active" signupcmd "github.com/launchdarkly/ldcli/cmd/signup" sourcemapscmd "github.com/launchdarkly/ldcli/cmd/sourcemaps" whoamicmd "github.com/launchdarkly/ldcli/cmd/whoami" @@ -268,6 +268,7 @@ func NewRootCommand( c.AddCommand(flagscmd.NewToggleOnCmd(clients.ResourcesClient)) c.AddCommand(flagscmd.NewToggleOffCmd(clients.ResourcesClient)) c.AddCommand(flagscmd.NewArchiveCmd(clients.ResourcesClient)) + c.AddCommand(flagscmd.NewUsageCmd()) } if c.Name() == "members" { c.AddCommand(memberscmd.NewMembersInviteCmd(clients.ResourcesClient)) diff --git a/go.mod b/go.mod index 17601e5e..471b0344 100644 --- a/go.mod +++ b/go.mod @@ -25,6 +25,7 @@ require ( github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c github.com/pkg/errors v0.9.1 github.com/samber/lo v1.51.0 + github.com/smacker/go-tree-sitter v0.0.0-20240827094217-dd81d9e9be82 github.com/spf13/cobra v1.9.1 github.com/spf13/pflag v1.0.10 github.com/spf13/viper v1.21.0 diff --git a/go.sum b/go.sum index 067b0ebb..69a8e8e6 100644 --- a/go.sum +++ b/go.sum @@ -307,6 +307,8 @@ github.com/samber/lo v1.51.0 h1:kysRYLbHy/MB7kQZf5DSN50JHmMsNEdeY24VzJFu7wI= github.com/samber/lo v1.51.0/go.mod h1:4+MXEGsJzbKGaUEQFKBq2xtfuznW9oz/WrgyzMzRoM0= github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0= github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= +github.com/smacker/go-tree-sitter v0.0.0-20240827094217-dd81d9e9be82 h1:6C8qej6f1bStuePVkLSFxoU22XBS165D3klxlzRg8F4= +github.com/smacker/go-tree-sitter v0.0.0-20240827094217-dd81d9e9be82/go.mod h1:xe4pgH49k4SsmkQq5OT8abwhWmnzkhpgnXeekbx2efw= github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw= github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U= github.com/speakeasy-api/openapi-overlay v0.9.0 h1:Wrz6NO02cNlLzx1fB093lBlYxSI54VRhy1aSutx0PQg= diff --git a/internal/flagusage/enrich/api_test.go b/internal/flagusage/enrich/api_test.go new file mode 100644 index 00000000..34594f25 --- /dev/null +++ b/internal/flagusage/enrich/api_test.go @@ -0,0 +1,278 @@ +package enrich + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/launchdarkly/ldcli/internal/flagusage/scanner" +) + +// mockLD stands in for the LaunchDarkly API + gonfalon internal endpoints so we +// can exercise the full client → fetch → Enrich path with no network and no +// token. It records the requests it saw so tests can assert request shape +// (e.g. the batched POST body, the contextsCount filter). +type mockLD struct { + t *testing.T + server *httptest.Server + + // canned responses + flagJSON string // GET /api/v2/flags/... + statusName string // GET /api/v2/flag-statuses/... + evalEnv map[string]apiEvalEnvVariations // POST evaluationSummaries, by env + kinds []string // GET monitor/contextKinds + uniqByKind map[string]int64 // GET monitor/contextsCount, by kind + + // recorded + evalReq apiEvalSummariesRequest + contextsHits []string // filter values seen by contextsCount +} + +func newMockLD(t *testing.T) *mockLD { + m := &mockLD{ + t: t, + statusName: "launched", + evalEnv: map[string]apiEvalEnvVariations{}, + uniqByKind: map[string]int64{}, + } + m.server = httptest.NewServer(http.HandlerFunc(m.handle)) + t.Cleanup(m.server.Close) + // Route cache writes to a throwaway HOME so tests don't share state or hit + // a real ~/.cache/flagpls. + t.Setenv("HOME", t.TempDir()) + return m +} + +func (m *mockLD) client() *Client { return NewClient("fake-token", m.server.URL) } + +func (m *mockLD) handle(w http.ResponseWriter, r *http.Request) { + p := r.URL.Path + switch { + case strings.HasPrefix(p, "/api/v2/flags/"): + io.WriteString(w, m.flagJSON) + case strings.HasPrefix(p, "/api/v2/flag-statuses/"): + json.NewEncoder(w).Encode(apiFlagStatusByFlag{Name: m.statusName, LastRequested: "2026-06-20T00:00:00Z"}) + case strings.HasSuffix(p, "/evaluationSummaries"): + body, _ := io.ReadAll(r.Body) + json.Unmarshal(body, &m.evalReq) + envs := map[string]apiEvalEnvVariations{} + for _, e := range m.evalReq.EnvironmentKeys { + if v, ok := m.evalEnv[e]; ok { + envs[e] = v + } + } + json.NewEncoder(w).Encode(apiEvalSummariesResponse{ + Data: []apiEvalSummary{{FlagKey: m.evalReq.FlagKeys[0], Environments: envs}}, + }) + case strings.HasSuffix(p, "/monitor/contextKinds"): + var resp apiContextKindsResponse + resp.Data.ContextKinds = m.kinds + json.NewEncoder(w).Encode(resp) + case strings.HasSuffix(p, "/monitor/contextsCount"): + filter := r.URL.Query().Get("filter") + m.contextsHits = append(m.contextsHits, filter) + var count int64 + for kind, c := range m.uniqByKind { + if strings.Contains(filter, "contextKind equals "+kind) { + count = c + } + } + var resp apiContextsCountResponse + resp.Data.TotalUniqueContexts = count + json.NewEncoder(w).Encode(resp) + default: + http.Error(w, "unexpected path: "+p, http.StatusNotFound) + } +} + +// boolFlagJSON builds a simple-toggle boolean flag config serving variation 0 +// in production, with two variations whose _ids match the eval-summary keys. +const boolFlagJSON = `{ + "key": "my-flag", "name": "My Flag", "kind": "boolean", "temporary": true, + "creationDate": 1700000000000, + "variations": [ + {"_id": "v0", "value": true, "name": "On", "valueHash": "h0"}, + {"_id": "v1", "value": false, "name": "Off", "valueHash": "h1"} + ], + "environments": { + "production": { + "on": true, "lastModified": 1700000000000, + "rules": [], "targets": [], "contextTargets": [], "prerequisites": [], + "fallthrough": {"variation": 0}, "offVariation": 1, + "_summary": {"variations": {"0": {"isFallthrough": true}, "1": {"isOff": true}}} + } + } +}` + +func scanWith(flagKey string) *scanner.ScanResult { + return &scanner.ScanResult{ + References: []scanner.FlagReference{ + {FlagKey: flagKey, Kind: "direct", FilePath: "a.ts", Line: 1}, + }, + } +} + +func TestEnrichBatchedEvalSummaries(t *testing.T) { + m := newMockLD(t) + m.flagJSON = boolFlagJSON + m.evalEnv["production"] = apiEvalEnvVariations{ + TotalEvaluations: 1500, + VariationCounts: map[string]int64{"v0": 1000, "v1": 500}, + } + + details, err := Enrich(m.client(), scanWith("my-flag"), Options{ + ProjectKey: "default", Environments: []string{"production"}, + }) + if err != nil { + t.Fatal(err) + } + if len(details) != 1 { + t.Fatalf("expected 1 flag, got %d", len(details)) + } + + // The batched POST must carry the flag, env, and a from= m.evalReq.To || m.evalReq.From == 0 { + t.Errorf("batched request window invalid: from=%d to=%d", m.evalReq.From, m.evalReq.To) + } + + env := details[0].Environments["production"] + if env.Evaluations.Total7d != 1500 { + t.Errorf("Total = %d, want 1500", env.Evaluations.Total7d) + } + // variationCounts keyed by _id must be relabeled to variation names. + if env.Evaluations.ByVariation["On"] != 1000 || env.Evaluations.ByVariation["Off"] != 500 { + t.Errorf("ByVariation = %v, want On:1000 Off:500", env.Evaluations.ByVariation) + } + // Default (non-series) mode must not populate per-bucket data. + if len(env.Evaluations.Daily) != 0 { + t.Errorf("expected no Daily buckets in batched mode, got %d", len(env.Evaluations.Daily)) + } + // Exposures off by default. + if env.UniqueContexts != nil { + t.Errorf("expected no UniqueContexts when -exposures off, got %+v", env.UniqueContexts) + } +} + +func TestEnrichExposures(t *testing.T) { + m := newMockLD(t) + m.flagJSON = boolFlagJSON + m.evalEnv["production"] = apiEvalEnvVariations{TotalEvaluations: 9999999} + m.kinds = []string{"user", "account"} + m.uniqByKind = map[string]int64{"user": 6971, "account": 12} + + details, err := Enrich(m.client(), scanWith("my-flag"), Options{ + ProjectKey: "default", Environments: []string{"production"}, + Exposures: true, EvalWindow: 6 * time.Hour, WindowLabel: "6h", + }) + if err != nil { + t.Fatal(err) + } + uc := details[0].Environments["production"].UniqueContexts + if uc == nil { + t.Fatal("expected UniqueContexts to be populated with -exposures") + } + if uc.Window != "6h" { + t.Errorf("window label = %q, want 6h", uc.Window) + } + if uc.ByContextKind["user"].Count != 6971 || uc.ByContextKind["account"].Count != 12 { + t.Errorf("byContextKind = %+v", uc.ByContextKind) + } + // Unique contexts must be wildly smaller than eval count — the whole point. + if uc.ByContextKind["user"].Count >= details[0].Environments["production"].Evaluations.Total7d { + t.Error("unique contexts should be far below total evaluations") + } + // The filter must scope both env and contextKind. + foundUser := false + for _, f := range m.contextsHits { + if strings.Contains(f, "env equals production") && strings.Contains(f, "contextKind equals user") { + foundUser = true + } + } + if !foundUser { + t.Errorf("contextsCount filter never scoped env+kind; saw %v", m.contextsHits) + } +} + +func TestEnrichExposuresExplicitKindsSkipsDiscovery(t *testing.T) { + m := newMockLD(t) + m.flagJSON = boolFlagJSON + m.evalEnv["production"] = apiEvalEnvVariations{TotalEvaluations: 100} + // kinds endpoint would return these, but we pass an explicit list instead. + m.kinds = []string{"user", "account", "member"} + m.uniqByKind = map[string]int64{"user": 50} + + details, err := Enrich(m.client(), scanWith("my-flag"), Options{ + ProjectKey: "default", Environments: []string{"production"}, + Exposures: true, ContextKinds: []string{"user"}, + }) + if err != nil { + t.Fatal(err) + } + uc := details[0].Environments["production"].UniqueContexts + if uc == nil || len(uc.ByContextKind) != 1 || uc.ByContextKind["user"].Count != 50 { + t.Fatalf("explicit ContextKinds should query only user; got %+v", uc) + } +} + +func TestEnrichSeriesMode(t *testing.T) { + m := newMockLD(t) + m.flagJSON = boolFlagJSON + // In series mode the usage endpoint is hit instead of evaluationSummaries. + usageHit := false + base := http.HandlerFunc(m.handle) + m.server.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.HasPrefix(r.URL.Path, "/api/v2/usage/evaluations/") { + usageHit = true + json.NewEncoder(w).Encode(apiUsageResponse{ + TotalEvaluations: 700, + Series: []map[string]any{ + {"time": float64(1700000000000), "0": float64(400)}, + {"time": float64(1700003600000), "0": float64(300)}, + }, + }) + return + } + base.ServeHTTP(w, r) + }) + + details, err := Enrich(m.client(), scanWith("my-flag"), Options{ + ProjectKey: "default", Environments: []string{"production"}, + Series: true, EvalWindow: 6 * time.Hour, + }) + if err != nil { + t.Fatal(err) + } + if !usageHit { + t.Error("series mode should hit the usage timeseries endpoint") + } + env := details[0].Environments["production"] + if env.Evaluations.Total7d != 700 { + t.Errorf("Total = %d, want 700", env.Evaluations.Total7d) + } + if len(env.Evaluations.Daily) != 2 { + t.Errorf("expected 2 daily/hourly buckets, got %d", len(env.Evaluations.Daily)) + } +} + +func TestEnrichOrphanedReference(t *testing.T) { + m := newMockLD(t) + m.server.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.NotFound(w, r) // every flag GET 404s + }) + details, err := Enrich(m.client(), scanWith("ghost-flag"), Options{ + ProjectKey: "default", Environments: []string{"production"}, + }) + if err != nil { + t.Fatal(err) + } + if len(details) != 1 || details[0].StaleState != "orphanedReference" { + t.Fatalf("expected orphanedReference, got %+v", details) + } +} diff --git a/internal/flagusage/enrich/cache.go b/internal/flagusage/enrich/cache.go new file mode 100644 index 00000000..9244556a --- /dev/null +++ b/internal/flagusage/enrich/cache.go @@ -0,0 +1,71 @@ +package enrich + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "os" + "path/filepath" + "sort" + "time" + + "github.com/adrg/xdg" +) + +var cacheTTL = 1 * time.Hour + +func cacheDir() string { + return filepath.Join(xdg.CacheHome, "ldcli", "flagusage") +} + +func cacheKey(method, path string, headers map[string]string, body []byte) string { + h := sha256.New() + h.Write([]byte(method)) + h.Write([]byte(path)) + // Hash headers in a stable order — map iteration order is random, which + // would otherwise produce a different key for the same request. + keys := make([]string, 0, len(headers)) + for k := range headers { + keys = append(keys, k) + } + sort.Strings(keys) + for _, k := range keys { + h.Write([]byte(k + "=" + headers[k])) + } + h.Write(body) + return hex.EncodeToString(h.Sum(nil))[:16] + ".json" +} + +func cacheGet(key string, result any) bool { + dir := cacheDir() + if dir == "" { + return false + } + path := filepath.Join(dir, key) + info, err := os.Stat(path) + if err != nil { + return false + } + if time.Since(info.ModTime()) > cacheTTL { + os.Remove(path) + return false + } + data, err := os.ReadFile(path) + if err != nil { + return false + } + return json.Unmarshal(data, result) == nil +} + +func cacheSet(key string, value any) { + dir := cacheDir() + if dir == "" { + return + } + os.MkdirAll(dir, 0o755) + data, err := json.Marshal(value) + if err != nil { + return + } + os.WriteFile(filepath.Join(dir, key), data, 0o644) +} diff --git a/internal/flagusage/enrich/cache_test.go b/internal/flagusage/enrich/cache_test.go new file mode 100644 index 00000000..3c2f1c0f --- /dev/null +++ b/internal/flagusage/enrich/cache_test.go @@ -0,0 +1,107 @@ +package enrich + +import ( + "os" + "path/filepath" + "testing" + "time" + + "github.com/adrg/xdg" +) + +func TestCacheSetAndGet(t *testing.T) { + origTTL := cacheTTL + cacheTTL = 1 * time.Hour + defer func() { cacheTTL = origTTL }() + + dir := t.TempDir() + t.Setenv("HOME", dir) + xdg.Reload() + t.Cleanup(xdg.Reload) + + type testData struct { + Name string `json:"name"` + Value int `json:"value"` + } + + key := cacheKey("GET", "/api/v2/flags/default/my-flag", nil, nil) + + original := testData{Name: "my-flag", Value: 42} + cacheSet(key, original) + + var result testData + if !cacheGet(key, &result) { + t.Fatal("expected cache hit") + } + if result.Name != "my-flag" || result.Value != 42 { + t.Errorf("expected {my-flag, 42}, got {%s, %d}", result.Name, result.Value) + } +} + +func TestCacheMiss(t *testing.T) { + dir := t.TempDir() + t.Setenv("HOME", dir) + xdg.Reload() + t.Cleanup(xdg.Reload) + + var result struct{} + if cacheGet("nonexistent.json", &result) { + t.Error("expected cache miss for nonexistent key") + } +} + +func TestCacheTTLExpiry(t *testing.T) { + origTTL := cacheTTL + cacheTTL = 1 * time.Millisecond + defer func() { cacheTTL = origTTL }() + + dir := t.TempDir() + t.Setenv("HOME", dir) + xdg.Reload() + t.Cleanup(xdg.Reload) + + key := cacheKey("GET", "/api/v2/test", nil, nil) + cacheSet(key, map[string]string{"foo": "bar"}) + + time.Sleep(5 * time.Millisecond) + + var result map[string]string + if cacheGet(key, &result) { + t.Error("expected cache miss after TTL expiry") + } + + // Verify file was cleaned up + cacheFile := filepath.Join(dir, ".cache", "ldcli", "flagusage", key) + if _, err := os.Stat(cacheFile); !os.IsNotExist(err) { + t.Error("expected expired cache file to be removed") + } +} + +func TestCacheKeyDiffersByHeaders(t *testing.T) { + k1 := cacheKey("GET", "/api/v2/test", nil, nil) + k2 := cacheKey("GET", "/api/v2/test", map[string]string{"LD-API-Version": "beta"}, nil) + if k1 == k2 { + t.Error("expected different cache keys for different headers") + } +} + +func TestCacheKeyDiffersByPath(t *testing.T) { + k1 := cacheKey("GET", "/api/v2/flags/default/flag-a", nil, nil) + k2 := cacheKey("GET", "/api/v2/flags/default/flag-b", nil, nil) + if k1 == k2 { + t.Error("expected different cache keys for different paths") + } +} + +func TestCacheKeyDiffersByMethodAndBody(t *testing.T) { + get := cacheKey("GET", "/x", nil, nil) + post := cacheKey("POST", "/x", nil, nil) + if get == post { + t.Error("expected different cache keys for different methods") + } + b1 := cacheKey("POST", "/x", nil, []byte(`{"flagKeys":["a"]}`)) + b2 := cacheKey("POST", "/x", nil, []byte(`{"flagKeys":["b"]}`)) + if b1 == b2 { + t.Error("expected different cache keys for different request bodies") + } +} diff --git a/internal/flagusage/enrich/client.go b/internal/flagusage/enrich/client.go new file mode 100644 index 00000000..621dceb6 --- /dev/null +++ b/internal/flagusage/enrich/client.go @@ -0,0 +1,104 @@ +package enrich + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "time" +) + +// ErrNotFound means the LD API returned 404 — the resource does not exist in +// the configured project. For a flag this signals an orphaned code reference. +var ErrNotFound = errors.New("not found in project") + +type Client struct { + apiToken string + baseURL string + http *http.Client +} + +func NewClient(apiToken string, baseURL string) *Client { + if baseURL == "" { + baseURL = "https://app.launchdarkly.com" + } + return &Client{ + apiToken: apiToken, + baseURL: baseURL, + http: &http.Client{ + Timeout: 30 * time.Second, + }, + } +} + +func (c *Client) get(path string, result any) error { + return c.getWithHeaders(path, nil, result) +} + +func (c *Client) getWithHeaders(path string, headers map[string]string, result any) error { + return c.do("GET", path, headers, nil, result) +} + +// postJSON sends a JSON body to path. The request body is part of the cache +// key, so two posts to the same path with different bodies cache separately. +func (c *Client) postJSON(path string, body any, headers map[string]string, result any) error { + payload, err := json.Marshal(body) + if err != nil { + return err + } + if headers == nil { + headers = map[string]string{} + } + if _, ok := headers["Content-Type"]; !ok { + headers["Content-Type"] = "application/json" + } + return c.do("POST", path, headers, payload, result) +} + +func (c *Client) do(method, path string, headers map[string]string, body []byte, result any) error { + key := cacheKey(method, path, headers, body) + if cacheGet(key, result) { + return nil + } + + var reqBody io.Reader + if body != nil { + reqBody = bytes.NewReader(body) + } + req, err := http.NewRequest(method, c.baseURL+path, reqBody) + if err != nil { + return err + } + req.Header.Set("Authorization", c.apiToken) + for k, v := range headers { + req.Header.Set(k, v) + } + + resp, err := c.http.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusNotFound { + return fmt.Errorf("%s: %w", path, ErrNotFound) + } + if resp.StatusCode != http.StatusOK { + respBody, _ := io.ReadAll(resp.Body) + return fmt.Errorf("API error %d: %s", resp.StatusCode, string(respBody)) + } + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return err + } + + if err := json.Unmarshal(respBody, result); err != nil { + return err + } + + cacheSet(key, result) + return nil +} diff --git a/internal/flagusage/enrich/client_test.go b/internal/flagusage/enrich/client_test.go new file mode 100644 index 00000000..259fe628 --- /dev/null +++ b/internal/flagusage/enrich/client_test.go @@ -0,0 +1,46 @@ +package enrich + +import ( + "errors" + "net/http" + "net/http/httptest" + "testing" +) + +func TestEnrichFlag_NotFound_IsOrphanedReference(t *testing.T) { + // HOME -> temp so the on-disk cache doesn't interfere. + t.Setenv("HOME", t.TempDir()) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, `{"code":"not_found","message":"Invalid resource identifier"}`, http.StatusNotFound) + })) + defer srv.Close() + + client := NewClient("api-test", srv.URL) + detail, err := enrichFlag(client, "renamed-or-deleted-flag", Options{ProjectKey: "default"}, 3) + if err != nil { + t.Fatalf("a 404 on a referenced flag should not error, got %v", err) + } + if detail.StaleState != "orphanedReference" { + t.Errorf("expected orphanedReference, got %q", detail.StaleState) + } + if detail.CallSites != 3 || detail.CodeRefs != 3 { + t.Errorf("expected call sites preserved (3), got callSites=%d codeRefs=%d", detail.CallSites, detail.CodeRefs) + } +} + +func TestClient_404_WrapsErrNotFound(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + })) + defer srv.Close() + + client := NewClient("api-test", srv.URL) + var out map[string]any + err := client.get("/api/v2/flags/default/nope", &out) + if !errors.Is(err, ErrNotFound) { + t.Errorf("expected ErrNotFound, got %v", err) + } +} diff --git a/internal/flagusage/enrich/enrich.go b/internal/flagusage/enrich/enrich.go new file mode 100644 index 00000000..41532e22 --- /dev/null +++ b/internal/flagusage/enrich/enrich.go @@ -0,0 +1,645 @@ +package enrich + +import ( + "errors" + "fmt" + "net/url" + "strconv" + "sync" + "time" + + "github.com/launchdarkly/ldcli/internal/flagusage/scanner" +) + +var DefaultCriticalEnvs = []string{ + "production", + "staging", + "catamorphic", + "managed-federal-stg", + "managed-federal-prod", + "managed-eu-production", +} + +type Options struct { + ProjectKey string + Environments []string // environments to check status for; empty = DefaultCriticalEnvs + EvalWindow time.Duration // evaluation lookback window; 0 = default (7 days) + WindowLabel string // human label for the window (e.g. "6h", "7d"); derived from EvalWindow if empty + + // Series, when set, fetches the per-bucket evaluation timeseries (daily, or + // hourly for windows <24h) via the public usage endpoint, one call per + // flag/env. Default (false) uses the batched evaluationSummaries endpoint, + // which returns window totals + per-variation split in a single call but no + // buckets. + Series bool + + // Exposures, when set, fetches true unique-context counts (the figure a + // guarded release gates on) per context kind via the contextsCount endpoint. + // Off by default — it's one call per flag/env/contextKind. + Exposures bool + // ContextKinds restricts exposure fetching to these kinds; empty = discover + // the flag's kinds via the contextKinds endpoint. + ContextKinds []string +} + +// defaultEvalWindow is the lookback used when Options.EvalWindow is unset. +const defaultEvalWindow = 7 * 24 * time.Hour + +// Enrich takes scan results and hydrates each unique flag with data from the LD API. +// It fetches flag config + per-environment status concurrently with bounded parallelism. +func Enrich(client *Client, scanResult *scanner.ScanResult, opts Options) ([]FlagDetail, error) { + flagKeys := uniqueFlagKeys(scanResult) + if len(flagKeys) == 0 { + return nil, nil + } + + callSitesByKey := countCallSites(scanResult) + + var ( + mu sync.Mutex + results []FlagDetail + errors []error + wg sync.WaitGroup + sem = make(chan struct{}, 10) // concurrency limit + ) + + for _, key := range flagKeys { + wg.Add(1) + go func(flagKey string) { + defer wg.Done() + sem <- struct{}{} + defer func() { <-sem }() + + detail, err := enrichFlag(client, flagKey, opts, callSitesByKey[flagKey]) + mu.Lock() + defer mu.Unlock() + if err != nil { + errors = append(errors, fmt.Errorf("%s: %w", flagKey, err)) + return + } + results = append(results, *detail) + }(key) + } + + wg.Wait() + + if len(errors) > 0 && len(results) == 0 { + return nil, fmt.Errorf("all flag lookups failed; first error: %w", errors[0]) + } + + return results, nil +} + +func enrichFlag(client *Client, flagKey string, opts Options, callSites int) (*FlagDetail, error) { + path := fmt.Sprintf("/api/v2/flags/%s/%s", opts.ProjectKey, flagKey) + + var flagResp apiFlagResponse + if err := client.get(path, &flagResp); err != nil { + // A flag that's referenced in code but missing from the project (404) + // is an orphaned reference: the SDK call always falls back to the + // in-code default, so the branch is dead (or the key was renamed and + // the code wasn't updated). Surface it as a prime cleanup candidate + // rather than silently dropping it. + if errors.Is(err, ErrNotFound) { + return &FlagDetail{ + Key: flagKey, + CallSites: callSites, + CodeRefs: callSites, + StaleState: "orphanedReference", + }, nil + } + return nil, err + } + + detail := &FlagDetail{ + Key: flagResp.Key, + Name: flagResp.Name, + Description: flagResp.Description, + Temporary: flagResp.Temporary, + Archived: flagResp.Archived, + CreationDate: time.UnixMilli(flagResp.CreationDate), + Tags: flagResp.Tags, + Kind: flagResp.Kind, + CallSites: callSites, + CodeRefs: callSites, // from our scan + Environments: make(map[string]EnvStatus), + } + + for _, v := range flagResp.Variations { + detail.Variations = append(detail.Variations, Variation{ + Value: v.Value, + Name: v.Name, + Description: v.Description, + }) + } + + envKeys := opts.Environments + if len(envKeys) == 0 { + envKeys = DefaultCriticalEnvs + } + + presentEnvs := make([]string, 0, len(envKeys)) + for _, envKey := range envKeys { + envConfig, ok := flagResp.Environments[envKey] + if !ok { + continue + } + + isSimple := envConfig.On && + len(envConfig.Rules) == 0 && + len(envConfig.Targets) == 0 && + len(envConfig.ContextTargets) == 0 && + len(envConfig.Prerequisites) == 0 && + envConfig.Fallthrough.Variation != nil && + envConfig.Fallthrough.Rollout == nil + + targeting := TargetingSummary{ + RuleCount: len(envConfig.Rules), + TargetCount: len(envConfig.Targets), + ContextTargetCount: len(envConfig.ContextTargets), + PrerequisiteCount: len(envConfig.Prerequisites), + IsSimpleToggle: isSimple, + FallthroughVariation: envConfig.Fallthrough.Variation, + OffVariation: envConfig.OffVariation, + } + + var exposure ExposureSummary + for idxStr, sv := range envConfig.Summary.Variations { + idx := 0 + fmt.Sscanf(idxStr, "%d", &idx) + exposure.VariationExposure = append(exposure.VariationExposure, VariationExposure{ + VariationIndex: idx, + Rules: sv.Rules, + Targets: sv.Targets, + ContextTargets: sv.ContextTargets, + IsFallthrough: sv.IsFallthrough, + IsOff: sv.IsOff, + }) + } + + envStatus := EnvStatus{ + On: envConfig.On, + LastModified: time.UnixMilli(envConfig.LastModified), + Targeting: targeting, + Exposure: exposure, + } + + // Fetch per-flag status in this environment + statusPath := fmt.Sprintf("/api/v2/flag-statuses/%s/%s/%s", + opts.ProjectKey, envKey, flagKey) + var statusResp apiFlagStatusByFlag + if err := client.get(statusPath, &statusResp); err == nil { + envStatus.FlagStatus = statusResp.Name + if statusResp.LastRequested != "" { + if t, err := time.Parse(time.RFC3339, statusResp.LastRequested); err == nil { + envStatus.LastEvaluation = t + } + } + } + + detail.Environments[envKey] = envStatus + presentEnvs = append(presentEnvs, envKey) + } + + // Evaluation counts (total SDK calls). Default: one batched call across all + // present envs. Series mode: per-env timeseries via the public usage endpoint. + if opts.Series { + for _, envKey := range presentEnvs { + es := detail.Environments[envKey] + es.Evaluations = fetchEvaluationCounts(client, opts.ProjectKey, envKey, flagKey, detail.Variations, opts.EvalWindow) + detail.Environments[envKey] = es + } + } else { + for envKey, counts := range fetchEvalSummaries(client, opts.ProjectKey, flagKey, presentEnvs, flagResp.Variations, opts.EvalWindow) { + es := detail.Environments[envKey] + es.Evaluations = counts + detail.Environments[envKey] = es + } + } + + // Unique-context exposures (opt-in) — the figure a guarded release gates on. + if opts.Exposures { + windowLabel := opts.WindowLabel + if windowLabel == "" { + windowLabel = formatWindow(opts.EvalWindow) + } + for _, envKey := range presentEnvs { + es := detail.Environments[envKey] + es.UniqueContexts = fetchUniqueContexts(client, opts.ProjectKey, flagKey, envKey, opts.ContextKinds, opts.EvalWindow, windowLabel) + detail.Environments[envKey] = es + } + } + + detail.finalize(time.Now()) + + return detail, nil +} + +// stableRolloutAge is how long a deterministic targeting state must have been +// settled before a still-evaluated flag is treated as safe to remove from code. +// Guards against flagging flags that are merely mid-rollout (turned fully on +// today but not yet finished ramping). +const stableRolloutAge = 14 * 24 * time.Hour + +// finalize computes the flag's staleState and, only when it's ready for code +// removal, the value it can be hardcoded to. +func (d *FlagDetail) finalize(now time.Time) { + d.StaleState = computeStaleStateAt(d, now) + if d.StaleState == "readyForCodeRemoval" { + if v, ok := recommendedRemovalValue(d); ok { + d.RecommendedValue = v + } + } +} + +func computeStaleStateAt(d *FlagDetail, now time.Time) string { + if d.Archived { + return "archived" + } + + allInactive := true + allLaunched := true + hasAnyEnv := false + hasTraffic := false + + for _, env := range d.Environments { + hasAnyEnv = true + if env.FlagStatus != "inactive" { + allInactive = false + } + if env.FlagStatus != "launched" { + allLaunched = false + } + if env.Evaluations.Total7d > 0 { + hasTraffic = true + } + } + + if !hasAnyEnv { + return "unknown" + } + + // Permanent flags (temporary=false) are a declaration by the owner that the + // flag is meant to stay — kill switches, ops toggles, configurable limits. + // A permanent flag serving one variation is just config doing its job, not + // debt, so it's never a code-removal candidate regardless of rollout state. + // `temporary` is therefore the first gate on every removal recommendation. + if d.Temporary && d.CodeRefs > 0 { + // A fully rolled-out flag is still evaluated constantly, so eval volume + // and LD's per-env "launched" status (which lags, and never agrees + // across the no-traffic federal/EU envs) miss it. Check directly whether + // every traffic-bearing env serves a single deterministic variation: if + // so, the code branch is dead and the flag is ready to be hardcoded out. + if isFullyRolledOut(d, now) { + return "readyForCodeRemoval" + } + // allLaunched is a coarse proxy for "rolled out" — it can't tell whether + // the envs serve the *same* variation. Trust it only when we have no + // traffic data to run the precise isFullyRolledOut check above. + if allLaunched && !hasTraffic { + return "readyForCodeRemoval" + } + } + + if allInactive && d.CodeRefs == 0 { + return "readyToArchive" + } + if allInactive { + return "inactive" + } + if allLaunched { + return "launched" + } + + return "active" +} + +// servedVariation returns the single variation index an environment serves +// deterministically, or ok=false if the env still branches (percentage rollout, +// targeting rules, or individual targets — i.e. the flag still does real work). +func servedVariation(env EnvStatus) (idx int, ok bool) { + if !env.On { + if env.Targeting.OffVariation != nil { + return *env.Targeting.OffVariation, true + } + return 0, false + } + if env.Targeting.IsSimpleToggle && env.Targeting.FallthroughVariation != nil { + return *env.Targeting.FallthroughVariation, true + } + return 0, false +} + +// consensusVariation returns the single variation index served across the +// flag's environments, or ok=false if any env still branches or the envs serve +// different variations. When trafficOnly is set, only relevant (traffic-bearing) +// envs are considered, so zero-traffic envs (e.g. managed-federal-*) can't mask +// a finished rollout. +func consensusVariation(d *FlagDetail, trafficOnly bool) (int, bool) { + served := -1 + seen := 0 + for _, env := range d.Environments { + if trafficOnly && env.Evaluations.Total7d <= 0 { + continue + } + idx, ok := servedVariation(env) + if !ok { + return 0, false // this env still branches + } + if served == -1 { + served = idx + } else if served != idx { + return 0, false // envs serve different variations + } + seen++ + } + if seen == 0 { + return 0, false + } + return served, true +} + +// isFullyRolledOut reports whether every relevant (traffic-bearing) environment +// serves the same single variation, and that state has been stable long enough +// to be confident the rollout is finished rather than in progress. +func isFullyRolledOut(d *FlagDetail, now time.Time) bool { + if _, ok := consensusVariation(d, true); !ok { + return false + } + for _, env := range d.Environments { + if env.Evaluations.Total7d <= 0 { + continue + } + if env.FlagStatus == "launched" || + (!env.LastModified.IsZero() && now.Sub(env.LastModified) > stableRolloutAge) { + return true // stable: launched, or settled long enough ago + } + } + return false +} + +// recommendedRemovalValue returns the variation value a fully-rolled-out flag +// can be hardcoded to. Prefers the value agreed on by traffic-bearing envs; +// falls back to all configured envs for the no-traffic launched case. +func recommendedRemovalValue(d *FlagDetail) (any, bool) { + idx, ok := consensusVariation(d, true) + if !ok { + idx, ok = consensusVariation(d, false) + } + if !ok || idx < 0 || idx >= len(d.Variations) { + return nil, false + } + return d.Variations[idx].Value, true +} + +// evalWindowStart returns the start of the evaluation window as a millisecond +// timestamp, aligned to a bucket boundary so the value (and therefore the API +// cache key) is stable across repeated runs — a raw time.Now() here makes every +// request URL unique and defeats the response cache entirely. The window length +// also implicitly selects the API's bucket granularity: windows >= 24h return +// daily buckets (aligned to the UTC day), shorter windows return hourly buckets +// (aligned to the hour) so a guarded-rollout-scale window can see per-hour rates. +func evalWindowStart(now time.Time, window time.Duration) int64 { + from, _ := evalWindowRange(now, window) + return from +} + +// evalWindowRange returns the [from, to) window as millisecond timestamps, both +// aligned to the same bucket boundary as evalWindowStart so endpoints that need +// an explicit `to` (contextsCount, evaluationSummaries) stay cache-stable across +// repeated same-bucket runs. +func evalWindowRange(now time.Time, window time.Duration) (from, to int64) { + if window <= 0 { + window = defaultEvalWindow + } + align := time.Hour + if window >= 24*time.Hour { + align = 24 * time.Hour + } + end := now.UTC().Truncate(align) + return end.Add(-window).UnixMilli(), end.UnixMilli() +} + +// formatWindow renders a window duration as a short label (e.g. "6h", "7d"). +func formatWindow(d time.Duration) string { + if d <= 0 { + d = defaultEvalWindow + } + if d%(24*time.Hour) == 0 { + return fmt.Sprintf("%dd", d/(24*time.Hour)) + } + if d%time.Hour == 0 { + return fmt.Sprintf("%dh", d/time.Hour) + } + return d.String() +} + +// variationLabels maps a variation _id to a human label (name, else its value), +// for translating the evaluationSummaries variationCounts (keyed by _id). +func variationLabels(variations []apiVariation) map[string]string { + out := make(map[string]string, len(variations)) + for _, v := range variations { + label := v.Name + if label == "" { + label = fmt.Sprintf("%v", v.Value) + } + out[v.ID] = label + } + return out +} + +// fetchEvalSummaries fetches total evaluation counts (and the per-variation +// split) for one flag across many envs in a single batched POST. This is the +// default eval source — far fewer requests than the per-flag/env usage endpoint, +// at the cost of no per-bucket timeseries (use Series mode for that). +func fetchEvalSummaries(client *Client, projectKey, flagKey string, envKeys []string, variations []apiVariation, window time.Duration) map[string]EvaluationCounts { + out := make(map[string]EvaluationCounts) + if len(envKeys) == 0 { + return out + } + from, to := evalWindowRange(time.Now(), window) + idToLabel := variationLabels(variations) + path := fmt.Sprintf("/internal/projects/%s/evaluationSummaries", projectKey) + + // The endpoint caps each request at 10 envs; chunk defensively. + for _, chunk := range chunkStrings(envKeys, 10) { + req := apiEvalSummariesRequest{ + FlagKeys: []string{flagKey}, + EnvironmentKeys: chunk, + From: from, + To: to, + } + var resp apiEvalSummariesResponse + if err := client.postJSON(path, req, nil, &resp); err != nil { + continue + } + for _, summary := range resp.Data { + if summary.FlagKey != flagKey { + continue + } + for envKey, ev := range summary.Environments { + counts := EvaluationCounts{Total7d: ev.TotalEvaluations} + if len(ev.VariationCounts) > 0 { + counts.ByVariation = make(map[string]int64, len(ev.VariationCounts)) + for id, c := range ev.VariationCounts { + label := idToLabel[id] + if label == "" { + label = id + } + counts.ByVariation[label] += c + } + } + out[envKey] = counts + } + } + } + return out +} + +// fetchContextKinds returns the context kinds a flag is evaluated for in an env. +func fetchContextKinds(client *Client, projectKey, flagKey, envKey string) []string { + path := fmt.Sprintf("/internal/projects/%s/flags/%s/monitor/contextKinds?filter=%s", + projectKey, flagKey, url.QueryEscape("env equals "+envKey)) + var resp apiContextKindsResponse + if err := client.get(path, &resp); err != nil { + return nil + } + return resp.Data.ContextKinds +} + +// fetchUniqueContexts returns true unique-context counts (uniqExact) per context +// kind for a flag/env over the window — the "exposures" a guarded release gates +// on, as opposed to total evaluation calls. Returns nil if no kind has data. +func fetchUniqueContexts(client *Client, projectKey, flagKey, envKey string, kinds []string, window time.Duration, windowLabel string) *UniqueContextCounts { + if len(kinds) == 0 { + kinds = fetchContextKinds(client, projectKey, flagKey, envKey) + } + if len(kinds) == 0 { + return nil + } + from, to := evalWindowRange(time.Now(), window) + result := &UniqueContextCounts{Window: windowLabel, ByContextKind: make(map[string]UniqueContextStat)} + for _, kind := range kinds { + filter := url.QueryEscape(fmt.Sprintf("env equals %s,contextKind equals %s", envKey, kind)) + path := fmt.Sprintf("/internal/projects/%s/flags/%s/monitor/contextsCount?from=%d&to=%d&filter=%s", + projectKey, flagKey, from, to, filter) + var resp apiContextsCountResponse + if err := client.get(path, &resp); err != nil { + continue + } + result.ByContextKind[kind] = UniqueContextStat{ + Count: resp.Data.TotalUniqueContexts, + IsSampled: resp.Data.IsSampled, + } + } + if len(result.ByContextKind) == 0 { + return nil + } + return result +} + +// chunkStrings splits s into consecutive slices of at most size elements. +func chunkStrings(s []string, size int) [][]string { + if size <= 0 { + return [][]string{s} + } + var chunks [][]string + for i := 0; i < len(s); i += size { + end := i + size + if end > len(s) { + end = len(s) + } + chunks = append(chunks, s[i:end]) + } + return chunks +} + +func fetchEvaluationCounts(client *Client, projectKey, envKey, flagKey string, variations []Variation, window time.Duration) EvaluationCounts { + var counts EvaluationCounts + + if window <= 0 { + window = defaultEvalWindow + } + from := evalWindowStart(time.Now(), window) + path := fmt.Sprintf("/api/v2/usage/evaluations/%s/%s/%s?from=%d", + projectKey, envKey, flagKey, from) + + var resp apiUsageResponse + if err := client.getWithHeaders(path, map[string]string{"LD-API-Version": "beta"}, &resp); err != nil { + return counts + } + + counts.Total7d = resp.TotalEvaluations + + // Build variation index → label map from metadata + varLabels := make(map[string]string) + for i, m := range resp.Metadata { + idx := strconv.Itoa(i) + label := fmt.Sprintf("%v", m.Key) + if i < len(variations) && variations[i].Name != "" { + label = variations[i].Name + } + varLabels[idx] = label + } + + counts.ByVariation = make(map[string]int64) + for _, day := range resp.Series { + ts, ok := day["time"].(float64) + if !ok { + continue + } + dayTotal := int64(0) + for k, v := range day { + if k == "time" { + continue + } + if count, ok := v.(float64); ok { + dayTotal += int64(count) + label := k + if l, exists := varLabels[k]; exists { + label = l + } + counts.ByVariation[label] += int64(count) + } + } + counts.Daily = append(counts.Daily, DailyEvaluation{ + Time: time.UnixMilli(int64(ts)), + Count: dayTotal, + }) + } + + return counts +} + +// enrichable reports whether a scan reference carries a real LD flag key worth +// looking up. Wrapper definitions aren't call sites, and unresolved wrapper +// calls hold a guessed export name rather than a real key — enriching either +// would just produce bogus 404s. +func enrichable(ref scanner.FlagReference) bool { + return ref.Kind != "wrapper-definition" && ref.Kind != "wrapper-call-unresolved" +} + +func uniqueFlagKeys(result *scanner.ScanResult) []string { + seen := make(map[string]bool) + var keys []string + for _, ref := range result.References { + if !enrichable(ref) { + continue + } + if !seen[ref.FlagKey] { + seen[ref.FlagKey] = true + keys = append(keys, ref.FlagKey) + } + } + return keys +} + +func countCallSites(result *scanner.ScanResult) map[string]int { + counts := make(map[string]int) + for _, ref := range result.References { + if enrichable(ref) { + counts[ref.FlagKey]++ + } + } + return counts +} diff --git a/internal/flagusage/enrich/enrich_test.go b/internal/flagusage/enrich/enrich_test.go new file mode 100644 index 00000000..d78fbd58 --- /dev/null +++ b/internal/flagusage/enrich/enrich_test.go @@ -0,0 +1,287 @@ +package enrich + +import ( + "fmt" + "testing" + "time" +) + +var fixedNow = time.Date(2026, 6, 16, 12, 0, 0, 0, time.UTC) + +func intp(i int) *int { return &i } + +// simpleEnv builds a traffic-bearing env that deterministically serves one +// variation via a simple on-toggle. +func simpleEnv(total7d int64, variation int, launched bool, modified time.Time) EnvStatus { + status := "active" + if launched { + status = "launched" + } + return EnvStatus{ + On: true, + FlagStatus: status, + LastModified: modified, + Evaluations: EvaluationCounts{Total7d: total7d}, + Targeting: TargetingSummary{IsSimpleToggle: true, FallthroughVariation: intp(variation)}, + } +} + +// branchingEnv is on but still does real work (a targeting rule), so it does not +// serve a single deterministic variation. +func branchingEnv(total7d int64) EnvStatus { + return EnvStatus{ + On: true, + FlagStatus: "active", + LastModified: fixedNow, + Evaluations: EvaluationCounts{Total7d: total7d}, + Targeting: TargetingSummary{RuleCount: 1, FallthroughVariation: intp(0)}, + } +} + +func TestRolledOut_StableAndTrafficked_IsReadyForCodeRemoval(t *testing.T) { + d := &FlagDetail{ + Temporary: true, + CodeRefs: 2, + Variations: []Variation{{Value: true}, {Value: false}}, + Environments: map[string]EnvStatus{ + "production": simpleEnv(20000, 0, false, fixedNow.AddDate(0, 0, -3)), + "staging": simpleEnv(15000, 0, true, fixedNow.AddDate(0, 0, -30)), // launched => stable + }, + } + if got := computeStaleStateAt(d, fixedNow); got != "readyForCodeRemoval" { + t.Errorf("fully-rolled-out + stable should be readyForCodeRemoval, got %q", got) + } +} + +func TestRolledOut_PermanentFlag_IsNotRemovable(t *testing.T) { + // Same fully-rolled-out, stable shape as above, but permanent (temporary=false): + // it's config the owner intends to keep, not debt. + d := &FlagDetail{ + Temporary: false, + CodeRefs: 2, + Variations: []Variation{{Value: 11}, {Value: 0}}, + Environments: map[string]EnvStatus{ + "production": simpleEnv(20000, 0, true, fixedNow.AddDate(0, 0, -300)), + "staging": simpleEnv(15000, 0, true, fixedNow.AddDate(0, 0, -300)), + }, + } + if got := computeStaleStateAt(d, fixedNow); got == "readyForCodeRemoval" { + t.Errorf("permanent flags are config, never readyForCodeRemoval, got %q", got) + } +} + +func TestRolledOut_RecentAndNotLaunched_IsNotRemovable(t *testing.T) { + // Every relevant env serves variation 0, but it was flipped on a few days ago + // and LD hasn't marked it launched — likely mid-rollout, so not yet removable. + d := &FlagDetail{ + CodeRefs: 1, + Environments: map[string]EnvStatus{ + "production": simpleEnv(20000, 0, false, fixedNow.AddDate(0, 0, -3)), + "staging": simpleEnv(15000, 0, false, fixedNow.AddDate(0, 0, -4)), + }, + } + if got := computeStaleStateAt(d, fixedNow); got == "readyForCodeRemoval" { + t.Errorf("recently-flipped, not-yet-launched flag must not be removable, got %q", got) + } +} + +func TestRolledOut_EnvsServeDifferentVariations_IsNotRemovable(t *testing.T) { + d := &FlagDetail{ + CodeRefs: 1, + Environments: map[string]EnvStatus{ + "production": simpleEnv(20000, 0, true, fixedNow.AddDate(0, 0, -30)), + "staging": simpleEnv(15000, 1, true, fixedNow.AddDate(0, 0, -30)), + }, + } + if got := computeStaleStateAt(d, fixedNow); got == "readyForCodeRemoval" { + t.Errorf("envs serving different variations are not a single hardcode, got %q", got) + } +} + +func TestRolledOut_RelevantEnvStillBranches_IsNotRemovable(t *testing.T) { + d := &FlagDetail{ + CodeRefs: 1, + Environments: map[string]EnvStatus{ + "production": branchingEnv(20000), // has a targeting rule + "staging": simpleEnv(15000, 0, true, fixedNow.AddDate(0, 0, -30)), + }, + } + if got := computeStaleStateAt(d, fixedNow); got == "readyForCodeRemoval" { + t.Errorf("a relevant env that still branches blocks removal, got %q", got) + } +} + +func TestRolledOut_ZeroTrafficEnvIsIgnored(t *testing.T) { + // Federal-style env has no traffic and complex targeting; it must not block + // the otherwise-finished rollout. + d := &FlagDetail{ + Temporary: true, + CodeRefs: 1, + Environments: map[string]EnvStatus{ + "production": simpleEnv(20000, 0, true, fixedNow.AddDate(0, 0, -30)), + "managed-federal-prod": branchingEnv(0), // zero evals => irrelevant + }, + } + if got := computeStaleStateAt(d, fixedNow); got != "readyForCodeRemoval" { + t.Errorf("zero-traffic env should be ignored; expected readyForCodeRemoval, got %q", got) + } +} + +func TestFinalize_SetsRecommendedValueOnlyWhenRemovable(t *testing.T) { + // Temporary, fully rolled out to variation 0 (=true) → removable, value set. + removable := &FlagDetail{ + Temporary: true, + CodeRefs: 1, + Variations: []Variation{{Value: true}, {Value: false}}, + Environments: map[string]EnvStatus{ + "production": simpleEnv(20000, 0, true, fixedNow.AddDate(0, 0, -30)), + "staging": simpleEnv(15000, 0, true, fixedNow.AddDate(0, 0, -30)), + }, + } + removable.finalize(fixedNow) + if removable.StaleState != "readyForCodeRemoval" { + t.Fatalf("expected readyForCodeRemoval, got %q", removable.StaleState) + } + if removable.RecommendedValue != true { + t.Errorf("expected RecommendedValue true, got %v", removable.RecommendedValue) + } + + // Permanent flag in the same shape → not removable, value must stay unset. + permanent := &FlagDetail{ + Temporary: false, + CodeRefs: 1, + Variations: []Variation{{Value: 11}, {Value: 0}}, + Environments: map[string]EnvStatus{ + "production": simpleEnv(20000, 0, true, fixedNow.AddDate(0, 0, -30)), + "staging": simpleEnv(15000, 0, true, fixedNow.AddDate(0, 0, -30)), + }, + } + permanent.finalize(fixedNow) + if permanent.RecommendedValue != nil { + t.Errorf("non-removable flag must not carry a recommendedValue, got %v", permanent.RecommendedValue) + } +} + +func TestRecommendedRemovalValue_OffServesOffVariation(t *testing.T) { + // All relevant envs off → serves offVariation index 1 (=false). + off := func(total7d int64) EnvStatus { + return EnvStatus{ + On: false, + FlagStatus: "active", + LastModified: fixedNow.AddDate(0, 0, -30), + Evaluations: EvaluationCounts{Total7d: total7d}, + Targeting: TargetingSummary{OffVariation: intp(1)}, + } + } + d := &FlagDetail{ + Temporary: true, + CodeRefs: 1, + Variations: []Variation{{Value: true}, {Value: false}}, + Environments: map[string]EnvStatus{ + "production": off(9000), + "staging": off(8000), + }, + } + v, ok := recommendedRemovalValue(d) + if !ok || v != false { + t.Errorf("expected off flag to recommend offVariation value false, got %v (ok=%v)", v, ok) + } +} + +func TestRolledOut_NoCodeRefs_IsNotCodeRemoval(t *testing.T) { + // Nothing in code to remove, even if fully rolled out. + d := &FlagDetail{ + CodeRefs: 0, + Environments: map[string]EnvStatus{ + "production": simpleEnv(20000, 0, true, fixedNow.AddDate(0, 0, -30)), + }, + } + if got := computeStaleStateAt(d, fixedNow); got == "readyForCodeRemoval" { + t.Errorf("no code refs means nothing to remove, got %q", got) + } +} + +// The evaluation-counts endpoint is parameterized by a `from` timestamp. +// If that timestamp moves every run, the request URL — and therefore the +// response cache key — is unique each time and the cache never hits. These +// tests pin the day-aligned behavior that keeps repeated runs cacheable. + +func TestEvalWindowStable_WithinSameDay(t *testing.T) { + morning := time.Date(2026, 6, 16, 8, 30, 0, 0, time.UTC) + evening := time.Date(2026, 6, 16, 23, 59, 59, 0, time.UTC) + + if evalWindowStart(morning, defaultEvalWindow) != evalWindowStart(evening, defaultEvalWindow) { + t.Errorf("eval window must be identical across the same UTC day; got %d vs %d", + evalWindowStart(morning, defaultEvalWindow), evalWindowStart(evening, defaultEvalWindow)) + } +} + +func TestEvalWindowStable_IgnoresSubSecondJitter(t *testing.T) { + a := time.Date(2026, 6, 16, 12, 0, 0, 123_000_000, time.UTC) + b := time.Date(2026, 6, 16, 12, 0, 0, 456_000_000, time.UTC) + + if evalWindowStart(a, defaultEvalWindow) != evalWindowStart(b, defaultEvalWindow) { + t.Error("millisecond jitter must not change the eval window (this was the cache-busting bug)") + } +} + +func TestEvalWindowChanges_AcrossDays(t *testing.T) { + day1 := time.Date(2026, 6, 16, 12, 0, 0, 0, time.UTC) + day2 := time.Date(2026, 6, 17, 12, 0, 0, 0, time.UTC) + + if evalWindowStart(day1, defaultEvalWindow) == evalWindowStart(day2, defaultEvalWindow) { + t.Error("eval window should advance to a new value on a new UTC day") + } +} + +func TestEvalWindowIsSevenDaysBack_DayAligned(t *testing.T) { + now := time.Date(2026, 6, 16, 15, 45, 0, 0, time.UTC) + got := evalWindowStart(now, defaultEvalWindow) + want := time.Date(2026, 6, 9, 0, 0, 0, 0, time.UTC).UnixMilli() + if got != want { + t.Errorf("expected window start %d (2026-06-09 00:00 UTC), got %d", want, got) + } +} + +// A short (<24h) window aligns to the HOUR, not the day, so a guarded-rollout-scale +// lookback still gets a cache-stable key while the API returns hourly buckets. +func TestEvalWindowShort_HourAligned(t *testing.T) { + now := time.Date(2026, 6, 16, 15, 45, 30, 0, time.UTC) + got := evalWindowStart(now, 6*time.Hour) + want := time.Date(2026, 6, 16, 9, 0, 0, 0, time.UTC).UnixMilli() // 15:00 truncated, minus 6h + if got != want { + t.Errorf("expected 6h window start %d (2026-06-16 09:00 UTC), got %d", want, got) + } +} + +// Short-window key must stay stable within the same hour (cache property) but +// advance to a new hour — the same guarantee day-alignment gives the default. +func TestEvalWindowShort_StableWithinHour(t *testing.T) { + a := time.Date(2026, 6, 16, 15, 5, 0, 0, time.UTC) + b := time.Date(2026, 6, 16, 15, 55, 0, 0, time.UTC) + c := time.Date(2026, 6, 16, 16, 5, 0, 0, time.UTC) + if evalWindowStart(a, 6*time.Hour) != evalWindowStart(b, 6*time.Hour) { + t.Error("6h window must be identical within the same UTC hour (cache stability)") + } + if evalWindowStart(a, 6*time.Hour) == evalWindowStart(c, 6*time.Hour) { + t.Error("6h window should advance to a new value on a new UTC hour") + } +} + +// Guards the end-to-end property the bug violated: two enrichment runs on the +// same day must produce the identical cache key for the eval-counts request. +func TestEvalCacheKeyStableAcrossRuns(t *testing.T) { + run := func(now time.Time) string { + from := evalWindowStart(now, defaultEvalWindow) + path := fmt.Sprintf("/api/v2/usage/evaluations/%s/%s/%s?from=%d", + "default", "production", "my-flag", from) + return cacheKey("GET", path, map[string]string{"LD-API-Version": "beta"}, nil) + } + + first := run(time.Date(2026, 6, 16, 9, 0, 0, 0, time.UTC)) + second := run(time.Date(2026, 6, 16, 17, 30, 0, 0, time.UTC)) + + if first != second { + t.Error("eval-counts cache key must be stable across same-day runs") + } +} diff --git a/internal/flagusage/enrich/helpers_test.go b/internal/flagusage/enrich/helpers_test.go new file mode 100644 index 00000000..debc51b6 --- /dev/null +++ b/internal/flagusage/enrich/helpers_test.go @@ -0,0 +1,85 @@ +package enrich + +import ( + "testing" + "time" +) + +func TestChunkStrings(t *testing.T) { + in := []string{"a", "b", "c", "d", "e"} + got := chunkStrings(in, 2) + if len(got) != 3 || len(got[0]) != 2 || len(got[2]) != 1 { + t.Fatalf("chunkStrings(%v,2) = %v", in, got) + } + if n := len(chunkStrings(in, 10)); n != 1 { + t.Errorf("oversize chunk should yield 1 slice, got %d", n) + } + if n := len(chunkStrings(nil, 5)); n != 0 { + t.Errorf("nil input should yield 0 chunks, got %d", n) + } +} + +func TestFormatWindow(t *testing.T) { + cases := map[time.Duration]string{ + 0: "7d", // default + 6 * time.Hour: "6h", + 18 * time.Hour: "18h", + 24 * time.Hour: "1d", + 7 * 24 * time.Hour: "7d", + 90 * time.Minute: "1h30m0s", + } + for d, want := range cases { + if got := formatWindow(d); got != want { + t.Errorf("formatWindow(%v) = %q, want %q", d, got, want) + } + } +} + +func TestVariationLabels(t *testing.T) { + labels := variationLabels([]apiVariation{ + {ID: "v0", Name: "On", Value: true}, + {ID: "v1", Value: false}, // no name → stringified value + }) + if labels["v0"] != "On" { + t.Errorf("named variation label = %q, want On", labels["v0"]) + } + if labels["v1"] != "false" { + t.Errorf("unnamed variation label = %q, want false", labels["v1"]) + } +} + +// evalWindowRange must be stable within a bucket (so the request URL / cache key +// doesn't change between same-bucket runs) but advance across buckets, for both +// the daily (>=24h) and hourly (<24h) alignments. +func TestEvalWindowRangeStability(t *testing.T) { + // Hourly alignment: two times in the same UTC hour → identical range. + a := time.Date(2026, 6, 16, 9, 5, 0, 0, time.UTC) + b := time.Date(2026, 6, 16, 9, 55, 0, 0, time.UTC) + fa, ta := evalWindowRange(a, 6*time.Hour) + fb, tb := evalWindowRange(b, 6*time.Hour) + if fa != fb || ta != tb { + t.Errorf("same-hour range unstable: (%d,%d) vs (%d,%d)", fa, ta, fb, tb) + } + if fa >= ta { + t.Errorf("from must precede to: from=%d to=%d", fa, ta) + } + if ta-fa != (6 * time.Hour).Milliseconds() { + t.Errorf("window width = %dms, want %dms", ta-fa, (6 * time.Hour).Milliseconds()) + } + + // Next hour → different range. + c := time.Date(2026, 6, 16, 10, 5, 0, 0, time.UTC) + fc, _ := evalWindowRange(c, 6*time.Hour) + if fc == fa { + t.Error("range should advance to the next hourly bucket") + } + + // Daily alignment: two times on the same UTC day → identical range. + d1 := time.Date(2026, 6, 16, 9, 0, 0, 0, time.UTC) + d2 := time.Date(2026, 6, 16, 23, 0, 0, 0, time.UTC) + f1, _ := evalWindowRange(d1, defaultEvalWindow) + f2, _ := evalWindowRange(d2, defaultEvalWindow) + if f1 != f2 { + t.Error("same-day default-window range must be stable") + } +} diff --git a/internal/flagusage/enrich/types.go b/internal/flagusage/enrich/types.go new file mode 100644 index 00000000..ee5de9e8 --- /dev/null +++ b/internal/flagusage/enrich/types.go @@ -0,0 +1,248 @@ +package enrich + +import "time" + +// FlagDetail is the enriched view of a flag, combining scan data with LD API data. +type FlagDetail struct { + Key string `json:"key"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + Temporary bool `json:"temporary"` + Archived bool `json:"archived"` + CreationDate time.Time `json:"creationDate"` + Tags []string `json:"tags,omitempty"` + Kind string `json:"kind"` // boolean, multivariate + Environments map[string]EnvStatus `json:"environments"` + CodeRefs int `json:"codeRefs"` + CallSites int `json:"callSites"` + StaleState string `json:"staleState"` + // RecommendedValue is the variation value a readyForCodeRemoval flag can be + // hardcoded to (the single variation its relevant envs serve). Only set when + // StaleState == "readyForCodeRemoval"; omitted otherwise. + RecommendedValue any `json:"recommendedValue,omitempty"` + Variations []Variation `json:"variations"` +} + +type EnvStatus struct { + On bool `json:"on"` + LastModified time.Time `json:"lastModified"` + LastEvaluation time.Time `json:"lastEvaluation,omitempty"` + FlagStatus string `json:"flagStatus"` // active, inactive, launched, new + + // Targeting complexity + Targeting TargetingSummary `json:"targeting"` + + // Exposure: which variations are being served and how (from targeting config) + Exposure ExposureSummary `json:"exposure"` + + // Evaluation counts: total SDK evaluation calls (NOT unique contexts). + Evaluations EvaluationCounts `json:"evaluations"` + + // UniqueContexts: true unique-context counts (the "exposures" a guarded + // release actually gates on), per context kind, over the eval window. + // Only populated when exposure fetching is enabled (-exposures); omitted + // otherwise. + UniqueContexts *UniqueContextCounts `json:"uniqueContexts,omitempty"` +} + +// UniqueContextCounts holds unique-context cardinality per context kind, over +// a window. Unlike EvaluationCounts (total SDK calls), each context is counted +// once — this is the "exposures" figure LD's guarded-release UI reports. +type UniqueContextCounts struct { + Window string `json:"window"` + ByContextKind map[string]UniqueContextStat `json:"byContextKind"` +} + +type UniqueContextStat struct { + Count int64 `json:"count"` + IsSampled bool `json:"isSampled"` +} + +type EvaluationCounts struct { + Total7d int64 `json:"total7d"` + Daily []DailyEvaluation `json:"daily,omitempty"` + ByVariation map[string]int64 `json:"byVariation,omitempty"` +} + +type DailyEvaluation struct { + Time time.Time `json:"time"` + Count int64 `json:"count"` +} + +type TargetingSummary struct { + RuleCount int `json:"ruleCount"` + TargetCount int `json:"targetCount"` // individual user targets + ContextTargetCount int `json:"contextTargetCount"` // context-based targets + PrerequisiteCount int `json:"prerequisiteCount"` + IsSimpleToggle bool `json:"isSimpleToggle"` // on + no rules/targets, single fallthrough variation + FallthroughVariation *int `json:"fallthroughVariation,omitempty"` + OffVariation *int `json:"offVariation,omitempty"` +} + +type ExposureSummary struct { + // Per-variation summary from _summary field + VariationExposure []VariationExposure `json:"variationExposure,omitempty"` +} + +type VariationExposure struct { + VariationIndex int `json:"variationIndex"` + Rules int `json:"rules"` + Targets int `json:"targets"` + ContextTargets int `json:"contextTargets"` + IsFallthrough bool `json:"isFallthrough"` + IsOff bool `json:"isOff"` +} + +type Variation struct { + Value any `json:"value"` + Name string `json:"name,omitempty"` + Description string `json:"description,omitempty"` +} + +// API response types + +type apiFlagResponse struct { + Key string `json:"key"` + Name string `json:"name"` + Description string `json:"description"` + Temporary bool `json:"temporary"` + Archived bool `json:"archived"` + CreationDate int64 `json:"creationDate"` + Tags []string `json:"tags"` + Kind string `json:"kind"` + Variations []apiVariation `json:"variations"` + Environments map[string]apiEnvConfig `json:"environments"` +} + +type apiVariation struct { + ID string `json:"_id"` + Value any `json:"value"` + Name string `json:"name"` + Description string `json:"description"` + ValueHash string `json:"valueHash"` +} + +type apiEnvConfig struct { + On bool `json:"on"` + LastModified int64 `json:"lastModified"` + Rules []apiRule `json:"rules"` + Targets []apiTarget `json:"targets"` + ContextTargets []apiTarget `json:"contextTargets"` + Prerequisites []apiPrerequisite `json:"prerequisites"` + Fallthrough apiFallthrough `json:"fallthrough"` + OffVariation *int `json:"offVariation"` + Summary apiSummary `json:"_summary"` +} + +type apiRule struct { + ID string `json:"_id"` + Variation *int `json:"variation"` + Clauses []apiClause `json:"clauses"` +} + +type apiClause struct { + Attribute string `json:"attribute"` + Op string `json:"op"` + Values []any `json:"values"` + ContextKind string `json:"contextKind"` + Negate bool `json:"negate"` +} + +type apiTarget struct { + Values []string `json:"values"` + Variation int `json:"variation"` + ContextKind string `json:"contextKind"` +} + +type apiPrerequisite struct { + Key string `json:"key"` + Variation int `json:"variation"` +} + +type apiFallthrough struct { + Variation *int `json:"variation"` + Rollout *apiRollout `json:"rollout,omitempty"` +} + +type apiRollout struct { + Variations []apiWeightedVariation `json:"variations"` +} + +type apiWeightedVariation struct { + Variation int `json:"variation"` + Weight int `json:"weight"` +} + +type apiSummary struct { + Variations map[string]apiSummaryVariation `json:"variations"` + Prerequisites int `json:"prerequisites"` +} + +type apiSummaryVariation struct { + Rules int `json:"rules"` + NullRules int `json:"nullRules"` + Targets int `json:"targets"` + ContextTargets int `json:"contextTargets"` + IsFallthrough bool `json:"isFallthrough"` + IsOff bool `json:"isOff"` +} + +type apiLink struct { + Href string `json:"href"` + Type string `json:"type"` +} + +type apiFlagStatusByFlag struct { + Name string `json:"name"` + LastRequested string `json:"lastRequested"` +} + +type apiUsageResponse struct { + TotalEvaluations int64 `json:"totalEvaluations"` + Series []map[string]any `json:"series"` + Metadata []apiUsageMetadata `json:"metadata"` +} + +type apiUsageMetadata struct { + Key any `json:"key"` +} + +// POST /internal/projects/{p}/evaluationSummaries — batched eval totals for +// many flags × envs in one call. variationCounts is keyed by variation _id. +type apiEvalSummariesRequest struct { + FlagKeys []string `json:"flagKeys"` + EnvironmentKeys []string `json:"environmentKeys"` + From int64 `json:"from"` + To int64 `json:"to"` +} + +type apiEvalSummariesResponse struct { + Data []apiEvalSummary `json:"data"` +} + +type apiEvalSummary struct { + FlagKey string `json:"flagKey"` + Environments map[string]apiEvalEnvVariations `json:"environments"` +} + +type apiEvalEnvVariations struct { + TotalEvaluations int64 `json:"totalEvaluations"` + VariationCounts map[string]int64 `json:"variationCounts"` +} + +// GET /internal/projects/{p}/flags/{key}/monitor/contextsCount — true unique +// context cardinality (uniqExact) for one flag/env/contextKind over a window. +type apiContextsCountResponse struct { + Data struct { + TotalUniqueContexts int64 `json:"totalUniqueContexts"` + IsSampled bool `json:"isSampled"` + } `json:"data"` +} + +// GET /internal/projects/{p}/flags/{key}/monitor/contextKinds — the context +// kinds a flag is evaluated for (so we know which to query for exposures). +type apiContextKindsResponse struct { + Data struct { + ContextKinds []string `json:"contextKinds"` + } `json:"data"` +} diff --git a/internal/flagusage/render/aliases.go b/internal/flagusage/render/aliases.go new file mode 100644 index 00000000..c3b66cdc --- /dev/null +++ b/internal/flagusage/render/aliases.go @@ -0,0 +1,51 @@ +package render + +import ( + _ "embed" + "encoding/json" + "os" + + "github.com/adrg/xdg" +) + +// embeddedAliases holds the default env-key → display-name map, kept in a JSON +// file (aliases.json) for easy editing rather than buried in code. Users can +// override/extend it via $XDG_CONFIG_HOME/ldcli/flagusage-aliases.json. +// +//go:embed aliases.json +var embeddedAliases []byte + +// envAliases is loaded lazily on first use (nil = not yet loaded). Tests may set +// it directly to control aliasing. +var envAliases map[string]string + +// loadEnvAliases merges the embedded defaults with an optional user file +// (user entries win). +func loadEnvAliases() map[string]string { + m := map[string]string{} + _ = json.Unmarshal(embeddedAliases, &m) + if path, err := xdg.SearchConfigFile("ldcli/flagusage-aliases.json"); err == nil { + if data, err := os.ReadFile(path); err == nil { + var u map[string]string + if json.Unmarshal(data, &u) == nil { + for k, v := range u { + m[k] = v + } + } + } + } + return m +} + +// aliasEnv maps a raw env key to its display alias (e.g. managed-federal-prod → +// "federal prod"), falling back to the key itself. Rendering is single-threaded, +// so the lazy load needs no synchronization. +func aliasEnv(key string) string { + if envAliases == nil { + envAliases = loadEnvAliases() + } + if a, ok := envAliases[key]; ok && a != "" { + return a + } + return key +} diff --git a/internal/flagusage/render/aliases.json b/internal/flagusage/render/aliases.json new file mode 100644 index 00000000..14c9059f --- /dev/null +++ b/internal/flagusage/render/aliases.json @@ -0,0 +1,5 @@ +{ + "managed-federal-stg": "federal staging", + "managed-federal-prod": "federal prod", + "managed-eu-production": "EU production" +} diff --git a/internal/flagusage/render/render.go b/internal/flagusage/render/render.go new file mode 100644 index 00000000..33fd9728 --- /dev/null +++ b/internal/flagusage/render/render.go @@ -0,0 +1,375 @@ +// Package render is the third pipeline stage (scan → enrich → render): it turns +// enriched flag data into the human-facing terminal view — a single box-drawing +// table fit to the terminal width. The machine-readable contract is `-format +// json` (handled in main); this package is display-only. +package render + +import ( + "encoding/json" + "fmt" + "io" + "sort" + "strings" + "time" + "unicode/utf8" + + "github.com/launchdarkly/ldcli/internal/flagusage/enrich" +) + +// stateOrder is the lifecycle sort order (most actionable first). Its index is the +// primary sort key for the combined table. +var stateOrder = []string{"orphanedReference", "readyToArchive", "readyForCodeRemoval", "inactive", "launched", "active", "unknown", "archived"} + +func lifecycleRank(state string) int { + for i, s := range stateOrder { + if s == state { + return i + } + } + return len(stateOrder) // unknown states sort last +} + +// lifecycleLabel maps the internal staleState to its display label. The JSON +// staleState value is unchanged (the machine contract); this is display-only. +func lifecycleLabel(state string) string { + if state == "readyForCodeRemoval" { + return "code removable" + } + return state +} + +// Enriched renders all flags as a single box-drawing table with a leftmost +// Lifecycle column, sorted by lifecycle (stateOrder) then flag key, fit to +// maxWidth (0 = unconstrained). +func Enriched(w io.Writer, details []enrich.FlagDetail, windowLabel string, envs []string, maxWidth int) { + if len(details) == 0 { + return + } + + sorted := append([]enrich.FlagDetail(nil), details...) + sort.Slice(sorted, func(i, j int) bool { + if ri, rj := lifecycleRank(sorted[i].StaleState), lifecycleRank(sorted[j].StaleState); ri != rj { + return ri < rj + } + return sorted[i].Key < sorted[j].Key + }) + + // Uniq column appears only with -exposures data. + hasUniq := false + for _, d := range sorted { + for _, envKey := range envs { + if env, ok := d.Environments[envKey]; ok && env.UniqueContexts != nil { + hasUniq = true + } + } + } + + headers := []string{"Lifecycle", "Flag", "Env", "On", "Status", "Last Eval", "Evals/" + windowLabel} + if hasUniq { + headers = append(headers, "Uniq/"+windowLabel) + } + headers = append(headers, "Targeting") + + var rows [][]string + for i, d := range sorted { + if i > 0 { + rows = append(rows, nil) // separator between flags + } + rows = append(rows, flagRows(d, envs, hasUniq)...) + } + fmt.Fprintln(w, renderTable(headers, rows, maxWidth)) +} + +// flagRows builds the rows for one flag (one per present env). The Lifecycle label +// and Flag key sit on the first env row; the age·callsites meta on the second (or +// its own row for a single-env flag). Env keys are shown via their display alias. +// A recommended hardcode value, when present, is appended at the bottom of the +// Lifecycle cell (below "code removable"). +func flagRows(d enrich.FlagDetail, envs []string, hasUniq bool) [][]string { + meta := flagMetaLine(d) + + // assemble builds one row in header order, honoring the optional Uniq column. + assemble := func(lifecycle, flag, env, on, status, lastEval, evals, uniq, targeting string) []string { + cells := []string{lifecycle, flag, env, on, status, lastEval, evals} + if hasUniq { + cells = append(cells, uniq) + } + return append(cells, targeting) + } + + var present []string + for _, envKey := range envs { + if _, ok := d.Environments[envKey]; ok { + present = append(present, envKey) + } + } + + var rows [][]string + if len(present) == 0 { + // No env data (e.g. orphaned): a dashed row + a meta continuation row. + rows = append(rows, + assemble(lifecycleLabel(d.StaleState), d.Key, "—", "—", "—", "—", "—", "—", "—"), + assemble("", meta, "", "", "", "", "", "", ""), + ) + } else { + for i, envKey := range present { + env := d.Environments[envKey] + lifecycle, flag := "", "" + switch i { + case 0: + lifecycle, flag = lifecycleLabel(d.StaleState), d.Key + case 1: + flag = meta + } + rows = append(rows, assemble(lifecycle, flag, aliasEnv(envKey), onOffCell(env), dashIfEmpty(env.FlagStatus), + lastEvalCell(env), evalsCell(env), uniqCell(env), targetingCell(env, d.Variations))) + } + // A single-env flag never got the meta row (it would live on env row #2). + if len(present) == 1 { + rows = append(rows, assemble("", meta, "", "", "", "", "", "", "")) + } + } + + // Recommended hardcode value goes at the bottom of the Lifecycle cell — on the + // flag's last existing row, not a new one. (Env-bearing flags always have ≥2 + // rows, so this never clobbers the label on row 0.) + if d.RecommendedValue != nil && len(rows) > 0 { + b, _ := json.Marshal(d.RecommendedValue) + rows[len(rows)-1][0] = "hardcode " + string(b) + } + return rows +} + +func flagMetaLine(d enrich.FlagDetail) string { + age := "?" + if !d.CreationDate.IsZero() { + age = fmt.Sprintf("%dd", int(time.Since(d.CreationDate).Hours()/24)) + } + meta := fmt.Sprintf("%s · %d×", age, d.CallSites) + if d.Temporary { + meta += " · temp" + } + return meta +} + +func onOffCell(env enrich.EnvStatus) string { + if env.On { + return "ON" + } + return "OFF" +} + +func dashIfEmpty(s string) string { + if s == "" { + return "—" + } + return s +} + +// lastEvalCell shows how long ago the flag was last evaluated (e.g. "5d", "3h"), +// not the absolute date. +func lastEvalCell(env enrich.EnvStatus) string { + if env.LastEvaluation.IsZero() { + return "—" + } + d := time.Since(env.LastEvaluation) + switch { + case d < time.Minute: + return "<1m" + case d < time.Hour: + return fmt.Sprintf("%dm", int(d.Minutes())) + case d < 24*time.Hour: + return fmt.Sprintf("%dh", int(d.Hours())) + default: + return fmt.Sprintf("%dd", int(d.Hours()/24)) + } +} + +func evalsCell(env enrich.EnvStatus) string { + if env.Evaluations.Total7d > 0 { + return formatCount(env.Evaluations.Total7d) + } + return "—" +} + +func uniqCell(env enrich.EnvStatus) string { + if env.UniqueContexts == nil { + return "—" + } + var parts []string + for kind, stat := range env.UniqueContexts.ByContextKind { + s := kind + "=" + formatCount(stat.Count) + if stat.IsSampled { + s += "~" // sampled estimate + } + parts = append(parts, s) + } + sort.Strings(parts) + if len(parts) == 0 { + return "—" + } + return strings.Join(parts, ", ") +} + +func targetingCell(env enrich.EnvStatus, variations []enrich.Variation) string { + if !env.On { + return "off" + } + if env.Targeting.IsSimpleToggle { + if env.Targeting.FallthroughVariation != nil && *env.Targeting.FallthroughVariation >= 0 && + *env.Targeting.FallthroughVariation < len(variations) { + return fmt.Sprintf("serves %v", variations[*env.Targeting.FallthroughVariation].Value) + } + return "on (simple)" + } + var parts []string + if env.Targeting.RuleCount > 0 { + parts = append(parts, fmt.Sprintf("%d rules", env.Targeting.RuleCount)) + } + if env.Targeting.TargetCount > 0 { + parts = append(parts, fmt.Sprintf("%d targets", env.Targeting.TargetCount)) + } + if env.Targeting.ContextTargetCount > 0 { + parts = append(parts, fmt.Sprintf("%d ctx-targets", env.Targeting.ContextTargetCount)) + } + if env.Targeting.PrerequisiteCount > 0 { + parts = append(parts, fmt.Sprintf("%d prereqs", env.Targeting.PrerequisiteCount)) + } + if len(parts) == 0 { + return "on" + } + return strings.Join(parts, ", ") +} + +// maxCellWidth caps any single cell so one long value (e.g. a targeting summary) +// can't blow out the table when there's no width limit (piped/redirected output). +const maxCellWidth = 60 + +// minColWidth is the floor a column is shrunk to when fitting a narrow terminal. +const minColWidth = 6 + +// clipTo truncates s (by rune) to at most w columns, using an ellipsis. +func clipTo(s string, w int) string { + if w <= 0 { + return "" + } + if utf8.RuneCountInString(s) <= w { + return s + } + r := []rune(s) + if w == 1 { + return "…" + } + return string(r[:w-1]) + "…" +} + +// fitWidths shrinks the widest columns (one at a time, down to minColWidth) until +// the rendered table fits maxWidth. Table overhead is the bars and per-cell +// padding: n+1 vertical bars plus 2 padding spaces per column. +func fitWidths(width []int, maxWidth int) { + n := len(width) + overhead := (n + 1) + 2*n + for { + total := overhead + for _, w := range width { + total += w + } + if total <= maxWidth { + return + } + widest, wi := minColWidth, -1 + for i, w := range width { + if w > widest { + widest, wi = w, i + } + } + if wi == -1 { + return // every column already at the floor; can't shrink further + } + width[wi]-- + } +} + +// renderTable draws a box-drawing table, fitting maxWidth (0 = unconstrained) by +// shrinking the widest columns and truncating their cells with an ellipsis. A nil +// row renders as a horizontal separator (used between flags within a group). +func renderTable(headers []string, rows [][]string, maxWidth int) string { + n := len(headers) + + // Natural width per column, bounded by maxCellWidth so one huge cell can't + // dominate before we even consider the terminal. + width := make([]int, n) + note := func(i int, s string) { + if l := utf8.RuneCountInString(s); l > width[i] { + width[i] = l + if width[i] > maxCellWidth { + width[i] = maxCellWidth + } + } + } + for i := range headers { + note(i, headers[i]) + } + for _, row := range rows { + for i := 0; i < n && i < len(row); i++ { + note(i, row[i]) + } + } + + if maxWidth > 0 { + fitWidths(width, maxWidth) + } + + var b strings.Builder + border := func(left, mid, right string) { + b.WriteString(left) + for i, w := range width { + b.WriteString(strings.Repeat("─", w+2)) + if i < n-1 { + b.WriteString(mid) + } else { + b.WriteString(right) + } + } + b.WriteString("\n") + } + rowLine := func(cells []string) { + b.WriteString("│") + for i, w := range width { + cell := "" + if i < len(cells) { + cell = clipTo(cells[i], w) + } + b.WriteString(" " + cell + strings.Repeat(" ", w-utf8.RuneCountInString(cell)) + " │") + } + b.WriteString("\n") + } + + border("┌", "┬", "┐") + rowLine(headers) + border("├", "┼", "┤") + for _, row := range rows { + if row == nil { + border("├", "┼", "┤") + } else { + rowLine(row) + } + } + border("└", "┴", "┘") + return b.String() +} + +func formatCount(n int64) string { + switch { + case n >= 1_000_000_000_000: + return fmt.Sprintf("%.1fT", float64(n)/1e12) + case n >= 1_000_000_000: + return fmt.Sprintf("%.1fB", float64(n)/1e9) + case n >= 1_000_000: + return fmt.Sprintf("%.1fM", float64(n)/1e6) + case n >= 1_000: + return fmt.Sprintf("%.1fK", float64(n)/1e3) + default: + return fmt.Sprintf("%d", n) + } +} diff --git a/internal/flagusage/render/render_test.go b/internal/flagusage/render/render_test.go new file mode 100644 index 00000000..814c5da9 --- /dev/null +++ b/internal/flagusage/render/render_test.go @@ -0,0 +1,181 @@ +package render + +import ( + "strings" + "testing" + "time" + + "github.com/launchdarkly/ldcli/internal/flagusage/enrich" +) + +func intp(i int) *int { return &i } + +// simpleEnv is a traffic-bearing on-toggle serving one variation. +func simpleEnv(total int64, variation int, status string) enrich.EnvStatus { + return enrich.EnvStatus{ + On: true, + FlagStatus: status, + Evaluations: enrich.EvaluationCounts{Total7d: total}, + Targeting: enrich.TargetingSummary{IsSimpleToggle: true, FallthroughVariation: intp(variation)}, + } +} + +func TestRenderTable_HasBoxBordersAndSeparator(t *testing.T) { + out := renderTable([]string{"A", "B"}, [][]string{ + {"one", "two"}, + nil, // separator + {"three", "four"}, + }, 0) + for _, glyph := range []string{"┌", "┬", "┐", "├", "┼", "┤", "└", "┴", "┘", "│", "─"} { + if !strings.Contains(out, glyph) { + t.Errorf("table missing box glyph %q\n%s", glyph, out) + } + } + // Every rendered line should be the same display width (aligned box). + var width int + for i, line := range strings.Split(strings.TrimRight(out, "\n"), "\n") { + w := len([]rune(line)) + if i == 0 { + width = w + } else if w != width { + t.Errorf("line %d width %d != %d\n%s", i, w, width, out) + } + } +} + +func TestRenderTable_FitsMaxWidth(t *testing.T) { + const maxWidth = 40 + out := renderTable( + []string{"Flag", "Targeting"}, + [][]string{{"a-very-long-flag-key-that-overflows", "1 rules, 2 targets, 3 ctx-targets"}}, + maxWidth, + ) + for i, line := range strings.Split(strings.TrimRight(out, "\n"), "\n") { + if w := len([]rune(line)); w > maxWidth { + t.Errorf("line %d width %d exceeds maxWidth %d\n%s", i, w, maxWidth, out) + } + } + if !strings.Contains(out, "…") { + t.Errorf("expected truncation ellipsis when shrinking to fit\n%s", out) + } +} + +func TestPrintEnriched_RemovalFlag_LifecycleAndRecommend(t *testing.T) { + details := []enrich.FlagDetail{{ + Key: "enable-foo", + Temporary: true, + CallSites: 3, + CreationDate: time.Now().AddDate(0, 0, -62), + StaleState: "readyForCodeRemoval", + Variations: []enrich.Variation{{Value: true}, {Value: false}}, + RecommendedValue: true, + Environments: map[string]enrich.EnvStatus{ + "production": simpleEnv(1_200_000, 0, "launched"), + "staging": simpleEnv(45_100, 0, "launched"), + }, + }} + + var b strings.Builder + Enriched(&b, details, "7d", []string{"production", "staging"}, 0) + out := b.String() + + for _, want := range []string{ + "Lifecycle", // leftmost combined column + "code removable", // display label for readyForCodeRemoval + "enable-foo", // flag key on first env row + "production", "staging", + "launched", + "1.2M", // formatted eval count + "serves true", // simple-toggle serving variation 0 (=true) + "hardcode true", // recommendation, at the bottom of the Lifecycle cell + "62d · 3× · temp", // meta continuation row + "┌", "│", // rendered as a box table + } { + if !strings.Contains(out, want) { + t.Errorf("combined table missing %q\n%s", want, out) + } + } + for _, absent := range []string{"(1)", "Recommend", "readyForCodeRemoval"} { + if strings.Contains(out, absent) { + t.Errorf("combined table should not contain %q\n%s", absent, out) + } + } +} + +func TestLastEvalCell_RelativeDuration(t *testing.T) { + if got := lastEvalCell(enrich.EnvStatus{}); got != "—" { + t.Errorf("zero time: got %q, want —", got) + } + cases := []struct { + ago time.Duration + want string + }{ + {50 * time.Hour, "2d"}, + {3 * time.Hour, "3h"}, + {20 * time.Minute, "20m"}, + {10 * time.Second, "<1m"}, + } + for _, c := range cases { + got := lastEvalCell(enrich.EnvStatus{LastEvaluation: time.Now().Add(-c.ago)}) + if got != c.want { + t.Errorf("%s ago: got %q, want %q", c.ago, got, c.want) + } + } +} + +func TestPrintEnriched_OrphanedFlag_DashedRowInCombinedTable(t *testing.T) { + details := []enrich.FlagDetail{{ + Key: "ghost-flag", + CallSites: 2, + StaleState: "orphanedReference", + }} + var b strings.Builder + Enriched(&b, details, "7d", []string{"production"}, 0) + out := b.String() + + // Orphaned flags join the same table (Lifecycle=orphanedReference) with dashed + // env cells rather than a separate note table. + for _, want := range []string{"Lifecycle", "orphanedReference", "ghost-flag", "Last Eval", "—"} { + if !strings.Contains(out, want) { + t.Errorf("combined table missing %q\n%s", want, out) + } + } +} + +func TestPrintEnriched_EnvAliasApplied(t *testing.T) { + envAliases = map[string]string{"managed-eu-production": "EU prod"} + defer func() { envAliases = map[string]string{} }() + + details := []enrich.FlagDetail{{ + Key: "a-flag", StaleState: "active", + Environments: map[string]enrich.EnvStatus{"managed-eu-production": simpleEnv(10, 0, "active")}, + }} + var b strings.Builder + Enriched(&b, details, "7d", []string{"managed-eu-production"}, 0) + out := b.String() + + if !strings.Contains(out, "EU prod") { + t.Errorf("expected aliased env name 'EU prod'\n%s", out) + } + if strings.Contains(out, "managed-eu-production") { + t.Errorf("raw env key should be replaced by its alias\n%s", out) + } +} + +func TestPrintEnriched_SortedByLifecycleThenKey(t *testing.T) { + env := map[string]enrich.EnvStatus{"production": simpleEnv(10, 0, "active")} + details := []enrich.FlagDetail{ + {Key: "zebra-active", StaleState: "active", Environments: env}, + {Key: "apple-active", StaleState: "active", Environments: env}, + {Key: "gamma-orphan", StaleState: "orphanedReference"}, + } + var b strings.Builder + Enriched(&b, details, "7d", []string{"production"}, 0) + out := b.String() + + // orphanedReference outranks active; within active, apple before zebra. + io, ia, iz := strings.Index(out, "gamma-orphan"), strings.Index(out, "apple-active"), strings.Index(out, "zebra-active") + if !(io >= 0 && ia >= 0 && iz >= 0 && io < ia && ia < iz) { + t.Errorf("expected order orphan exported name for bindings that are flag accessors. A binding +// qualifies if it's imported from a named wrapper module, OR its exported name +// is a known wrapper definition (auto-discovery — so no -wrapper-modules flag is +// needed once the definitions are in scope). +// Handles: import { foo, bar as baz } from '@gonfalon/dogfood-flags' +func importedWrappers(root *sitter.Node, src []byte, wrapperModules map[string]bool, exportToFlagKey map[string]string) map[string]string { + result := make(map[string]string) + + walkNodes(root, func(node *sitter.Node) { + if node.Type() != "import_statement" { + return + } + source := node.ChildByFieldName("source") + if source == nil { + return + } + moduleName := strings.Trim(source.Content(src), "\"'`") + fromWrapperModule := wrapperModules[moduleName] + + specs := make(map[string]string) + for i := 0; i < int(node.NamedChildCount()); i++ { + child := node.NamedChild(i) + if child.Type() == "import_clause" { + extractNamedImports(child, src, specs) + } + } + for local, exported := range specs { + if fromWrapperModule || exportToFlagKey[exported] != "" { + result[local] = exported + } + } + }) + return result +} + +func extractNamedImports(clause *sitter.Node, src []byte, result map[string]string) { + // import_specifier nodes don't nest, so descending into one after recording it + // is harmless — its children are the name/alias identifiers, not more specifiers. + walkNodes(clause, func(node *sitter.Node) { + if node.Type() != "import_specifier" { + return + } + name := node.ChildByFieldName("name") + alias := node.ChildByFieldName("alias") + if name != nil { + exportedName := name.Content(src) + localName := exportedName + if alias != nil { + localName = alias.Content(src) + } + result[localName] = exportedName + } + }) +} + +// findWrapperCalls finds all call expressions where the callee is one of the +// imported wrapper functions (zero-arg calls like enableCommandPalette()). +// If exportToFlagKey is provided, it maps export names to flag keys. +// Otherwise, the export name itself is used as the flag key identifier. +func findWrapperCalls(root *sitter.Node, src []byte, filePath string, lines []string, localToExport map[string]string, exportToFlagKey map[string]string) []FlagReference { + var refs []FlagReference + + walkNodes(root, func(node *sitter.Node) { + if node.Type() != "call_expression" { + return + } + fn := node.ChildByFieldName("function") + if fn == nil || fn.Type() != "identifier" { + return + } + localName := fn.Content(src) + exportedName, isWrapper := localToExport[localName] + if !isWrapper { + return + } + + flagKey := exportedName + // "wrapper-call-unresolved" means we recognized the call but have no + // definition mapping export name -> flag key, so the key is the raw export + // name (a guess), NOT a real LD flag key. Enrichment must not look these up + // as flags. Once ANY definition is in scope (exportToFlagKey non-empty), an + // unknown accessor is dropped rather than guessed. + kind := "wrapper-call-unresolved" + if len(exportToFlagKey) > 0 { + resolved, known := exportToFlagKey[exportedName] + if !known { + return + } + flagKey = resolved + kind = "wrapper-call" + } + line := int(node.StartPoint().Row) + 1 + col := int(node.StartPoint().Column) + 1 + refs = append(refs, FlagReference{ + FlagKey: flagKey, + FilePath: filePath, + Line: line, + Column: col, + Kind: kind, + Method: localName + "()", + SurroundingCode: getSurroundingLines(lines, line-1, 1), + WrapperName: exportedName, + }) + }) + return refs +} diff --git a/internal/flagusage/scanner/lang_go.go b/internal/flagusage/scanner/lang_go.go new file mode 100644 index 00000000..9695332d --- /dev/null +++ b/internal/flagusage/scanner/lang_go.go @@ -0,0 +1,370 @@ +package scanner + +import ( + sitter "github.com/smacker/go-tree-sitter" + "github.com/smacker/go-tree-sitter/golang" +) + +var LangGo = Language{ + Name: "go", + Extensions: []string{".go"}, + GetParser: golang.GetLanguage, + ScanFile: scanFileGo, + ResolveCalls: resolveCallsGo, +} + +// flagfn factory functions register a generated flag accessor. In gonfalon's +// generated raw_flags_auto.go, each flag produces a registration like +// +// rawFlagFnsDogfood.EnableFoo = flagfn.NewBool(EnableFooFlagKey, false, ...) +// +// mapping a struct method name (EnableFoo) to a flag-key const. This is the Go +// analog of TS's createFlagFunction wrapper factory: the method name is the +// accessor, resolved at call sites like `get.Flags(ctx).EnableFoo()`. The value +// is the variation type the factory produces. +var goWrapperFactories = map[string]string{ + "NewBool": "bool", + "NewInt": "int", + "NewFloat64": "float64", + "NewString": "string", + "NewJson": "json", + "NewJSON": "json", +} + +// Go SDK variation methods on *LDClient. +// Key is always the first string arg for non-Ctx methods, +// and the second arg (after context.Context) for *Ctx methods. +var goVariationMethods = map[string]VariationMethod{ + // Bool + "BoolVariation": {FlagKeyArg: 0, DefaultArg: 2}, + "BoolVariationCtx": {FlagKeyArg: 1, DefaultArg: 3}, + "BoolVariationDetail": {FlagKeyArg: 0, DefaultArg: 2}, + "BoolVariationDetailCtx": {FlagKeyArg: 1, DefaultArg: 3}, + // Int + "IntVariation": {FlagKeyArg: 0, DefaultArg: 2}, + "IntVariationCtx": {FlagKeyArg: 1, DefaultArg: 3}, + "IntVariationDetail": {FlagKeyArg: 0, DefaultArg: 2}, + "IntVariationDetailCtx": {FlagKeyArg: 1, DefaultArg: 3}, + // Float64 + "Float64Variation": {FlagKeyArg: 0, DefaultArg: 2}, + "Float64VariationCtx": {FlagKeyArg: 1, DefaultArg: 3}, + "Float64VariationDetail": {FlagKeyArg: 0, DefaultArg: 2}, + "Float64VariationDetailCtx": {FlagKeyArg: 1, DefaultArg: 3}, + // String + "StringVariation": {FlagKeyArg: 0, DefaultArg: 2}, + "StringVariationCtx": {FlagKeyArg: 1, DefaultArg: 3}, + "StringVariationDetail": {FlagKeyArg: 0, DefaultArg: 2}, + "StringVariationDetailCtx": {FlagKeyArg: 1, DefaultArg: 3}, + // JSON + "JSONVariation": {FlagKeyArg: 0, DefaultArg: 2}, + "JSONVariationCtx": {FlagKeyArg: 1, DefaultArg: 3}, + "JSONVariationDetail": {FlagKeyArg: 0, DefaultArg: 2}, + "JSONVariationDetailCtx": {FlagKeyArg: 1, DefaultArg: 3}, + // Migration + "MigrationVariation": {FlagKeyArg: 0, DefaultArg: 2}, + "MigrationVariationCtx": {FlagKeyArg: 1, DefaultArg: 3}, +} + +// Dogfood wrapper patterns — foundation's dogfood package wraps the SDK. +// dogfood.BoolVariation(key, user, default) — key at arg 0 +// dogfood.BoolVariation2(ctx, key, ldCtx, default) — key at arg 1 +var goDogfoodMethods = map[string]VariationMethod{ + "BoolVariation2": {FlagKeyArg: 1, DefaultArg: 3}, + "IntVariation2": {FlagKeyArg: 1, DefaultArg: 3}, + "Float64Variation2": {FlagKeyArg: 1, DefaultArg: 3}, + "StringVariation2": {FlagKeyArg: 1, DefaultArg: 3}, + "JsonVariation2": {FlagKeyArg: 1, DefaultArg: 3}, + "JSONVariation2": {FlagKeyArg: 1, DefaultArg: 3}, +} + +func scanFileGo(root *sitter.Node, src []byte, filePath string) []FlagReference { + var refs []FlagReference + lines := splitLines(src) + + // Collect file-local string constants first so flag keys referenced through a + // const (e.g. flagfn.NewBool(EnableFooFlagKey, ...) or + // client.BoolVariation(fooKey, ...)) resolve. Genuinely dynamic keys stay "". + consts := collectGoConsts(root, src) + + walkNodes(root, func(node *sitter.Node) { + if node.Type() == "call_expression" { + if found := checkCallExpressionGo(node, src, filePath, lines, consts); found != nil { + refs = append(refs, *found) + } + } + }) + return refs +} + +func checkCallExpressionGo(node *sitter.Node, src []byte, filePath string, lines []string, consts map[string]string) *FlagReference { + fn := node.ChildByFieldName("function") + if fn == nil { + return nil + } + args := node.ChildByFieldName("arguments") + if args == nil { + return nil + } + + fnName, receiver := extractFunctionNameGo(fn, src) + if fnName == "" { + return nil + } + + if method, ok := goVariationMethods[fnName]; ok && receiver != "" { + return extractGoVariationCall(node, args, src, filePath, lines, fnName, "variation", method, consts) + } + + if method, ok := goDogfoodMethods[fnName]; ok && receiver != "" { + return extractGoVariationCall(node, args, src, filePath, lines, fnName, "variation", method, consts) + } + + if varType, ok := goWrapperFactories[fnName]; ok && receiver != "" { + return extractGoWrapperDefinition(node, args, src, filePath, lines, fnName, varType, consts) + } + + return nil +} + +// Go uses selector_expression for method calls: receiver.Method(...) +func extractFunctionNameGo(fn *sitter.Node, src []byte) (name string, receiver string) { + if fn.Type() == "selector_expression" { + operand := fn.ChildByFieldName("operand") + field := fn.ChildByFieldName("field") + if field != nil { + fieldName := field.Content(src) + receiverName := "" + if operand != nil { + receiverName = operand.Content(src) + } + return fieldName, receiverName + } + } + if fn.Type() == "identifier" { + return fn.Content(src), "" + } + return "", "" +} + +func extractGoVariationCall(callNode, args *sitter.Node, src []byte, filePath string, lines []string, method, kind string, vm VariationMethod, consts map[string]string) *FlagReference { + flagKeyNode := nthNamedChild(args, vm.FlagKeyArg) + if flagKeyNode == nil { + return nil + } + + flagKey := resolveGoFlagKeyNode(flagKeyNode, src, consts) + if flagKey == "" { + return nil + } + + line := int(callNode.StartPoint().Row) + 1 + col := int(callNode.StartPoint().Column) + 1 + + defaultVal := "" + if vm.DefaultArg >= 0 { + defaultNode := nthNamedChild(args, vm.DefaultArg) + if defaultNode != nil { + defaultVal = defaultNode.Content(src) + } + } + + return &FlagReference{ + FlagKey: flagKey, + FilePath: filePath, + Line: line, + Column: col, + Kind: kind, + Method: method, + DefaultValue: truncate(defaultVal, 80), + SurroundingCode: getSurroundingLines(lines, line-1, 1), + } +} + +// extractGoWrapperDefinition turns a flagfn.New* registration into a +// wrapper-definition: the accessor (method) name comes from the assignment +// target (`rawFlagFnsDogfood.EnableFoo = ...`), the flag key from the first +// argument resolved through file-local consts. +func extractGoWrapperDefinition(callNode, args *sitter.Node, src []byte, filePath string, lines []string, method, varType string, consts map[string]string) *FlagReference { + firstArg := nthNamedChild(args, 0) + if firstArg == nil { + return nil + } + + flagKey := resolveGoFlagKeyNode(firstArg, src, consts) + if flagKey == "" { + return nil + } + + exportName := goAssignmentTargetName(callNode, src) + if exportName == "" { + return nil + } + + line := int(callNode.StartPoint().Row) + 1 + col := int(callNode.StartPoint().Column) + 1 + + defaultVal := "" + if secondArg := nthNamedChild(args, 1); secondArg != nil { + defaultVal = secondArg.Content(src) + } + + return &FlagReference{ + FlagKey: flagKey, + FilePath: filePath, + Line: line, + Column: col, + Kind: "wrapper-definition", + Method: method, + DefaultValue: truncate(defaultVal, 80), + SurroundingCode: getSurroundingLines(lines, line-1, 0), + WrapperName: exportName, + VariationType: varType, + } +} + +// goAssignmentTargetName walks up from a factory call to the assignment that +// stores it and returns the accessor name — the field of a selector target +// (`x.EnableFoo = ...` -> "EnableFoo") or a bare identifier target. +func goAssignmentTargetName(node *sitter.Node, src []byte) string { + current := node + for current != nil { + if current.Type() == "assignment_statement" || current.Type() == "short_var_declaration" { + left := current.ChildByFieldName("left") + if left == nil { + return "" + } + target := left.NamedChild(0) + if target == nil { + return "" + } + switch target.Type() { + case "selector_expression": + if field := target.ChildByFieldName("field"); field != nil { + return field.Content(src) + } + case "identifier": + return target.Content(src) + } + return "" + } + current = current.Parent() + } + return "" +} + +// resolveCallsGo is the Go pass-2: a generated flag accessor is invoked as a +// zero-arg method whose name is a known accessor — `get.Flags(ctx).EnableFoo()` +// or, via a stored receiver, `flags.EnableFoo()`. The accessor names are +// flag-specific generated identifiers, so matching the method name against the +// definition map (and requiring zero args) resolves them without data-flow. +func resolveCallsGo(rc ResolveContext) []FlagReference { + if len(rc.ExportToFlagKey) == 0 { + return nil + } + + var refs []FlagReference + walkNodes(rc.Root, func(node *sitter.Node) { + if node.Type() != "call_expression" { + return + } + fn := node.ChildByFieldName("function") + args := node.ChildByFieldName("arguments") + if fn == nil || fn.Type() != "selector_expression" || args == nil || args.NamedChildCount() != 0 { + return + } + field := fn.ChildByFieldName("field") + if field == nil { + return + } + name := field.Content(rc.Src) + flagKey, ok := rc.ExportToFlagKey[name] + if !ok { + return + } + line := int(node.StartPoint().Row) + 1 + col := int(node.StartPoint().Column) + 1 + refs = append(refs, FlagReference{ + FlagKey: flagKey, + FilePath: rc.FilePath, + Line: line, + Column: col, + Kind: "wrapper-call", + Method: name + "()", + SurroundingCode: getSurroundingLines(rc.Lines, line-1, 1), + WrapperName: name, + }) + }) + return refs +} + +// collectGoConsts maps const names bound to a string literal to their value, so +// flag keys referenced through a const resolve. Handles single-name const specs +// (`Foo = "foo"`), including grouped `const ( ... )` blocks. +func collectGoConsts(root *sitter.Node, src []byte) map[string]string { + consts := make(map[string]string) + + walkNodes(root, func(node *sitter.Node) { + if node.Type() != "const_spec" { + return + } + var name string + var lit string + for i := 0; i < int(node.NamedChildCount()); i++ { + child := node.NamedChild(i) + switch child.Type() { + case "identifier": + if name == "" { + name = child.Content(src) + } + case "expression_list": + if v := child.NamedChild(0); v != nil { + lit = extractGoStringLiteral(v, src) + } + } + } + if name != "" && lit != "" { + consts[name] = lit + } + }) + return consts +} + +// resolveGoFlagKeyNode resolves a flag-key argument to a string: a direct string +// literal, or an identifier/selector that maps to one via file-local consts. A +// genuinely dynamic expression resolves to "" and is intentionally skipped. +func resolveGoFlagKeyNode(node *sitter.Node, src []byte, consts map[string]string) string { + if lit := extractGoStringLiteral(node, src); lit != "" { + return lit + } + switch node.Type() { + case "identifier", "selector_expression": + return consts[node.Content(src)] + } + return "" +} + +func extractGoStringLiteral(node *sitter.Node, src []byte) string { + // Go interpreted string literal: "flag-key" + if node.Type() == "interpreted_string_literal" { + raw := node.Content(src) + if len(raw) >= 2 { + return raw[1 : len(raw)-1] + } + } + // Go raw string literal: `flag-key` + if node.Type() == "raw_string_literal" { + raw := node.Content(src) + if len(raw) >= 2 { + return raw[1 : len(raw)-1] + } + } + return "" +} + +func nthNamedChild(node *sitter.Node, n int) *sitter.Node { + count := int(node.NamedChildCount()) + if n < count { + return node.NamedChild(n) + } + return nil +} diff --git a/internal/flagusage/scanner/lang_ts.go b/internal/flagusage/scanner/lang_ts.go new file mode 100644 index 00000000..864168b0 --- /dev/null +++ b/internal/flagusage/scanner/lang_ts.go @@ -0,0 +1,242 @@ +package scanner + +import ( + sitter "github.com/smacker/go-tree-sitter" + "github.com/smacker/go-tree-sitter/typescript/typescript" +) + +var LangTypeScript = Language{ + Name: "typescript", + Extensions: []string{".ts", ".tsx", ".js", ".jsx"}, + GetParser: typescript.GetLanguage, + ScanFile: scanFileTS, + ResolveCalls: resolveCallsTS, +} + +// resolveCallsTS is the TS pass-2: a wrapper accessor is a bare function imported +// from a wrapper module (or whose export name is a known definition) and invoked +// as `enableFoo()`. See importedWrappers / findWrapperCalls. +func resolveCallsTS(rc ResolveContext) []FlagReference { + localToExport := importedWrappers(rc.Root, rc.Src, rc.WrapperModules, rc.ExportToFlagKey) + if len(localToExport) == 0 { + return nil + } + return findWrapperCalls(rc.Root, rc.Src, rc.FilePath, rc.Lines, localToExport, rc.ExportToFlagKey) +} + +// SDK method names that take a flag key as the first argument. +var tsVariationMethods = map[string]bool{ + "variation": true, + "boolVariation": true, + "stringVariation": true, + "intVariation": true, + "floatVariation": true, + "numberVariation": true, + "jsonVariation": true, + "doubleVariation": true, + "variationDetail": true, + "boolVariationDetail": true, + "stringVariationDetail": true, + "intVariationDetail": true, + "floatVariationDetail": true, + "numberVariationDetail": true, + "jsonVariationDetail": true, +} + +// React hook names that take a flag key as the first argument. +var tsVariationHooks = map[string]bool{ + "useVariation": true, + "useBoolVariation": true, + "useStringVariation": true, + "useNumberVariation": true, + "useJsonVariation": true, + "useVariationDetail": true, +} + +// Hooks that return a flags object where property accesses are flag keys. +var tsFlagsObjectHooks = map[string]bool{ + "useFlags": true, + "useLDFlags": true, +} + +// Known wrapper factory function names (like gonfalon's createFlagFunction). +var tsWrapperFactories = map[string]bool{ + "createFlagFunction": true, +} + +func scanFileTS(root *sitter.Node, src []byte, filePath string) []FlagReference { + var refs []FlagReference + lines := splitLines(src) + + // Collect file-local string constants and enum members first, so flag-key + // arguments referenced indirectly (const FOO = 'foo'; variation(FOO)) resolve. + consts := collectTSConsts(root, src) + + walkNodes(root, func(node *sitter.Node) { + if node.Type() != "call_expression" { + return + } + found := checkCallExpressionTS(node, src, filePath, lines, consts) + if found == nil { + return + } + refs = append(refs, *found) + // If this variation/hook call is the *entire body* of a named function, + // that function is a thin wrapper — register it so its remote call sites + // (which carry no literal key) resolve too. + if found.Kind == "variation" || found.Kind == "hook" { + if name := thinWrapperName(node, src); name != "" { + wd := *found + wd.Kind = "wrapper-definition" + wd.WrapperName = name + refs = append(refs, wd) + } + } + }) + return refs +} + +func checkCallExpressionTS(node *sitter.Node, src []byte, filePath string, lines []string, consts map[string]string) *FlagReference { + fn := node.ChildByFieldName("function") + if fn == nil { + return nil + } + args := node.ChildByFieldName("arguments") + if args == nil { + return nil + } + + fnName, receiver := extractFunctionNameTS(fn, src) + if fnName == "" { + return nil + } + + if tsVariationMethods[fnName] && receiver != "" { + return extractVariationCall(node, args, src, filePath, lines, fnName, "variation", 0, 1, consts) + } + + if tsVariationHooks[fnName] { + return extractVariationCall(node, args, src, filePath, lines, fnName, "hook", 0, 1, consts) + } + + if tsWrapperFactories[fnName] { + return extractWrapperDefinition(node, args, src, filePath, lines, fnName, consts) + } + + return nil +} + +// collectTSConsts builds a map of identifiers and enum members that are bound to +// a string literal, so flag keys referenced through a constant can be resolved. +// Handles `const FOO = 'foo'` (identifier -> literal) and +// `enum E { Member = 'foo' }` (E.Member -> literal). +func collectTSConsts(root *sitter.Node, src []byte) map[string]string { + consts := make(map[string]string) + + var walk func(node *sitter.Node, enumName string) + walk = func(node *sitter.Node, enumName string) { + if node == nil { + return + } + + switch node.Type() { + case "variable_declarator": + name := node.ChildByFieldName("name") + value := node.ChildByFieldName("value") + if name != nil && name.Type() == "identifier" && value != nil { + if lit := extractStringLiteral(value, src); lit != "" { + consts[name.Content(src)] = lit + } + } + case "enum_declaration": + en := "" + if nm := node.ChildByFieldName("name"); nm != nil { + en = nm.Content(src) + } + for i := 0; i < int(node.ChildCount()); i++ { + walk(node.Child(i), en) + } + return + case "enum_assignment", "property_signature", "pair": + // `Member = 'literal'` inside an enum body. + if enumName != "" { + nm := node.ChildByFieldName("name") + val := node.ChildByFieldName("value") + if nm != nil && val != nil { + if lit := extractStringLiteral(val, src); lit != "" { + consts[enumName+"."+nm.Content(src)] = lit + } + } + } + } + + for i := 0; i < int(node.ChildCount()); i++ { + walk(node.Child(i), enumName) + } + } + + walk(root, "") + return consts +} + +// thinWrapperName returns the bound name if `call` is the *entire body* of a +// thin wrapper function — an arrow whose expression body is the call, or a +// function/arrow whose only statement returns the call. Anything with other +// statements (e.g. a component that uses a flag and returns JSX) yields "" and +// is NOT treated as a wrapper. +func thinWrapperName(call *sitter.Node, src []byte) string { + parent := call.Parent() + if parent == nil { + return "" + } + switch parent.Type() { + case "arrow_function": + if parent.ChildByFieldName("body") != call { + return "" // call is an argument/sub-expression, not the whole body + } + return bindingName(parent, src) + case "return_statement": + block := parent.Parent() + if block == nil || block.Type() != "statement_block" || block.NamedChildCount() != 1 { + return "" // wrapper body must be a sole `return ` + } + return bindingName(block.Parent(), src) + } + return "" +} + +// bindingName returns the name a function/arrow node is bound to: +// +// export function NAME() {...} -> function_declaration name field +// export const NAME = () => ... -> enclosing variable_declarator +func bindingName(fnNode *sitter.Node, src []byte) string { + if fnNode == nil { + return "" + } + if fnNode.Type() == "function_declaration" { + if nm := fnNode.ChildByFieldName("name"); nm != nil { + return nm.Content(src) + } + return "" + } + return findExportName(fnNode, src) +} + +func extractFunctionNameTS(fn *sitter.Node, src []byte) (name string, receiver string) { + switch fn.Type() { + case "identifier": + return fn.Content(src), "" + case "member_expression": + obj := fn.ChildByFieldName("object") + prop := fn.ChildByFieldName("property") + if prop != nil { + propName := prop.Content(src) + objName := "" + if obj != nil { + objName = obj.Content(src) + } + return propName, objName + } + } + return "", "" +} diff --git a/internal/flagusage/scanner/patterns.go b/internal/flagusage/scanner/patterns.go new file mode 100644 index 00000000..40fcd828 --- /dev/null +++ b/internal/flagusage/scanner/patterns.go @@ -0,0 +1,43 @@ +package scanner + +import sitter "github.com/smacker/go-tree-sitter" + +// Language holds everything the scanner needs to handle a specific language: +// which tree-sitter grammar to use, which file extensions to collect, +// and how to extract flag references from AST nodes. +type Language struct { + Name string + Extensions []string + GetParser func() *sitter.Language + + // ScanFile is pass 1: it finds direct SDK calls and *accessor definitions* + // (kind "wrapper-definition", carrying a WrapperName) within a single file. + ScanFile func(root *sitter.Node, src []byte, filePath string) []FlagReference + + // ResolveCalls is pass 2 (optional): given the accessor-name→flag-key map + // built from every definition found across the scan, it finds call sites in + // one file that invoke those accessors and emits resolved "wrapper-call" + // references. Each language expresses "calling an accessor" differently — a + // bare imported function in TS (`enableFoo()`), a method on a flags struct in + // Go (`get.Flags(ctx).EnableFoo()`) — so the resolution lives per language + // while the definition map is shared. Nil means the language has no pass-2. + ResolveCalls func(rc ResolveContext) []FlagReference +} + +// ResolveContext carries everything pass 2 needs to resolve accessor call sites +// in a single file. The same struct serves every language; fields a given +// language doesn't use (e.g. WrapperModules for Go) are simply ignored. +type ResolveContext struct { + Root *sitter.Node + Src []byte + FilePath string + Lines []string + ExportToFlagKey map[string]string // accessor name -> flag key (shared across the scan) + WrapperModules map[string]bool // modules whose imports are always treated as accessors (TS) +} + +// VariationMethod describes an SDK method that evaluates a flag. +type VariationMethod struct { + FlagKeyArg int // 0-based argument position of the flag key + DefaultArg int // 0-based argument position of the default value (-1 if none) +} diff --git a/internal/flagusage/scanner/resolve.go b/internal/flagusage/scanner/resolve.go new file mode 100644 index 00000000..76c3f8cb --- /dev/null +++ b/internal/flagusage/scanner/resolve.go @@ -0,0 +1,230 @@ +package scanner + +import ( + "bytes" + "encoding/json" + "os" + "path/filepath" + "strings" + + sitter "github.com/smacker/go-tree-sitter" +) + +// wrapperFactoryPatterns are byte patterns that indicate a file might contain +// wrapper definitions. Used as a fast pre-filter before tree-sitter parsing. +var wrapperFactoryPatterns = [][]byte{ + []byte("createFlagFunction"), +} + +func init() { + for name := range tsWrapperFactories { + p := []byte(name) + found := false + for _, existing := range wrapperFactoryPatterns { + if string(existing) == string(p) { + found = true + break + } + } + if !found { + wrapperFactoryPatterns = append(wrapperFactoryPatterns, p) + } + } +} + +// dirContainsWrapperFactory does a fast byte scan of files in dir to check +// if any contain a wrapper factory pattern. Returns true on first match. +func dirContainsWrapperFactory(dir string) bool { + files, err := collectFiles(dir) + if err != nil { + return false + } + for _, path := range files { + src, err := os.ReadFile(path) + if err != nil { + continue + } + for _, pattern := range wrapperFactoryPatterns { + if bytes.Contains(src, pattern) { + return true + } + } + } + return false +} + +// resolveModuleSourceDir resolves a bare npm module specifier (e.g. +// "@gonfalon/dogfood-flags") to its source directory by walking up from +// startDir looking for node_modules/. If found, it reads the +// package's package.json to locate the source entry point and returns +// the directory containing it. Falls back to the package root if no +// entry point is found. +func resolveModuleSourceDir(module, startDir string) string { + pkgDir := findNodeModulesPackage(module, startDir) + if pkgDir == "" { + return "" + } + + entryDir := sourceEntryDir(pkgDir) + if entryDir != "" { + return entryDir + } + return pkgDir +} + +// findNodeModulesPackage walks up from dir looking for node_modules/. +func findNodeModulesPackage(module, dir string) string { + dir, _ = filepath.Abs(dir) + for { + candidate := filepath.Join(dir, "node_modules", module) + resolved, err := filepath.EvalSymlinks(candidate) + if err == nil { + info, err := os.Stat(resolved) + if err == nil && info.IsDir() { + return resolved + } + } + + parent := filepath.Dir(dir) + if parent == dir { + return "" + } + dir = parent + } +} + +// sourceEntryDir reads a package's package.json and returns the directory +// containing the main source entry point. Checks exports["."], then main. +func sourceEntryDir(pkgDir string) string { + data, err := os.ReadFile(filepath.Join(pkgDir, "package.json")) + if err != nil { + return "" + } + + var pkg struct { + Main string `json:"main"` + Exports json.RawMessage `json:"exports"` + } + if json.Unmarshal(data, &pkg) != nil { + return "" + } + + if entry := parseExportsDot(pkg.Exports); entry != "" { + dir := filepath.Dir(filepath.Join(pkgDir, entry)) + if info, err := os.Stat(dir); err == nil && info.IsDir() { + return dir + } + } + + if pkg.Main != "" { + dir := filepath.Dir(filepath.Join(pkgDir, pkg.Main)) + if info, err := os.Stat(dir); err == nil && info.IsDir() { + return dir + } + } + + return "" +} + +// parseExportsDot extracts the "." entry from a package.json exports field. +// Handles both `exports: { ".": "./src/index.ts" }` and `exports: { ".": { import: "...", default: "..." } }`. +func parseExportsDot(raw json.RawMessage) string { + if len(raw) == 0 { + return "" + } + + var obj map[string]json.RawMessage + if json.Unmarshal(raw, &obj) != nil { + return "" + } + + dot, ok := obj["."] + if !ok { + return "" + } + + var s string + if json.Unmarshal(dot, &s) == nil { + return s + } + + var nested map[string]string + if json.Unmarshal(dot, &nested) == nil { + for _, key := range []string{"import", "default", "require"} { + if v, ok := nested[key]; ok { + return v + } + } + } + + return "" +} + +// collectWrapperCandidateModules finds bare modules that have at least one +// import used as a zero-arg function call — the wrapper call-site pattern. +// This narrows the search from 90+ imported modules to typically 1-2. +func collectWrapperCandidateModules(parsed []scanParsedFile) map[string]bool { + candidates := make(map[string]bool) + for _, pf := range parsed { + if pf.lang.Name != "typescript" { + continue + } + root, src := pf.tree.RootNode(), pf.src + calledNames := collectCalledIdentifiers(root, src) + if len(calledNames) == 0 { + continue + } + forEachImport(root, src, func(mod, localName string) { + if calledNames[localName] { + candidates[mod] = true + } + }) + } + return candidates +} + +// forEachImport calls fn(module, localName) for every named import from a bare module. +func forEachImport(root *sitter.Node, src []byte, fn func(mod, localName string)) { + walkNodes(root, func(node *sitter.Node) { + if node.Type() != "import_statement" { + return + } + source := node.ChildByFieldName("source") + if source == nil { + return + } + mod := strings.Trim(source.Content(src), "\"'`") + if mod == "" || strings.HasPrefix(mod, ".") { + return + } + specs := make(map[string]string) + for i := 0; i < int(node.NamedChildCount()); i++ { + child := node.NamedChild(i) + if child.Type() == "import_clause" { + extractNamedImports(child, src, specs) + } + } + for local := range specs { + fn(mod, local) + } + }) +} + +// collectCalledIdentifiers returns identifiers used as zero-arg function calls. +func collectCalledIdentifiers(root *sitter.Node, src []byte) map[string]bool { + result := make(map[string]bool) + walkNodes(root, func(node *sitter.Node) { + if node.Type() != "call_expression" { + return + } + fn := node.ChildByFieldName("function") + if fn == nil || fn.Type() != "identifier" { + return + } + args := node.ChildByFieldName("arguments") + if args != nil && args.NamedChildCount() == 0 { + result[fn.Content(src)] = true + } + }) + return result +} diff --git a/internal/flagusage/scanner/scanner.go b/internal/flagusage/scanner/scanner.go new file mode 100644 index 00000000..03e8d47c --- /dev/null +++ b/internal/flagusage/scanner/scanner.go @@ -0,0 +1,432 @@ +package scanner + +import ( + "context" + "io/fs" + "os" + "path/filepath" + "strings" + + sitter "github.com/smacker/go-tree-sitter" +) + +var languages = []Language{ + LangTypeScript, + LangGo, +} + +// extToLang maps file extensions to their Language config. +var extToLang map[string]*Language + +func init() { + extToLang = make(map[string]*Language) + for i := range languages { + for _, ext := range languages[i].Extensions { + extToLang[ext] = &languages[i] + } + } +} + +type ScanOptions struct { + // Module paths that export wrapper functions (e.g. "@gonfalon/dogfood-flags"). + // If set, the scanner will resolve imports from these modules and track call sites. + WrapperModules []string + + // DefinitionsDir is an optional separate directory containing wrapper definition files + // (e.g. the dogfood-flags package). If set, definitions are loaded from here first + // to build the exportName→flagKey mapping, even if the main scan dir doesn't contain them. + DefinitionsDir string +} + +func Scan(rootDir string, opts ...ScanOptions) (*ScanResult, error) { + files, err := collectFiles(rootDir) + if err != nil { + return nil, err + } + + var opt ScanOptions + if len(opts) > 0 { + opt = opts[0] + } + + result := &ScanResult{ + Stats: ScanStats{ByKind: make(map[string]int)}, + } + + // Build per-language parsers lazily. + parsers := make(map[string]*sitter.Parser) + getParser := func(lang *Language) *sitter.Parser { + if p, ok := parsers[lang.Name]; ok { + return p + } + p := sitter.NewParser() + p.SetLanguage(lang.GetParser()) + parsers[lang.Name] = p + return p + } + + type parsedFile = scanParsedFile + + // Pre-load definitions from a separate directory if specified. + exportToFlagKey := make(map[string]string) + if opt.DefinitionsDir != "" { + defFiles, err := collectFiles(opt.DefinitionsDir) + if err != nil { + return nil, err + } + for _, path := range defFiles { + lang := langForFile(path) + if lang == nil { + continue + } + src, err := os.ReadFile(path) + if err != nil { + continue + } + tree, err := getParser(lang).ParseCtx(context.Background(), nil, src) + if err != nil { + continue + } + for _, ref := range lang.ScanFile(tree.RootNode(), src, path) { + if ref.Kind == "wrapper-definition" && ref.WrapperName != "" { + exportToFlagKey[ref.WrapperName] = ref.FlagKey + result.Wrappers = append(result.Wrappers, WrapperMapping{ + ExportName: ref.WrapperName, + FlagKey: ref.FlagKey, + DefaultValue: ref.DefaultValue, + FilePath: ref.FilePath, + Line: ref.Line, + }) + } + } + } + } + + var parsed []parsedFile + + // Pass 1: parse all files, collect direct SDK refs and wrapper definitions + for _, path := range files { + lang := langForFile(path) + if lang == nil { + continue + } + + src, err := os.ReadFile(path) + if err != nil { + continue + } + + tree, err := getParser(lang).ParseCtx(context.Background(), nil, src) + if err != nil { + continue + } + + refs := lang.ScanFile(tree.RootNode(), src, path) + result.References = append(result.References, refs...) + result.Stats.FilesScanned++ + + parsed = append(parsed, parsedFile{path: path, src: src, tree: tree, lang: lang}) + } + + // Also collect definitions from the main scan (for single-dir mode) + for _, ref := range result.References { + if ref.Kind == "wrapper-definition" && ref.WrapperName != "" { + if _, exists := exportToFlagKey[ref.WrapperName]; !exists { + exportToFlagKey[ref.WrapperName] = ref.FlagKey + result.Wrappers = append(result.Wrappers, WrapperMapping{ + ExportName: ref.WrapperName, + FlagKey: ref.FlagKey, + DefaultValue: ref.DefaultValue, + FilePath: ref.FilePath, + Line: ref.Line, + }) + } + } + } + + // Auto-discover wrapper definitions from imported modules. This ALWAYS runs and + // only *adds* mappings the explicit sources didn't already provide (existing + // keys from -definitions / in-tree win via the !exists checks below). Running it + // unconditionally means an explicit -definitions pointing at the wrong directory + // can no longer SUPPRESS resolution — at worst it contributes nothing and + // node_modules discovery still resolves the wrappers. + { + modules := collectWrapperCandidateModules(parsed) + for mod := range modules { + srcDir := resolveModuleSourceDir(mod, rootDir) + if srcDir == "" { + continue + } + if !dirContainsWrapperFactory(srcDir) { + continue + } + defFiles, err := collectFiles(srcDir) + if err != nil { + continue + } + for _, path := range defFiles { + lang := langForFile(path) + if lang == nil { + continue + } + src, err := os.ReadFile(path) + if err != nil { + continue + } + tree, err := getParser(lang).ParseCtx(context.Background(), nil, src) + if err != nil { + continue + } + for _, ref := range lang.ScanFile(tree.RootNode(), src, path) { + if ref.Kind == "wrapper-definition" && ref.WrapperName != "" { + if _, exists := exportToFlagKey[ref.WrapperName]; !exists { + exportToFlagKey[ref.WrapperName] = ref.FlagKey + result.Wrappers = append(result.Wrappers, WrapperMapping{ + ExportName: ref.WrapperName, + FlagKey: ref.FlagKey, + DefaultValue: ref.DefaultValue, + FilePath: ref.FilePath, + Line: ref.Line, + }) + } + } + } + } + } + } + + // Pass 2: resolve accessor call sites. Each language with a ResolveCalls hook + // gets the shared accessor-name→flag-key map (definitions discovered in-tree, + // loaded via -definitions, or auto-discovered) plus any explicitly named + // wrapper modules. Runs whenever we have something to map against — once + // definitions are in scope, call sites resolve with no -wrapper-modules flag. + if len(opt.WrapperModules) > 0 || len(exportToFlagKey) > 0 { + wrapperModuleSet := make(map[string]bool, len(opt.WrapperModules)) + for _, m := range opt.WrapperModules { + wrapperModuleSet[m] = true + } + + for _, pf := range parsed { + if pf.lang.ResolveCalls == nil { + continue + } + refs := pf.lang.ResolveCalls(ResolveContext{ + Root: pf.tree.RootNode(), + Src: pf.src, + FilePath: pf.path, + Lines: strings.Split(string(pf.src), "\n"), + ExportToFlagKey: exportToFlagKey, + WrapperModules: wrapperModuleSet, + }) + result.References = append(result.References, refs...) + } + } + + flagKeys := make(map[string]bool) + for _, ref := range result.References { + flagKeys[ref.FlagKey] = true + result.Stats.ByKind[ref.Kind]++ + } + result.Stats.ReferencesFound = len(result.References) + result.Stats.UniqueFlags = len(flagKeys) + + return result, nil +} + +func langForFile(path string) *Language { + ext := filepath.Ext(path) + return extToLang[ext] +} + +func collectFiles(root string) ([]string, error) { + var files []string + skipDirs := map[string]bool{ + "node_modules": true, "dist": true, "build": true, + ".git": true, ".next": true, "coverage": true, + "__generated__": true, ".cache": true, "vendor": true, + } + err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return nil + } + if d.IsDir() { + if skipDirs[d.Name()] || (strings.HasPrefix(d.Name(), ".") && d.Name() != ".") { + return filepath.SkipDir + } + return nil + } + if langForFile(path) != nil { + files = append(files, path) + } + return nil + }) + return files, err +} + +// splitLines is a shared helper for all languages. +func splitLines(src []byte) []string { + return strings.Split(string(src), "\n") +} + +// walkNodes performs a pre-order depth-first traversal of the tree rooted at +// node, invoking visit on every node. It is the shared traversal primitive for +// every language's pass-1/pass-2 scan, so detection code declares *what* to match +// (the visit body) rather than re-implementing the recursion. Visitors that must +// short-circuit descent or thread contextual state down the tree (e.g. +// collectTSConsts' enum name) keep their own recursion instead. +func walkNodes(node *sitter.Node, visit func(*sitter.Node)) { + if node == nil { + return + } + visit(node) + for i := 0; i < int(node.ChildCount()); i++ { + walkNodes(node.Child(i), visit) + } +} + +func extractVariationCall(callNode, args *sitter.Node, src []byte, filePath string, lines []string, method, kind string, flagKeyArg, defaultArg int, consts map[string]string) *FlagReference { + flagKeyNode := nthNamedChild(args, flagKeyArg) + if flagKeyNode == nil { + return nil + } + + flagKey := resolveFlagKeyNode(flagKeyNode, src, consts) + if flagKey == "" { + return nil + } + + line := int(callNode.StartPoint().Row) + 1 + col := int(callNode.StartPoint().Column) + 1 + + defaultVal := "" + if defaultArg >= 0 { + defaultNode := nthNamedChild(args, defaultArg) + if defaultNode != nil { + defaultVal = defaultNode.Content(src) + } + } + + return &FlagReference{ + FlagKey: flagKey, + FilePath: filePath, + Line: line, + Column: col, + Kind: kind, + Method: method, + DefaultValue: truncate(defaultVal, 80), + SurroundingCode: getSurroundingLines(lines, line-1, 1), + } +} + +func extractWrapperDefinition(callNode, args *sitter.Node, src []byte, filePath string, lines []string, method string, consts map[string]string) *FlagReference { + firstArg := nthNamedChild(args, 0) + if firstArg == nil { + return nil + } + + flagKey := resolveFlagKeyNode(firstArg, src, consts) + if flagKey == "" { + return nil + } + + line := int(callNode.StartPoint().Row) + 1 + col := int(callNode.StartPoint().Column) + 1 + + defaultVal := "" + secondArg := nthNamedChild(args, 1) + if secondArg != nil { + defaultVal = secondArg.Content(src) + } + + exportName := findExportName(callNode, src) + + return &FlagReference{ + FlagKey: flagKey, + FilePath: filePath, + Line: line, + Column: col, + Kind: "wrapper-definition", + Method: method, + DefaultValue: truncate(defaultVal, 80), + SurroundingCode: getSurroundingLines(lines, line-1, 0), + WrapperName: exportName, + } +} + +// findExportName walks up from a call expression to find the variable declaration name. +// Handles: export const foo = createFlagFunction(...) +// +// const foo = createFlagFunction(...) +func findExportName(node *sitter.Node, src []byte) string { + current := node + for current != nil { + if current.Type() == "variable_declarator" { + nameNode := current.ChildByFieldName("name") + if nameNode != nil { + return nameNode.Content(src) + } + } + if current.Type() == "lexical_declaration" || current.Type() == "export_statement" { + break + } + current = current.Parent() + } + return "" +} + +// resolveFlagKeyNode resolves a flag-key argument to a string. It accepts a +// direct string/template literal, or — following flag-key constants — an +// identifier or member expression (e.g. an enum member) that maps to a string +// literal in `consts`. A genuinely dynamic expression (function param, call, +// concatenation) resolves to "" and is intentionally skipped. +func resolveFlagKeyNode(node *sitter.Node, src []byte, consts map[string]string) string { + if lit := extractStringLiteral(node, src); lit != "" { + return lit + } + switch node.Type() { + case "identifier", "member_expression": + return consts[node.Content(src)] + } + return "" +} + +func extractStringLiteral(node *sitter.Node, src []byte) string { + if node.Type() == "string" { + raw := node.Content(src) + return strings.Trim(raw, "\"'`") + } + // template_string with no interpolation + if node.Type() == "template_string" { + for i := 0; i < int(node.NamedChildCount()); i++ { + if node.NamedChild(i).Type() == "template_substitution" { + return "" + } + } + raw := node.Content(src) + return strings.Trim(raw, "`") + } + return "" +} + +func getSurroundingLines(lines []string, centerIdx, context int) string { + start := centerIdx - context + if start < 0 { + start = 0 + } + end := centerIdx + context + 1 + if end > len(lines) { + end = len(lines) + } + selected := lines[start:end] + for i := range selected { + selected[i] = strings.TrimRight(selected[i], " \t\r") + } + return strings.Join(selected, "\n") +} + +func truncate(s string, max int) string { + if len(s) <= max { + return s + } + return s[:max] + "..." +} diff --git a/internal/flagusage/scanner/scanner_characterization_test.go b/internal/flagusage/scanner/scanner_characterization_test.go new file mode 100644 index 00000000..1a1aa9b5 --- /dev/null +++ b/internal/flagusage/scanner/scanner_characterization_test.go @@ -0,0 +1,199 @@ +package scanner + +import ( + "fmt" + "reflect" + "sort" + "strings" + "testing" +) + +// This is a characterization ("golden") test: it locks in the exact set of +// references the scanner emits today across every detection kind for both +// languages, so the shared-traversal refactor can be proven behavior-preserving. +// It is intentionally broad rather than deep — the per-pattern edge cases live in +// scanner_test.go / scanner_go_test.go; this one guards the *aggregate* output of +// every walk closure at once. + +// refProjection is the stable, location-normalized shape we assert on. Column and +// SurroundingCode are omitted (cosmetic / whitespace-sensitive); everything that +// identifies a detection is kept. +type refProjection struct { + File string + Line int + Kind string + FlagKey string + Method string + DefaultValue string + WrapperName string + VariationType string +} + +func projectRefs(t *testing.T, dir string, refs []FlagReference) []refProjection { + t.Helper() + out := make([]refProjection, 0, len(refs)) + for _, r := range refs { + file := strings.TrimPrefix(r.FilePath, dir+"/") + out = append(out, refProjection{ + File: file, + Line: r.Line, + Kind: r.Kind, + FlagKey: r.FlagKey, + Method: r.Method, + DefaultValue: r.DefaultValue, + WrapperName: r.WrapperName, + VariationType: r.VariationType, + }) + } + sort.Slice(out, func(i, j int) bool { + a, b := out[i], out[j] + if a.File != b.File { + return a.File < b.File + } + if a.Line != b.Line { + return a.Line < b.Line + } + if a.Kind != b.Kind { + return a.Kind < b.Kind + } + return a.FlagKey < b.FlagKey + }) + return out +} + +func TestScannerCharacterization(t *testing.T) { + dir := t.TempDir() + + // --- TypeScript: direct calls, hook, const, enum, detail --- + writeFile(t, dir, "ts_app.ts", ` +import { useBoolVariation } from 'launchdarkly-react-client-sdk'; +const client = getClient(); +const a = client.boolVariation('ts-direct', false); +const b = useBoolVariation('ts-hook', true); +const K = 'ts-const-key'; +const c = client.variation(K, 0); +enum Flags { Banner = 'ts-enum-key' } +const d = client.stringVariation(Flags.Banner, ''); +const e = client.boolVariationDetail('ts-detail', false); +`) + + // --- TypeScript: wrapper factory defs + a thin wrapper --- + writeFile(t, dir, "ts_flags.ts", ` +import { createFlagFunction } from './createFlagFunction'; +export const enableFoo = createFlagFunction('ts-wrapper-foo', false); +export const barLimit = createFlagFunction('ts-wrapper-bar', 10); +export const showBanner = () => client.boolVariation('ts-thin', false); +`) + + // --- TypeScript: wrapper call sites (resolved + unresolved) --- + writeFile(t, dir, "ts_use.ts", ` +import { enableFoo, showBanner } from './flags'; +import { mysteryFlag } from '@acme/flags'; +function render() { + if (enableFoo()) return null; + if (showBanner()) return null; + return mysteryFlag(); +} +`) + + // --- Go: flagfn wrapper registrations --- + writeFile(t, dir, "go_flags.go", `package flags + +const EnableBarFlagKey = "go-wrapper-bar" + +func register(rawFlags *RawFlags) { + rawFlags.EnableBar = flagfn.NewBool(EnableBarFlagKey, false) + rawFlags.MaxItems = flagfn.NewInt("go-wrapper-max", 5) +} +`) + + // --- Go: direct calls, Ctx variant, dogfood, const, wrapper calls --- + writeFile(t, dir, "go_app.go", `package app + +const goKey = "go-const-key" + +func check(client *ld.LDClient, ctx context.Context, ldCtx ldcontext.Context) { + client.BoolVariation("go-direct", ldCtx, false) + client.BoolVariationCtx(ctx, "go-ctx", ldCtx, false) + dogfood.BoolVariation2(ctx, "go-dogfood", ldCtx, false) + client.IntVariation(goKey, ldCtx, 0) + _ = get.Flags(ctx).EnableBar() + _ = get.Flags(ctx).MaxItems() +} +`) + + result, err := Scan(dir, ScanOptions{WrapperModules: []string{"@acme/flags"}}) + if err != nil { + t.Fatal(err) + } + + got := projectRefs(t, dir, result.References) + + want := []refProjection{ + {File: "go_app.go", Line: 6, Kind: "variation", FlagKey: "go-direct", Method: "BoolVariation", DefaultValue: "false"}, + {File: "go_app.go", Line: 7, Kind: "variation", FlagKey: "go-ctx", Method: "BoolVariationCtx", DefaultValue: "false"}, + {File: "go_app.go", Line: 8, Kind: "variation", FlagKey: "go-dogfood", Method: "BoolVariation2", DefaultValue: "false"}, + {File: "go_app.go", Line: 9, Kind: "variation", FlagKey: "go-const-key", Method: "IntVariation", DefaultValue: "0"}, + {File: "go_app.go", Line: 10, Kind: "wrapper-call", FlagKey: "go-wrapper-bar", Method: "EnableBar()", WrapperName: "EnableBar"}, + {File: "go_app.go", Line: 11, Kind: "wrapper-call", FlagKey: "go-wrapper-max", Method: "MaxItems()", WrapperName: "MaxItems"}, + {File: "go_flags.go", Line: 6, Kind: "wrapper-definition", FlagKey: "go-wrapper-bar", Method: "NewBool", DefaultValue: "false", WrapperName: "EnableBar", VariationType: "bool"}, + {File: "go_flags.go", Line: 7, Kind: "wrapper-definition", FlagKey: "go-wrapper-max", Method: "NewInt", DefaultValue: "5", WrapperName: "MaxItems", VariationType: "int"}, + {File: "ts_app.ts", Line: 4, Kind: "variation", FlagKey: "ts-direct", Method: "boolVariation", DefaultValue: "false"}, + {File: "ts_app.ts", Line: 5, Kind: "hook", FlagKey: "ts-hook", Method: "useBoolVariation", DefaultValue: "true"}, + {File: "ts_app.ts", Line: 7, Kind: "variation", FlagKey: "ts-const-key", Method: "variation", DefaultValue: "0"}, + {File: "ts_app.ts", Line: 9, Kind: "variation", FlagKey: "ts-enum-key", Method: "stringVariation", DefaultValue: "''"}, + {File: "ts_app.ts", Line: 10, Kind: "variation", FlagKey: "ts-detail", Method: "boolVariationDetail", DefaultValue: "false"}, + {File: "ts_flags.ts", Line: 3, Kind: "wrapper-definition", FlagKey: "ts-wrapper-foo", Method: "createFlagFunction", DefaultValue: "false", WrapperName: "enableFoo"}, + {File: "ts_flags.ts", Line: 4, Kind: "wrapper-definition", FlagKey: "ts-wrapper-bar", Method: "createFlagFunction", DefaultValue: "10", WrapperName: "barLimit"}, + {File: "ts_flags.ts", Line: 5, Kind: "variation", FlagKey: "ts-thin", Method: "boolVariation", DefaultValue: "false"}, + {File: "ts_flags.ts", Line: 5, Kind: "wrapper-definition", FlagKey: "ts-thin", Method: "boolVariation", DefaultValue: "false", WrapperName: "showBanner"}, + {File: "ts_use.ts", Line: 5, Kind: "wrapper-call", FlagKey: "ts-wrapper-foo", Method: "enableFoo()", WrapperName: "enableFoo"}, + {File: "ts_use.ts", Line: 6, Kind: "wrapper-call", FlagKey: "ts-thin", Method: "showBanner()", WrapperName: "showBanner"}, + // ts_use.ts:7 `mysteryFlag()` is intentionally absent: once ANY definition is + // in scope the map is non-empty, so an unknown accessor is dropped rather than + // emitted as wrapper-call-unresolved. The isolated test below covers the + // emit-when-map-empty branch. + } + + if !reflect.DeepEqual(got, want) { + t.Errorf("scanner output changed.\n--- got (%d refs) ---\n%s\n--- want (%d refs) ---\n%s", + len(got), dumpProjections(got), len(want), dumpProjections(want)) + } +} + +// TestScannerCharacterization_UnresolvedWrapperCall locks the other branch of +// findWrapperCalls: when no definitions are in scope at all, a call to an +// accessor imported from a declared wrapper module is emitted as +// wrapper-call-unresolved (flag key = raw export name). +func TestScannerCharacterization_UnresolvedWrapperCall(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "u.ts", ` +import { mysteryFlag } from '@acme/flags'; +export function render() { return mysteryFlag(); } +`) + + result, err := Scan(dir, ScanOptions{WrapperModules: []string{"@acme/flags"}}) + if err != nil { + t.Fatal(err) + } + + got := projectRefs(t, dir, result.References) + want := []refProjection{ + {File: "u.ts", Line: 3, Kind: "wrapper-call-unresolved", FlagKey: "mysteryFlag", Method: "mysteryFlag()", WrapperName: "mysteryFlag"}, + } + if !reflect.DeepEqual(got, want) { + t.Errorf("scanner output changed.\n--- got (%d refs) ---\n%s\n--- want (%d refs) ---\n%s", + len(got), dumpProjections(got), len(want), dumpProjections(want)) + } +} + +// dumpProjections renders projections as copy-pasteable Go literals, so a +// legitimate behavior change (or the first run) is easy to re-baseline. +func dumpProjections(ps []refProjection) string { + var b strings.Builder + for _, p := range ps { + b.WriteString(fmt.Sprintf("\t\t{File: %q, Line: %d, Kind: %q, FlagKey: %q, Method: %q, DefaultValue: %q, WrapperName: %q, VariationType: %q},\n", + p.File, p.Line, p.Kind, p.FlagKey, p.Method, p.DefaultValue, p.WrapperName, p.VariationType)) + } + return b.String() +} diff --git a/internal/flagusage/scanner/scanner_go_test.go b/internal/flagusage/scanner/scanner_go_test.go new file mode 100644 index 00000000..a37f1457 --- /dev/null +++ b/internal/flagusage/scanner/scanner_go_test.go @@ -0,0 +1,336 @@ +package scanner + +import ( + "testing" +) + +func TestGo_DirectBoolVariation(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "main.go", `package main + +import ld "github.com/launchdarkly/go-server-sdk/v7" + +func check(client *ld.LDClient) { + val, _ := client.BoolVariation("my-flag", ldCtx, false) + _ = val +} +`) + + result, err := Scan(dir) + if err != nil { + t.Fatal(err) + } + + assertRefCount(t, result, "my-flag", "variation", 1) + if len(result.References) != 1 { + t.Fatalf("expected 1 ref, got %d", len(result.References)) + } + ref := result.References[0] + if ref.Method != "BoolVariation" { + t.Errorf("expected method BoolVariation, got %s", ref.Method) + } + if ref.DefaultValue != "false" { + t.Errorf("expected default false, got %s", ref.DefaultValue) + } +} + +func TestGo_CtxVariant(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "main.go", `package main + +func check(client *ld.LDClient) { + val, _ := client.BoolVariationCtx(ctx, "ctx-flag", ldCtx, true) + _ = val +} +`) + + result, err := Scan(dir) + if err != nil { + t.Fatal(err) + } + + assertRefCount(t, result, "ctx-flag", "variation", 1) + if result.References[0].DefaultValue != "true" { + t.Errorf("expected default true, got %s", result.References[0].DefaultValue) + } +} + +func TestGo_StringVariation(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "main.go", `package main + +func check(client *ld.LDClient) { + val, _ := client.StringVariation("string-flag", ldCtx, "default-val") + _ = val +} +`) + + result, err := Scan(dir) + if err != nil { + t.Fatal(err) + } + + assertRefCount(t, result, "string-flag", "variation", 1) +} + +func TestGo_AllVariationMethods(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "main.go", `package main + +func check(client *ld.LDClient) { + client.BoolVariation("f1", ldCtx, false) + client.IntVariation("f2", ldCtx, 0) + client.Float64Variation("f3", ldCtx, 0.0) + client.StringVariation("f4", ldCtx, "") + client.JSONVariation("f5", ldCtx, ldvalue.Null()) + client.BoolVariationDetail("f6", ldCtx, false) + client.StringVariationCtx(ctx, "f7", ldCtx, "") + client.IntVariationDetailCtx(ctx, "f8", ldCtx, 0) +} +`) + + result, err := Scan(dir) + if err != nil { + t.Fatal(err) + } + + for i := 1; i <= 8; i++ { + key := "f" + string(rune('0'+i)) + assertRefCount(t, result, key, "variation", 1) + } +} + +func TestGo_DogfoodWrapper(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "handler.go", `package handler + +import "github.com/launchdarkly/foundation/main/dogfood" + +func check() { + val, _ := dogfood.BoolVariation2(ctx, "dogfood-flag", ldCtx, false) + _ = val +} +`) + + result, err := Scan(dir) + if err != nil { + t.Fatal(err) + } + + assertRefCount(t, result, "dogfood-flag", "variation", 1) +} + +func TestGo_VariableKey_NotDetected(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "main.go", `package main + +func check(client *ld.LDClient) { + key := "dynamic-flag" + val, _ := client.BoolVariation(key, ldCtx, false) + _ = val +} +`) + + result, err := Scan(dir) + if err != nil { + t.Fatal(err) + } + + if len(result.References) != 0 { + t.Errorf("expected 0 refs for variable flag key, got %d", len(result.References)) + } +} + +func TestGo_CommentNotDetected(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "main.go", `package main + +func check(client *ld.LDClient) { + // client.BoolVariation("commented-flag", ldCtx, false) + /* client.StringVariation("block-commented", ldCtx, "") */ +} +`) + + result, err := Scan(dir) + if err != nil { + t.Fatal(err) + } + + if len(result.References) != 0 { + t.Errorf("expected 0 refs in comments, got %d", len(result.References)) + } +} + +func TestGo_RawStringLiteral(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "main.go", "package main\n\nfunc check(client *ld.LDClient) {\n\tclient.BoolVariation(`raw-flag`, ldCtx, false)\n}\n") + + result, err := Scan(dir) + if err != nil { + t.Fatal(err) + } + + assertRefCount(t, result, "raw-flag", "variation", 1) +} + +func TestGo_MixedWithTypeScript(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "main.go", `package main + +func check(client *ld.LDClient) { + client.BoolVariation("go-flag", ldCtx, false) +} +`) + writeFile(t, dir, "app.ts", ` +const val = client.variation('ts-flag', false); +`) + + result, err := Scan(dir) + if err != nil { + t.Fatal(err) + } + + assertRefCount(t, result, "go-flag", "variation", 1) + assertRefCount(t, result, "ts-flag", "variation", 1) + if result.Stats.FilesScanned != 2 { + t.Errorf("expected 2 files scanned, got %d", result.Stats.FilesScanned) + } +} + +// flagfnGenerated mirrors gonfalon's generated raw_flags_auto.go: a const block +// of `FlagKey = ""` plus flagfn.New* registrations that bind a struct +// method name to that const. Used by the generated-accessor tests below. +const flagfnGenerated = `package dogfood + +import "github.com/launchdarkly/foundation/dogfood/flagfn" + +const ( + EnableFooFlagKey = "enable-foo" + BarLimitFlagKey = "bar-limit" + BazModeFlagKey = "baz-mode" +) + +func init() { + rawFlagFnsNonDogfood.EnableFoo = flagfn.NewBool(EnableFooFlagKey, false, logDogfoodErrors) + rawFlagFnsDogfood.EnableFoo = flagfn.NewBool(EnableFooFlagKey, false, logDogfoodErrors) + rawFlagFnsDogfood.BarLimit = flagfn.NewInt(BarLimitFlagKey, 5, logDogfoodErrors) + rawFlagFnsDogfood.BazMode = flagfn.NewString(BazModeFlagKey, "control", logDogfoodErrors) +} +` + +// TestGo_FlagfnGeneratedAccessors covers the dominant gonfalon Go pattern: +// flagfn.New* registrations define method accessors that resolve at call sites +// via get.Flags(ctx).Method() (and a stored receiver flags.Method()). +func TestGo_FlagfnGeneratedAccessors(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "internal/dogfood/raw_flags_auto.go", flagfnGenerated) + writeFile(t, dir, "internal/web/handler.go", `package web + +func handle(ctx context.Context) { + if get.Flags(ctx).EnableFoo() { + } + limit := get.Flags(ctx).BarLimit() + _ = limit + flags := get.Flags(ctx) + if flags.EnableFoo() { + } + _ = flags.BazMode() +} +`) + + result, err := Scan(dir) + if err != nil { + t.Fatal(err) + } + + // Definitions are picked up from the in-tree generated file. + assertWrapper(t, result, "EnableFoo", "enable-foo", "false") + assertWrapper(t, result, "BarLimit", "bar-limit", "5") + assertWrapper(t, result, "BazMode", "baz-mode", `"control"`) + + // Call sites resolve to flag keys through the accessor method names. + assertRefCount(t, result, "enable-foo", "wrapper-call", 2) + assertRefCount(t, result, "bar-limit", "wrapper-call", 1) + assertRefCount(t, result, "baz-mode", "wrapper-call", 1) +} + +// TestGo_FlagfnAccessors_SeparateDefinitionsDir covers scanning a sub-package +// while the generated definitions live elsewhere (the -definitions flag). +func TestGo_FlagfnAccessors_SeparateDefinitionsDir(t *testing.T) { + defsDir := t.TempDir() + writeFile(t, defsDir, "raw_flags_auto.go", flagfnGenerated) + + scanDir := t.TempDir() + writeFile(t, scanDir, "handler.go", `package web + +func handle(ctx context.Context) { + if get.Flags(ctx).EnableFoo() { + } +} +`) + + result, err := Scan(scanDir, ScanOptions{DefinitionsDir: defsDir}) + if err != nil { + t.Fatal(err) + } + + assertRefCount(t, result, "enable-foo", "wrapper-call", 1) +} + +// TestGo_FlagfnAccessor_UnknownMethod_NotResolved guards against false positives: +// a zero-arg method call whose name isn't a registered accessor is ignored. +func TestGo_FlagfnAccessor_UnknownMethod_NotResolved(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "internal/dogfood/raw_flags_auto.go", flagfnGenerated) + writeFile(t, dir, "internal/web/handler.go", `package web + +func handle(ctx context.Context) { + _ = somethingElse.NotAFlag() + _ = get.Flags(ctx).EnableFoo() +} +`) + + result, err := Scan(dir) + if err != nil { + t.Fatal(err) + } + + assertRefCount(t, result, "enable-foo", "wrapper-call", 1) + if c := countKind(result, "wrapper-call"); c != 1 { + t.Errorf("expected exactly 1 wrapper-call, got %d", c) + } +} + +func countKind(result *ScanResult, kind string) int { + n := 0 + for _, ref := range result.References { + if ref.Kind == kind { + n++ + } + } + return n +} + +func TestGo_SkipsVendorDir(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "vendor/pkg/main.go", `package pkg + +func check(client *ld.LDClient) { + client.BoolVariation("vendored-flag", ldCtx, false) +} +`) + writeFile(t, dir, "src/main.go", `package main + +func check(client *ld.LDClient) { + client.BoolVariation("real-flag", ldCtx, false) +} +`) + + result, err := Scan(dir) + if err != nil { + t.Fatal(err) + } + + assertRefCount(t, result, "real-flag", "variation", 1) + assertRefCount(t, result, "vendored-flag", "variation", 0) +} diff --git a/internal/flagusage/scanner/scanner_test.go b/internal/flagusage/scanner/scanner_test.go new file mode 100644 index 00000000..29981ba7 --- /dev/null +++ b/internal/flagusage/scanner/scanner_test.go @@ -0,0 +1,780 @@ +package scanner + +import ( + "os" + "path/filepath" + "testing" +) + +func writeFile(t *testing.T, dir, name, content string) string { + t.Helper() + path := filepath.Join(dir, name) + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + return path +} + +func TestDirectVariationCall(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "app.ts", ` +import { LDClient } from '@launchdarkly/js-client-sdk'; +const client: LDClient = getClient(); +const val = client.variation('my-flag', false); +const detail = client.boolVariation('other-flag', true); +`) + + result, err := Scan(dir) + if err != nil { + t.Fatal(err) + } + + assertRefCount(t, result, "my-flag", "variation", 1) + assertRefCount(t, result, "other-flag", "variation", 1) +} + +func TestDirectVariationWithDynamicKey_NotDetected(t *testing.T) { + // Keys that aren't a static string literal (function param, runtime call) + // can't be resolved and must be skipped — only genuinely dynamic keys. + dir := t.TempDir() + writeFile(t, dir, "app.ts", ` +function check(flagKey: string) { + return client.variation(flagKey, false); +} +const dynamic = getName(); +client.variation(dynamic, false); +`) + + result, err := Scan(dir) + if err != nil { + t.Fatal(err) + } + + if len(result.References) != 0 { + t.Errorf("expected 0 refs for dynamic flag key, got %d", len(result.References)) + } +} + +func TestDirectVariationWithConstLiteral_Resolved(t *testing.T) { + // A flag key referenced through a string constant should resolve. + dir := t.TempDir() + writeFile(t, dir, "app.ts", ` +const MY_FLAG = 'my-flag'; +const val = client.variation(MY_FLAG, false); +`) + + result, err := Scan(dir) + if err != nil { + t.Fatal(err) + } + + assertRefCount(t, result, "my-flag", "variation", 1) +} + +func TestEnumFlagKey_Resolved(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "app.ts", ` +enum Flags { ShowBanner = 'show-banner' } +const val = client.boolVariation(Flags.ShowBanner, false); +`) + + result, err := Scan(dir) + if err != nil { + t.Fatal(err) + } + + assertRefCount(t, result, "show-banner", "variation", 1) +} + +func TestWrapperDefinition(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "flags.ts", ` +import { createFlagFunction } from './createFlagFunction'; +export const enableFoo = createFlagFunction('enable-foo', false); +export const barLimit = createFlagFunction('bar-limit', 10); +`) + + result, err := Scan(dir) + if err != nil { + t.Fatal(err) + } + + if len(result.Wrappers) != 2 { + t.Fatalf("expected 2 wrappers, got %d", len(result.Wrappers)) + } + assertWrapper(t, result, "enableFoo", "enable-foo", "false") + assertWrapper(t, result, "barLimit", "bar-limit", "10") +} + +func TestWrapperCallSite_WithDefinitions(t *testing.T) { + dir := t.TempDir() + + // Definitions in a "package" subdirectory + writeFile(t, dir, "pkg/flags.ts", ` +import { createFlagFunction } from './createFlagFunction'; +export const enableFoo = createFlagFunction('enable-foo', false); +export const getBarLimit = createFlagFunction('bar-limit', 10); +`) + + // Usage file imports from the package + writeFile(t, dir, "app.ts", ` +import { enableFoo, getBarLimit } from '@myapp/flags'; +if (enableFoo()) { + console.log('foo enabled, limit:', getBarLimit()); +} +`) + + result, err := Scan(dir, ScanOptions{ + WrapperModules: []string{"@myapp/flags"}, + }) + if err != nil { + t.Fatal(err) + } + + assertRefCount(t, result, "enable-foo", "wrapper-call", 1) + assertRefCount(t, result, "bar-limit", "wrapper-call", 1) +} + +func TestWrapperCallSite_WithoutDefinitions(t *testing.T) { + // When scanning a directory that doesn't contain the definitions file, + // wrapper calls are marked unresolved (export name is only a guess, not a real flag key). + dir := t.TempDir() + writeFile(t, dir, "app.ts", ` +import { enableFoo } from '@myapp/flags'; +if (enableFoo()) { + console.log('foo enabled'); +} +`) + + result, err := Scan(dir, ScanOptions{ + WrapperModules: []string{"@myapp/flags"}, + }) + if err != nil { + t.Fatal(err) + } + + // Without definitions, flag key = export name + assertRefCount(t, result, "enableFoo", "wrapper-call-unresolved", 1) +} + +func TestWrapperCallSite_WithSeparateDefinitionsDir(t *testing.T) { + // Definitions and usage are in separate directories. + // We scan both by passing a DefinitionsDir. + dir := t.TempDir() + + writeFile(t, dir, "packages/flags/flags.ts", ` +import { createFlagFunction } from './createFlagFunction'; +export const enableFoo = createFlagFunction('enable-foo', false); +`) + + writeFile(t, dir, "app/component.ts", ` +import { enableFoo } from '@myapp/flags'; +if (enableFoo()) { + doStuff(); +} +`) + + result, err := Scan(filepath.Join(dir, "app"), ScanOptions{ + WrapperModules: []string{"@myapp/flags"}, + DefinitionsDir: filepath.Join(dir, "packages/flags"), + }) + if err != nil { + t.Fatal(err) + } + + // Should resolve enableFoo -> enable-foo via the definitions + assertRefCount(t, result, "enable-foo", "wrapper-call", 1) +} + +func TestWrapperCallSite_AutoResolvedViaDefinitionsDir(t *testing.T) { + // No -wrapper-modules: resolution happens purely by matching the imported + // name against definitions loaded from -definitions (export-name discovery). + dir := t.TempDir() + + writeFile(t, dir, "packages/flags/flags.ts", ` +import { createFlagFunction } from './createFlagFunction'; +export const enableFoo = createFlagFunction('enable-foo', false); +`) + writeFile(t, dir, "app/component.ts", ` +import { enableFoo } from '@myapp/flags'; +if (enableFoo()) { doStuff(); } +`) + + result, err := Scan(filepath.Join(dir, "app"), ScanOptions{ + DefinitionsDir: filepath.Join(dir, "packages/flags"), + // deliberately NO WrapperModules + }) + if err != nil { + t.Fatal(err) + } + + assertRefCount(t, result, "enable-foo", "wrapper-call", 1) +} + +func TestWrapperCallSite_AutoResolvedInTree(t *testing.T) { + // Whole-tree scan with NO options at all: definitions discovered in-tree + // auto-resolve the call sites by export name. + dir := t.TempDir() + writeFile(t, dir, "flags.ts", ` +import { createFlagFunction } from './createFlagFunction'; +export const enableFoo = createFlagFunction('enable-foo', false); +`) + writeFile(t, dir, "app.ts", ` +import { enableFoo } from '@myapp/flags'; +if (enableFoo()) { doStuff(); } +`) + + result, err := Scan(dir) + if err != nil { + t.Fatal(err) + } + + assertRefCount(t, result, "enable-foo", "wrapper-call", 1) +} + +func TestWrapperCallSite_AutoDiscoverViaNodeModules(t *testing.T) { + // Zero-config: scanner resolves import module via node_modules symlink, + // scans the package's source dir for definitions, and resolves call sites. + dir := t.TempDir() + + // Simulate a monorepo: packages/flags has definitions, app imports them, + // and node_modules/@myapp/flags symlinks to packages/flags. + writeFile(t, dir, "packages/flags/package.json", `{ + "name": "@myapp/flags", + "exports": { ".": "./src/flags.ts" } +}`) + writeFile(t, dir, "packages/flags/src/flags.ts", ` +import { createFlagFunction } from './createFlagFunction'; +export const enableFoo = createFlagFunction('enable-foo', false); +export const barLimit = createFlagFunction('bar-limit', 10); +`) + + // Create symlink: app/node_modules/@myapp/flags -> ../../packages/flags + nmDir := filepath.Join(dir, "app", "node_modules", "@myapp") + if err := os.MkdirAll(nmDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.Symlink(filepath.Join(dir, "packages", "flags"), filepath.Join(nmDir, "flags")); err != nil { + t.Fatal(err) + } + + writeFile(t, dir, "app/src/component.ts", ` +import { enableFoo, barLimit } from '@myapp/flags'; +if (enableFoo()) { + console.log(barLimit()); +} +`) + + result, err := Scan(filepath.Join(dir, "app", "src")) + if err != nil { + t.Fatal(err) + } + + assertRefCount(t, result, "enable-foo", "wrapper-call", 1) + assertRefCount(t, result, "bar-limit", "wrapper-call", 1) +} + +// TestWrapperCallSite_WrongDefinitionsDir_DoesNotSuppressAutoDiscovery is a +// regression guard: passing a -definitions dir that contains no matching wrapper +// definitions (a common agent mistake — e.g. pointing at the Go flagfn dir for a +// TS scan) must NOT suppress node_modules auto-discovery. The wrong dir should +// contribute nothing and the wrappers should still resolve. +func TestWrapperCallSite_WrongDefinitionsDir_DoesNotSuppressAutoDiscovery(t *testing.T) { + dir := t.TempDir() + + writeFile(t, dir, "packages/flags/package.json", `{ + "name": "@myapp/flags", + "exports": { ".": "./src/flags.ts" } +}`) + writeFile(t, dir, "packages/flags/src/flags.ts", ` +import { createFlagFunction } from './createFlagFunction'; +export const enableFoo = createFlagFunction('enable-foo', false); +`) + nmDir := filepath.Join(dir, "app", "node_modules", "@myapp") + if err := os.MkdirAll(nmDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.Symlink(filepath.Join(dir, "packages", "flags"), filepath.Join(nmDir, "flags")); err != nil { + t.Fatal(err) + } + writeFile(t, dir, "app/src/component.ts", ` +import { enableFoo } from '@myapp/flags'; +if (enableFoo()) { doStuff(); } +`) + + // A non-empty definitions dir with NOTHING relevant in it (the agent's mistake). + writeFile(t, dir, "wrong-defs/unrelated.go", `package x + +func main() {} +`) + + result, err := Scan(filepath.Join(dir, "app", "src"), ScanOptions{ + DefinitionsDir: filepath.Join(dir, "wrong-defs"), + }) + if err != nil { + t.Fatal(err) + } + + // Resolves via node_modules auto-discovery despite the wrong -definitions. + assertRefCount(t, result, "enable-foo", "wrapper-call", 1) +} + +func TestWrapperCallSite_BarrelFileDefinitionsDir(t *testing.T) { + // Regression: gonfalon shape — barrel file in a separate definitions dir + // with many exports, multiple consuming files across subdirs, no -wrapper-modules. + dir := t.TempDir() + + writeFile(t, dir, "packages/flags/flags.ts", ` +import { createFlagFunction } from './createFlagFunction'; +export const enableAlpha = createFlagFunction('enable-alpha', false); +export const betaLimit = createFlagFunction('beta-limit', 10); +export const gammaMode = createFlagFunction<'on' | 'off'>('gamma-mode', 'off'); +`) + + writeFile(t, dir, "app/src/components/Alpha.tsx", ` +import { enableAlpha } from '@myapp/flags'; +export function Alpha() { + const enabled = enableAlpha(); + return enabled ?
Alpha
: null; +} +`) + writeFile(t, dir, "app/src/hooks/useBeta.ts", ` +import { betaLimit } from '@myapp/flags'; +export function useBeta() { + return betaLimit(); +} +`) + writeFile(t, dir, "app/src/pages/Gamma.tsx", ` +import { gammaMode, enableAlpha } from '@myapp/flags'; +export function Gamma() { + const mode = gammaMode(); + const alpha = enableAlpha(); + return
{mode}{alpha}
; +} +`) + + result, err := Scan(filepath.Join(dir, "app"), ScanOptions{ + DefinitionsDir: filepath.Join(dir, "packages/flags"), + }) + if err != nil { + t.Fatal(err) + } + + assertRefCount(t, result, "enable-alpha", "wrapper-call", 2) + assertRefCount(t, result, "beta-limit", "wrapper-call", 1) + assertRefCount(t, result, "gamma-mode", "wrapper-call", 1) + + if result.Stats.UniqueFlags != 3 { + t.Errorf("expected 3 unique flags, got %d", result.Stats.UniqueFlags) + } +} + +func TestAliasedImport(t *testing.T) { + dir := t.TempDir() + + writeFile(t, dir, "pkg/flags.ts", ` +import { createFlagFunction } from './createFlagFunction'; +export const enableFoo = createFlagFunction('enable-foo', false); +`) + + writeFile(t, dir, "app.ts", ` +import { enableFoo as isFooEnabled } from '@myapp/flags'; +const x = isFooEnabled(); +`) + + result, err := Scan(dir, ScanOptions{ + WrapperModules: []string{"@myapp/flags"}, + }) + if err != nil { + t.Fatal(err) + } + + assertRefCount(t, result, "enable-foo", "wrapper-call", 1) +} + +func TestFunctionWrapperDefinition_ArrowBody(t *testing.T) { + // A thin function wrapper (not the createFlagFunction factory) should be + // detected generically, and its remote call sites resolved. + dir := t.TempDir() + writeFile(t, dir, "flags.ts", ` +import { client } from './client'; +export const isFooEnabled = () => client.boolVariation('foo-flag', false); +`) + writeFile(t, dir, "app.ts", ` +import { isFooEnabled } from '@myapp/flags'; +if (isFooEnabled()) { go(); } +`) + + result, err := Scan(dir) + if err != nil { + t.Fatal(err) + } + + assertWrapper(t, result, "isFooEnabled", "foo-flag", "false") + assertRefCount(t, result, "foo-flag", "wrapper-call", 1) +} + +func TestFunctionWrapperDefinition_ReturnBody(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "flags.ts", ` +export function isFooEnabled() { + return client.boolVariation('foo-flag', false); +} +`) + writeFile(t, dir, "app.ts", ` +import { isFooEnabled } from '@myapp/flags'; +isFooEnabled(); +`) + + result, err := Scan(dir) + if err != nil { + t.Fatal(err) + } + + assertRefCount(t, result, "foo-flag", "wrapper-call", 1) +} + +func TestComponentUsingFlag_NotAWrapper(t *testing.T) { + // A component that consumes a flag internally and returns something else + // must NOT be registered as a flag wrapper. + dir := t.TempDir() + writeFile(t, dir, "flags.ts", ` +export function Banner() { + const show = client.boolVariation('show-banner', false); + return show; +} +`) + writeFile(t, dir, "app.ts", ` +import { Banner } from '@myapp/flags'; +Banner(); +`) + + result, err := Scan(dir) + if err != nil { + t.Fatal(err) + } + + // show-banner is still found as a direct call, but Banner() is not a wrapper. + assertRefCount(t, result, "show-banner", "variation", 1) + assertRefCount(t, result, "show-banner", "wrapper-call", 0) + assertRefCount(t, result, "show-banner", "wrapper-call-unresolved", 0) +} + +func TestReactHook(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "component.tsx", ` +import { useBoolVariation } from '@launchdarkly/react-client-sdk'; +function MyComponent() { + const show = useBoolVariation('show-banner', false); + return show ? : null; +} +`) + + result, err := Scan(dir) + if err != nil { + t.Fatal(err) + } + + assertRefCount(t, result, "show-banner", "hook", 1) +} + +func TestCommentNotDetected(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "app.ts", ` +// client.variation('commented-out-flag', false); +/* client.boolVariation('block-commented', true); */ +`) + + result, err := Scan(dir) + if err != nil { + t.Fatal(err) + } + + if len(result.References) != 0 { + t.Errorf("expected 0 refs in comments, got %d", len(result.References)) + } +} + +func TestMultipleCallSitesSameFlag(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "a.ts", ` +import { enableFoo } from '@myapp/flags'; +enableFoo(); +`) + writeFile(t, dir, "b.ts", ` +import { enableFoo } from '@myapp/flags'; +if (enableFoo()) { doStuff(); } +`) + + result, err := Scan(dir, ScanOptions{ + WrapperModules: []string{"@myapp/flags"}, + }) + if err != nil { + t.Fatal(err) + } + + assertRefCount(t, result, "enableFoo", "wrapper-call-unresolved", 2) +} + +func TestAllVariationMethods(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "app.ts", ` +const a = client.variation('f1', false); +const b = client.boolVariation('f2', false); +const c = client.stringVariation('f3', 'default'); +const d = client.intVariation('f4', 0); +const e = client.numberVariation('f5', 0); +const f = client.jsonVariation('f6', {}); +const g = client.variationDetail('f7', false); +const h = client.boolVariationDetail('f8', true); +`) + + result, err := Scan(dir) + if err != nil { + t.Fatal(err) + } + + for i := 1; i <= 8; i++ { + key := "f" + string(rune('0'+i)) + assertRefCount(t, result, key, "variation", 1) + } +} + +func TestAllReactHooks(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "component.tsx", ` +const a = useVariation('h1', false); +const b = useBoolVariation('h2', false); +const c = useStringVariation('h3', ''); +const d = useNumberVariation('h4', 0); +const e = useJsonVariation('h5', {}); +`) + + result, err := Scan(dir) + if err != nil { + t.Fatal(err) + } + + for i := 1; i <= 5; i++ { + key := "h" + string(rune('0'+i)) + assertRefCount(t, result, key, "hook", 1) + } +} + +func TestTemplateLiteralFlagKey(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "app.ts", "const v = client.variation(`template-key`, false);\n") + + result, err := Scan(dir) + if err != nil { + t.Fatal(err) + } + + assertRefCount(t, result, "template-key", "variation", 1) +} + +func TestTemplateLiteralWithInterpolation_NotDetected(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "app.ts", "const v = client.variation(`${prefix}-flag`, false);\n") + + result, err := Scan(dir) + if err != nil { + t.Fatal(err) + } + + if len(result.References) != 0 { + t.Errorf("expected 0 refs for interpolated template, got %d", len(result.References)) + } +} + +func TestWrapperNotCalledAsFunction_NotDetected(t *testing.T) { + // Passing a wrapper as a callback without calling it should not count + dir := t.TempDir() + writeFile(t, dir, "pkg/flags.ts", ` +import { createFlagFunction } from './createFlagFunction'; +export const enableFoo = createFlagFunction('enable-foo', false); +`) + writeFile(t, dir, "app.ts", ` +import { enableFoo } from '@myapp/flags'; +const ref = enableFoo; +someArray.filter(enableFoo); +`) + + result, err := Scan(dir, ScanOptions{ + WrapperModules: []string{"@myapp/flags"}, + }) + if err != nil { + t.Fatal(err) + } + + // Should not have wrapper-call refs — enableFoo is referenced but not called + assertRefCount(t, result, "enable-foo", "wrapper-call", 0) +} + +func TestMultipleWrapperModules(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "pkg-a/flags.ts", ` +import { createFlagFunction } from './createFlagFunction'; +export const flagA = createFlagFunction('flag-a', false); +`) + writeFile(t, dir, "pkg-b/flags.ts", ` +import { createFlagFunction } from './createFlagFunction'; +export const flagB = createFlagFunction('flag-b', false); +`) + writeFile(t, dir, "app.ts", ` +import { flagA } from '@pkg/a'; +import { flagB } from '@pkg/b'; +flagA(); +flagB(); +`) + + result, err := Scan(dir, ScanOptions{ + WrapperModules: []string{"@pkg/a", "@pkg/b"}, + }) + if err != nil { + t.Fatal(err) + } + + assertRefCount(t, result, "flag-a", "wrapper-call", 1) + assertRefCount(t, result, "flag-b", "wrapper-call", 1) +} + +func TestNestedCallExpression(t *testing.T) { + // Flag call nested inside another expression + dir := t.TempDir() + writeFile(t, dir, "app.ts", ` +const result = someFunction(client.boolVariation('nested-flag', false)); +const ternary = client.variation('ternary-flag', false) ? 'a' : 'b'; +`) + + result, err := Scan(dir) + if err != nil { + t.Fatal(err) + } + + assertRefCount(t, result, "nested-flag", "variation", 1) + assertRefCount(t, result, "ternary-flag", "variation", 1) +} + +func TestDefaultValueCapture(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "app.ts", ` +const a = client.variation('bool-flag', false); +const b = client.variation('string-flag', 'hello'); +const c = client.variation('number-flag', 42); +`) + + result, err := Scan(dir) + if err != nil { + t.Fatal(err) + } + + defaults := map[string]string{ + "bool-flag": "false", + "string-flag": "'hello'", + "number-flag": "42", + } + for _, ref := range result.References { + if expected, ok := defaults[ref.FlagKey]; ok { + if ref.DefaultValue != expected { + t.Errorf("flag %q: expected default %q, got %q", ref.FlagKey, expected, ref.DefaultValue) + } + } + } +} + +func TestSkipsNodeModules(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "node_modules/pkg/index.ts", ` +const v = client.variation('should-skip', false); +`) + writeFile(t, dir, "src/app.ts", ` +const v = client.variation('should-find', false); +`) + + result, err := Scan(dir) + if err != nil { + t.Fatal(err) + } + + assertRefCount(t, result, "should-find", "variation", 1) + assertRefCount(t, result, "should-skip", "variation", 0) +} + +func TestJSXFile(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "component.tsx", ` +import { useBoolVariation } from '@launchdarkly/react-client-sdk'; +export function Banner() { + const show = useBoolVariation('show-banner', false); + return show ?
Hello
: null; +} +`) + + result, err := Scan(dir) + if err != nil { + t.Fatal(err) + } + + assertRefCount(t, result, "show-banner", "hook", 1) +} + +func TestSurroundingCodeCaptured(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "app.ts", ` +function check() { + const val = client.variation('my-flag', false); + return val; +} +`) + + result, err := Scan(dir) + if err != nil { + t.Fatal(err) + } + + if len(result.References) != 1 { + t.Fatalf("expected 1 ref, got %d", len(result.References)) + } + ref := result.References[0] + if ref.SurroundingCode == "" { + t.Error("expected surrounding code to be captured") + } + if ref.Line != 3 { + t.Errorf("expected line 3, got %d", ref.Line) + } +} + +// --- helpers --- + +func assertRefCount(t *testing.T, result *ScanResult, flagKey, kind string, expected int) { + t.Helper() + count := 0 + for _, ref := range result.References { + if ref.FlagKey == flagKey && ref.Kind == kind { + count++ + } + } + if count != expected { + t.Errorf("expected %d %s refs for %q, got %d", expected, kind, flagKey, count) + } +} + +func assertWrapper(t *testing.T, result *ScanResult, exportName, flagKey, defaultVal string) { + t.Helper() + for _, w := range result.Wrappers { + if w.ExportName == exportName { + if w.FlagKey != flagKey { + t.Errorf("wrapper %s: expected flagKey %q, got %q", exportName, flagKey, w.FlagKey) + } + if w.DefaultValue != defaultVal { + t.Errorf("wrapper %s: expected default %q, got %q", exportName, defaultVal, w.DefaultValue) + } + return + } + } + t.Errorf("wrapper %q not found", exportName) +} diff --git a/internal/flagusage/scanner/types.go b/internal/flagusage/scanner/types.go new file mode 100644 index 00000000..86d7ea2d --- /dev/null +++ b/internal/flagusage/scanner/types.go @@ -0,0 +1,45 @@ +package scanner + +import sitter "github.com/smacker/go-tree-sitter" + +type scanParsedFile struct { + path string + src []byte + tree *sitter.Tree + lang *Language +} + +type FlagReference struct { + FlagKey string `json:"flagKey"` + FilePath string `json:"filePath"` + Line int `json:"line"` + Column int `json:"column"` + Kind string `json:"kind"` // variation, hook, useFlags-property, wrapper-definition, wrapper-call + Method string `json:"method"` + DefaultValue string `json:"defaultValue,omitempty"` + SurroundingCode string `json:"surroundingCode,omitempty"` + WrapperName string `json:"wrapperName,omitempty"` + VariationType string `json:"variationType,omitempty"` +} + +type WrapperMapping struct { + ExportName string `json:"exportName"` + FlagKey string `json:"flagKey"` + DefaultValue string `json:"defaultValue"` + VariationType string `json:"variationType,omitempty"` + FilePath string `json:"filePath"` + Line int `json:"line"` +} + +type ScanStats struct { + FilesScanned int `json:"filesScanned"` + ReferencesFound int `json:"referencesFound"` + UniqueFlags int `json:"uniqueFlags"` + ByKind map[string]int `json:"byKind"` +} + +type ScanResult struct { + References []FlagReference `json:"references"` + Wrappers []WrapperMapping `json:"wrappers"` + Stats ScanStats `json:"stats"` +}