Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
154 changes: 154 additions & 0 deletions cmd/flags/usage.go
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)

Copy link
Copy Markdown

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 --envs is omitted, enrichment still loads per-environment data via DefaultCriticalEnvs, but text rendering passes an empty environment list into render.Enriched. flagRows only 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.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 032b957. Configure here.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Width zero skips terminal detection

Medium Severity

The --width help says 0 auto-detects the terminal, but runUsage forwards 0 unchanged. render.Enriched treats maxWidth of 0 as unconstrained width, and render.TerminalWidth() is never called despite being added for this purpose.

Fix in Cursor Fix in Web

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
}
3 changes: 2 additions & 1 deletion cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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))
Expand Down
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
Loading
Loading