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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .agents/contracts/shared-findings.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
200 changes: 200 additions & 0 deletions cmd/wave/commands/audit_complexity.go
Original file line number Diff line number Diff line change
@@ -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
}
161 changes: 161 additions & 0 deletions cmd/wave/commands/audit_complexity_test.go
Original file line number Diff line number Diff line change
@@ -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"}))
}
3 changes: 2 additions & 1 deletion cmd/wave/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -225,6 +226,6 @@ func main() {
} else {
commands.RenderTextError(os.Stderr, err, debug)
}
os.Exit(1)
os.Exit(commands.ExitCodeFor(err))
}
}
Loading
Loading