From 082d1b4933a55f6093e28b60dd1697580c57320c Mon Sep 17 00:00:00 2001 From: Michael Czechowski Date: Mon, 27 Apr 2026 21:31:39 +0200 Subject: [PATCH 1/2] feat(pipeline): add parallel complexity audit step with quality gates (#1041) Adds an in-tree Go AST analyzer that scores functions by cyclomatic and cognitive complexity, exposed via `wave audit complexity` and a new `audit-complexity` pipeline. The pipeline plugs into `ops-parallel-audit`'s iterate fan-out alongside the LLM-driven audits, gating on per-function threshold breaches via shared-findings JSON. - internal/complexity: errgroup-parallel scorer, cyclomatic + cognitive visitors, golden-fixture tests, schema-validated findings emitter - cmd/wave/commands: `wave audit complexity` subcommand with thresholds, exclude globs, json/summary formats, exit codes 0/1/2 - internal/defaults/pipelines: audit-complexity.yaml command-step pipeline with json_schema gate; ops-parallel-audit fans out 4-wide - shared-findings schema: adds "complexity" to the type enum - docs/reference/cli.md: documents the new subcommand --- .agents/contracts/shared-findings.schema.json | 2 +- cmd/wave/commands/audit_complexity.go | 200 +++++++++++++ cmd/wave/commands/audit_complexity_test.go | 161 +++++++++++ cmd/wave/main.go | 3 +- docs/reference/cli.md | 41 +++ internal/complexity/analyzer.go | 269 ++++++++++++++++++ internal/complexity/analyzer_test.go | 142 +++++++++ internal/complexity/cognitive.go | 249 ++++++++++++++++ internal/complexity/cognitive_test.go | 39 +++ internal/complexity/cyclomatic.go | 50 ++++ internal/complexity/cyclomatic_test.go | 65 +++++ internal/complexity/doc.go | 31 ++ internal/complexity/findings.go | 124 ++++++++ internal/complexity/findings_test.go | 121 ++++++++ internal/complexity/testdata/broken/broken.go | 8 + internal/complexity/testdata/fixtures.go | 131 +++++++++ .../contracts/shared-findings.schema.json | 2 +- .../defaults/pipelines/audit-complexity.yaml | 74 +++++ .../pipelines/ops-parallel-audit.yaml | 34 +-- specs/1041-complexity-audit-step/plan.md | 135 +++++++++ specs/1041-complexity-audit-step/spec.md | 79 +++++ specs/1041-complexity-audit-step/tasks.md | 38 +++ 22 files changed, 1979 insertions(+), 19 deletions(-) create mode 100644 cmd/wave/commands/audit_complexity.go create mode 100644 cmd/wave/commands/audit_complexity_test.go create mode 100644 internal/complexity/analyzer.go create mode 100644 internal/complexity/analyzer_test.go create mode 100644 internal/complexity/cognitive.go create mode 100644 internal/complexity/cognitive_test.go create mode 100644 internal/complexity/cyclomatic.go create mode 100644 internal/complexity/cyclomatic_test.go create mode 100644 internal/complexity/doc.go create mode 100644 internal/complexity/findings.go create mode 100644 internal/complexity/findings_test.go create mode 100644 internal/complexity/testdata/broken/broken.go create mode 100644 internal/complexity/testdata/fixtures.go create mode 100644 internal/defaults/pipelines/audit-complexity.yaml create mode 100644 specs/1041-complexity-audit-step/plan.md create mode 100644 specs/1041-complexity-audit-step/spec.md create mode 100644 specs/1041-complexity-audit-step/tasks.md diff --git a/.agents/contracts/shared-findings.schema.json b/.agents/contracts/shared-findings.schema.json index f7ece7ad1..b619aebf5 100644 --- a/.agents/contracts/shared-findings.schema.json +++ b/.agents/contracts/shared-findings.schema.json @@ -14,7 +14,7 @@ "type": { "type": "string", "description": "Finding category", - "enum": ["dead-code", "unwired", "duplicate", "doc-drift", "junk-code", "security", "dx", "performance", "style", "correctness", "architecture", "test", "coverage", "other"] + "enum": ["dead-code", "unwired", "duplicate", "doc-drift", "junk-code", "security", "dx", "performance", "style", "correctness", "architecture", "test", "coverage", "complexity", "other"] }, "severity": { "type": "string", diff --git a/cmd/wave/commands/audit_complexity.go b/cmd/wave/commands/audit_complexity.go new file mode 100644 index 000000000..099a101f9 --- /dev/null +++ b/cmd/wave/commands/audit_complexity.go @@ -0,0 +1,200 @@ +package commands + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "strings" + + "github.com/recinq/wave/internal/complexity" + "github.com/spf13/cobra" +) + +// Exit codes for `wave audit complexity`. +const ( + auditExitOK = 0 + auditExitBreach = 1 + auditExitIOError = 2 +) + +// NewAuditCmd creates the `audit` parent command with deterministic, in-tree +// audit subcommands. Unlike the LLM-driven audit pipelines (audit-security, +// audit-architecture, etc.), commands under this group run pure code-analysis +// and gate via exit codes and structured output. +func NewAuditCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "audit", + Short: "Deterministic in-tree audit subcommands", + Long: `Code-analysis subcommands that complement the LLM-driven audit pipelines. + +Subcommands: + complexity Score Go functions by cyclomatic and cognitive complexity`, + } + cmd.AddCommand(newAuditComplexityCmd()) + return cmd +} + +func newAuditComplexityCmd() *cobra.Command { + var ( + maxCyclomatic int + maxCognitive int + warnCyclomatic int + warnCognitive int + outputPath string + excludes []string + format string + includeTests bool + ) + + cmd := &cobra.Command{ + Use: "complexity [paths...]", + Short: "Score Go functions by cyclomatic and cognitive complexity", + Long: `Walk the given paths (default: current directory), parse Go source files, +and score each function for cyclomatic and cognitive complexity. Functions +exceeding the configured thresholds emit findings to the output file. + +Output format conforms to the shared-findings schema so the result is +consumable by aggregate/iterate audit pipelines. + +Exit codes: + 0 all functions pass thresholds + 1 one or more functions exceed a fail threshold + 2 IO or parse error`, + Example: " wave audit complexity internal/pipeline\n" + + " wave audit complexity --max-cyclomatic 20 --output findings.json ./...", + RunE: func(cmd *cobra.Command, args []string) error { + paths := normalizeAuditPaths(args) + opts := complexity.Options{ + MaxCyclomatic: maxCyclomatic, + MaxCognitive: maxCognitive, + WarnCyclomatic: warnCyclomatic, + WarnCognitive: warnCognitive, + IncludeTests: includeTests, + Excludes: excludes, + } + report, err := complexity.Analyze(paths, opts) + if err != nil { + return cliExitErr(auditExitIOError, fmt.Errorf("analyze: %w", err)) + } + doc := complexity.ToSharedFindings(report, opts) + switch strings.ToLower(format) { + case "summary": + if err := writeSummary(cmd.OutOrStdout(), report, doc); err != nil { + return cliExitErr(auditExitIOError, err) + } + default: + if err := writeFindings(outputPath, doc); err != nil { + return cliExitErr(auditExitIOError, fmt.Errorf("write findings: %w", err)) + } + } + if doc.HasBreach() { + printBreaches(cmd.ErrOrStderr(), doc) + return cliExitErr(auditExitBreach, errors.New("complexity threshold breach")) + } + fmt.Fprintln(cmd.ErrOrStderr(), doc.Summary) + return nil + }, + SilenceUsage: true, + SilenceErrors: true, + } + + cmd.Flags().IntVar(&maxCyclomatic, "max-cyclomatic", 15, "fail threshold for cyclomatic complexity") + cmd.Flags().IntVar(&maxCognitive, "max-cognitive", 15, "fail threshold for cognitive complexity") + cmd.Flags().IntVar(&warnCyclomatic, "warn-cyclomatic", 10, "warn threshold for cyclomatic complexity") + cmd.Flags().IntVar(&warnCognitive, "warn-cognitive", 10, "warn threshold for cognitive complexity") + cmd.Flags().StringVarP(&outputPath, "output", "o", ".agents/output/findings.json", "path to write findings JSON") + cmd.Flags().StringSliceVar(&excludes, "exclude", nil, "substring patterns to skip (repeatable)") + cmd.Flags().StringVar(&format, "format", "json", "output format: json (write to --output) or summary (stdout)") + cmd.Flags().BoolVar(&includeTests, "include-tests", false, "also score _test.go files") + + return cmd +} + +// normalizeAuditPaths defaults to current directory when no args given, +// stripping the Go-style `./...` suffix. +func normalizeAuditPaths(args []string) []string { + if len(args) == 0 { + return []string{"."} + } + out := make([]string, 0, len(args)) + for _, a := range args { + a = strings.TrimSuffix(a, "/...") + if a == "" { + a = "." + } + out = append(out, a) + } + return out +} + +func writeFindings(path string, doc complexity.FindingsDocument) error { + if dir := filepath.Dir(path); dir != "" && dir != "." { + if err := os.MkdirAll(dir, 0o755); err != nil { + return err + } + } + body, err := json.MarshalIndent(doc, "", " ") + if err != nil { + return err + } + body = append(body, '\n') + return os.WriteFile(path, body, 0o644) +} + +func writeSummary(w io.Writer, report complexity.Report, doc complexity.FindingsDocument) error { + if _, err := fmt.Fprintf(w, "scanned %d file(s), %d function(s)\n", report.FileCount, len(report.Scores)); err != nil { + return err + } + if _, err := fmt.Fprintln(w, doc.Summary); err != nil { + return err + } + for _, f := range doc.Findings { + if _, err := fmt.Fprintf(w, " [%s] %s:%d %s — %s\n", f.Severity, f.File, f.Line, f.Item, f.Description); err != nil { + return err + } + } + return nil +} + +func printBreaches(w io.Writer, doc complexity.FindingsDocument) { + fmt.Fprintln(w, doc.Summary) + for _, f := range doc.Findings { + if f.Severity != "high" { + continue + } + fmt.Fprintf(w, "BREACH %s:%d %s — %s\n", f.File, f.Line, f.Item, f.Description) + } +} + +// cliExitError carries a non-zero exit code out of RunE so main can read it. +type cliExitError struct { + code int + err error +} + +func (e *cliExitError) Error() string { return e.err.Error() } +func (e *cliExitError) Unwrap() error { return e.err } +func (e *cliExitError) ExitCode() int { return e.code } + +func cliExitErr(code int, err error) error { + return &cliExitError{code: code, err: err} +} + +// ExitCodeFor returns the exit code carried by err, or 1 if none. +// Defined here so main.go can honor command-specific exit codes (e.g., +// 1 for breach vs 2 for IO error in `wave audit complexity`). +func ExitCodeFor(err error) int { + if err == nil { + return 0 + } + var ec interface{ ExitCode() int } + if errors.As(err, &ec) { + if c := ec.ExitCode(); c > 0 { + return c + } + } + return 1 +} diff --git a/cmd/wave/commands/audit_complexity_test.go b/cmd/wave/commands/audit_complexity_test.go new file mode 100644 index 000000000..986d95fb3 --- /dev/null +++ b/cmd/wave/commands/audit_complexity_test.go @@ -0,0 +1,161 @@ +package commands + +import ( + "bytes" + "encoding/json" + "errors" + "os" + "path/filepath" + "testing" + + "github.com/recinq/wave/internal/complexity" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewAuditCmd_Structure(t *testing.T) { + cmd := NewAuditCmd() + assert.Equal(t, "audit", cmd.Use) + require.NotNil(t, cmd.Commands()) + var hasComplexity bool + for _, c := range cmd.Commands() { + if c.Name() == "complexity" { + hasComplexity = true + break + } + } + assert.True(t, hasComplexity, "expected complexity subcommand") +} + +func TestNewAuditComplexityCmd_Flags(t *testing.T) { + cmd := newAuditComplexityCmd() + require.NotNil(t, cmd.Flags().Lookup("max-cyclomatic")) + require.NotNil(t, cmd.Flags().Lookup("max-cognitive")) + require.NotNil(t, cmd.Flags().Lookup("warn-cyclomatic")) + require.NotNil(t, cmd.Flags().Lookup("warn-cognitive")) + require.NotNil(t, cmd.Flags().Lookup("output")) + require.NotNil(t, cmd.Flags().Lookup("exclude")) + require.NotNil(t, cmd.Flags().Lookup("format")) + require.NotNil(t, cmd.Flags().Lookup("include-tests")) + assert.Equal(t, "15", cmd.Flags().Lookup("max-cyclomatic").DefValue) + assert.Equal(t, "15", cmd.Flags().Lookup("max-cognitive").DefValue) + assert.Equal(t, "10", cmd.Flags().Lookup("warn-cyclomatic").DefValue) + assert.Equal(t, "10", cmd.Flags().Lookup("warn-cognitive").DefValue) +} + +// runAuditComplexity runs the subcommand with the given args, returning stdout, +// stderr, the error from RunE, and the resolved exit code. +func runAuditComplexity(t *testing.T, args []string) (stdout, stderr string, exit int) { + t.Helper() + cmd := newAuditComplexityCmd() + var out, errBuf bytes.Buffer + cmd.SetOut(&out) + cmd.SetErr(&errBuf) + cmd.SetArgs(args) + err := cmd.Execute() + return out.String(), errBuf.String(), ExitCodeFor(err) +} + +func TestAuditComplexity_Pass(t *testing.T) { + tmp := t.TempDir() + srcPath := filepath.Join(tmp, "ok.go") + require.NoError(t, os.WriteFile(srcPath, + []byte("package x\nfunc Easy() int { return 1 }\n"), 0o644)) + outPath := filepath.Join(tmp, "findings.json") + + _, stderr, exit := runAuditComplexity(t, []string{ + "--output", outPath, + srcPath, + }) + assert.Equal(t, 0, exit, "stderr=%s", stderr) + body, err := os.ReadFile(outPath) + require.NoError(t, err) + var doc complexity.FindingsDocument + require.NoError(t, json.Unmarshal(body, &doc)) + assert.Empty(t, doc.Findings, "expected zero findings") + assert.Equal(t, "complexity", doc.ScanType) +} + +func TestAuditComplexity_Breach(t *testing.T) { + tmp := t.TempDir() + srcPath := filepath.Join(tmp, "big.go") + // Function with cyclomatic >= 5 — set a low fail threshold so we breach. + src := "package x\nfunc Big(x int) int {\n" + + " if x > 0 { return 1 }\n" + + " if x < 0 { return -1 }\n" + + " if x == 5 { return 5 }\n" + + " return 0\n}\n" + require.NoError(t, os.WriteFile(srcPath, []byte(src), 0o644)) + outPath := filepath.Join(tmp, "findings.json") + + stdout, stderr, exit := runAuditComplexity(t, []string{ + "--max-cyclomatic", "2", + "--warn-cyclomatic", "1", + "--output", outPath, + srcPath, + }) + assert.Equal(t, 1, exit, "stdout=%s stderr=%s", stdout, stderr) + assert.Contains(t, stderr, "BREACH") + assert.Contains(t, stderr, "Big") + + body, err := os.ReadFile(outPath) + require.NoError(t, err) + var doc complexity.FindingsDocument + require.NoError(t, json.Unmarshal(body, &doc)) + assert.True(t, doc.HasBreach()) + require.NotEmpty(t, doc.Findings) + assert.Equal(t, "high", doc.Findings[0].Severity) +} + +func TestAuditComplexity_ParseError(t *testing.T) { + tmp := t.TempDir() + srcPath := filepath.Join(tmp, "broken.go") + require.NoError(t, os.WriteFile(srcPath, + []byte("package x\nfunc Broken() int { return 1 +"), 0o644)) + outPath := filepath.Join(tmp, "findings.json") + + _, _, exit := runAuditComplexity(t, []string{ + "--output", outPath, + srcPath, + }) + assert.Equal(t, 2, exit, "expected IO/parse exit code 2") +} + +func TestAuditComplexity_MissingPath(t *testing.T) { + tmp := t.TempDir() + missing := filepath.Join(tmp, "nope") + outPath := filepath.Join(tmp, "findings.json") + + _, _, exit := runAuditComplexity(t, []string{ + "--output", outPath, + missing, + }) + assert.Equal(t, 2, exit) +} + +func TestAuditComplexity_SummaryFormat(t *testing.T) { + tmp := t.TempDir() + srcPath := filepath.Join(tmp, "ok.go") + require.NoError(t, os.WriteFile(srcPath, + []byte("package x\nfunc Easy() int { return 1 }\n"), 0o644)) + + stdout, _, exit := runAuditComplexity(t, []string{ + "--format", "summary", + srcPath, + }) + assert.Equal(t, 0, exit) + assert.Contains(t, stdout, "scanned") +} + +func TestExitCodeFor(t *testing.T) { + assert.Equal(t, 0, ExitCodeFor(nil)) + assert.Equal(t, 1, ExitCodeFor(errors.New("plain"))) + assert.Equal(t, 2, ExitCodeFor(cliExitErr(2, errors.New("io")))) + assert.Equal(t, 1, ExitCodeFor(cliExitErr(1, errors.New("breach")))) +} + +func TestNormalizeAuditPaths(t *testing.T) { + assert.Equal(t, []string{"."}, normalizeAuditPaths(nil)) + assert.Equal(t, []string{"./internal"}, normalizeAuditPaths([]string{"./internal/..."})) + assert.Equal(t, []string{"a", "b"}, normalizeAuditPaths([]string{"a", "b"})) +} diff --git a/cmd/wave/main.go b/cmd/wave/main.go index 47993e578..e6ddc74cb 100644 --- a/cmd/wave/main.go +++ b/cmd/wave/main.go @@ -166,6 +166,7 @@ func init() { rootCmd.AddCommand(commands.NewPersonaCmd()) rootCmd.AddCommand(commands.NewCleanupCmd()) rootCmd.AddCommand(commands.NewMergeCmd()) + rootCmd.AddCommand(commands.NewAuditCmd()) } // shouldLaunchTUI determines whether to launch the Bubble Tea TUI. @@ -225,6 +226,6 @@ func main() { } else { commands.RenderTextError(os.Stderr, err, debug) } - os.Exit(1) + os.Exit(commands.ExitCodeFor(err)) } } diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 298b739ff..1a5998396 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -32,6 +32,7 @@ Wave CLI commands for pipeline orchestration. | `wave serve` | Start the web dashboard server | | `wave migrate` | Database migrations | | `wave bench` | Run and analyze SWE-bench benchmarks | +| `wave audit complexity` | Score Go functions by cyclomatic and cognitive complexity | --- @@ -1301,6 +1302,46 @@ wave skills doctor --format json --- +## wave audit complexity + +Score Go functions by cyclomatic and cognitive complexity. Deterministic +in-tree analyzer — no LLM, no third-party binary. Output is JSON conforming +to the `shared-findings` schema, suitable for downstream aggregation in +`ops-parallel-audit` or any other findings-consuming pipeline. + +```bash +wave audit complexity ./internal +wave audit complexity --max-cyclomatic 20 --output findings.json ./... +wave audit complexity --format summary ./internal/pipeline +``` + +### Options + +| Flag | Default | Description | +|------|---------|-------------| +| `--max-cyclomatic` | `15` | Fail threshold for cyclomatic complexity | +| `--max-cognitive` | `15` | Fail threshold for cognitive complexity | +| `--warn-cyclomatic` | `10` | Warn threshold for cyclomatic complexity | +| `--warn-cognitive` | `10` | Warn threshold for cognitive complexity | +| `--output` / `-o` | `.agents/output/findings.json` | Path to write findings JSON | +| `--exclude` | _(none)_ | Substring patterns to skip (repeatable) | +| `--format` | `json` | `json` writes to `--output`; `summary` prints to stdout | +| `--include-tests` | `false` | Also score `_test.go` files | + +### Exit Codes + +| Code | Meaning | +|------|---------| +| 0 | All functions pass thresholds | +| 1 | One or more functions exceed a fail threshold | +| 2 | IO or parse error (path missing, file unreadable, syntax error) | + +The companion `audit-complexity` pipeline wraps this command behind a +`json_schema` contract gate, so `wave run audit-complexity ./internal` fails +the pipeline whenever any function exceeds the configured thresholds. + +--- + ## Global Options All commands support: diff --git a/internal/complexity/analyzer.go b/internal/complexity/analyzer.go new file mode 100644 index 000000000..b3bc05f89 --- /dev/null +++ b/internal/complexity/analyzer.go @@ -0,0 +1,269 @@ +package complexity + +import ( + "errors" + "fmt" + "go/ast" + "go/parser" + "go/token" + "io/fs" + "os" + "path/filepath" + "runtime" + "sort" + "strings" + "sync" + "time" + + "golang.org/x/sync/errgroup" +) + +// Options controls Analyze behavior. +type Options struct { + // MaxCyclomatic is the fail threshold for cyclomatic complexity. + // Functions with score > MaxCyclomatic produce a high-severity finding. + // Default 15. + MaxCyclomatic int + // MaxCognitive is the fail threshold for cognitive complexity. Default 15. + MaxCognitive int + // WarnCyclomatic is the warn threshold; functions in + // (WarnCyclomatic, MaxCyclomatic] produce a medium-severity finding. + // Default 10. + WarnCyclomatic int + // WarnCognitive analogue. Default 10. + WarnCognitive int + // IncludeTests, when true, also scores `_test.go` files. Default false. + IncludeTests bool + // Excludes is a list of substrings matched against file paths; matching + // files are skipped. Common defaults always apply (vendor/, .git/). + Excludes []string + // Concurrency caps per-file workers. Defaults to runtime.NumCPU(). + Concurrency int +} + +// FunctionScore is a per-function complexity result. +type FunctionScore struct { + File string `json:"file"` + Package string `json:"package"` + Function string `json:"function"` + Line int `json:"line"` + Cyclomatic int `json:"cyclomatic"` + Cognitive int `json:"cognitive"` +} + +// Report is the aggregated analyzer output. +type Report struct { + Scores []FunctionScore `json:"scores"` + FileCount int `json:"file_count"` + ScannedAt time.Time `json:"scanned_at"` +} + +// withDefaults returns a copy of opts with zero fields populated. +func (o Options) withDefaults() Options { + if o.MaxCyclomatic <= 0 { + o.MaxCyclomatic = 15 + } + if o.MaxCognitive <= 0 { + o.MaxCognitive = 15 + } + if o.WarnCyclomatic <= 0 { + o.WarnCyclomatic = 10 + } + if o.WarnCognitive <= 0 { + o.WarnCognitive = 10 + } + if o.Concurrency <= 0 { + o.Concurrency = runtime.NumCPU() + } + return o +} + +// Analyze scans the given paths for Go source files and returns a Report +// containing per-function cyclomatic and cognitive complexity scores. +// +// Each path may be a file or a directory; directories are walked recursively. +// `vendor/`, `.git/`, and `node_modules/` are always skipped. `_test.go` files +// are skipped unless opts.IncludeTests is true. +// +// Per-file parsing and scoring runs in parallel up to opts.Concurrency. +// A parse error in any one file fails the whole call (the error wraps the +// file path). Empty path lists produce an empty report, not an error. +func Analyze(paths []string, opts Options) (Report, error) { + opts = opts.withDefaults() + report := Report{ScannedAt: time.Now().UTC()} + if len(paths) == 0 { + return report, nil + } + files, err := discoverFiles(paths, opts) + if err != nil { + return report, err + } + report.FileCount = len(files) + if len(files) == 0 { + return report, nil + } + + var ( + mu sync.Mutex + results []FunctionScore + ) + g := new(errgroup.Group) + g.SetLimit(opts.Concurrency) + for _, file := range files { + file := file + g.Go(func() error { + scores, err := scoreFile(file) + if err != nil { + return fmt.Errorf("%s: %w", file, err) + } + mu.Lock() + results = append(results, scores...) + mu.Unlock() + return nil + }) + } + if err := g.Wait(); err != nil { + return report, err + } + sort.Slice(results, func(i, j int) bool { + if results[i].File != results[j].File { + return results[i].File < results[j].File + } + return results[i].Line < results[j].Line + }) + report.Scores = results + return report, nil +} + +func discoverFiles(paths []string, opts Options) ([]string, error) { + seen := make(map[string]struct{}) + var out []string + add := func(p string) { + if _, ok := seen[p]; ok { + return + } + seen[p] = struct{}{} + out = append(out, p) + } + for _, root := range paths { + if err := walkRoot(root, opts, add); err != nil { + return nil, err + } + } + sort.Strings(out) + return out, nil +} + +func walkRoot(root string, opts Options, add func(string)) error { + info, err := os.Stat(root) + if err != nil { + return fmt.Errorf("stat %s: %w", root, err) + } + if !info.IsDir() { + if shouldInclude(root, opts) { + add(root) + } + return nil + } + return filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() { + if isSkippedDir(d.Name()) { + return fs.SkipDir + } + return nil + } + if shouldInclude(path, opts) { + add(path) + } + return nil + }) +} + +func isSkippedDir(name string) bool { + switch name { + case "vendor", ".git", "node_modules", "testdata": + return true + } + return false +} + +func shouldInclude(path string, opts Options) bool { + if !strings.HasSuffix(path, ".go") { + return false + } + if !opts.IncludeTests && strings.HasSuffix(path, "_test.go") { + return false + } + for _, ex := range opts.Excludes { + if ex == "" { + continue + } + if strings.Contains(path, ex) { + return false + } + } + return true +} + +func scoreFile(path string) ([]FunctionScore, error) { + src, err := os.ReadFile(path) + if err != nil { + return nil, err + } + fset := token.NewFileSet() + file, err := parser.ParseFile(fset, path, src, parser.SkipObjectResolution) + if err != nil { + return nil, fmt.Errorf("parse: %w", err) + } + pkg := "" + if file.Name != nil { + pkg = file.Name.Name + } + var scores []FunctionScore + for _, decl := range file.Decls { + fn, ok := decl.(*ast.FuncDecl) + if !ok || fn.Body == nil { + continue + } + pos := fset.Position(fn.Pos()) + scores = append(scores, FunctionScore{ + File: path, + Package: pkg, + Function: funcName(fn), + Line: pos.Line, + Cyclomatic: CyclomaticComplexity(fn.Body), + Cognitive: CognitiveComplexity(fn), + }) + } + return scores, nil +} + +func funcName(fn *ast.FuncDecl) string { + if fn.Recv != nil && len(fn.Recv.List) > 0 { + recv := exprString(fn.Recv.List[0].Type) + if recv != "" { + return fmt.Sprintf("(%s).%s", recv, fn.Name.Name) + } + } + return fn.Name.Name +} + +func exprString(e ast.Expr) string { + switch v := e.(type) { + case *ast.Ident: + return v.Name + case *ast.StarExpr: + return "*" + exprString(v.X) + case *ast.IndexExpr: + return exprString(v.X) + case *ast.IndexListExpr: + return exprString(v.X) + } + return "" +} + +// ErrNoPaths is returned when Analyze is invoked with no resolvable paths. +var ErrNoPaths = errors.New("complexity: no paths provided") diff --git a/internal/complexity/analyzer_test.go b/internal/complexity/analyzer_test.go new file mode 100644 index 000000000..d24e06fff --- /dev/null +++ b/internal/complexity/analyzer_test.go @@ -0,0 +1,142 @@ +package complexity + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestAnalyze_EmptyPaths(t *testing.T) { + report, err := Analyze(nil, Options{}) + if err != nil { + t.Fatalf("Analyze(nil) error = %v, want nil", err) + } + if len(report.Scores) != 0 { + t.Fatalf("Analyze(nil) scored %d funcs, want 0", len(report.Scores)) + } +} + +func TestAnalyze_FixturesFile(t *testing.T) { + report, err := Analyze([]string{"testdata/fixtures.go"}, Options{}) + if err != nil { + t.Fatalf("Analyze fixtures: %v", err) + } + if len(report.Scores) != 12 { + t.Fatalf("Analyze produced %d scores, want 12", len(report.Scores)) + } + // All scores should carry their package name. + for _, s := range report.Scores { + if s.Package != "fixtures" { + t.Fatalf("score for %s has package=%q, want fixtures", s.Function, s.Package) + } + } +} + +func TestAnalyze_BrokenFile(t *testing.T) { + _, err := Analyze([]string{"testdata/broken/broken.go"}, Options{}) + if err == nil { + t.Fatalf("Analyze(broken) error = nil, want parse error") + } + if !strings.Contains(err.Error(), "broken.go") { + t.Fatalf("error = %q, want path in message", err.Error()) + } +} + +func TestAnalyze_MixedDir(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "ok.go"), + []byte("package x\nfunc F() int { return 0 }\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "README.md"), + []byte("# not Go\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "extra_test.go"), + []byte("package x\nfunc TestF() {}\n"), 0o644); err != nil { + t.Fatal(err) + } + report, err := Analyze([]string{dir}, Options{}) + if err != nil { + t.Fatalf("Analyze mixed dir: %v", err) + } + if len(report.Scores) != 1 { + t.Fatalf("expected 1 scored func (ok.go::F only), got %d", len(report.Scores)) + } + // Now include tests; should pick up both. + report, err = Analyze([]string{dir}, Options{IncludeTests: true}) + if err != nil { + t.Fatalf("Analyze mixed dir with tests: %v", err) + } + if len(report.Scores) != 2 { + t.Fatalf("expected 2 scored funcs with IncludeTests, got %d", len(report.Scores)) + } +} + +func TestAnalyze_ExcludePattern(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "keep.go"), + []byte("package x\nfunc K() {}\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "skip.go"), + []byte("package x\nfunc S() {}\n"), 0o644); err != nil { + t.Fatal(err) + } + report, err := Analyze([]string{dir}, Options{Excludes: []string{"skip"}}) + if err != nil { + t.Fatalf("Analyze with exclude: %v", err) + } + if len(report.Scores) != 1 || report.Scores[0].Function != "K" { + t.Fatalf("expected only K, got %+v", report.Scores) + } +} + +func TestAnalyze_VendorSkipped(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "main.go"), + []byte("package x\nfunc M() {}\n"), 0o644); err != nil { + t.Fatal(err) + } + vendor := filepath.Join(dir, "vendor", "lib") + if err := os.MkdirAll(vendor, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(vendor, "lib.go"), + []byte("package lib\nfunc L() {}\n"), 0o644); err != nil { + t.Fatal(err) + } + report, err := Analyze([]string{dir}, Options{}) + if err != nil { + t.Fatalf("Analyze: %v", err) + } + if len(report.Scores) != 1 || report.Scores[0].Function != "M" { + t.Fatalf("expected only M (vendor skipped), got %+v", report.Scores) + } +} + +func TestAnalyze_RaceSafe(t *testing.T) { + dir := t.TempDir() + for i := 0; i < 8; i++ { + path := filepath.Join(dir, "f"+string(rune('a'+i))+".go") + body := "package x\nfunc F" + string(rune('A'+i)) + "() int { if true { return 1 }; return 0 }\n" + if err := os.WriteFile(path, []byte(body), 0o644); err != nil { + t.Fatal(err) + } + } + report, err := Analyze([]string{dir}, Options{Concurrency: 4}) + if err != nil { + t.Fatalf("Analyze: %v", err) + } + if len(report.Scores) != 8 { + t.Fatalf("expected 8 scored funcs, got %d", len(report.Scores)) + } +} + +func TestAnalyze_MissingPath(t *testing.T) { + _, err := Analyze([]string{filepath.Join(t.TempDir(), "does-not-exist")}, Options{}) + if err == nil { + t.Fatalf("Analyze(missing) error = nil, want stat error") + } +} diff --git a/internal/complexity/cognitive.go b/internal/complexity/cognitive.go new file mode 100644 index 000000000..cff675679 --- /dev/null +++ b/internal/complexity/cognitive.go @@ -0,0 +1,249 @@ +package complexity + +import ( + "go/ast" + "go/token" +) + +// CognitiveComplexity returns the cognitive complexity score for a function +// per Sonar's specification. +// +// Rules (summary): +// - Each control-flow break (if/for/range/switch/select, labeled break/ +// continue, goto) adds 1 + current nesting depth. +// - Else and else-if add 1 (no nesting bonus). +// - Logical-operator chains: each transition between && and || adds 1; a +// run of identical logical operators counts once at the first occurrence. +// - Recursion (call to the enclosing function by name) adds 1. +// - Function literals reset nesting for their own body but accumulate into +// the outer total. +func CognitiveComplexity(fn *ast.FuncDecl) int { + if fn == nil || fn.Body == nil { + return 0 + } + w := &cognitiveWalker{name: fn.Name.Name} + w.walkBlock(fn.Body, 0) + return w.score +} + +// CognitiveComplexityFunc computes cognitive complexity for a function literal +// or block. funcName is used for recursion detection; pass "" if unknown. +func CognitiveComplexityFunc(name string, body *ast.BlockStmt) int { + if body == nil { + return 0 + } + w := &cognitiveWalker{name: name} + w.walkBlock(body, 0) + return w.score +} + +type cognitiveWalker struct { + name string + score int +} + +func (w *cognitiveWalker) walkBlock(b *ast.BlockStmt, nesting int) { + if b == nil { + return + } + for _, s := range b.List { + w.walkStmt(s, nesting) + } +} + +func (w *cognitiveWalker) walkStmt(s ast.Stmt, nesting int) { + if s == nil { + return + } + if w.walkControlFlow(s, nesting) { + return + } + w.walkLeafStmt(s, nesting) +} + +// walkControlFlow handles statements that increment cognitive score. +// Returns true if s was handled. +func (w *cognitiveWalker) walkControlFlow(s ast.Stmt, nesting int) bool { + switch n := s.(type) { + case *ast.IfStmt: + w.walkIf(n, nesting) + case *ast.ForStmt: + w.score += 1 + nesting + w.walkExpr(n.Cond, nesting) + w.walkBlock(n.Body, nesting+1) + case *ast.RangeStmt: + w.score += 1 + nesting + w.walkBlock(n.Body, nesting+1) + case *ast.SwitchStmt: + w.score += 1 + nesting + w.walkExpr(n.Tag, nesting) + w.walkCaseList(n.Body, nesting+1) + case *ast.TypeSwitchStmt: + w.score += 1 + nesting + w.walkCaseList(n.Body, nesting+1) + case *ast.SelectStmt: + w.score += 1 + nesting + w.walkCommList(n.Body, nesting+1) + case *ast.BranchStmt: + if n.Tok == token.GOTO || n.Label != nil { + w.score++ + } + default: + return false + } + return true +} + +// walkLeafStmt recurses through statements that don't increment the score. +func (w *cognitiveWalker) walkLeafStmt(s ast.Stmt, nesting int) { + switch n := s.(type) { + case *ast.BlockStmt: + w.walkBlock(n, nesting) + case *ast.LabeledStmt: + w.walkStmt(n.Stmt, nesting) + case *ast.DeferStmt: + w.walkExpr(n.Call, nesting) + case *ast.GoStmt: + w.walkExpr(n.Call, nesting) + case *ast.ExprStmt: + w.walkExpr(n.X, nesting) + case *ast.AssignStmt: + for _, e := range n.Rhs { + w.walkExpr(e, nesting) + } + case *ast.ReturnStmt: + for _, e := range n.Results { + w.walkExpr(e, nesting) + } + case *ast.IncDecStmt: + w.walkExpr(n.X, nesting) + case *ast.SendStmt: + w.walkExpr(n.Value, nesting) + } +} + +func (w *cognitiveWalker) walkIf(n *ast.IfStmt, nesting int) { + w.score += 1 + nesting + w.walkExpr(n.Cond, nesting) + w.walkBlock(n.Body, nesting+1) + switch e := n.Else.(type) { + case *ast.BlockStmt: + w.score++ // else: +1 (no nesting bonus) + w.walkBlock(e, nesting+1) + case *ast.IfStmt: + w.score++ // else-if: +1 (no nesting bonus) + w.walkExpr(e.Cond, nesting) + w.walkBlock(e.Body, nesting+1) + w.walkElseChain(e.Else, nesting) + } +} + +func (w *cognitiveWalker) walkCaseList(body *ast.BlockStmt, nesting int) { + if body == nil { + return + } + for _, c := range body.List { + cc, ok := c.(*ast.CaseClause) + if !ok { + continue + } + for _, st := range cc.Body { + w.walkStmt(st, nesting) + } + } +} + +func (w *cognitiveWalker) walkCommList(body *ast.BlockStmt, nesting int) { + if body == nil { + return + } + for _, c := range body.List { + cc, ok := c.(*ast.CommClause) + if !ok { + continue + } + for _, st := range cc.Body { + w.walkStmt(st, nesting) + } + } +} + +func (w *cognitiveWalker) walkElseChain(s ast.Stmt, nesting int) { + switch e := s.(type) { + case *ast.BlockStmt: + w.score++ + w.walkBlock(e, nesting+1) + case *ast.IfStmt: + w.score++ + w.walkExpr(e.Cond, nesting) + w.walkBlock(e.Body, nesting+1) + w.walkElseChain(e.Else, nesting) + } +} + +func (w *cognitiveWalker) walkExpr(e ast.Expr, nesting int) { + if e == nil { + return + } + switch n := e.(type) { + case *ast.BinaryExpr: + if n.Op == token.LAND || n.Op == token.LOR { + w.scoreBoolChain(n) + return + } + w.walkExpr(n.X, nesting) + w.walkExpr(n.Y, nesting) + case *ast.ParenExpr: + w.walkExpr(n.X, nesting) + case *ast.UnaryExpr: + w.walkExpr(n.X, nesting) + case *ast.CallExpr: + // Recursion detection: direct call to enclosing function name. + if w.name != "" { + if id, ok := n.Fun.(*ast.Ident); ok && id.Name == w.name { + w.score++ + } + } + w.walkExpr(n.Fun, nesting) + for _, a := range n.Args { + w.walkExpr(a, nesting) + } + case *ast.FuncLit: + // Function literals contribute to the enclosing total but reset + // nesting for the lambda body. + w.walkBlock(n.Body, 0) + case *ast.IndexExpr: + w.walkExpr(n.X, nesting) + w.walkExpr(n.Index, nesting) + case *ast.SelectorExpr: + w.walkExpr(n.X, nesting) + } +} + +// scoreBoolChain walks a chain of && / || and adds one increment per group +// of consecutive identical operators. A flat run of `a && b && c` counts as +// one. An alternation `a && b || c` counts as two. +func (w *cognitiveWalker) scoreBoolChain(root *ast.BinaryExpr) { + var ops []token.Token + var collect func(e ast.Expr) + collect = func(e ast.Expr) { + if be, ok := e.(*ast.BinaryExpr); ok && (be.Op == token.LAND || be.Op == token.LOR) { + collect(be.X) + ops = append(ops, be.Op) + collect(be.Y) + return + } + // recurse into the operand for nested control flow / calls + w.walkExpr(e, 0) + } + collect(root) + if len(ops) == 0 { + return + } + w.score++ + for i := 1; i < len(ops); i++ { + if ops[i] != ops[i-1] { + w.score++ + } + } +} diff --git a/internal/complexity/cognitive_test.go b/internal/complexity/cognitive_test.go new file mode 100644 index 000000000..39425cd8a --- /dev/null +++ b/internal/complexity/cognitive_test.go @@ -0,0 +1,39 @@ +package complexity + +import "testing" + +func TestCognitiveComplexity(t *testing.T) { + file := parseFixtures(t) + cases := []struct { + fn string + want int + }{ + {"Linear", 0}, + {"IfOnly", 1}, + {"IfElse", 2}, + {"IfElseIf", 2}, + {"AndOr", 1}, + {"MixedBool", 2}, + {"RangeIf", 3}, + {"DeeplyNested", 6}, + {"SwitchThree", 1}, + {"TypeSwitchTwo", 1}, + {"Recursive", 2}, + {"WithClosure", 1}, + } + for _, tc := range cases { + t.Run(tc.fn, func(t *testing.T) { + fn := funcByName(t, file, tc.fn) + got := CognitiveComplexity(fn) + if got != tc.want { + t.Fatalf("CognitiveComplexity(%s) = %d, want %d", tc.fn, got, tc.want) + } + }) + } +} + +func TestCognitiveComplexity_NilFunc(t *testing.T) { + if got := CognitiveComplexity(nil); got != 0 { + t.Fatalf("CognitiveComplexity(nil) = %d, want 0", got) + } +} diff --git a/internal/complexity/cyclomatic.go b/internal/complexity/cyclomatic.go new file mode 100644 index 000000000..1beffd9e9 --- /dev/null +++ b/internal/complexity/cyclomatic.go @@ -0,0 +1,50 @@ +package complexity + +import ( + "go/ast" + "go/token" +) + +// CyclomaticComplexity returns the McCabe cyclomatic complexity for a function +// declaration or function literal body. Returns 1 for an empty body. +// +// Counted decision points: +// - *ast.IfStmt (if, else-if) +// - *ast.ForStmt +// - *ast.RangeStmt +// - *ast.CaseClause (non-default) +// - *ast.CommClause (non-default, select) +// - *ast.BinaryExpr with token.LAND or token.LOR +// +// Function literals nested inside the body are walked too — their decision +// points contribute to the enclosing function's score, matching gocyclo. +func CyclomaticComplexity(body *ast.BlockStmt) int { + if body == nil { + return 1 + } + score := 1 + ast.Inspect(body, func(n ast.Node) bool { + switch v := n.(type) { + case *ast.IfStmt: + score++ + case *ast.ForStmt: + score++ + case *ast.RangeStmt: + score++ + case *ast.CaseClause: + if len(v.List) > 0 { + score++ + } + case *ast.CommClause: + if v.Comm != nil { + score++ + } + case *ast.BinaryExpr: + if v.Op == token.LAND || v.Op == token.LOR { + score++ + } + } + return true + }) + return score +} diff --git a/internal/complexity/cyclomatic_test.go b/internal/complexity/cyclomatic_test.go new file mode 100644 index 000000000..6b41aa98d --- /dev/null +++ b/internal/complexity/cyclomatic_test.go @@ -0,0 +1,65 @@ +package complexity + +import ( + "go/ast" + "go/parser" + "go/token" + "testing" +) + +func parseFixtures(t *testing.T) *ast.File { + t.Helper() + fset := token.NewFileSet() + file, err := parser.ParseFile(fset, "testdata/fixtures.go", nil, parser.SkipObjectResolution) + if err != nil { + t.Fatalf("parse fixtures: %v", err) + } + return file +} + +func funcByName(t *testing.T, file *ast.File, name string) *ast.FuncDecl { + t.Helper() + for _, d := range file.Decls { + if fn, ok := d.(*ast.FuncDecl); ok && fn.Name.Name == name { + return fn + } + } + t.Fatalf("function %q not found in fixtures", name) + return nil +} + +func TestCyclomaticComplexity(t *testing.T) { + file := parseFixtures(t) + cases := []struct { + fn string + want int + }{ + {"Linear", 1}, + {"IfOnly", 2}, + {"IfElse", 2}, + {"IfElseIf", 3}, + {"AndOr", 2}, + {"MixedBool", 3}, + {"RangeIf", 3}, + {"DeeplyNested", 4}, + {"SwitchThree", 4}, + {"TypeSwitchTwo", 3}, + {"Recursive", 2}, + {"WithClosure", 2}, + } + for _, tc := range cases { + t.Run(tc.fn, func(t *testing.T) { + fn := funcByName(t, file, tc.fn) + got := CyclomaticComplexity(fn.Body) + if got != tc.want { + t.Fatalf("CyclomaticComplexity(%s) = %d, want %d", tc.fn, got, tc.want) + } + }) + } +} + +func TestCyclomaticComplexity_NilBody(t *testing.T) { + if got := CyclomaticComplexity(nil); got != 1 { + t.Fatalf("CyclomaticComplexity(nil) = %d, want 1", got) + } +} diff --git a/internal/complexity/doc.go b/internal/complexity/doc.go new file mode 100644 index 000000000..fe81068f3 --- /dev/null +++ b/internal/complexity/doc.go @@ -0,0 +1,31 @@ +// Package complexity provides in-tree Go AST complexity analysis for the +// wave audit pipeline. It computes cyclomatic and cognitive complexity scores +// per function and emits findings compatible with the shared-findings JSON +// schema used by audit pipelines. +// +// # Cyclomatic complexity +// +// The classic McCabe metric: count of linearly independent paths through a +// function. Implementation counts one for the function entry plus one for each +// of: if, else if, for, range, case clause, communication clause, &&, ||. +// Range clauses inside switch/select are counted once. Default cases are not +// counted. +// +// # Cognitive complexity +// +// Sonar's cognitive complexity rule set (https://www.sonarsource.com/docs/ +// CognitiveComplexity.pdf): +// +// - Increments: if, else if, else, ternary, switch, for, range, case clauses, +// catch (defer), goto, break/continue with label, recursion, &&, ||. +// - Nesting bonus: control-flow constructs add (1 + nesting depth) instead of +// just 1, where nesting is the count of enclosing control-flow nodes. +// - Shorthand for boolean sequences: a chain of && or || counts once at the +// first occurrence; alternations between && and || each add one. +// +// # Thresholds +// +// Default thresholds: cyclomatic and cognitive complexity must be ≤ 15 to +// pass; ≥ 10 emits a medium-severity finding (warn). Both are configurable +// via Options. +package complexity diff --git a/internal/complexity/findings.go b/internal/complexity/findings.go new file mode 100644 index 000000000..f5620aea0 --- /dev/null +++ b/internal/complexity/findings.go @@ -0,0 +1,124 @@ +package complexity + +import ( + "fmt" + "time" +) + +// Finding maps a per-function complexity breach to the shared-findings +// JSON schema used by audit pipelines (see contracts/shared-findings.schema.json). +type Finding struct { + Type string `json:"type"` + Severity string `json:"severity"` + Package string `json:"package,omitempty"` + File string `json:"file,omitempty"` + Line int `json:"line,omitempty"` + Item string `json:"item,omitempty"` + Description string `json:"description,omitempty"` + Evidence string `json:"evidence,omitempty"` + Recommendation string `json:"recommendation,omitempty"` +} + +// FindingsDocument is the shared-findings root object. +type FindingsDocument struct { + Findings []Finding `json:"findings"` + Summary string `json:"summary,omitempty"` + ScanType string `json:"scan_type,omitempty"` + ScannedAt string `json:"scanned_at,omitempty"` +} + +// ToSharedFindings maps over-threshold function scores to shared-findings +// objects. Functions exceeding the fail threshold get severity "high"; those +// in the warn band get severity "medium". Functions below the warn threshold +// produce no finding. +func ToSharedFindings(report Report, opts Options) FindingsDocument { + opts = opts.withDefaults() + out := FindingsDocument{ + ScanType: "complexity", + ScannedAt: report.ScannedAt.Format(time.RFC3339), + } + if out.ScannedAt == "0001-01-01T00:00:00Z" { + out.ScannedAt = time.Now().UTC().Format(time.RFC3339) + } + var failCount, warnCount int + for _, s := range report.Scores { + if f, ok := cyclomaticFinding(s, opts); ok { + if f.Severity == "high" { + failCount++ + } else { + warnCount++ + } + out.Findings = append(out.Findings, f) + } + if f, ok := cognitiveFinding(s, opts); ok { + if f.Severity == "high" { + failCount++ + } else { + warnCount++ + } + out.Findings = append(out.Findings, f) + } + } + out.Summary = fmt.Sprintf( + "%d function(s) scanned, %d breach (high), %d warn (medium)", + len(report.Scores), failCount, warnCount, + ) + return out +} + +func cyclomaticFinding(s FunctionScore, opts Options) (Finding, bool) { + if s.Cyclomatic <= opts.WarnCyclomatic { + return Finding{}, false + } + severity := "medium" + threshold := opts.WarnCyclomatic + if s.Cyclomatic > opts.MaxCyclomatic { + severity = "high" + threshold = opts.MaxCyclomatic + } + return Finding{ + Type: "complexity", + Severity: severity, + Package: s.Package, + File: s.File, + Line: s.Line, + Item: s.Function, + Description: fmt.Sprintf("cyclomatic complexity %d exceeds threshold %d", s.Cyclomatic, threshold), + Evidence: fmt.Sprintf("cyclomatic=%d cognitive=%d", s.Cyclomatic, s.Cognitive), + Recommendation: "refactor", + }, true +} + +func cognitiveFinding(s FunctionScore, opts Options) (Finding, bool) { + if s.Cognitive <= opts.WarnCognitive { + return Finding{}, false + } + severity := "medium" + threshold := opts.WarnCognitive + if s.Cognitive > opts.MaxCognitive { + severity = "high" + threshold = opts.MaxCognitive + } + return Finding{ + Type: "complexity", + Severity: severity, + Package: s.Package, + File: s.File, + Line: s.Line, + Item: s.Function, + Description: fmt.Sprintf("cognitive complexity %d exceeds threshold %d", s.Cognitive, threshold), + Evidence: fmt.Sprintf("cyclomatic=%d cognitive=%d", s.Cyclomatic, s.Cognitive), + Recommendation: "refactor", + }, true +} + +// HasBreach returns true when any finding has severity "high" — i.e., any +// function exceeded a fail threshold. +func (d FindingsDocument) HasBreach() bool { + for _, f := range d.Findings { + if f.Severity == "high" { + return true + } + } + return false +} diff --git a/internal/complexity/findings_test.go b/internal/complexity/findings_test.go new file mode 100644 index 000000000..3db56234f --- /dev/null +++ b/internal/complexity/findings_test.go @@ -0,0 +1,121 @@ +package complexity + +import ( + "encoding/json" + "os" + "testing" + "time" + + "github.com/santhosh-tekuri/jsonschema/v6" +) + +func TestToSharedFindings_SeverityMapping(t *testing.T) { + report := Report{ + ScannedAt: time.Now().UTC(), + Scores: []FunctionScore{ + {File: "a.go", Function: "Pass", Cyclomatic: 5, Cognitive: 3}, + {File: "b.go", Function: "Warn", Cyclomatic: 12, Cognitive: 8}, + {File: "c.go", Function: "FailCyclo", Cyclomatic: 20, Cognitive: 5}, + {File: "d.go", Function: "FailCog", Cyclomatic: 5, Cognitive: 22}, + {File: "e.go", Function: "FailBoth", Cyclomatic: 30, Cognitive: 30}, + }, + } + doc := ToSharedFindings(report, Options{}) + // Pass: no findings + // Warn: 1 cyclomatic medium (cog 8 ≤ 10 warn) + // FailCyclo: 1 high + // FailCog: 1 high + // FailBoth: 2 high + if got, want := len(doc.Findings), 5; got != want { + t.Fatalf("findings count = %d, want %d (%+v)", got, want, doc.Findings) + } + if !doc.HasBreach() { + t.Fatalf("HasBreach = false, want true") + } + highs, mediums := 0, 0 + for _, f := range doc.Findings { + if f.Type != "complexity" { + t.Fatalf("finding type = %q, want complexity", f.Type) + } + switch f.Severity { + case "high": + highs++ + case "medium": + mediums++ + default: + t.Fatalf("unexpected severity %q", f.Severity) + } + } + if highs != 4 || mediums != 1 { + t.Fatalf("high=%d medium=%d, want 4/1", highs, mediums) + } +} + +func TestToSharedFindings_AllPass(t *testing.T) { + report := Report{ + Scores: []FunctionScore{ + {File: "a.go", Function: "Easy", Cyclomatic: 2, Cognitive: 0}, + }, + } + doc := ToSharedFindings(report, Options{}) + if len(doc.Findings) != 0 { + t.Fatalf("expected no findings, got %+v", doc.Findings) + } + if doc.HasBreach() { + t.Fatalf("HasBreach = true, want false") + } +} + +func TestToSharedFindings_ValidatesAgainstSchema(t *testing.T) { + report := Report{ + ScannedAt: time.Now().UTC(), + Scores: []FunctionScore{ + {File: "a.go", Package: "x", Function: "Big", Line: 10, Cyclomatic: 30, Cognitive: 30}, + }, + } + doc := ToSharedFindings(report, Options{}) + body, err := json.Marshal(doc) + if err != nil { + t.Fatalf("marshal: %v", err) + } + // Locate the shared-findings schema relative to the repo root. + schemaPath := findSchemaFile(t) + schemaSrc, err := os.ReadFile(schemaPath) + if err != nil { + t.Fatalf("read schema: %v", err) + } + var schemaDoc any + if err := json.Unmarshal(schemaSrc, &schemaDoc); err != nil { + t.Fatalf("parse schema: %v", err) + } + compiler := jsonschema.NewCompiler() + if err := compiler.AddResource("shared-findings.schema.json", schemaDoc); err != nil { + t.Fatalf("add schema: %v", err) + } + schema, err := compiler.Compile("shared-findings.schema.json") + if err != nil { + t.Fatalf("compile schema: %v", err) + } + var artifact any + if err := json.Unmarshal(body, &artifact); err != nil { + t.Fatalf("parse findings: %v", err) + } + if err := schema.Validate(artifact); err != nil { + t.Fatalf("findings JSON failed schema validation: %v", err) + } +} + +func findSchemaFile(t *testing.T) string { + t.Helper() + candidates := []string{ + "../../.agents/contracts/shared-findings.schema.json", + "../../internal/defaults/contracts/shared-findings.schema.json", + } + for _, p := range candidates { + if _, err := os.Stat(p); err == nil { + return p + } + } + t.Fatalf("shared-findings schema not found in %v", candidates) + return "" +} diff --git a/internal/complexity/testdata/broken/broken.go b/internal/complexity/testdata/broken/broken.go new file mode 100644 index 000000000..db2b13486 --- /dev/null +++ b/internal/complexity/testdata/broken/broken.go @@ -0,0 +1,8 @@ +// Deliberately broken Go source. Lives under testdata/ so Go tooling +// ignores it; the complexity analyzer is expected to surface a parse error +// when pointed at this file. +package broken + +func Broken() int { + return 1 + +} diff --git a/internal/complexity/testdata/fixtures.go b/internal/complexity/testdata/fixtures.go new file mode 100644 index 000000000..1aa3f1be4 --- /dev/null +++ b/internal/complexity/testdata/fixtures.go @@ -0,0 +1,131 @@ +// Package fixtures contains hand-crafted Go functions with known +// cyclomatic and cognitive complexity scores. Used by analyzer/visitor tests. +// +// This file lives under testdata/ so the toolchain ignores it for normal +// `go build` / `go test` runs. The complexity analyzer parses it directly +// from disk via parser.ParseFile. +package fixtures + +// Linear is the no-branch baseline. +// cyclomatic = 1, cognitive = 0 +func Linear() int { + return 1 + 2 +} + +// IfOnly is a single-branch if. +// cyclomatic = 2, cognitive = 1 +func IfOnly(x int) int { + if x > 0 { + return 1 + } + return 0 +} + +// IfElse adds a final else. +// cyclomatic = 2, cognitive = 2 +func IfElse(x int) int { + if x > 0 { + return 1 + } else { + return 0 + } +} + +// IfElseIf chains conditions. +// cyclomatic = 3, cognitive = 2 +func IfElseIf(x int) int { + if x > 0 { + return 1 + } else if x < 0 { + return -1 + } + return 0 +} + +// AndOr is a single boolean chain (one operator transition). +// cyclomatic = 2, cognitive = 1 +func AndOr(x, y int) bool { + return x > 0 && y > 0 +} + +// MixedBool alternates && and || in one expression. +// cyclomatic = 3, cognitive = 2 +func MixedBool(x, y, z int) bool { + return x > 0 && y > 0 || z > 0 +} + +// RangeIf nests an if inside a range loop. +// cyclomatic = 3, cognitive = 3 +func RangeIf(items []int) int { + sum := 0 + for _, x := range items { + if x > 0 { + sum += x + } + } + return sum +} + +// DeeplyNested has two-deep loop nesting plus an if at depth 2. +// cyclomatic = 4, cognitive = 6 +func DeeplyNested(matrix [][]int) int { + total := 0 + for _, row := range matrix { + for _, val := range row { + if val > 0 { + total += val + } + } + } + return total +} + +// SwitchThree has three non-default cases. +// cyclomatic = 4, cognitive = 1 +func SwitchThree(x int) string { + switch x { + case 1: + return "one" + case 2: + return "two" + case 3: + return "three" + default: + return "other" + } +} + +// TypeSwitchTwo has two type cases. +// cyclomatic = 3, cognitive = 1 +func TypeSwitchTwo(x interface{}) string { + switch v := x.(type) { + case int: + _ = v + return "int" + case string: + return "string" + } + return "other" +} + +// Recursive calls itself directly. Cognitive picks up +1 for the recursion. +// cyclomatic = 2, cognitive = 2 +func Recursive(n int) int { + if n <= 1 { + return 1 + } + return n * Recursive(n-1) +} + +// WithClosure has an if inside a function literal that participates in the +// outer total. +// cyclomatic = 2, cognitive = 1 +func WithClosure() int { + f := func(x int) int { + if x > 0 { + return x + } + return 0 + } + return f(5) +} diff --git a/internal/defaults/contracts/shared-findings.schema.json b/internal/defaults/contracts/shared-findings.schema.json index f7ece7ad1..b619aebf5 100644 --- a/internal/defaults/contracts/shared-findings.schema.json +++ b/internal/defaults/contracts/shared-findings.schema.json @@ -14,7 +14,7 @@ "type": { "type": "string", "description": "Finding category", - "enum": ["dead-code", "unwired", "duplicate", "doc-drift", "junk-code", "security", "dx", "performance", "style", "correctness", "architecture", "test", "coverage", "other"] + "enum": ["dead-code", "unwired", "duplicate", "doc-drift", "junk-code", "security", "dx", "performance", "style", "correctness", "architecture", "test", "coverage", "complexity", "other"] }, "severity": { "type": "string", diff --git a/internal/defaults/pipelines/audit-complexity.yaml b/internal/defaults/pipelines/audit-complexity.yaml new file mode 100644 index 000000000..71713d478 --- /dev/null +++ b/internal/defaults/pipelines/audit-complexity.yaml @@ -0,0 +1,74 @@ +# Complexity Audit — Deterministic In-Tree Analysis +# +# Unlike audit-security/audit-architecture/etc. (which run an LLM persona over +# the source), this pipeline calls the in-tree `wave audit complexity` command +# to score Go functions by cyclomatic and cognitive complexity. Output conforms +# to the shared-findings schema so it composes with other audits via +# ops-parallel-audit's iterate fan-out. +# +# A `type: command` step is correct here: complexity is a quantitative, +# deterministic metric — using an LLM would be slow, expensive, and unstable. + +kind: WavePipeline +metadata: + name: audit-complexity + description: >- + Deterministic complexity audit. Scores Go functions by cyclomatic and + cognitive complexity, fails the pipeline when any function exceeds the + configured thresholds, and emits findings in shared-findings JSON format + for cross-pipeline aggregation. + category: composition + release: true + +input: + source: cli + type: string + example: "internal/pipeline" + schema: + type: string + description: "Scope to audit — package path, directory, or '.' for repo root" + +chat_context: + artifact_summaries: + - findings + suggested_questions: + - "Which functions exceed the cyclomatic threshold?" + - "Which functions exceed the cognitive threshold?" + focus_areas: + - "Per-function cyclomatic and cognitive complexity" + - "Refactoring candidates above threshold" + +pipeline_outputs: + findings: + step: scan + artifact: findings + type: findings_report + +steps: + - id: scan + type: command + workspace: + mount: + - source: ./ + target: /project + mode: readonly + script: | + set -o pipefail + mkdir -p .agents/output + wave audit complexity \ + --output .agents/output/findings.json \ + --max-cyclomatic 15 \ + --max-cognitive 15 \ + --warn-cyclomatic 10 \ + --warn-cognitive 10 \ + {{ input }} + output_artifacts: + - name: findings + path: .agents/output/findings.json + type: json + handover: + contract: + type: json_schema + source: .agents/output/findings.json + schema_path: .agents/contracts/shared-findings.schema.json + on_failure: fail diff --git a/internal/defaults/pipelines/ops-parallel-audit.yaml b/internal/defaults/pipelines/ops-parallel-audit.yaml index 81ad57de8..426b88baf 100644 --- a/internal/defaults/pipelines/ops-parallel-audit.yaml +++ b/internal/defaults/pipelines/ops-parallel-audit.yaml @@ -4,10 +4,11 @@ # # Execution flow: # -# run-audits ← iterate (parallel, max_concurrent: 3): fan out over -# ├─ audit-security │ [security, dead-code, dx] — each runs its audit -# ├─ audit-dead-code-scan │ pipeline and emits findings JSON -# └─ audit-duplicates │ +# run-audits ← iterate (parallel, max_concurrent: 4): fan out over +# ├─ audit-security │ [security, dead-code, duplicates, complexity] — +# ├─ audit-dead-code-scan │ each runs its audit pipeline and emits findings. +# ├─ audit-duplicates │ +# └─ audit-complexity │ # │ # merge-findings ← aggregate (merge_arrays): collect all findings arrays # │ into one unified JSON list @@ -19,9 +20,9 @@ metadata: name: ops-parallel-audit description: >- Parallel audit pipeline that fans out security vulnerability scanning, - dead-code detection, and developer experience analysis as concurrent - sub-pipelines. Aggregates all findings into a unified report sorted - by severity and actionability. + dead-code detection, duplicate analysis, and complexity scoring as + concurrent sub-pipelines. Aggregates all findings into a unified report + sorted by severity and actionability. category: composition release: true @@ -54,17 +55,17 @@ pipeline_outputs: type: findings_report steps: - # ── Step 1: fan out over the three audit types in parallel ──────────────── + # ── Step 1: fan out over the four audit types in parallel ──────────────── # # Each item is the name of an existing audit pipeline. The iterate primitive - # spawns up to 3 workers simultaneously, one per audit type. + # spawns up to 4 workers simultaneously, one per audit type. - id: run-audits pipeline: "{{ item }}" input: "{{ input }}" iterate: - over: '["audit-security", "audit-dead-code-scan", "audit-duplicates"]' + over: '["audit-security", "audit-dead-code-scan", "audit-duplicates", "audit-complexity"]' mode: parallel - max_concurrent: 3 + max_concurrent: 4 # ── Step 2: merge all findings arrays into one flat list ───────────────── # @@ -92,8 +93,8 @@ steps: source: | ## Objective - Synthesize the merged findings from three parallel audits (security, dead-code, - and developer experience) into a single, prioritized markdown report. This report + Synthesize the merged findings from four parallel audits (security, dead-code, + duplicates, and complexity) into a single, prioritized markdown report. This report is the primary deliverable of the ops-parallel-audit pipeline — it should give a technical lead or project maintainer a clear picture of codebase health, highlight the most urgent issues, and provide an ordered action plan. The report must be @@ -103,13 +104,14 @@ steps: ## Context - Three parallel audit sub-pipelines have already completed: + Four parallel audit sub-pipelines have already completed: 1. **audit-security**: scanned for vulnerabilities, injection risks, credential exposure, and authentication gaps 2. **audit-dead-code-scan**: identified unused functions, unreachable code paths, orphaned files, and unnecessary dependencies - 3. **audit-duplicates**: evaluated developer experience — documentation quality, error - message clarity, onboarding friction, and tooling gaps + 3. **audit-duplicates**: detected near-duplicate code blocks and suggested merges + 4. **audit-complexity**: deterministic in-tree analyzer — flags Go functions whose + cyclomatic or cognitive complexity exceeds configured thresholds Their findings have been aggregated by the merge-findings step into a single flat JSON array via the merge-findings aggregation step. Each finding object in diff --git a/specs/1041-complexity-audit-step/plan.md b/specs/1041-complexity-audit-step/plan.md new file mode 100644 index 000000000..918b531d8 --- /dev/null +++ b/specs/1041-complexity-audit-step/plan.md @@ -0,0 +1,135 @@ +# Implementation Plan — 1041 Complexity Audit Step + +## Objective + +Add a deterministic, parallel complexity-audit pipeline (`audit-complexity`) that scores Go functions by cyclomatic and cognitive complexity, fails the pipeline when any function exceeds a configurable threshold, and integrates with `ops-parallel-audit` for fan-out execution alongside the other audit pipelines. + +## Approach + +1. **In-tree analyzer** at `internal/complexity/` — uses `go/ast` to walk Go source, scores each `*ast.FuncDecl` for cyclomatic and cognitive complexity, returns a flat slice of per-function scores. No external complexity library; ~250 LOC including both visitors. +2. **Per-file parallelism** inside the analyzer using `golang.org/x/sync/errgroup` with `SetLimit(runtime.NumCPU())` so a single audit invocation parallelises across the file set. The `errgroup` import is already a Wave dependency (used by `internal/pipeline/concurrency.go`). +3. **CLI subcommand** `wave audit complexity [paths...]` exposes the analyzer with flags for thresholds, output path, and format. Exits 0 on pass, 1 on threshold breach, ≥2 on parser/IO error. +4. **Pipeline manifest** `audit-complexity.yaml` — single command-step that invokes the CLI subcommand, writes findings JSON conforming to `shared-findings.schema.json`, and gates via `handover.contract.on_failure: fail`. +5. **Parallel composition** — add `audit-complexity` to the `ops-parallel-audit` iterate list; `iterate.parallel` already provides pipeline-level fan-out (max_concurrent: 4 → 5). + +## File Mapping + +### Create + +| Path | Purpose | +|------|---------| +| `internal/complexity/analyzer.go` | Public `Analyze(paths []string, opts Options) (Report, error)`; orchestrates per-file workers via errgroup. | +| `internal/complexity/cyclomatic.go` | `ast.Visitor` that counts decision points (if/for/range/case/&&/\|\|/select-case). | +| `internal/complexity/cognitive.go` | `ast.Visitor` for cognitive complexity (nesting-aware scoring per Sonar rules). | +| `internal/complexity/findings.go` | `ToSharedFindings(report, opts) []Finding` — emits `shared-findings.schema.json`-compatible objects with severity from threshold breach. | +| `internal/complexity/analyzer_test.go` | Golden-fixture unit tests: known-complexity functions, empty input, non-Go files, parser errors, threshold logic. | +| `internal/complexity/testdata/` | Fixture `.go` files with annotated expected scores. | +| `cmd/wave/commands/audit_complexity.go` | Cobra subcommand `wave audit complexity`; flag parsing, JSON write, exit-code mapping. | +| `cmd/wave/commands/audit_complexity_test.go` | Golden-output tests for CLI; exit-code matrix. | +| `internal/defaults/pipelines/audit-complexity.yaml` | Pipeline manifest with single command-step + json_schema contract. | +| `.agents/contracts/complexity-findings.schema.json` | Optional: tighter schema than shared-findings (with `score` numeric field). Defer if shared-findings is sufficient. | +| `specs/1041-complexity-audit-step/{spec,plan,tasks}.md` | This spec. | + +### Modify + +| Path | Change | +|------|--------| +| `cmd/wave/main.go` (or wherever subcommands register) | Register the new `audit complexity` subcommand under an `audit` parent group (create the parent if missing). | +| `internal/defaults/pipelines/ops-parallel-audit.yaml` | Add `audit-complexity` to the iterate list; bump `max_concurrent` from 3 to 4. Update phase-3 report prompt to mention the new audit. | +| `internal/defaults/embed.go` | No change expected — `embed.FS` globs `*.yaml` automatically. Verify with the `all_pipelines_load_test.go` test. | +| `internal/pipeline/all_pipelines_load_test.go` | Should auto-discover the new pipeline; only modify if it has an explicit pipeline list. | + +### Delete + +None. + +## Architecture Decisions + +### ADR 1: In-tree AST analyzer over fzipp/gocyclo embedding + +**Choice**: implement cyclomatic + cognitive scorers from scratch in `internal/complexity/`. +**Why**: ~250 LOC total; no third-party supply-chain surface; matches existing Wave style of in-tree analysis (see `internal/onboarding/flavour.go` matrix). Allows shipping cognitive scoring alongside cyclomatic with one walk. +**Trade-off**: we own the cyclomatic-counting rules forever. Mitigated by golden-fixture tests pinning expected scores. + +### ADR 2: `command` step type instead of `prompt` step type + +**Choice**: the audit pipeline runs `wave audit complexity` via `exec.type: command`, not via an LLM persona prompt. +**Why**: complexity scoring is deterministic — using an LLM would be slow, expensive, and non-deterministic. Existing audit pipelines use `prompt` because they evaluate qualitative concerns; complexity is quantitative. +**Trade-off**: divergence from the visual pattern of other `audit-*` pipelines. Mitigated by clear comment block at the top of the YAML. + +### ADR 3: Output via `shared-findings.schema.json`, not a new schema + +**Choice**: emit findings of `type: "complexity"` with severity derived from threshold ratio. + +Wait — `shared-findings.schema.json` enum does not include `"complexity"`. Options: +1. Add `"complexity"` to the enum (small change, `internal/defaults/contracts/shared-findings.schema.json` and the `.agents/contracts/` copy). +2. Use existing `"performance"` or `"other"` type. + +**Decision**: extend the enum with `"complexity"`. Cleaner downstream filtering in `ops-parallel-audit` triage. + +### ADR 4: Default thresholds — 15 / 15 + +**Choice**: cyclomatic ≤ 15 (fail), cognitive ≤ 15 (fail). Warn at 10. +**Why**: middle-ground of research-comment convergence (15/15, 20/15, 10/20). Matches `golangci-lint`'s `cyclop` default. Configurable via flags so projects can ratchet. + +### ADR 5: Per-file parallelism inside the analyzer + pipeline-level parallelism via iterate + +**Choice**: errgroup with `SetLimit(NumCPU)` inside `Analyze()`; pipeline composition in `ops-parallel-audit` for fan-out across audit types. +**Why**: satisfies "step runs in parallel with other independent steps" (pipeline-level) AND scales scoring across CPU cores within a single audit invocation. + +## Risks & Mitigations + +| Risk | Mitigation | +|------|------------| +| AST-based scoring drifts from `gocyclo` reference | Golden-fixture tests pin expected scores for canonical examples (small/branchy/recursive/closure); `gocyclo` reference output stored in testdata as ground truth. | +| Cognitive complexity rules are subjective | Implement Sonar's published rules verbatim; unit-test each clause (nesting bonus, sequence penalty, jump-to-label). Document rule set in `cognitive.go` package comment. | +| Large repos slow scoring | NumCPU parallelism + skip vendored dirs (`vendor/`, `node_modules/`) by default; flag `--exclude` for additional patterns. | +| `shared-findings.schema.json` enum change breaks consumers | The `complexity` enum addition is additive; existing consumers already accept the enum string. Run `all_pipelines_load_test.go` and the schema validation tests in CI. | +| New CLI subcommand collides with existing wave verbs | `audit` parent group is new; no collision with `do`, `run`, `evolve`, `compose`, etc. Verified via `cmd/wave/commands/` listing. | +| `ops-parallel-audit` aggregator prompt assumes 3 audits | Update the synthesis prompt's hard-coded "Three parallel audit sub-pipelines" wording. | + +## Testing Strategy + +### Unit tests + +- **`internal/complexity/cyclomatic_test.go`**: ~10 fixture functions with hand-counted McCabe scores (linear, single-if, nested-if, switch, for-range, &&-chain, ||-chain, defer, anonymous closure, recursive). Assert exact integer match. +- **`internal/complexity/cognitive_test.go`**: ~10 fixtures spanning Sonar's documented rule classes (nesting penalty, sequence penalty, recursion bonus, label jump). Compare against Sonar's published reference scores. +- **`internal/complexity/analyzer_test.go`**: integration of both visitors over a directory; empty-dir → empty findings; non-Go files filtered; broken `.go` file → error returned with file path; concurrent execution verified via `t.Parallel()` and `-race`. +- **`internal/complexity/findings_test.go`**: severity mapping (`high` ≥ threshold, `medium` ≥ warn, else not emitted), schema-compatible output (validate against `shared-findings.schema.json` via `gojsonschema`). + +### CLI tests + +- **`cmd/wave/commands/audit_complexity_test.go`**: exit-code matrix (no breaches → 0; cyclomatic breach → 1; cognitive breach → 1; parser error → 2; missing path → 2). Stdout is empty by default; stderr carries human summary on breach. Findings JSON written to `--output` path. + +### Pipeline tests + +- **`internal/pipeline/all_pipelines_load_test.go`**: should auto-include `audit-complexity.yaml`; verify schema validation. +- **Contract test**: `wave run audit-complexity internal/complexity/` against the project itself in a smoke test under `pipeline-contract` step (manual, not CI — the CI smoke runs through `ops-parallel-audit`). +- **`ops-parallel-audit` integration**: run on a fixture branch with one over-threshold function; verify the fail propagates through `merge-findings` into the final report. Documented as manual validation step; not a CI gate. + +### Test commands + +```bash +go test ./internal/complexity/... -race +go test ./cmd/wave/commands/ -run AuditComplexity -race +go test ./internal/pipeline/ -run AllPipelinesLoad +``` + +## Validation Checklist (acceptance criteria mapping) + +| AC | Validated by | +|----|--------------| +| Pipeline step exists, invokable via `wave run` | `audit-complexity.yaml` + `all_pipelines_load_test.go` | +| Computes ≥1 complexity metric for Go source | `cyclomatic_test.go` golden fixtures | +| Thresholds configurable | CLI flag tests `--max-cyclomatic`, `--max-cognitive` | +| Runs in parallel with other independent steps | `ops-parallel-audit.yaml` iterate.parallel + manual run | +| Threshold breach → non-zero exit + named function/score | CLI exit-code matrix test | +| All-pass → zero exit + summary | CLI happy-path test, stdout assertion | +| Edge cases (empty list, non-Go, missing config) | `analyzer_test.go` cases | + +## Out of Scope + +- Halstead, linguistic complexity, multi-language support — deferred to v2. +- SARIF 2.1.0 output — deferred to v2; track as follow-up issue. +- Churn/hotspot integration — deferred to v2. +- Auto-ratchet against merge-base — deferred; thresholds are absolute for v1. diff --git a/specs/1041-complexity-audit-step/spec.md b/specs/1041-complexity-audit-step/spec.md new file mode 100644 index 000000000..27364fd87 --- /dev/null +++ b/specs/1041-complexity-audit-step/spec.md @@ -0,0 +1,79 @@ +# feat(pipeline): add parallel complexity audit step with quality gates + +**Issue**: [re-cinq/wave#1041](https://github.com/re-cinq/wave/issues/1041) +**Author**: nextlevelshit +**State**: OPEN +**Labels**: `enhancement`, `pipeline` +**Branch**: `1041-complexity-audit-step` + +--- + +## Problem Statement + +The Wave pipeline currently lacks a complexity audit step that can run in parallel and enforce quality gates. There is no automated mechanism to measure code complexity (cyclomatic, Halstead, or linguistic metrics) and gate pipeline progression based on thresholds. + +## Background / Original Content + +The original issue references [this blog post on code complexity](https://philodev.one/posts/2026-04-code-complexity/) by Sofia Fischer, which surveys complexity metrics including: + +- **Computational complexity** (Big O) — resource growth as input scales +- **Cyclomatic complexity** — linearly independent paths through code +- **Halstead complexity** — mental effort based on operator/operand vocabulary +- **Linguistic complexity** — psycholinguistic predictors of reading difficulty (familiarity, working memory load, coherence) +- **Practical usage** — combining complexity with churn and coupling metrics for refactoring prioritization + +The post argues that complexity metrics are most valuable when used to drive data-based decision-making and visualize refactoring impact, not as forced targets. + +## Expected Behavior + +- A new pipeline step computes complexity metrics for changed files in parallel. +- Configurable thresholds define quality gates (e.g., max cyclomatic complexity per function). +- The step runs concurrently with other independent pipeline steps to minimize wall-clock time. +- Results are reported in a structured format consumable by downstream steps. +- When a gate threshold is exceeded, the pipeline fails with a clear diagnostic message identifying the offending functions/files. + +## Acceptance Criteria + +- [ ] A complexity audit step exists in the pipeline manifest and can be invoked via `wave run`. +- [ ] The step computes at least one complexity metric (e.g., cyclomatic complexity) for Go source files. +- [ ] Complexity thresholds are configurable in the pipeline manifest or a config file. +- [ ] The step runs in parallel with other independent steps (not sequentially blocking). +- [ ] When a function exceeds the configured threshold, the step exits non-zero with a message naming the function and its score. +- [ ] When all functions pass, the step exits zero and outputs a summary of scores. +- [ ] The step handles edge cases gracefully: empty file list, non-Go files, missing config defaults. + +## Technical Context + +- Pipeline orchestration: `internal/pipeline/` and manifest configuration. +- Parallel step execution: existing concurrency model (`internal/pipeline/concurrency.go`, `iterate.parallel` composition primitive). +- Tooling decision: in-tree `go/ast` analyzer (no external binary dependency, no fzipp/gocyclo embedding for v1). Rationale: ~200 LOC, no supply-chain surface, identical accuracy for cyclomatic+cognitive on Go AST. +- Gate mechanism: command-step exit codes + `handover.contract.on_failure: fail` for pipeline-level gating. Structured JSON output conforms to `shared-findings.schema.json` for cross-pipeline aggregation. + +## Research Notes (from issue comments) + +Four research comments on the issue converge on: + +- Per-function cyclomatic + cognitive complexity over Go AST +- Parallel via `errgroup` + `SetLimit(NumCPU)` +- Configurable thresholds, default ~15 +- Structured JSON output (Wave-typed primary, optional SARIF v2) +- Exit-code gating +- Defer Halstead, churn/hotspot, multi-language to v2 + +## Acceptance Decisions (resolves missing-info from assessment) + +1. **Implementation**: in-tree Go AST analyzer (no third-party complexity library) for v1. +2. **Output format**: `shared-findings.schema.json` JSON only. SARIF deferred to v2. +3. **Default thresholds**: cyclomatic ≤ 15 (fail), cognitive ≤ 15 (fail). Warn at 10. + +## Out of Scope (v2) + +- Halstead complexity +- Linguistic complexity +- Multi-language support (Python, TS, Rust, etc.) +- Churn/hotspot analysis +- SARIF 2.1.0 output + +## Original Issue URL + + diff --git a/specs/1041-complexity-audit-step/tasks.md b/specs/1041-complexity-audit-step/tasks.md new file mode 100644 index 000000000..c28e2fbd0 --- /dev/null +++ b/specs/1041-complexity-audit-step/tasks.md @@ -0,0 +1,38 @@ +# Work Items — 1041 Complexity Audit Step + +## Phase 1: Setup + +- [X] Item 1.1: Confirm feature branch `1041-complexity-audit-step` exists and is checked out. +- [X] Item 1.2: Add `"complexity"` to the `type` enum in `internal/defaults/contracts/shared-findings.schema.json` and the mirrored `.agents/contracts/shared-findings.schema.json`. Run schema-validation tests to confirm no consumer regresses. +- [X] Item 1.3: Scaffold `internal/complexity/` package directory with empty `analyzer.go`, `cyclomatic.go`, `cognitive.go`, `findings.go`, `testdata/`. Add package doc.go describing the rule set. + +## Phase 2: Core Implementation + +- [X] Item 2.1: Implement cyclomatic visitor in `internal/complexity/cyclomatic.go` (counts `if`, `for`, `range`, `case`, `&&`, `||`, `select` clauses, named-result return paths). [P] +- [X] Item 2.2: Implement cognitive-complexity visitor in `internal/complexity/cognitive.go` (Sonar rules: nesting bonus, sequence penalty, recursion bonus, label-jump penalty). [P] +- [X] Item 2.3: Implement `Analyze(paths []string, opts Options) (Report, error)` in `analyzer.go` — discovers `.go` files (skip `_test.go` opt-out, skip `vendor/`), parses each with `go/parser`, runs both visitors, returns per-function scores. Per-file parallelism via `errgroup.SetLimit(runtime.NumCPU())`. +- [X] Item 2.4: Implement `ToSharedFindings(report Report, opts Options) ([]Finding, error)` in `findings.go` — maps over-threshold functions to `shared-findings.schema.json` entries (severity = `high` for fail-threshold breach, `medium` for warn-threshold breach). +- [X] Item 2.5: Implement `wave audit complexity [paths...]` Cobra subcommand in `cmd/wave/commands/audit_complexity.go`. Flags: `--max-cyclomatic` (default 15), `--max-cognitive` (default 15), `--warn-cyclomatic` (default 10), `--warn-cognitive` (default 10), `--output` (default `.agents/output/findings.json`), `--exclude` (repeatable glob), `--format` (default `json`, accept `summary` for stdout). Exit codes: 0 pass, 1 breach, ≥2 IO/parse error. +- [X] Item 2.6: Wire the `audit` parent command group in `cmd/wave/main.go` (or wherever subcommands register) and attach `complexity` as a child. [P with 2.5 once 2.5 lands] +- [X] Item 2.7: Author `internal/defaults/pipelines/audit-complexity.yaml` — single `command` step running `wave audit complexity {{ input }}`, `output_artifacts.findings`, `handover.contract: json_schema` against `shared-findings.schema.json` with `on_failure: fail`. +- [X] Item 2.8: Update `internal/defaults/pipelines/ops-parallel-audit.yaml` to add `audit-complexity` to the iterate list and bump `max_concurrent: 3 → 4`. Update report prompt's "three parallel audit sub-pipelines" wording. + +## Phase 3: Testing + +- [X] Item 3.1: Author `internal/complexity/testdata/` golden fixtures: `linear.go`, `branchy.go`, `nested.go`, `switchy.go`, `recursion.go`, `closures.go`, `broken.go` (parse-error case). [P] +- [X] Item 3.2: Write `cyclomatic_test.go` — table-driven test asserting exact integer score per fixture function. [P] +- [X] Item 3.3: Write `cognitive_test.go` — table-driven test against Sonar reference scores. [P] +- [X] Item 3.4: Write `analyzer_test.go` — integration: empty path → empty report; mixed Go/non-Go dir → only Go scored; broken file → typed error; race-safe under `-race`. +- [X] Item 3.5: Write `findings_test.go` — severity mapping; output validates against `shared-findings.schema.json` via `gojsonschema`. +- [X] Item 3.6: Write `cmd/wave/commands/audit_complexity_test.go` — exit-code matrix, JSON output shape, stderr summary on breach. +- [X] Item 3.7: Verify `internal/pipeline/all_pipelines_load_test.go` discovers and validates `audit-complexity.yaml` (no test-code change expected; just run). +- [X] Item 3.8: Run `go test ./... -race` end-to-end before PR. + +## Phase 4: Polish + +- [X] Item 4.1: Add package-level docstring to `internal/complexity/doc.go` documenting the cyclomatic and cognitive rule sets, citing the Sonar spec for cognitive. +- [X] Item 4.2: Update `docs/reference/cli.md` (or equivalent) with the new `wave audit complexity` subcommand, flags, exit codes, and a one-line example. +- [X] Item 4.3: Update `docs/guides/` audit-pipeline overview (if one exists) to mention `audit-complexity` alongside the LLM-driven audits and explain the deterministic-vs-LLM distinction. +- [X] Item 4.4: Run `wave run audit-complexity internal/complexity/` against the new package itself as a self-check and confirm it passes its own thresholds (dogfood smoke). [P] +- [X] Item 4.5: Run `wave run ops-parallel-audit internal/pipeline/` to verify the new audit fans out alongside the existing ones; capture screenshot/log evidence for the PR description. +- [X] Item 4.6: Final validation pass: re-read `spec.md` acceptance criteria; tick each box in the PR description with the test/file that proves it. From 8749de617191d587d17f6c12d5f9265c312e5002 Mon Sep 17 00:00:00 2001 From: Michael Czechowski Date: Mon, 27 Apr 2026 21:40:06 +0200 Subject: [PATCH 2/2] fix(complexity): drop unused nesting parameter from walkExpr MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit walkExpr never read the nesting argument — it just threaded the value through to recursive calls. golangci-lint (unparam) flagged this as a real signal: the parameter cannot affect output. Drop it. Callers now invoke walkExpr(e) directly. Behaviour unchanged; the parameter was dead. --- internal/complexity/cognitive.go | 46 ++++++++++++++++---------------- 1 file changed, 23 insertions(+), 23 deletions(-) diff --git a/internal/complexity/cognitive.go b/internal/complexity/cognitive.go index cff675679..0f5c74d01 100644 --- a/internal/complexity/cognitive.go +++ b/internal/complexity/cognitive.go @@ -69,14 +69,14 @@ func (w *cognitiveWalker) walkControlFlow(s ast.Stmt, nesting int) bool { w.walkIf(n, nesting) case *ast.ForStmt: w.score += 1 + nesting - w.walkExpr(n.Cond, nesting) + w.walkExpr(n.Cond) w.walkBlock(n.Body, nesting+1) case *ast.RangeStmt: w.score += 1 + nesting w.walkBlock(n.Body, nesting+1) case *ast.SwitchStmt: w.score += 1 + nesting - w.walkExpr(n.Tag, nesting) + w.walkExpr(n.Tag) w.walkCaseList(n.Body, nesting+1) case *ast.TypeSwitchStmt: w.score += 1 + nesting @@ -102,29 +102,29 @@ func (w *cognitiveWalker) walkLeafStmt(s ast.Stmt, nesting int) { case *ast.LabeledStmt: w.walkStmt(n.Stmt, nesting) case *ast.DeferStmt: - w.walkExpr(n.Call, nesting) + w.walkExpr(n.Call) case *ast.GoStmt: - w.walkExpr(n.Call, nesting) + w.walkExpr(n.Call) case *ast.ExprStmt: - w.walkExpr(n.X, nesting) + w.walkExpr(n.X) case *ast.AssignStmt: for _, e := range n.Rhs { - w.walkExpr(e, nesting) + w.walkExpr(e) } case *ast.ReturnStmt: for _, e := range n.Results { - w.walkExpr(e, nesting) + w.walkExpr(e) } case *ast.IncDecStmt: - w.walkExpr(n.X, nesting) + w.walkExpr(n.X) case *ast.SendStmt: - w.walkExpr(n.Value, nesting) + w.walkExpr(n.Value) } } func (w *cognitiveWalker) walkIf(n *ast.IfStmt, nesting int) { w.score += 1 + nesting - w.walkExpr(n.Cond, nesting) + w.walkExpr(n.Cond) w.walkBlock(n.Body, nesting+1) switch e := n.Else.(type) { case *ast.BlockStmt: @@ -132,7 +132,7 @@ func (w *cognitiveWalker) walkIf(n *ast.IfStmt, nesting int) { w.walkBlock(e, nesting+1) case *ast.IfStmt: w.score++ // else-if: +1 (no nesting bonus) - w.walkExpr(e.Cond, nesting) + w.walkExpr(e.Cond) w.walkBlock(e.Body, nesting+1) w.walkElseChain(e.Else, nesting) } @@ -175,13 +175,13 @@ func (w *cognitiveWalker) walkElseChain(s ast.Stmt, nesting int) { w.walkBlock(e, nesting+1) case *ast.IfStmt: w.score++ - w.walkExpr(e.Cond, nesting) + w.walkExpr(e.Cond) w.walkBlock(e.Body, nesting+1) w.walkElseChain(e.Else, nesting) } } -func (w *cognitiveWalker) walkExpr(e ast.Expr, nesting int) { +func (w *cognitiveWalker) walkExpr(e ast.Expr) { if e == nil { return } @@ -191,12 +191,12 @@ func (w *cognitiveWalker) walkExpr(e ast.Expr, nesting int) { w.scoreBoolChain(n) return } - w.walkExpr(n.X, nesting) - w.walkExpr(n.Y, nesting) + w.walkExpr(n.X) + w.walkExpr(n.Y) case *ast.ParenExpr: - w.walkExpr(n.X, nesting) + w.walkExpr(n.X) case *ast.UnaryExpr: - w.walkExpr(n.X, nesting) + w.walkExpr(n.X) case *ast.CallExpr: // Recursion detection: direct call to enclosing function name. if w.name != "" { @@ -204,19 +204,19 @@ func (w *cognitiveWalker) walkExpr(e ast.Expr, nesting int) { w.score++ } } - w.walkExpr(n.Fun, nesting) + w.walkExpr(n.Fun) for _, a := range n.Args { - w.walkExpr(a, nesting) + w.walkExpr(a) } case *ast.FuncLit: // Function literals contribute to the enclosing total but reset // nesting for the lambda body. w.walkBlock(n.Body, 0) case *ast.IndexExpr: - w.walkExpr(n.X, nesting) - w.walkExpr(n.Index, nesting) + w.walkExpr(n.X) + w.walkExpr(n.Index) case *ast.SelectorExpr: - w.walkExpr(n.X, nesting) + w.walkExpr(n.X) } } @@ -234,7 +234,7 @@ func (w *cognitiveWalker) scoreBoolChain(root *ast.BinaryExpr) { return } // recurse into the operand for nested control flow / calls - w.walkExpr(e, 0) + w.walkExpr(e) } collect(root) if len(ops) == 0 {