Files
gadfly/cmd/gadfly/tools_test.go
T
steve ac6ce06cdd
Build & push image / build-and-push (push) Successful in 33s
feat: re-platform agentic review onto executus + large-PR cost controls (#20)
Makes gadfly a consumer of executus (run.Executor compaction/bounding/budget/critic + fanout) and fixes the large-PR token burn in size-gated layers: paginated get_diff, downshift above GADFLY_HUGE_DIFF_BYTES, and a swarm-wide GADFLY_PR_BUDGET_SECS backstop. Small PRs untouched; advisory-only and the static binary preserved. Dogfood swarm reviewed it (6 models, 21 real findings graded + folded in).

Co-authored-by: Steve Dudenhoeffer <[email protected]>
Co-committed-by: Steve Dudenhoeffer <[email protected]>
2026-06-30 15:41:03 +00:00

322 lines
10 KiB
Go

package main
import (
"context"
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
)
// buildFixtureRepo lays down a small repo tree for the toolbox tests and
// returns its root.
func buildFixtureRepo(t *testing.T) string {
t.Helper()
root := t.TempDir()
write := func(rel, content string) {
p := filepath.Join(root, rel)
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(p, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
}
write("pkg/foo/foo.go", "package foo\n\nfunc Hello() string {\n\treturn \"hi\"\n}\n")
write("pkg/foo/bar.go", "package foo\n\n// TODO: refactor\nvar Answer = 42\n")
write("README.md", "# Fixture\n\nHello world.\n")
write(".git/config", "[core]\n\tbare = false\n") // must be skipped by grep/find
write("secret.txt", "this file lives at the repo root\n")
return root
}
// call invokes a tool from the sandbox's toolbox by name with JSON args and
// returns the result string (or the error).
func call(t *testing.T, fs *repoFS, name string, args map[string]any) (string, error) {
t.Helper()
box, err := fs.toolbox()
if err != nil {
t.Fatalf("toolbox: %v", err)
}
tool, ok := box.Get(name)
if !ok {
t.Fatalf("tool %q not in toolbox", name)
}
raw, err := json.Marshal(args)
if err != nil {
t.Fatal(err)
}
out, herr := tool.Handler(context.Background(), raw)
if herr != nil {
return "", herr
}
s, _ := out.(string)
return s, nil
}
func TestRepoFS_ResolveSandbox(t *testing.T) {
root := buildFixtureRepo(t)
fs, err := newRepoFS(root, "")
if err != nil {
t.Fatalf("newRepoFS: %v", err)
}
// In-bounds paths resolve.
if _, err := fs.resolve("pkg/foo/foo.go"); err != nil {
t.Errorf("in-bounds path rejected: %v", err)
}
if got, err := fs.resolve(""); err != nil || got != fs.root {
t.Errorf("empty path should be root: got %q err %v", got, err)
}
// Escapes are rejected.
for _, bad := range []string{"../outside", "../../etc/passwd", "pkg/../../escape", "/etc/passwd"} {
if _, err := fs.resolve(bad); err == nil {
t.Errorf("path %q escaped the sandbox but was allowed", bad)
}
}
}
func TestReadFileTool(t *testing.T) {
root := buildFixtureRepo(t)
fs, _ := newRepoFS(root, "")
out, err := call(t, fs, "read_file", map[string]any{"path": "pkg/foo/foo.go"})
if err != nil {
t.Fatalf("read_file: %v", err)
}
if !strings.Contains(out, "func Hello()") {
t.Errorf("expected file body, got:\n%s", out)
}
if !strings.Contains(out, "1\t") {
t.Errorf("expected line numbers, got:\n%s", out)
}
// Line slicing.
out, err = call(t, fs, "read_file", map[string]any{"path": "pkg/foo/foo.go", "start_line": 3, "limit": 1})
if err != nil {
t.Fatalf("read_file slice: %v", err)
}
if !strings.Contains(out, "func Hello()") || strings.Contains(out, "package foo") {
t.Errorf("slice should start at line 3 only, got:\n%s", out)
}
// Reading a directory is an error directing to list_dir.
if _, err := call(t, fs, "read_file", map[string]any{"path": "pkg/foo"}); err == nil {
t.Error("reading a directory should error")
}
// Escape is rejected.
if _, err := call(t, fs, "read_file", map[string]any{"path": "../escape"}); err == nil {
t.Error("read_file should reject sandbox escape")
}
}
func TestListDirTool(t *testing.T) {
root := buildFixtureRepo(t)
fs, _ := newRepoFS(root, "")
out, err := call(t, fs, "list_dir", map[string]any{"path": "pkg/foo"})
if err != nil {
t.Fatalf("list_dir: %v", err)
}
for _, want := range []string{"foo.go", "bar.go"} {
if !strings.Contains(out, want) {
t.Errorf("list_dir missing %q in:\n%s", want, out)
}
}
// Root listing marks directories with a trailing slash.
out, _ = call(t, fs, "list_dir", map[string]any{})
if !strings.Contains(out, "pkg/") {
t.Errorf("expected pkg/ (dir with trailing slash) in root listing:\n%s", out)
}
}
func TestGrepTool(t *testing.T) {
root := buildFixtureRepo(t)
fs, _ := newRepoFS(root, "")
out, err := call(t, fs, "grep", map[string]any{"pattern": "func Hello"})
if err != nil {
t.Fatalf("grep: %v", err)
}
if !strings.Contains(out, "pkg/foo/foo.go:") {
t.Errorf("grep should locate the func, got:\n%s", out)
}
// .git is skipped.
out, _ = call(t, fs, "grep", map[string]any{"pattern": "bare = false"})
if strings.Contains(out, ".git/") {
t.Errorf("grep must not descend into .git, got:\n%s", out)
}
// No matches is a clean message, not an error.
out, err = call(t, fs, "grep", map[string]any{"pattern": "zzz_no_such_token_zzz"})
if err != nil || !strings.Contains(out, "no matches") {
t.Errorf("expected clean no-match, got %q err %v", out, err)
}
// Invalid regexp surfaces as an error.
if _, err := call(t, fs, "grep", map[string]any{"pattern": "([unterminated"}); err == nil {
t.Error("invalid regexp should error")
}
// Scoped grep honors the path.
out, _ = call(t, fs, "grep", map[string]any{"pattern": "Answer", "path": "pkg/foo/bar.go"})
if !strings.Contains(out, "bar.go:") {
t.Errorf("scoped grep missed the match:\n%s", out)
}
}
func TestFindFilesTool(t *testing.T) {
root := buildFixtureRepo(t)
fs, _ := newRepoFS(root, "")
out, err := call(t, fs, "find_files", map[string]any{"name": "foo.go"})
if err != nil {
t.Fatalf("find_files: %v", err)
}
if !strings.Contains(out, "pkg/foo/foo.go") {
t.Errorf("find_files missed foo.go:\n%s", out)
}
// Case-insensitive substring on the path.
out, _ = call(t, fs, "find_files", map[string]any{"name": "PKG/FOO"})
if !strings.Contains(out, "pkg/foo/") {
t.Errorf("find_files should be case-insensitive on the path:\n%s", out)
}
// .git entries are not surfaced.
out, _ = call(t, fs, "find_files", map[string]any{"name": "config"})
if strings.Contains(out, ".git/") {
t.Errorf("find_files must skip .git, got:\n%s", out)
}
}
func TestGetDiffTool(t *testing.T) {
root := buildFixtureRepo(t)
const diff = "diff --git a/x b/x\n--- a/x\n+++ b/x\n+added line\n" +
"diff --git a/y.go b/y.go\n--- a/y.go\n+++ b/y.go\n+y change\n"
fs, _ := newRepoFS(root, diff)
// Default: the whole diff as a NUMBERED window (paginated), not a raw dump —
// so a huge PR can't be poured into the transcript in one call.
out, err := call(t, fs, "get_diff", map[string]any{})
if err != nil {
t.Fatalf("get_diff: %v", err)
}
if !strings.Contains(out, "1\tdiff --git a/x b/x") {
t.Errorf("get_diff should return a numbered window, got:\n%s", out)
}
if !strings.Contains(out, "+added line") || !strings.Contains(out, "+y change") {
t.Errorf("the full (short) diff window should include every hunk, got:\n%s", out)
}
// path filter: only the named file's hunks come back.
out, err = call(t, fs, "get_diff", map[string]any{"path": "y.go"})
if err != nil {
t.Fatalf("get_diff path: %v", err)
}
if !strings.Contains(out, "y change") {
t.Errorf("get_diff path=y.go should include y's hunk, got:\n%s", out)
}
if strings.Contains(out, "added line") {
t.Errorf("get_diff path=y.go must NOT include x's hunk, got:\n%s", out)
}
// unknown path: a clear note, never an error.
out, err = call(t, fs, "get_diff", map[string]any{"path": "nope.txt"})
if err != nil {
t.Fatalf("get_diff unknown path: %v", err)
}
if !strings.Contains(out, "no diff hunks") {
t.Errorf("get_diff for an unknown path should note no hunks, got:\n%s", out)
}
}
// TestGetDiffTool_Paginates: a diff longer than the per-call line cap is returned
// as a truncated window with a paging hint, and start_line pages past it — the
// mechanism that stops get_diff from dumping a multi-hundred-KB diff at once.
func TestGetDiffTool_Paginates(t *testing.T) {
diff := "diff --git a/big b/big\n" + strings.Repeat("+line\n", maxGetDiffLines+50)
fs, _ := newRepoFS(t.TempDir(), diff)
out, err := call(t, fs, "get_diff", map[string]any{})
if err != nil {
t.Fatalf("get_diff: %v", err)
}
if !strings.Contains(out, "truncated after line") {
t.Error("a diff longer than the per-call cap should be truncated with a paging hint")
}
if strings.Contains(out, "801\t") {
t.Error("the first window must stop at the line cap, not reach line 801")
}
out, err = call(t, fs, "get_diff", map[string]any{"start_line": 805})
if err != nil {
t.Fatalf("get_diff page: %v", err)
}
if !strings.Contains(out, "805\t") {
t.Error("paging with start_line=805 should include line 805")
}
}
// TestDiffLinesForPath_Anchored: get_diff path= matches on a WHOLE path token,
// so "foo.go" never pulls in "barfoo.go" (the unanchored-substring weakness the
// swarm flagged), while a trailing "/" still scopes to a directory.
func TestDiffLinesForPath_Anchored(t *testing.T) {
diff := "diff --git a/foo.go b/foo.go\n+foo change\n" +
"diff --git a/barfoo.go b/barfoo.go\n+barfoo change\n" +
"diff --git a/pkg/x.go b/pkg/x.go\n+x change\n"
joined := strings.Join(diffLinesForPath(diff, "foo.go"), "\n")
if !strings.Contains(joined, "foo change") {
t.Errorf("foo.go should match its own hunk:\n%s", joined)
}
if strings.Contains(joined, "barfoo change") {
t.Errorf("foo.go must NOT match barfoo.go (unanchored substring regression):\n%s", joined)
}
if !strings.Contains(strings.Join(diffLinesForPath(diff, "pkg/"), "\n"), "x change") {
t.Error("a trailing-slash path should scope to the directory (pkg/ -> pkg/x.go)")
}
if len(diffLinesForPath(diff, "nope.go")) != 0 {
t.Error("an unknown path should yield no lines")
}
}
func TestNewRepoFS_BadRoot(t *testing.T) {
// A file (not a directory) is rejected.
f := filepath.Join(t.TempDir(), "afile")
if err := os.WriteFile(f, []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
if _, err := newRepoFS(f, ""); err == nil {
t.Error("newRepoFS should reject a non-directory root")
}
if _, err := newRepoFS(filepath.Join(t.TempDir(), "missing"), ""); err == nil {
t.Error("newRepoFS should reject a missing root")
}
}
// Ensure the toolbox exposes exactly the expected tools (guards against an
// accidental rename breaking the system prompt's tool references).
func TestToolbox_Names(t *testing.T) {
fs, _ := newRepoFS(t.TempDir(), "")
box, err := fs.toolbox()
if err != nil {
t.Fatalf("toolbox: %v", err)
}
got := map[string]bool{}
for _, tl := range box.Tools() {
got[tl.Name] = true
}
for _, want := range []string{"read_file", "list_dir", "grep", "find_files", "get_diff"} {
if !got[want] {
t.Errorf("toolbox missing tool %q", want)
}
}
}