Skip to content
Merged
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
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,11 @@ brew install flaglint/tap/flaglint-go
flaglint-go scan ./services
flaglint-go scan ./services --format json

# --strict-types: additionally resolves findings only provable with real
# go/types information (interface satisfaction) — requires the module to
# build; see docs/adr/005-strict-types-pass.md
flaglint-go scan ./services --strict-types

# Inventory + migration-readiness score
flaglint-go audit ./services
flaglint-go audit ./services --write-baseline .flaglint-baseline.json
Expand Down
5 changes: 4 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
module github.com/flaglint/flaglint-go

go 1.22
go 1.22.0

require (
github.com/bmatcuk/doublestar/v4 v4.10.0
github.com/spf13/cobra v1.10.2
golang.org/x/tools v0.30.0
)

require (
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/spf13/pflag v1.0.9 // indirect
golang.org/x/mod v0.23.0 // indirect
golang.org/x/sync v0.11.0 // indirect
)
8 changes: 8 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
github.com/bmatcuk/doublestar/v4 v4.10.0 h1:zU9WiOla1YA122oLM6i4EXvGW62DvKZVxIe6TYWexEs=
github.com/bmatcuk/doublestar/v4 v4.10.0/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
Expand All @@ -9,4 +11,10 @@ github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiT
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/mod v0.23.0 h1:Zb7khfcRGKk+kqfxFaP5tZqCnDZMjC5VtUBs87Hr6QM=
golang.org/x/mod v0.23.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY=
golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w=
golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/tools v0.30.0 h1:BgcpHewrV5AUp2G9MebG4XPFI1E2W41zU1SaqVA9vJY=
golang.org/x/tools v0.30.0/go.mod h1:c347cR/OJfw5TI+GfX7RUPNMdDRRbjvYTS0jPyvsVtY=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
7 changes: 4 additions & 3 deletions internal/cli/audit.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,11 @@ import (
"github.com/flaglint/flaglint-go/internal/baseline"
"github.com/flaglint/flaglint-go/internal/readiness"
"github.com/flaglint/flaglint-go/internal/reporter"
"github.com/flaglint/flaglint-go/internal/scanner"
)

