diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d57674c..6df2f73 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,6 +22,12 @@ jobs: go-version-file: go.mod cache: true + - name: Lint + uses: golangci/golangci-lint-action@v9 + with: + version: v2.12.2 + args: ./... + - name: Check formatting run: | unformatted="$(gofmt -l .)" diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..024a905 --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,164 @@ +version: "2" +run: + tests: true +linters: + enable: + - asasalint + - asciicheck + - bidichk + - containedctx + - copyloopvar + - decorder + - depguard + - dogsled + - durationcheck + - embeddedstructfieldcheck + - errcheck + - errname + - errorlint + - exptostd + - fatcontext + - forbidigo + - ginkgolinter + - gocheckcompilerdirectives + - godoclint + - gochecknoinits + - gochecksumtype + - gocritic + - gosec + - gomoddirectives + - gomodguard_v2 + - goprintffuncname + - govet + - grouper + - iface + - importas + - inamedparam + - ineffassign + - intrange + - iotamixing + - loggercheck + - misspell + - mirror + - modernize + - nakedret + - noctx + - nolintlint + - nosprintfhostport + - nilnesserr + - perfsprint + - predeclared + - reassign + - recvcheck + - revive + - rowserrcheck + - sloglint + - staticcheck + - testableexamples + - testifylint + - thelper + - unconvert + - unparam + - unused + - usestdlibvars + - unqueryvet + - usetesting + - whitespace + - wastedassign + - bodyclose + - makezero + - sqlclosecheck + settings: + forbidigo: + forbid: + - pattern: context\.Background() + msg: "do not use context.Background() in tests, use t.Context()" + - pattern: context\.TODO() + msg: "do not use context.TODO() in tests, use t.Context()" + - pattern: os\.MkdirTemp() + msg: "do not use os.MkdirTemp() in tests, use t.TempDir()" + - pattern: os\.Setenv() + msg: "do not use os.Setenv() in tests, use t.Setenv()" + - pattern: os\.Chdir() + msg: "do not use os.Chdir() in tests, use t.Chdir()" + - pattern: fmt\.Print.*() + msg: "do not use fmt.Print() or fmt.Println() in tests" + depguard: + rules: + old_yaml: + files: + - "**/*.go" + deny: + - pkg: gopkg.in/yaml.v3 + desc: don't use deprecated gopkg.in/yaml.v3, use github.com/goccy/go-yaml instead + testify_is_for_tests: + files: + - "**/*.go" + - "!**/*_test.go" + deny: + - pkg: github.com/stretchr/testify + desc: don't use testify in production code + gocritic: + disabled-checks: + - dupImport + - hugeParam + - rangeValCopy + - unnamedResult + - appendAssign + enabled-tags: + - diagnostic + - style + - performance + revive: + rules: + - name: exported + arguments: + - checkPrivateReceivers + - name: package-comments + disabled: false + staticcheck: + checks: + - all + gosec: + excludes: + - G104 + - G101 + - G204 + - G304 + - G404 + - G602 + - G702 + - G703 + - G704 + exclusions: + generated: lax + presets: + - comments + - std-error-handling + rules: + - path-except: _test\.go + linters: + - forbidigo + - path: _test\.go + linters: + - gosec +issues: + max-same-issues: 3 +formatters: + enable: + - gci + - gofmt + - gofumpt + settings: + gci: + custom-order: true + sections: + - standard + - default + - prefix(github.com/rumpl/harness) + gofmt: + rewrite-rules: + - pattern: 'interface{}' + replacement: 'any' + gofumpt: + extra-rules: true diff --git a/claudecode/claudecode.go b/claudecode/claudecode.go index 4573632..6262506 100644 --- a/claudecode/claudecode.go +++ b/claudecode/claudecode.go @@ -58,11 +58,11 @@ func normalizeModel(model string) string { func (p *provider) PrintCommand(prompt string) string { modelFlag := "" if p.model != "" { - modelFlag = fmt.Sprintf(" --model %s", harness.ShellEscape(p.model)) + modelFlag = " --model " + harness.ShellEscape(p.model) } effortFlag := "" if p.effort != "" { - effortFlag = fmt.Sprintf(" --effort %s", p.effort) + effortFlag = " --effort " + string(p.effort) } return fmt.Sprintf( "claude --print --verbose --dangerously-skip-permissions --include-partial-messages --output-format stream-json%s%s -p %s", diff --git a/claudecode/claudecode_test.go b/claudecode/claudecode_test.go index d02e2d2..89d1086 100644 --- a/claudecode/claudecode_test.go +++ b/claudecode/claudecode_test.go @@ -2,6 +2,7 @@ package claudecode import ( "encoding/json" + "slices" "strings" "testing" @@ -95,7 +96,7 @@ func TestInteractiveArgs(t *testing.T) { if args[0] != "claude" { t.Errorf("args[0] = %q, want claude", args[0]) } - if !contains(args, "claude-sonnet-4-6") || !contains(args, "--model") { + if !slices.Contains(args, "claude-sonnet-4-6") || !slices.Contains(args, "--model") { t.Errorf("args missing model: %v", args) } }) @@ -103,10 +104,10 @@ func TestInteractiveArgs(t *testing.T) { t.Run("strips anthropic provider prefix from model", func(t *testing.T) { p := New("anthropic/claude-opus-4-6") args := p.InteractiveArgs("") - if !contains(args, "claude-opus-4-6") { + if !slices.Contains(args, "claude-opus-4-6") { t.Errorf("args missing normalized model: %v", args) } - if contains(args, "anthropic/claude-opus-4-6") { + if slices.Contains(args, "anthropic/claude-opus-4-6") { t.Errorf("args should not pass provider/model form to claude: %v", args) } }) @@ -114,7 +115,7 @@ func TestInteractiveArgs(t *testing.T) { t.Run("includes effort when set", func(t *testing.T) { p := New("claude-opus-4-6", WithEffort(EffortLow)) args := p.InteractiveArgs("") - if !contains(args, "--effort") || !contains(args, "low") { + if !slices.Contains(args, "--effort") || !slices.Contains(args, "low") { t.Errorf("args missing effort: %v", args) } }) @@ -122,7 +123,7 @@ func TestInteractiveArgs(t *testing.T) { t.Run("omits model when empty", func(t *testing.T) { p := New("") args := p.InteractiveArgs("") - if contains(args, "--model") { + if slices.Contains(args, "--model") { t.Errorf("args should not contain model: %v", args) } }) @@ -130,7 +131,7 @@ func TestInteractiveArgs(t *testing.T) { t.Run("omits effort when not set", func(t *testing.T) { p := New("claude-opus-4-6") args := p.InteractiveArgs("") - if contains(args, "--effort") { + if slices.Contains(args, "--effort") { t.Errorf("args should not contain --effort: %v", args) } }) @@ -367,15 +368,6 @@ func jsonStr(v any) string { return string(b) } -func contains(ss []string, s string) bool { - for _, v := range ss { - if v == s { - return true - } - } - return false -} - func assertEqual(t *testing.T, got, want []harness.Event) { t.Helper() if len(got) != len(want) { diff --git a/claudecode/parse.go b/claudecode/parse.go index 2939451..8015fc8 100644 --- a/claudecode/parse.go +++ b/claudecode/parse.go @@ -7,14 +7,6 @@ import ( "github.com/rumpl/harness" ) -// parseStreamLine handles a single Claude Code stream-json line without -// retaining cross-line state. Provider.ParseStreamLine uses a stateful parser -// so it can de-duplicate Claude's partial stream events from the full message -// snapshots that follow them. -func parseStreamLine(line string) []harness.Event { - return newParser().parseLine(line) -} - type parser struct { blocks map[int]*streamBlock streamedTextSinceAssistant bool @@ -340,9 +332,5 @@ func join(ss []string) string { if len(ss) == 1 { return ss[0] } - out := "" - for _, s := range ss { - out += s - } - return out + return strings.Join(ss, "") } diff --git a/cmd/harness-example/main.go b/cmd/harness-example/main.go index ca38811..d683382 100644 --- a/cmd/harness-example/main.go +++ b/cmd/harness-example/main.go @@ -55,7 +55,6 @@ func main() { } ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt) - defer cancel() fmt.Fprintf(os.Stderr, "provider: %s\n", p.Name()) fmt.Fprintf(os.Stderr, "prompt: %s\n\n", prompt) @@ -87,6 +86,7 @@ func main() { } } }) + cancel() if err != nil { fmt.Fprintf(os.Stderr, "error: %v\n", err) os.Exit(1) diff --git a/codex/codex.go b/codex/codex.go index eb4bfcf..dc6af69 100644 --- a/codex/codex.go +++ b/codex/codex.go @@ -22,7 +22,7 @@ func (p *provider) Name() string { return "codex" } func (p *provider) PrintCommand(prompt string) string { modelFlag := "" if p.model != "" { - modelFlag = fmt.Sprintf(" -m %s", harness.ShellEscape(p.model)) + modelFlag = " -m " + harness.ShellEscape(p.model) } return fmt.Sprintf( "codex exec --json --dangerously-bypass-approvals-and-sandbox%s %s", diff --git a/codex/codex_test.go b/codex/codex_test.go index 93dff91..d6770f2 100644 --- a/codex/codex_test.go +++ b/codex/codex_test.go @@ -2,6 +2,7 @@ package codex import ( "encoding/json" + "slices" "strings" "testing" @@ -59,7 +60,7 @@ func TestInteractiveArgs(t *testing.T) { if args[0] != "codex" { t.Errorf("args[0] = %q, want codex", args[0]) } - if !contains(args, "gpt-5.4-mini") || !contains(args, "--model") { + if !slices.Contains(args, "gpt-5.4-mini") || !slices.Contains(args, "--model") { t.Errorf("args missing model: %v", args) } }) @@ -67,7 +68,7 @@ func TestInteractiveArgs(t *testing.T) { t.Run("omits model when empty", func(t *testing.T) { p := New("") args := p.InteractiveArgs("") - if contains(args, "--model") { + if slices.Contains(args, "--model") { t.Errorf("args should not contain model: %v", args) } }) @@ -233,15 +234,6 @@ func jsonStr(v any) string { return string(b) } -func contains(ss []string, s string) bool { - for _, v := range ss { - if v == s { - return true - } - } - return false -} - func assertEqual(t *testing.T, got, want []harness.Event) { t.Helper() if len(got) != len(want) { diff --git a/dockeragent/dockeragent_test.go b/dockeragent/dockeragent_test.go index 5f7b665..eb7cb4e 100644 --- a/dockeragent/dockeragent_test.go +++ b/dockeragent/dockeragent_test.go @@ -2,6 +2,7 @@ package dockeragent import ( "encoding/json" + "slices" "strings" "testing" @@ -53,7 +54,7 @@ func TestInteractiveArgs(t *testing.T) { if args[0] != "docker-agent" { t.Errorf("args[0] = %q, want docker-agent", args[0]) } - if !contains(args, "coder") || !contains(args, "--yolo") { + if !slices.Contains(args, "coder") || !slices.Contains(args, "--yolo") { t.Errorf("args missing image or --yolo: %v", args) } } @@ -262,15 +263,6 @@ func jsonStr(v any) string { return string(b) } -func contains(ss []string, s string) bool { - for _, v := range ss { - if v == s { - return true - } - } - return false -} - func assertEqual(t *testing.T, got, want []harness.Event) { t.Helper() if len(got) != len(want) { diff --git a/harness_test.go b/harness_test.go index b054aa4..2d7ab0c 100644 --- a/harness_test.go +++ b/harness_test.go @@ -3,8 +3,16 @@ package harness import ( "context" "testing" + "time" ) +type testContext struct{} + +func (testContext) Deadline() (time.Time, bool) { return time.Time{}, false } +func (testContext) Done() <-chan struct{} { return nil } +func (testContext) Err() error { return nil } +func (testContext) Value(any) any { return nil } + func TestShellEscape(t *testing.T) { tests := []struct { in string @@ -133,7 +141,7 @@ func (p *customRunProvider) Run(_ context.Context, prompt string, fn func(Event) func TestRunUsesCustomStreamingProvider(t *testing.T) { p := &customRunProvider{} var got []Event - if err := Run(context.Background(), p, "hello", func(ev Event) { + if err := Run(testContext{}, p, "hello", func(ev Event) { got = append(got, ev) }); err != nil { t.Fatalf("Run returned error: %v", err) diff --git a/opencode/opencode.go b/opencode/opencode.go index 7e3310f..ff41744 100644 --- a/opencode/opencode.go +++ b/opencode/opencode.go @@ -47,10 +47,10 @@ func (p *provider) Name() string { return "opencode" } func (p *provider) PrintCommand(prompt string) string { extra := "" if p.model != "" { - extra += fmt.Sprintf(" --model %s", harness.ShellEscape(p.model)) + extra += " --model " + harness.ShellEscape(p.model) } if p.agent != "" { - extra += fmt.Sprintf(" --agent %s", harness.ShellEscape(p.agent)) + extra += " --agent " + harness.ShellEscape(p.agent) } if p.thinking { extra += " --thinking" diff --git a/opencode/opencode_test.go b/opencode/opencode_test.go index 9a4344b..9b6af7b 100644 --- a/opencode/opencode_test.go +++ b/opencode/opencode_test.go @@ -2,6 +2,7 @@ package opencode import ( "encoding/json" + "slices" "strings" "testing" @@ -87,7 +88,7 @@ func TestInteractiveArgs(t *testing.T) { if args[0] != "opencode" { t.Errorf("args[0] = %q, want opencode", args[0]) } - if !contains(args, "anthropic/claude-3-5-sonnet") || !contains(args, "--model") { + if !slices.Contains(args, "anthropic/claude-3-5-sonnet") || !slices.Contains(args, "--model") { t.Errorf("args missing model: %v", args) } }) @@ -95,7 +96,7 @@ func TestInteractiveArgs(t *testing.T) { t.Run("includes agent when set", func(t *testing.T) { p := New("anthropic/claude-3-5-sonnet", WithAgent("plan")) args := p.InteractiveArgs("") - if !contains(args, "--agent") || !contains(args, "plan") { + if !slices.Contains(args, "--agent") || !slices.Contains(args, "plan") { t.Errorf("args missing agent: %v", args) } }) @@ -103,7 +104,7 @@ func TestInteractiveArgs(t *testing.T) { t.Run("omits model when empty", func(t *testing.T) { p := New("") args := p.InteractiveArgs("") - if contains(args, "--model") { + if slices.Contains(args, "--model") { t.Errorf("args should not contain model: %v", args) } }) @@ -111,7 +112,7 @@ func TestInteractiveArgs(t *testing.T) { t.Run("omits agent when not set", func(t *testing.T) { p := New("anthropic/claude-3-5-sonnet") args := p.InteractiveArgs("") - if contains(args, "--agent") { + if slices.Contains(args, "--agent") { t.Errorf("args should not contain --agent: %v", args) } }) @@ -670,15 +671,6 @@ func jsonStr(v any) string { return string(b) } -func contains(ss []string, s string) bool { - for _, v := range ss { - if v == s { - return true - } - } - return false -} - func assertEqual(t *testing.T, got, want []harness.Event) { t.Helper() if len(got) != len(want) { diff --git a/opencode/runner.go b/opencode/runner.go index fe8b60d..bbdfd85 100644 --- a/opencode/runner.go +++ b/opencode/runner.go @@ -38,7 +38,7 @@ func (p *provider) Run(ctx context.Context, prompt string, fn func(harness.Event return fmt.Errorf("get cwd: %w", err) } - port, err := freePort() + port, err := freePort(ctx) if err != nil { return err } @@ -194,7 +194,7 @@ func (p *provider) readEvents(body io.Reader, sessionID string, fn func(harness. } func openEventStream(ctx context.Context, client *http.Client, baseURL, cwd string) (*http.Response, error) { - req, err := http.NewRequestWithContext(ctx, http.MethodGet, baseURL+"/event?"+directoryQuery(cwd), nil) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, baseURL+"/event?"+directoryQuery(cwd), http.NoBody) if err != nil { return nil, err } @@ -232,7 +232,7 @@ func waitForServer(ctx context.Context, client *http.Client, baseURL string) err func serverHealthy(ctx context.Context, client *http.Client, baseURL string) bool { reqCtx, cancel := context.WithTimeout(ctx, time.Second) defer cancel() - req, err := http.NewRequestWithContext(reqCtx, http.MethodGet, baseURL+"/global/health", nil) + req, err := http.NewRequestWithContext(reqCtx, http.MethodGet, baseURL+"/global/health", http.NoBody) if err != nil { return false } @@ -241,11 +241,11 @@ func serverHealthy(ctx context.Context, client *http.Client, baseURL string) boo return false } defer resp.Body.Close() - io.Copy(io.Discard, io.LimitReader(resp.Body, 1024)) + _, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 1024)) return resp.StatusCode >= 200 && resp.StatusCode < 300 } -func doJSON(ctx context.Context, client *http.Client, method, endpoint string, body any, out any) error { +func doJSON(ctx context.Context, client *http.Client, method, endpoint string, body, out any) error { var reader io.Reader if body != nil { b, err := json.Marshal(body) @@ -274,7 +274,7 @@ func doJSON(ctx context.Context, client *http.Client, method, endpoint string, b return fmt.Errorf("%s: %s", resp.Status, strings.TrimSpace(string(b))) } if out == nil { - io.Copy(io.Discard, resp.Body) + _, _ = io.Copy(io.Discard, resp.Body) return nil } return json.NewDecoder(resp.Body).Decode(out) @@ -317,12 +317,12 @@ func titleFromPrompt(prompt string) string { if prompt == "" { return "harness run" } - const max = 80 + const maxTitleRunes = 80 runes := []rune(prompt) - if len(runes) <= max { + if len(runes) <= maxTitleRunes { return prompt } - return string(runes[:max]) + return string(runes[:maxTitleRunes]) } func directoryQuery(cwd string) string { @@ -345,8 +345,9 @@ func withoutEnv(env []string, keys ...string) []string { return out } -func freePort() (int, error) { - ln, err := net.Listen("tcp", "127.0.0.1:0") +func freePort(ctx context.Context) (int, error) { + var listenConfig net.ListenConfig + ln, err := listenConfig.Listen(ctx, "tcp", "127.0.0.1:0") if err != nil { return 0, fmt.Errorf("find free port: %w", err) } diff --git a/pi/parse.go b/pi/parse.go index d98fd60..ad5f106 100644 --- a/pi/parse.go +++ b/pi/parse.go @@ -2,6 +2,7 @@ package pi import ( "encoding/json" + "strings" "github.com/rumpl/harness" ) @@ -150,7 +151,7 @@ func extractTextContent(msg map[string]any) string { if !ok { return "" } - var out string + var out strings.Builder for _, raw := range content { block, ok := raw.(map[string]any) if !ok { @@ -158,9 +159,9 @@ func extractTextContent(msg map[string]any) string { } if bt, _ := block["type"].(string); bt == "text" { if t, ok := block["text"].(string); ok { - out += t + out.WriteString(t) } } } - return out + return out.String() } diff --git a/pi/pi.go b/pi/pi.go index 9daab30..3b6e100 100644 --- a/pi/pi.go +++ b/pi/pi.go @@ -21,7 +21,7 @@ func (p *provider) Name() string { return "pi" } func (p *provider) PrintCommand(prompt string) string { modelFlag := "" if p.model != "" { - modelFlag = fmt.Sprintf(" --model %s", harness.ShellEscape(p.model)) + modelFlag = " --model " + harness.ShellEscape(p.model) } return fmt.Sprintf( "pi -p --mode json --no-session%s %s", diff --git a/pi/pi_test.go b/pi/pi_test.go index 2e7e131..b9e1d74 100644 --- a/pi/pi_test.go +++ b/pi/pi_test.go @@ -2,6 +2,7 @@ package pi import ( "encoding/json" + "slices" "strings" "testing" @@ -58,7 +59,7 @@ func TestInteractiveArgs(t *testing.T) { if args[0] != "pi" { t.Errorf("args[0] = %q, want pi", args[0]) } - if !contains(args, "claude-sonnet-4-6") || !contains(args, "--model") { + if !slices.Contains(args, "claude-sonnet-4-6") || !slices.Contains(args, "--model") { t.Errorf("args missing model: %v", args) } }) @@ -66,7 +67,7 @@ func TestInteractiveArgs(t *testing.T) { t.Run("omits model when empty", func(t *testing.T) { p := New("") args := p.InteractiveArgs("") - if contains(args, "--model") { + if slices.Contains(args, "--model") { t.Errorf("args should not contain model: %v", args) } }) @@ -296,15 +297,6 @@ func jsonStr(v any) string { return string(b) } -func contains(ss []string, s string) bool { - for _, v := range ss { - if v == s { - return true - } - } - return false -} - func assertEqual(t *testing.T, got, want []harness.Event) { t.Helper() if len(got) != len(want) { diff --git a/run.go b/run.go index 416a786..043125f 100644 --- a/run.go +++ b/run.go @@ -12,7 +12,7 @@ import ( // transport to stream events. Providers that do not implement it fall back to // PrintCommand and ParseStreamLine below. type streamingProvider interface { - Run(context.Context, string, func(Event)) error + Run(ctx context.Context, prompt string, fn func(Event)) error } // Run executes the provider in print (non-interactive) mode and streams diff --git a/usage.go b/usage.go index 7fbc083..01f94cf 100644 --- a/usage.go +++ b/usage.go @@ -26,11 +26,11 @@ func ExtractUsage(obj map[string]any) *Usage { u := &Usage{ InputTokens: inputTokens, OutputTokens: outputTokens, - CacheReadInputTokens: jsonNumberOr(usageMap, "cache_read_input_tokens", 0), - CacheCreationInputTokens: jsonNumberOr(usageMap, "cache_creation_input_tokens", 0), + CacheReadInputTokens: jsonNumberOr(usageMap, "cache_read_input_tokens"), + CacheCreationInputTokens: jsonNumberOr(usageMap, "cache_creation_input_tokens"), TotalCostUSD: jsonFloatOr(obj, "total_cost_usd", 0), - NumTurns: jsonNumberOr(obj, "num_turns", 0), - DurationMS: jsonNumberOr(obj, "duration_ms", 0), + NumTurns: jsonNumberOr(obj, "num_turns"), + DurationMS: jsonNumberOr(obj, "duration_ms"), } return u } @@ -38,7 +38,7 @@ func ExtractUsage(obj map[string]any) *Usage { // ParseJSON is a convenience that unmarshals a line into a map. It returns // nil, false if the line is not valid JSON or does not start with '{'. func ParseJSON(line string) (map[string]any, bool) { - if len(line) == 0 || line[0] != '{' { + if line == "" || line[0] != '{' { return nil, false } var obj map[string]any @@ -66,10 +66,10 @@ func jsonNumber(m map[string]any, key string) (int, bool) { return 0, false } -func jsonNumberOr(m map[string]any, key string, fallback int) int { +func jsonNumberOr(m map[string]any, key string) int { v, ok := jsonNumber(m, key) if !ok { - return fallback + return 0 } return v } @@ -116,7 +116,7 @@ func ExtractPiUsage(msg map[string]any) *Usage { u := &Usage{ InputTokens: inputTokens, OutputTokens: outputTokens, - CacheReadInputTokens: jsonNumberOr(usageMap, "cacheRead", 0), + CacheReadInputTokens: jsonNumberOr(usageMap, "cacheRead"), } // Cost info is nested: usage.cost.total @@ -151,6 +151,6 @@ func ExtractCodexUsage(obj map[string]any) *Usage { return &Usage{ InputTokens: inputTokens, OutputTokens: outputTokens, - CacheReadInputTokens: jsonNumberOr(usageMap, "cached_input_tokens", 0), + CacheReadInputTokens: jsonNumberOr(usageMap, "cached_input_tokens"), } }