From 874af96c9c556ea6d6dba1cc49c084ef09f7c298 Mon Sep 17 00:00:00 2001 From: Shahar Margalit Date: Sat, 28 Feb 2026 13:36:12 +0200 Subject: [PATCH 1/2] forge: Multi-repo run scanner --- .github/workflows/ci.yml | 1 + cmd/forge/cmd_init.go | 2 +- internal/graph/topsort.go | 6 +- internal/intent/prompt_test.go | 6 +- .../.forge/runs/20260217-120000-test.yaml | 6 +- .../runs/20260219-120000-push-test.yaml | 4 +- internal/pipeline/batch.go | 11 +- internal/pipeline/pool_test.go | 3 +- internal/pipeline/run.go | 12 +- internal/provider/agent/claude.go | 3 +- internal/provider/agent/codex.go | 3 +- internal/provider/vcs/github.go | 3 +- internal/scanner/scanner.go | 193 ++++++++++++++++++ internal/scanner/scanner_test.go | 136 ++++++++++++ lefthook.yml | 3 + 15 files changed, 368 insertions(+), 24 deletions(-) create mode 100644 internal/scanner/scanner.go create mode 100644 internal/scanner/scanner_test.go diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3920503..9359140 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,6 +21,7 @@ jobs: - name: Format check run: | + go install mvdan.cc/gofumpt@latest go install golang.org/x/tools/cmd/goimports@latest make fmt git diff --exit-code || (echo "::error::goimports produced changes; run 'make fmt' locally" && exit 1) diff --git a/cmd/forge/cmd_init.go b/cmd/forge/cmd_init.go index dada570..51496b4 100644 --- a/cmd/forge/cmd_init.go +++ b/cmd/forge/cmd_init.go @@ -130,7 +130,7 @@ func cmdInit() error { return fmt.Errorf("rendering template: %w", err) } - if err := os.WriteFile(configPath, buf.Bytes(), 0644); err != nil { + if err := os.WriteFile(configPath, buf.Bytes(), 0o644); err != nil { return fmt.Errorf("writing %s: %w", configPath, err) } diff --git a/internal/graph/topsort.go b/internal/graph/topsort.go index 4d6f148..f1f5444 100644 --- a/internal/graph/topsort.go +++ b/internal/graph/topsort.go @@ -8,8 +8,10 @@ import ( "strings" ) -var depPattern = regexp.MustCompile(`(?i)(?:depends on|blocked by)\s+(#\d+(?:,\s*#\d+)*)`) -var issueNumPattern = regexp.MustCompile(`#(\d+)`) +var ( + depPattern = regexp.MustCompile(`(?i)(?:depends on|blocked by)\s+(#\d+(?:,\s*#\d+)*)`) + issueNumPattern = regexp.MustCompile(`#(\d+)`) +) // ParseDeps extracts issue dependencies from a GitHub issue body. // It looks for "Depends on #N" and "Blocked by #N" patterns (case-insensitive), diff --git a/internal/intent/prompt_test.go b/internal/intent/prompt_test.go index 13b7524..75e5d91 100644 --- a/internal/intent/prompt_test.go +++ b/internal/intent/prompt_test.go @@ -18,9 +18,11 @@ func TestBuildPrompt_ContainsSubcommands(t *testing.T) { dc := DynamicContext{} prompt := BuildPrompt("anything", dc) - subcommands := []string{"forge run", "forge push", "forge resume", "forge runs", + subcommands := []string{ + "forge run", "forge push", "forge resume", "forge runs", "forge status", "forge logs", "forge steps", "forge edit", - "forge cleanup", "forge init", "forge completion", "forge serve", "forge version"} + "forge cleanup", "forge init", "forge completion", "forge serve", "forge version", + } for _, sub := range subcommands { if !strings.Contains(prompt, sub) { diff --git a/internal/pipeline/.forge/runs/20260217-120000-test.yaml b/internal/pipeline/.forge/runs/20260217-120000-test.yaml index 8978142..cae8426 100644 --- a/internal/pipeline/.forge/runs/20260217-120000-test.yaml +++ b/internal/pipeline/.forge/runs/20260217-120000-test.yaml @@ -1,8 +1,8 @@ id: 20260217-120000-test -plan_path: /var/folders/tx/bm28bkzx7vj48wg0z_ls65gw0000gn/T/TestRun_LocalCR_SeparateReviewAgent3223185930/001/auth.md +plan_path: /var/folders/tx/bm28bkzx7vj48wg0z_ls65gw0000gn/T/TestRun_LocalCR_SeparateReviewAgent1795440872/001/auth.md status: completed -created_at: 2026-02-28T17:24:37.746616+02:00 -updated_at: 2026-02-28T17:24:37.749882+02:00 +created_at: 2026-02-28T20:04:38.362657+02:00 +updated_at: 2026-02-28T20:04:38.366259+02:00 branch: forge/auth worktree_path: /tmp/wt pr_url: https://github.com/owner/repo/pull/1 diff --git a/internal/pipeline/.forge/runs/20260219-120000-push-test.yaml b/internal/pipeline/.forge/runs/20260219-120000-push-test.yaml index 0eaf999..caa51b6 100644 --- a/internal/pipeline/.forge/runs/20260219-120000-push-test.yaml +++ b/internal/pipeline/.forge/runs/20260219-120000-push-test.yaml @@ -2,8 +2,8 @@ id: 20260219-120000-push-test plan_path: "" mode: push status: completed -created_at: 2026-02-28T17:24:37.49641+02:00 -updated_at: 2026-02-28T17:24:37.500963+02:00 +created_at: 2026-02-28T20:04:38.105526+02:00 +updated_at: 2026-02-28T20:04:38.110421+02:00 branch: forge/my-feature worktree_path: /tmp/repo pr_url: url diff --git a/internal/pipeline/batch.go b/internal/pipeline/batch.go index 55b51dc..9888690 100644 --- a/internal/pipeline/batch.go +++ b/internal/pipeline/batch.go @@ -19,8 +19,8 @@ import ( // sorts them by dependency, and executes each in order. Sequential within each // level for V1. func RunBatch(ctx context.Context, cfg *config.Config, providers Providers, - label string, dryRun bool, logger *slog.Logger) error { - + label string, dryRun bool, logger *slog.Logger, +) error { issues, err := providers.VCS.ListIssues(ctx, "open", label) if err != nil { return fmt.Errorf("listing issues: %w", err) @@ -154,8 +154,8 @@ func reportFailure(ctx context.Context, providers Providers, num int, err error, // runSingleIssue executes a single GitHub issue through the forge pipeline. func runSingleIssue(ctx context.Context, cfg *config.Config, providers Providers, - number int, title, body string, logger *slog.Logger) error { - + number int, title, body string, logger *slog.Logger, +) error { slug := SlugFromTitle(title) runID := time.Now().Format("20060102-150405") + "-" + slug @@ -184,7 +184,8 @@ func runSingleIssue(ctx context.Context, cfg *config.Config, providers Providers // Handles transitive deps. Fetch errors are logged and treated as external deps (skipped). func expandDeps(ctx context.Context, vcs interface { GetIssue(ctx context.Context, number int) (*provider.GitHubIssue, error) -}, issueSet map[int]bool, titleMap map[int]string, bodyMap map[int]string, logger *slog.Logger) error { +}, issueSet map[int]bool, titleMap map[int]string, bodyMap map[int]string, logger *slog.Logger, +) error { external := make(map[int]bool) // deps we tried to fetch and failed — treat as external for { var missing []int diff --git a/internal/pipeline/pool_test.go b/internal/pipeline/pool_test.go index 7dffb17..9986877 100644 --- a/internal/pipeline/pool_test.go +++ b/internal/pipeline/pool_test.go @@ -3,11 +3,10 @@ package pipeline import ( "context" "errors" + "log/slog" "os" "testing" - "log/slog" - "github.com/shahar-caura/forge/internal/provider" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" diff --git a/internal/pipeline/run.go b/internal/pipeline/run.go index a4d03bf..475c73c 100644 --- a/internal/pipeline/run.go +++ b/internal/pipeline/run.go @@ -518,8 +518,10 @@ func agentResultText(output string) string { return parsed.Result } -var crReviewMarker = "---CRREVIEW---" -var crSummaryMarker = "---CRSUMMARY---" +var ( + crReviewMarker = "---CRREVIEW---" + crSummaryMarker = "---CRSUMMARY---" +) // extractCRSummary extracts the text between ---CRSUMMARY--- markers from agent output. // Returns empty string if markers are missing or content is empty. @@ -724,8 +726,10 @@ func openAgentLog(runID string, step int, a provider.Agent, logger *slog.Logger) } } -var nonAlphanumeric = regexp.MustCompile(`[^a-z0-9-]+`) -var validBranch = regexp.MustCompile(`^[A-Z]+-[0-9]+(-[a-z0-9]+)+$`) +var ( + nonAlphanumeric = regexp.MustCompile(`[^a-z0-9-]+`) + validBranch = regexp.MustCompile(`^[A-Z]+-[0-9]+(-[a-z0-9]+)+$`) +) // SlugFromTitle converts a title string to a kebab-case slug. func SlugFromTitle(title string) string { diff --git a/internal/provider/agent/claude.go b/internal/provider/agent/claude.go index 529b68b..9359c19 100644 --- a/internal/provider/agent/claude.go +++ b/internal/provider/agent/claude.go @@ -49,7 +49,8 @@ func (c *Claude) Run(ctx context.Context, dir, prompt string) (string, error) { c.Logger.Info("running agent", "dir", dir, "timeout", c.Timeout) - args := []string{"-p", prompt, + args := []string{ + "-p", prompt, "--allowedTools", "Edit,Read,Write,Bash", "--output-format", "json", } diff --git a/internal/provider/agent/codex.go b/internal/provider/agent/codex.go index 17f30ac..caa511b 100644 --- a/internal/provider/agent/codex.go +++ b/internal/provider/agent/codex.go @@ -46,7 +46,8 @@ func (c *Codex) Run(ctx context.Context, dir, prompt string) (string, error) { c.Logger.Info("running codex agent", "dir", dir, "timeout", c.Timeout) - args := []string{"exec", + args := []string{ + "exec", "--full-auto", "--json", "--cd", dir, diff --git a/internal/provider/vcs/github.go b/internal/provider/vcs/github.go index 341a7ab..88a0dcc 100644 --- a/internal/provider/vcs/github.go +++ b/internal/provider/vcs/github.go @@ -199,7 +199,8 @@ func (g *GitHub) GetIssue(ctx context.Context, number int) (*provider.GitHubIssu func (g *GitHub) ListIssues(ctx context.Context, state string, label string) ([]provider.GitHubIssue, error) { g.Logger.Info("listing issues", "state", state, "label", label) - args := []string{"issue", "list", + args := []string{ + "issue", "list", "--repo", g.Repo, "--state", state, "--json", "number,title,body,url", diff --git a/internal/scanner/scanner.go b/internal/scanner/scanner.go new file mode 100644 index 0000000..5162034 --- /dev/null +++ b/internal/scanner/scanner.go @@ -0,0 +1,193 @@ +package scanner + +import ( + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + "sort" + "strings" + "sync" + + "github.com/shahar-caura/forge/internal/state" +) + +const defaultRunsDir = ".forge/runs" + +var stateLoadMu sync.Mutex + +// RepoRuns contains all run states discovered for a repository. +type RepoRuns struct { + RepoPath string + RepoName string + Runs []state.RunState +} + +// ScanRepos walks root directories and returns run states for each discovered repo. +func ScanRepos(roots []string) ([]RepoRuns, error) { + repos := make(map[string]RepoRuns) + + for _, root := range roots { + if strings.TrimSpace(root) == "" { + continue + } + + resolvedRoot, err := resolveRoot(root) + if err != nil { + if errors.Is(err, fs.ErrNotExist) || errors.Is(err, fs.ErrPermission) { + continue + } + return nil, fmt.Errorf("resolve root %q: %w", root, err) + } + + err = filepath.WalkDir(resolvedRoot, func(path string, d fs.DirEntry, walkErr error) error { + if walkErr != nil { + if errors.Is(walkErr, fs.ErrPermission) { + return filepath.SkipDir + } + return nil + } + + if d.Type()&os.ModeSymlink != 0 { + if d.IsDir() { + return filepath.SkipDir + } + return nil + } + + if !d.IsDir() { + return nil + } + + if path != resolvedRoot && isHiddenDir(d.Name()) { + return filepath.SkipDir + } + + if d.Name() == "runs" && filepath.Base(filepath.Dir(path)) == ".forge" { + repoPath := filepath.Clean(filepath.Dir(filepath.Dir(path))) + if _, exists := repos[repoPath]; exists { + return filepath.SkipDir + } + + runs, err := loadRuns(path) + if err != nil { + if errors.Is(err, fs.ErrPermission) { + return filepath.SkipDir + } + return nil + } + + repos[repoPath] = RepoRuns{ + RepoPath: repoPath, + RepoName: filepath.Base(repoPath), + Runs: runs, + } + return filepath.SkipDir + } + + return nil + }) + if err != nil { + if errors.Is(err, fs.ErrPermission) { + continue + } + return nil, fmt.Errorf("walk root %q: %w", resolvedRoot, err) + } + } + + out := make([]RepoRuns, 0, len(repos)) + for _, repo := range repos { + out = append(out, repo) + } + sortRepoRuns(out) + return out, nil +} + +func sortRepoRuns(repos []RepoRuns) { + sort.Slice(repos, func(i, j int) bool { + return repos[i].RepoPath < repos[j].RepoPath + }) +} + +func loadRuns(runsDir string) ([]state.RunState, error) { + entries, err := os.ReadDir(runsDir) + if err != nil { + return nil, err + } + + sort.Slice(entries, func(i, j int) bool { + return entries[i].Name() < entries[j].Name() + }) + + runs := make([]state.RunState, 0) + for _, entry := range entries { + if entry.IsDir() { + continue + } + + name := entry.Name() + if filepath.Ext(name) != ".yaml" { + continue + } + + id := strings.TrimSuffix(name, ".yaml") + rs, err := loadRun(runsDir, id) + if err != nil { + continue + } + runs = append(runs, *rs) + } + + return runs, nil +} + +func loadRun(runsDir, id string) (*state.RunState, error) { + stateLoadMu.Lock() + defer stateLoadMu.Unlock() + + state.SetRunsDir(runsDir) + defer state.SetRunsDir(defaultRunsDir) + + return state.Load(id) +} + +func resolveRoot(root string) (string, error) { + path := root + if strings.HasPrefix(path, "~/") || path == "~" { + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + if path == "~" { + path = home + } else { + path = filepath.Join(home, strings.TrimPrefix(path, "~/")) + } + } + + abs, err := filepath.Abs(path) + if err != nil { + return "", err + } + + resolved, err := filepath.EvalSymlinks(abs) + if err == nil { + abs = resolved + } else if !errors.Is(err, fs.ErrNotExist) && !errors.Is(err, fs.ErrPermission) { + return "", err + } + + info, err := os.Stat(abs) + if err != nil { + return "", err + } + if !info.IsDir() { + return "", fmt.Errorf("not a directory: %s", abs) + } + return abs, nil +} + +func isHiddenDir(name string) bool { + return strings.HasPrefix(name, ".") && name != ".forge" +} diff --git a/internal/scanner/scanner_test.go b/internal/scanner/scanner_test.go new file mode 100644 index 0000000..9fac1c7 --- /dev/null +++ b/internal/scanner/scanner_test.go @@ -0,0 +1,136 @@ +package scanner + +import ( + "os" + "path/filepath" + "runtime" + "testing" + + "github.com/shahar-caura/forge/internal/state" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestScanRepos_DiscoversNestedRepoRuns(t *testing.T) { + root := t.TempDir() + + repoA := filepath.Join(root, "repo-a") + repoB := filepath.Join(root, "nested", "repo-b") + + createRunFile(t, repoA, "run-a") + createRunFile(t, repoB, "run-b") + + repos, err := ScanRepos([]string{root}) + require.NoError(t, err) + require.Len(t, repos, 2) + + repoAResolved := mustResolvePath(t, repoA) + repoBResolved := mustResolvePath(t, repoB) + + byPath := make(map[string]RepoRuns) + for _, repo := range repos { + byPath[repo.RepoPath] = repo + } + + repoARuns, ok := byPath[repoAResolved] + require.True(t, ok) + assert.Equal(t, "repo-a", repoARuns.RepoName) + require.Len(t, repoARuns.Runs, 1) + assert.Equal(t, "run-a", repoARuns.Runs[0].ID) + + repoBRuns, ok := byPath[repoBResolved] + require.True(t, ok) + assert.Equal(t, "repo-b", repoBRuns.RepoName) + require.Len(t, repoBRuns.Runs, 1) + assert.Equal(t, "run-b", repoBRuns.Runs[0].ID) +} + +func TestScanRepos_EmptyRunsAndHiddenDirectories(t *testing.T) { + root := t.TempDir() + + emptyRepo := filepath.Join(root, "repo-empty") + require.NoError(t, os.MkdirAll(filepath.Join(emptyRepo, ".forge", "runs"), 0o755)) + + hiddenRepo := filepath.Join(root, ".hidden", "repo-hidden") + createRunFile(t, hiddenRepo, "hidden-run") + + repos, err := ScanRepos([]string{root}) + require.NoError(t, err) + require.Len(t, repos, 1) + + assert.Equal(t, mustResolvePath(t, emptyRepo), repos[0].RepoPath) + assert.Equal(t, "repo-empty", repos[0].RepoName) + assert.Empty(t, repos[0].Runs) +} + +func TestScanRepos_SkipsSymlinkedDirectories(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("symlink test is platform-specific") + } + + root := t.TempDir() + actualRepo := filepath.Join(root, "actual-repo") + createRunFile(t, actualRepo, "run-actual") + + linkPath := filepath.Join(root, "repo-link") + require.NoError(t, os.Symlink(actualRepo, linkPath)) + + repos, err := ScanRepos([]string{root}) + require.NoError(t, err) + require.Len(t, repos, 1) + assert.Equal(t, mustResolvePath(t, actualRepo), repos[0].RepoPath) +} + +func TestScanRepos_SkipsUnreadableDirectories(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("chmod permission semantics differ on windows") + } + + root := t.TempDir() + goodRepo := filepath.Join(root, "good-repo") + createRunFile(t, goodRepo, "good-run") + + blockedDir := filepath.Join(root, "blocked") + require.NoError(t, os.MkdirAll(filepath.Join(blockedDir, "child"), 0o755)) + require.NoError(t, os.Chmod(blockedDir, 0o000)) + defer func() { + _ = os.Chmod(blockedDir, 0o755) + }() + + repos, err := ScanRepos([]string{root}) + require.NoError(t, err) + require.Len(t, repos, 1) + assert.Equal(t, mustResolvePath(t, goodRepo), repos[0].RepoPath) + require.Len(t, repos[0].Runs, 1) + assert.Equal(t, "good-run", repos[0].Runs[0].ID) +} + +func createRunFile(t *testing.T, repoPath, id string) { + t.Helper() + + runsDir := filepath.Join(repoPath, ".forge", "runs") + require.NoError(t, os.MkdirAll(runsDir, 0o755)) + + rs := state.New(id, "plans/test.md") + + cleanup := setRunsDirForTest(t, runsDir) + defer cleanup() + require.NoError(t, rs.Save()) +} + +func setRunsDirForTest(t *testing.T, dir string) func() { + t.Helper() + state.SetRunsDir(dir) + return func() { state.SetRunsDir(".forge/runs") } +} + +func mustResolvePath(t *testing.T, path string) string { + t.Helper() + resolved, err := filepath.EvalSymlinks(path) + if err == nil { + return resolved + } + abs, absErr := filepath.Abs(path) + require.NoError(t, absErr) + return abs +} diff --git a/lefthook.yml b/lefthook.yml index 1bb1404..e7ef4b8 100644 --- a/lefthook.yml +++ b/lefthook.yml @@ -10,6 +10,9 @@ pre-commit: lint: glob: "*.go" run: golangci-lint run --new-from-rev=HEAD {staged_files} + test: + glob: "*.go" + run: go test -race ./... pre-push: commands: From bd02dd9eba867066a9a53a8f324b88e559b7e229 Mon Sep 17 00:00:00 2001 From: Shahar Margalit Date: Sun, 1 Mar 2026 11:28:02 +0200 Subject: [PATCH 2/2] fix: make intent tests CI-safe and add pre-push hook LookPath check for claude CLI ran before the mocked CommandContext, causing all intent tests to fail in CI where claude is not installed. Extract LookPath into an overridable var (same pattern as CommandContext). Add .githooks/pre-push that runs `make test` so test failures are caught before push. Update pre-commit to also run gofumpt. Co-Authored-By: Claude Opus 4.6 --- .githooks/pre-commit | 10 ++++++---- .githooks/pre-push | 9 +++++++++ internal/intent/classify.go | 5 ++++- internal/intent/classify_test.go | 26 ++++++++++++++++++++------ 4 files changed, 39 insertions(+), 11 deletions(-) create mode 100755 .githooks/pre-push diff --git a/.githooks/pre-commit b/.githooks/pre-commit index 3deb69c..612bc6b 100755 --- a/.githooks/pre-commit +++ b/.githooks/pre-commit @@ -9,15 +9,17 @@ if [ -z "$STAGED_GO" ]; then exit 0 fi -echo "pre-commit: goimports..." -if command -v goimports &>/dev/null; then +echo "pre-commit: formatting..." +if command -v gofumpt &>/dev/null && command -v goimports &>/dev/null; then + echo "$STAGED_GO" | xargs gofumpt -w echo "$STAGED_GO" | xargs goimports -w echo "$STAGED_GO" | xargs git diff --exit-code -- >/dev/null 2>&1 || { - echo "pre-commit: goimports produced changes — re-staged." + echo "pre-commit: formatter produced changes — re-staged." echo "$STAGED_GO" | xargs git add } else - echo "pre-commit: goimports not found, skipping format check" + echo "pre-commit: gofumpt/goimports not found, skipping format check" + echo " install: go install mvdan.cc/gofumpt@latest" echo " install: go install golang.org/x/tools/cmd/goimports@latest" fi diff --git a/.githooks/pre-push b/.githooks/pre-push new file mode 100755 index 0000000..7f3fbb8 --- /dev/null +++ b/.githooks/pre-push @@ -0,0 +1,9 @@ +#!/usr/bin/env bash +# Forge pre-push hook: runs full test suite so CI failures are caught locally. +# Install: git config core.hooksPath .githooks +set -euo pipefail + +echo "pre-push: running tests..." +make test + +echo "pre-push: OK" diff --git a/internal/intent/classify.go b/internal/intent/classify.go index 9b3620b..d192f1f 100644 --- a/internal/intent/classify.go +++ b/internal/intent/classify.go @@ -13,12 +13,15 @@ import ( // CommandContext is the function used to create exec.Cmd. Override in tests. var CommandContext = exec.CommandContext +// LookPath is the function used to find executables. Override in tests. +var LookPath = exec.LookPath + // MinConfidence is the minimum confidence score required to accept a classification. const MinConfidence = 0.5 // Classify interprets a natural language query as a forge command. func Classify(ctx context.Context, query string) (*Result, error) { - if _, err := exec.LookPath("claude"); err != nil { + if _, err := LookPath("claude"); err != nil { return nil, ErrNoClaude } diff --git a/internal/intent/classify_test.go b/internal/intent/classify_test.go index 723a431..9b84de9 100644 --- a/internal/intent/classify_test.go +++ b/internal/intent/classify_test.go @@ -39,13 +39,17 @@ func fakeCommandContext(output string, exitErr bool) func(ctx context.Context, n } } +func fakeLookPath(string) (string, error) { return "/fake/claude", nil } + func TestClassify_Success(t *testing.T) { // Envelope wrapping actual JSON result. envelope := `{"result":"{\"argv\":[\"run\",\"plans/auth.md\"],\"confidence\":0.95,\"reasoning\":\"user wants to run auth plan\"}"}` orig := CommandContext + origLP := LookPath CommandContext = fakeCommandContext(envelope, false) - defer func() { CommandContext = orig }() + LookPath = fakeLookPath + defer func() { CommandContext = orig; LookPath = origLP }() r, err := Classify(context.Background(), "run the auth plan") if err != nil { @@ -71,8 +75,10 @@ func TestClassify_NoClaude(t *testing.T) { func TestClassify_ExitError(t *testing.T) { orig := CommandContext + origLP := LookPath CommandContext = fakeCommandContext("something went wrong", true) - defer func() { CommandContext = orig }() + LookPath = fakeLookPath + defer func() { CommandContext = orig; LookPath = origLP }() _, err := Classify(context.Background(), "do something") if !errors.Is(err, ErrClassificationFailed) { @@ -82,8 +88,10 @@ func TestClassify_ExitError(t *testing.T) { func TestClassify_MalformedJSON(t *testing.T) { orig := CommandContext + origLP := LookPath CommandContext = fakeCommandContext(`{"result":"not json at all"}`, false) - defer func() { CommandContext = orig }() + LookPath = fakeLookPath + defer func() { CommandContext = orig; LookPath = origLP }() _, err := Classify(context.Background(), "do something") if !errors.Is(err, ErrClassificationFailed) { @@ -93,8 +101,10 @@ func TestClassify_MalformedJSON(t *testing.T) { func TestClassify_EmptyArgv(t *testing.T) { orig := CommandContext + origLP := LookPath CommandContext = fakeCommandContext(`{"result":"{\"argv\":[],\"confidence\":0.1,\"reasoning\":\"unclear\"}"}`, false) - defer func() { CommandContext = orig }() + LookPath = fakeLookPath + defer func() { CommandContext = orig; LookPath = origLP }() _, err := Classify(context.Background(), "do something") if !errors.Is(err, ErrClassificationFailed) { @@ -107,8 +117,10 @@ func TestClassify_CodeFencedJSON(t *testing.T) { fenced := "{\"result\":\"```json\\n{\\\"argv\\\":[\\\"runs\\\"],\\\"confidence\\\":0.9,\\\"reasoning\\\":\\\"list runs\\\"}\\n```\"}" orig := CommandContext + origLP := LookPath CommandContext = fakeCommandContext(fenced, false) - defer func() { CommandContext = orig }() + LookPath = fakeLookPath + defer func() { CommandContext = orig; LookPath = origLP }() r, err := Classify(context.Background(), "show my runs") if err != nil { @@ -134,8 +146,10 @@ func TestClassify_LowConfidence(t *testing.T) { envelope := `{"result":"{\"argv\":[\"run\",\"plans/auth.md\"],\"confidence\":0.2,\"reasoning\":\"not sure\"}"}` orig := CommandContext + origLP := LookPath CommandContext = fakeCommandContext(envelope, false) - defer func() { CommandContext = orig }() + LookPath = fakeLookPath + defer func() { CommandContext = orig; LookPath = origLP }() _, err := Classify(context.Background(), "maybe run something") if !errors.Is(err, ErrClassificationFailed) {