func newAuditCommand(version string) *cobra.Command {
var format, output, configPath, writeBaselinePath string
var strictTypes bool

cmd := &cobra.Command{
Use: "audit [dir]",
Expand All @@ -29,7 +29,7 @@ func newAuditCommand(version string) *cobra.Command {
return exitErr
}

result, err := scanner.Scan(dir, cfg)
result, err := runScan(dir, cfg, strictTypes)
if err != nil {
return internalError("scan failed: %v", err)
}
Expand All @@ -50,7 +50,7 @@ func newAuditCommand(version string) *cobra.Command {
r.LowRiskCalls, r.MediumRiskCalls, r.HighRiskCalls)
}
for _, w := range result.Warnings {
stderrInfo(cmd, "warning: %s: %s\n", w.Kind, w.File)
printWarning(cmd, w)
}

if writeBaselinePath != "" {
Expand Down Expand Up @@ -85,6 +85,7 @@ func newAuditCommand(version string) *cobra.Command {
cmd.Flags().StringVarP(&output, "output", "o", "", "write report to file instead of stdout")
cmd.Flags().StringVar(&configPath, "config", "", "path to config file")
cmd.Flags().StringVar(&writeBaselinePath, "write-baseline", "", "write current finding fingerprints to a baseline file")
cmd.Flags().BoolVar(&strictTypes, "strict-types", false, "additionally resolve findings only provable with real go/types information (requires the module to build; see docs/adr/005-strict-types-pass.md)")
return cmd
}

Expand Down
7 changes: 4 additions & 3 deletions internal/cli/scan.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,11 @@ import (
"github.com/spf13/cobra"

"github.com/flaglint/flaglint-go/internal/reporter"
"github.com/flaglint/flaglint-go/internal/scanner"
)

func newScanCommand() *cobra.Command {
var format, output, configPath string
var strictTypes bool

cmd := &cobra.Command{
Use: "scan [dir]",
Expand All @@ -27,7 +27,7 @@ func newScanCommand() *cobra.Command {
return exitErr
}

result, err := scanner.Scan(dir, cfg)
result, err := runScan(dir, cfg, strictTypes)
if err != nil {
return internalError("scan failed: %v", err)
}
Expand All @@ -40,7 +40,7 @@ func newScanCommand() *cobra.Command {
stderrInfo(cmd, "Scan complete — %d unique flag(s) across %d call site(s) (%s, %d file(s))\n",
len(result.UniqueFlags), result.TotalUsages, formatDuration(result.ScanDurationMs), result.ScannedFiles)
for _, w := range result.Warnings {
stderrInfo(cmd, "warning: %s: %s\n", w.Kind, w.File)
printWarning(cmd, w)
}

// scan is an inventory command — enforcement exit codes belong
Expand All @@ -53,5 +53,6 @@ func newScanCommand() *cobra.Command {
cmd.Flags().StringVarP(&format, "format", "f", "markdown", "output format: json | markdown")
cmd.Flags().StringVarP(&output, "output", "o", "", "write report to file instead of stdout")
cmd.Flags().StringVar(&configPath, "config", "", "path to config file")
cmd.Flags().BoolVar(&strictTypes, "strict-types", false, "additionally resolve findings only provable with real go/types information (requires the module to build; see docs/adr/005-strict-types-pass.md)")
return cmd
}
26 changes: 26 additions & 0 deletions internal/cli/shared.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import (
"github.com/spf13/cobra"

"github.com/flaglint/flaglint-go/internal/config"
"github.com/flaglint/flaglint-go/internal/scanner"
"github.com/flaglint/flaglint-go/internal/types"
)

// validateDirectory checks that dir exists and is a directory, returning an
Expand Down Expand Up @@ -46,6 +48,17 @@ func loadConfig(configPath string) (config.Config, *ExitError) {
return cfg, nil
}

// runScan calls scanner.Scan, or scanner.ScanStrict when strictTypes is
// set — the single place all three commands (scan/audit/validate) decide
// which to run, so the --strict-types flag behaves identically everywhere
// it's exposed. See docs/adr/005-strict-types-pass.md.
func runScan(dir string, cfg config.Config, strictTypes bool) (types.ScanResult, error) {
if strictTypes {
return scanner.ScanStrict(dir, cfg)
}
return scanner.Scan(dir, cfg)
}

// writeReport writes report to outputPath if non-empty, otherwise to the
// command's stdout. A file-write failure is an internal error (exit 3) —
// NOT matching flaglint-js's current shipped behavior, which calls
Expand All @@ -72,6 +85,19 @@ func writeReport(cmd *cobra.Command, report, outputPath string) error {
return nil
}

// printWarning writes one scan warning to cmd's stderr, appending w's
// Reason (only ever set for a "typecheck-failure" warning — see
// types.ScanWarning) when present — without it, a --strict-types package
// load failure would print only a bare package path with no clue why it
// failed.
func printWarning(cmd *cobra.Command, w types.ScanWarning) {
if w.Reason != "" {
stderrInfo(cmd, "warning: %s: %s: %s\n", w.Kind, w.File, w.Reason)
return
}
stderrInfo(cmd, "warning: %s: %s\n", w.Kind, w.File)
}

// stderrInfo writes a formatted progress/summary line to cmd's stderr,
// mirroring flaglint-js's own stderrInfo helper (src/commands/shared.ts).
// The write error is deliberately discarded: a progress line failing to
Expand Down
7 changes: 4 additions & 3 deletions internal/cli/validate.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import (
"github.com/spf13/cobra"

"github.com/flaglint/flaglint-go/internal/baseline"
"github.com/flaglint/flaglint-go/internal/scanner"
"github.com/flaglint/flaglint-go/internal/types"
"github.com/flaglint/flaglint-go/internal/validator"
)
Expand All @@ -21,6 +20,7 @@ func newValidateCommand() *cobra.Command {
configPath string
baselinePath string
failOnNew bool
strictTypes bool
)

cmd := &cobra.Command{
Expand All @@ -44,12 +44,12 @@ func newValidateCommand() *cobra.Command {
return exitErr
}

result, err := scanner.Scan(dir, cfg)
result, err := runScan(dir, cfg, strictTypes)
if err != nil {
return internalError("scan failed: %v", err)
}
for _, w := range result.Warnings {
stderrInfo(cmd, "warning: %s: %s\n", w.Kind, w.File)
printWarning(cmd, w)
}

vOpts := validator.Options{NoDirectLaunchDarkly: noDirectLD, BootstrapExclude: bootstrapExclude}
Expand Down Expand Up @@ -98,6 +98,7 @@ func newValidateCommand() *cobra.Command {
cmd.Flags().StringVar(&configPath, "config", "", "path to config file")
cmd.Flags().StringVar(&baselinePath, "baseline", "", "baseline file for comparing against known debt")
cmd.Flags().BoolVar(&failOnNew, "fail-on-new", false, "exit 1 if any findings are not in the baseline")
cmd.Flags().BoolVar(&strictTypes, "strict-types", false, "additionally resolve findings only provable with real go/types information (requires the module to build; see docs/adr/005-strict-types-pass.md)")
return cmd
}

Expand Down
71 changes: 61 additions & 10 deletions internal/scanner/identity.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package scanner
import (
"go/ast"
"go/token"
"go/types"
"strconv"
)

Expand Down Expand Up @@ -121,6 +122,12 @@ type fileContext struct {
// declared field type name (see structtypes.go), used to walk
// multi-level field-selector chains one hop at a time.
structFieldTypes map[string]string

// typesInfo is real go/types information for this file's package, only
// populated by the opt-in --strict-types pass (ScanStrict, strict.go) —
// nil for an ordinary Scan. See resolveAssignedBinding's fallback and
// docs/adr/005-strict-types-pass.md.
typesInfo *types.Info
}

// collectPackageLevelBindings finds client bindings established by
Expand Down Expand Up @@ -336,20 +343,64 @@ func opensScope(n ast.Node) bool {

// resolveAssignedBinding reports what a single assignment-target position
// should resolve to: the SDK version, if rhs is a recognized constructor
// or factory call, or ok=false for anything else — including a value we
// simply don't recognize. Callers that track scope (see walkScoped) must
// treat ok=false as "actively clear any inherited binding for this name",
// not just "don't add one" — see walkScoped's doc comment for why.
// or factory call, if ctx carries real type information and rhs's static
// type proves it directly (see resolveByStaticType), or ok=false for
// anything else — including a value we simply don't recognize. Callers
// that track scope (see walkScoped) must treat ok=false as "actively clear
// any inherited binding for this name", not just "don't add one" — see
// walkScoped's doc comment for why.
func resolveAssignedBinding(rhs ast.Expr, ctx fileContext) (version string, ok bool) {
call, ok := rhs.(*ast.CallExpr)
if !ok {
if call, isCall := rhs.(*ast.CallExpr); isCall {
if version, ok := isSDKConstructorCall(call, ctx.imports); ok {
return version, true
}
if version, ok := isFactoryCall(call, ctx.ownPkgKey, ctx.importAliases, ctx.factoryFunctions); ok {
return version, true
}
}
if ctx.typesInfo != nil {
if version, ok := resolveByStaticType(rhs, ctx.typesInfo); ok {
return version, true
}
}
return "", false
}

// resolveByStaticType checks rhs's real static type directly against the
// SDK's client type — only reachable when ctx.typesInfo is populated (the
// opt-in --strict-types pass, ScanStrict; see docs/adr/005-strict-types-
// pass.md). This is what proves identity through an interface —
// `var evaluator FlagEvaluator = client` has neither a recognized
// constructor call nor a factory call on its RHS for the syntax-only
// checks above to find, but go/types can tell us directly that the value
// underneath is really *ld.LDClient. Closes issue #15, same-scope only:
// rhs is checked as a single expression, not traced across a function
// boundary (that remains Phase 2b, issue #26).
func resolveByStaticType(rhs ast.Expr, info *types.Info) (version string, ok bool) {
t := info.TypeOf(rhs)
if t == nil {
return "", false
}
version, ok = isSDKConstructorCall(call, ctx.imports)
if ok {
return version, true
ptr, isPtr := t.(*types.Pointer)
if !isPtr {
return "", false
}
named, isNamed := ptr.Elem().(*types.Named)
if !isNamed || named.Obj().Name() != "LDClient" {
return "", false
}
pkg := named.Obj().Pkg()
if pkg == nil {
return "", false
}
switch pkg.Path() {
case sdkImportV6:
return "v6", true
case sdkImportV7:
return "v7", true
default:
return "", false
}
return isFactoryCall(call, ctx.ownPkgKey, ctx.importAliases, ctx.factoryFunctions)
}

// methodValueBinding records that an identifier was bound to a specific
Expand Down
8 changes: 8 additions & 0 deletions internal/scanner/scanner.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"go/ast"
"go/parser"
"go/token"
gotypes "go/types"
"os"
"path/filepath"
"sort"
Expand All @@ -32,6 +33,12 @@ type parsedFile struct {
dir string
file *ast.File
imports sdkImports

// typesInfo is real go/types information for this file's package, only
// populated by ScanStrict (strict.go) — nil for an ordinary Scan. See
// fileContext.typesInfo (identity.go) and docs/adr/005-strict-types-
// pass.md.
typesInfo *gotypes.Info
}

// Scan walks root for files matching cfg's include/exclude patterns, parses
Expand Down Expand Up @@ -181,6 +188,7 @@ func runWholeScanAnalysis(fset *token.FileSet, parsed []parsedFile) []types.Flag
importAliases: resolveImportAliases(pf.file, importPathToPkgKey, importPathToPkgName),
factoryFunctions: factoryFunctions,
structFieldTypes: pkgBindings(structFieldTypesByPkg, ownPkgKey[pf.file]),
typesInfo: pf.typesInfo,
}
}

Expand Down
Loading
Loading