-
Notifications
You must be signed in to change notification settings - Fork 14
Port flagpls's flag-usage scanner into ldcli as flags usage
#742
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Width zero skips terminal detectionMedium Severity The Reviewed by Cursor Bugbot for commit 032b957. Configure here. |
||
|
|
||
| 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 | ||
| } | ||


There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Text table omits default environments
High Severity
When
--envsis omitted, enrichment still loads per-environment data viaDefaultCriticalEnvs, but text rendering passes an empty environment list intorender.Enriched.flagRowsonly prints envs from that list, so the default text table shows all-dash env rows even though JSON and the API layer have real status.Reviewed by Cursor Bugbot for commit 032b957. Configure here.