Given only read_file, the agent had no way to answer "what does main.go
do?" in a repo with no main.go at the root except to guess paths. A live
run spent three turns and nine reads, six of them misses, on exactly that.
glob("main.go") answers it in one call.
grep shells out to ripgrep when present and walks in pure Go otherwise.
Matches stay structured until output and are sorted by path and line, so
both engines return byte-identical results: ripgrep walks in parallel and
its file order is nondeterministic, which would otherwise surface as eval
noise and as prompt cache misses depending on whether the machine has rg.
Both prune .git, node_modules and friends, cap at 100 results with a count
of what was suppressed, and skip binary files. A bare glob pattern with no
slash matches the file name at any depth, since that is what "*.go" is
always meant to mean.
The system prompt now says not to guess paths, which is only honest advice
now that there is something to guess with.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>Showing 8 changed files with 663 additions and 8 deletions.
| @@ -74,6 +74,8 @@ func run() error { | ||
| 74 | 74 | state := agent.NewState() |
| 75 | 75 | reg := tools.NewRegistry(log).Register( |
| 76 | 76 | tools.NewRead(root, state), |
| 77 | + tools.NewGlob(root), | |
| 78 | + tools.NewGrep(root), | |
| 77 | 79 | ) |
| 78 | 80 | |
| 79 | 81 | render := &ui.Renderer{W: os.Stdout, Verbose: *verbose} |
| @@ -6,6 +6,7 @@ require github.com/invopop/jsonschema v0.14.0 | ||
| 6 | 6 | |
| 7 | 7 | require ( |
| 8 | 8 | github.com/bahlo/generic-list-go v0.2.0 // indirect |
| 9 | + github.com/bmatcuk/doublestar/v4 v4.10.0 // indirect | |
| 9 | 10 | github.com/buger/jsonparser v1.1.2 // indirect |
| 10 | 11 | github.com/pb33f/ordered-map/v2 v2.3.1 // indirect |
| 11 | 12 | go.yaml.in/yaml/v4 v4.0.0-rc.2 // indirect |
| @@ -1,5 +1,7 @@ | ||
| 1 | 1 | github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= |
| 2 | 2 | github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= |
| 3 | + github.com/bmatcuk/doublestar/v4 v4.10.0 h1:zU9WiOla1YA122oLM6i4EXvGW62DvKZVxIe6TYWexEs= | |
| 4 | + github.com/bmatcuk/doublestar/v4 v4.10.0/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= | |
| 3 | 5 | github.com/buger/jsonparser v1.1.2 h1:frqHqw7otoVbk5M8LlE/L7HTnIq2v9RX6EJ48i9AxJk= |
| 4 | 6 | github.com/buger/jsonparser v1.1.2/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= |
| 5 | 7 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= |
| @@ -24,9 +24,14 @@ var ErrMaxTurns = errors.New("turn limit reached") | ||
| 24 | 24 | // every single request. |
| 25 | 25 | const defaultSystem = `You are a coding agent operating on a local repository through tools. |
| 26 | 26 | |
| 27 | - Work by reading before you act. Prefer reading the specific file or region you | |
| 28 | - need over dumping whole directories. When you change something, verify it by | |
| 29 | - running the project's own tests rather than asserting that it works. | |
| 27 | + Never guess at file paths. If you do not know where something lives, find it | |
| 28 | + with glob or grep first — one search costs less than a run of wrong guesses, | |
| 29 | + and a wrong guess tells you nothing about where the file actually is. Once you | |
| 30 | + know the path, read the specific file or region you need rather than dumping | |
| 31 | + whole directories. | |
| 32 | + | |
| 33 | + When you change something, verify it by running the project's own tests rather | |
| 34 | + than asserting that it works. | |
| 30 | 35 | |
| 31 | 36 | If a tool returns an error, read it: the error text usually says exactly what to |
| 32 | 37 | do differently. Do not repeat a failing call unchanged. |
| @@ -0,0 +1,150 @@ | ||
| 1 | + package tools | |
| 2 | + | |
| 3 | + import ( | |
| 4 | + "context" | |
| 5 | + "fmt" | |
| 6 | + "io/fs" | |
| 7 | + "os" | |
| 8 | + "path/filepath" | |
| 9 | + "sort" | |
| 10 | + "strings" | |
| 11 | + | |
| 12 | + "github.com/bmatcuk/doublestar/v4" | |
| 13 | + ) | |
| 14 | + | |
| 15 | + // maxResults caps how many paths or matches a search tool returns. The count of | |
| 16 | + // what was suppressed goes back too, so the model knows to narrow the pattern | |
| 17 | + // rather than assume it has seen everything. | |
| 18 | + const maxResults = 100 | |
| 19 | + | |
| 20 | + // skipDirs are never descended into. They are where a repo keeps things the | |
| 21 | + // agent did not write and should not be reading by accident. | |
| 22 | + var skipDirs = map[string]bool{ | |
| 23 | + ".git": true, ".hg": true, ".svn": true, | |
| 24 | + "node_modules": true, "vendor": true, "target": true, | |
| 25 | + ".venv": true, "venv": true, "__pycache__": true, | |
| 26 | + ".harness": true, ".next": true, ".cache": true, | |
| 27 | + } | |
| 28 | + | |
| 29 | + // GlobArgs is the glob input. | |
| 30 | + type GlobArgs struct { | |
| 31 | + Pattern string `json:"pattern" jsonschema:"description=Glob pattern. ** matches across directories; a pattern with no slash is matched against the file name at any depth"` | |
| 32 | + Path string `json:"path,omitempty" jsonschema:"description=Directory to search under; defaults to the working root"` | |
| 33 | + } | |
| 34 | + | |
| 35 | + // NewGlob builds the glob tool. | |
| 36 | + func NewGlob(root string) Tool { | |
| 37 | + return New("glob", strings.TrimSpace(` | |
| 38 | + Find files by name. Returns paths relative to the working root, most recently | |
| 39 | + modified first. "*.go" matches Go files at any depth; "internal/**/*_test.go" | |
| 40 | + matches within a subtree. Use this instead of guessing where a file lives.`), | |
| 41 | + func(ctx context.Context, in GlobArgs) (string, error) { | |
| 42 | + if in.Pattern == "" { | |
| 43 | + return "", fmt.Errorf("pattern is required") | |
| 44 | + } | |
| 45 | + base := resolveBase(root, in.Path) | |
| 46 | + if info, err := os.Stat(base); err != nil || !info.IsDir() { | |
| 47 | + return "", fmt.Errorf("no such directory: %s", displayPath(root, base)) | |
| 48 | + } | |
| 49 | + | |
| 50 | + type hit struct { | |
| 51 | + path string | |
| 52 | + mod int64 | |
| 53 | + } | |
| 54 | + var hits []hit | |
| 55 | + var scanned int | |
| 56 | + | |
| 57 | + err := filepath.WalkDir(base, func(p string, d fs.DirEntry, err error) error { | |
| 58 | + if err != nil { | |
| 59 | + return nil // unreadable entries are skipped, not fatal | |
| 60 | + } | |
| 61 | + if ctx.Err() != nil { | |
| 62 | + return ctx.Err() | |
| 63 | + } | |
| 64 | + if d.IsDir() { | |
| 65 | + if p != base && shouldSkip(d.Name(), in.Pattern) { | |
| 66 | + return fs.SkipDir | |
| 67 | + } | |
| 68 | + return nil | |
| 69 | + } | |
| 70 | + scanned++ | |
| 71 | + rel, relErr := filepath.Rel(base, p) | |
| 72 | + if relErr != nil { | |
| 73 | + return nil | |
| 74 | + } | |
| 75 | + if !matchPath(in.Pattern, rel) { | |
| 76 | + return nil | |
| 77 | + } | |
| 78 | + var mod int64 | |
| 79 | + if info, statErr := d.Info(); statErr == nil { | |
| 80 | + mod = info.ModTime().UnixNano() | |
| 81 | + } | |
| 82 | + hits = append(hits, hit{path: displayPath(root, p), mod: mod}) | |
| 83 | + return nil | |
| 84 | + }) | |
| 85 | + if err != nil { | |
| 86 | + return "", err | |
| 87 | + } | |
| 88 | + if len(hits) == 0 { | |
| 89 | + return fmt.Sprintf("No files match %q under %s (%d files scanned).", | |
| 90 | + in.Pattern, displayPath(root, base), scanned), nil | |
| 91 | + } | |
| 92 | + | |
| 93 | + // Most recently modified first: in a repo being worked on, that is | |
| 94 | + // usually the file the task is about. | |
| 95 | + sort.Slice(hits, func(i, j int) bool { | |
| 96 | + if hits[i].mod != hits[j].mod { | |
| 97 | + return hits[i].mod > hits[j].mod | |
| 98 | + } | |
| 99 | + return hits[i].path < hits[j].path | |
| 100 | + }) | |
| 101 | + | |
| 102 | + var b strings.Builder | |
| 103 | + shown := hits | |
| 104 | + if len(shown) > maxResults { | |
| 105 | + shown = shown[:maxResults] | |
| 106 | + } | |
| 107 | + for _, h := range shown { | |
| 108 | + b.WriteString(h.path) | |
| 109 | + b.WriteByte('\n') | |
| 110 | + } | |
| 111 | + if len(hits) > len(shown) { | |
| 112 | + fmt.Fprintf(&b, "\n(%d of %d shown; narrow the pattern to see the rest)", len(shown), len(hits)) | |
| 113 | + } | |
| 114 | + return b.String(), nil | |
| 115 | + }) | |
| 116 | + } | |
| 117 | + | |
| 118 | + // matchPath applies the pattern to the relative path, and — when the pattern | |
| 119 | + // names no directory — also to the bare file name, so "*.go" finds Go files at | |
| 120 | + // any depth rather than only at the top level. | |
| 121 | + func matchPath(pattern, rel string) bool { | |
| 122 | + rel = filepath.ToSlash(rel) | |
| 123 | + if ok, err := doublestar.Match(pattern, rel); err == nil && ok { | |
| 124 | + return true | |
| 125 | + } | |
| 126 | + if !strings.Contains(pattern, "/") { | |
| 127 | + if ok, err := doublestar.Match(pattern, filepath.Base(rel)); err == nil && ok { | |
| 128 | + return true | |
| 129 | + } | |
| 130 | + } | |
| 131 | + return false | |
| 132 | + } | |
| 133 | + | |
| 134 | + // shouldSkip prunes noise directories, unless the pattern explicitly names one. | |
| 135 | + func shouldSkip(name, pattern string) bool { | |
| 136 | + return skipDirs[name] && !strings.Contains(pattern, name) | |
| 137 | + } | |
| 138 | + | |
| 139 | + // displayPath prefers a path relative to the working root: shorter to read and | |
| 140 | + // shorter in the context window. | |
| 141 | + func displayPath(root, p string) string { | |
| 142 | + if root == "" { | |
| 143 | + return p | |
| 144 | + } | |
| 145 | + rel, err := filepath.Rel(root, p) | |
| 146 | + if err != nil || strings.HasPrefix(rel, "..") { | |
| 147 | + return p | |
| 148 | + } | |
| 149 | + return filepath.ToSlash(rel) | |
| 150 | + } | |
| @@ -0,0 +1,234 @@ | ||
| 1 | + package tools | |
| 2 | + | |
| 3 | + import ( | |
| 4 | + "bufio" | |
| 5 | + "bytes" | |
| 6 | + "context" | |
| 7 | + "fmt" | |
| 8 | + "io/fs" | |
| 9 | + "os" | |
| 10 | + "os/exec" | |
| 11 | + "path/filepath" | |
| 12 | + "regexp" | |
| 13 | + "sort" | |
| 14 | + "strconv" | |
| 15 | + "strings" | |
| 16 | + ) | |
| 17 | + | |
| 18 | + // ripgrep is the path to rg, or empty when it is not installed. It is a | |
| 19 | + // variable so tests can exercise the pure-Go fallback on a machine that has rg. | |
| 20 | + var ripgrep = func() string { | |
| 21 | + p, err := exec.LookPath("rg") | |
| 22 | + if err != nil { | |
| 23 | + return "" | |
| 24 | + } | |
| 25 | + return p | |
| 26 | + }() | |
| 27 | + | |
| 28 | + // match is one hit, kept structured until output so results from either engine | |
| 29 | + // can be sorted the same way. ripgrep walks in parallel and returns files in a | |
| 30 | + // nondeterministic order; without this, the same search would produce different | |
| 31 | + // bytes on different machines, which would show up as eval noise and as prompt | |
| 32 | + // cache misses. | |
| 33 | + type match struct { | |
| 34 | + path string | |
| 35 | + line int | |
| 36 | + text string | |
| 37 | + } | |
| 38 | + | |
| 39 | + func (m match) String() string { return fmt.Sprintf("%s:%d: %s", m.path, m.line, m.text) } | |
| 40 | + | |
| 41 | + func sortMatches(ms []match) { | |
| 42 | + sort.Slice(ms, func(i, j int) bool { | |
| 43 | + if ms[i].path != ms[j].path { | |
| 44 | + return ms[i].path < ms[j].path | |
| 45 | + } | |
| 46 | + return ms[i].line < ms[j].line | |
| 47 | + }) | |
| 48 | + } | |
| 49 | + | |
| 50 | + // GrepArgs is the grep input. | |
| 51 | + type GrepArgs struct { | |
| 52 | + Pattern string `json:"pattern" jsonschema:"description=Regular expression to search for"` | |
| 53 | + Path string `json:"path,omitempty" jsonschema:"description=File or directory to search; defaults to the working root"` | |
| 54 | + Glob string `json:"glob,omitempty" jsonschema:"description=Only search files matching this glob, e.g. *.go"` | |
| 55 | + IgnoreCase bool `json:"ignore_case,omitempty" jsonschema:"description=Match case-insensitively"` | |
| 56 | + } | |
| 57 | + | |
| 58 | + // NewGrep builds the grep tool. It shells out to ripgrep when available and | |
| 59 | + // falls back to a pure-Go walk otherwise; the output format is the same either | |
| 60 | + // way, so the model cannot tell which ran. | |
| 61 | + func NewGrep(root string) Tool { | |
| 62 | + return New("grep", strings.TrimSpace(` | |
| 63 | + Search file contents by regular expression. Returns "path:line: text" for each | |
| 64 | + match. Narrow with glob (e.g. "*.go") or path. This is how you find code you | |
| 65 | + have not been shown — reach for it before guessing at file names.`), | |
| 66 | + func(ctx context.Context, in GrepArgs) (string, error) { | |
| 67 | + if in.Pattern == "" { | |
| 68 | + return "", fmt.Errorf("pattern is required") | |
| 69 | + } | |
| 70 | + re, err := compile(in.Pattern, in.IgnoreCase) | |
| 71 | + if err != nil { | |
| 72 | + return "", err | |
| 73 | + } | |
| 74 | + base := resolveBase(root, in.Path) | |
| 75 | + if _, err := os.Stat(base); err != nil { | |
| 76 | + return "", fmt.Errorf("no such file or directory: %s", displayPath(root, base)) | |
| 77 | + } | |
| 78 | + | |
| 79 | + var matches []match | |
| 80 | + if ripgrep != "" { | |
| 81 | + matches, err = grepRipgrep(ctx, root, base, in) | |
| 82 | + } else { | |
| 83 | + matches, err = grepWalk(ctx, root, base, in, re) | |
| 84 | + } | |
| 85 | + if err != nil { | |
| 86 | + return "", err | |
| 87 | + } | |
| 88 | + if len(matches) == 0 { | |
| 89 | + where := displayPath(root, base) | |
| 90 | + if in.Glob != "" { | |
| 91 | + where += " matching " + in.Glob | |
| 92 | + } | |
| 93 | + return fmt.Sprintf("No matches for %q in %s.", in.Pattern, where), nil | |
| 94 | + } | |
| 95 | + | |
| 96 | + sortMatches(matches) | |
| 97 | + shown := matches | |
| 98 | + if len(shown) > maxResults { | |
| 99 | + shown = shown[:maxResults] | |
| 100 | + } | |
| 101 | + lines := make([]string, 0, len(shown)) | |
| 102 | + for _, m := range shown { | |
| 103 | + lines = append(lines, m.String()) | |
| 104 | + } | |
| 105 | + out := strings.Join(lines, "\n") | |
| 106 | + if len(matches) > len(shown) { | |
| 107 | + out += fmt.Sprintf("\n\n(%d of %d matches shown; narrow the pattern, path or glob to see the rest)", | |
| 108 | + len(shown), len(matches)) | |
| 109 | + } | |
| 110 | + return out, nil | |
| 111 | + }) | |
| 112 | + } | |
| 113 | + | |
| 114 | + // compile validates the pattern up front so a bad regex comes back as a clear | |
| 115 | + // error the model can fix, rather than as ripgrep's exit status. | |
| 116 | + func compile(pattern string, ignoreCase bool) (*regexp.Regexp, error) { | |
| 117 | + if ignoreCase { | |
| 118 | + pattern = "(?i)" + pattern | |
| 119 | + } | |
| 120 | + re, err := regexp.Compile(pattern) | |
| 121 | + if err != nil { | |
| 122 | + return nil, fmt.Errorf("invalid regular expression: %w", err) | |
| 123 | + } | |
| 124 | + return re, nil | |
| 125 | + } | |
| 126 | + | |
| 127 | + func grepRipgrep(ctx context.Context, root, base string, in GrepArgs) ([]match, error) { | |
| 128 | + args := []string{"--line-number", "--no-heading", "--color=never", "--no-messages"} | |
| 129 | + if in.IgnoreCase { | |
| 130 | + args = append(args, "--ignore-case") | |
| 131 | + } | |
| 132 | + if in.Glob != "" { | |
| 133 | + args = append(args, "--glob", in.Glob) | |
| 134 | + } | |
| 135 | + for dir := range skipDirs { | |
| 136 | + args = append(args, "--glob", "!"+dir+"/") | |
| 137 | + } | |
| 138 | + args = append(args, "--regexp", in.Pattern, base) | |
| 139 | + | |
| 140 | + cmd := exec.CommandContext(ctx, ripgrep, args...) | |
| 141 | + out, err := cmd.Output() | |
| 142 | + if err != nil { | |
| 143 | + // rg exits 1 when there are simply no matches; that is not a failure. | |
| 144 | + var exitErr *exec.ExitError | |
| 145 | + if !bytes.Contains(out, []byte(":")) && errorsAs(err, &exitErr) && exitErr.ExitCode() == 1 { | |
| 146 | + return nil, nil | |
| 147 | + } | |
| 148 | + if ctx.Err() != nil { | |
| 149 | + return nil, ctx.Err() | |
| 150 | + } | |
| 151 | + if !errorsAs(err, &exitErr) { | |
| 152 | + return nil, fmt.Errorf("grep: %w", err) | |
| 153 | + } | |
| 154 | + } | |
| 155 | + | |
| 156 | + var matches []match | |
| 157 | + sc := bufio.NewScanner(bytes.NewReader(out)) | |
| 158 | + sc.Buffer(make([]byte, 0, 64*1024), 1024*1024) | |
| 159 | + for sc.Scan() { | |
| 160 | + if m, ok := parseRipgrepLine(root, sc.Text()); ok { | |
| 161 | + matches = append(matches, m) | |
| 162 | + } | |
| 163 | + } | |
| 164 | + return matches, nil | |
| 165 | + } | |
| 166 | + | |
| 167 | + // parseRipgrepLine splits "abs/path:12:text" into a match. | |
| 168 | + func parseRipgrepLine(root, line string) (match, bool) { | |
| 169 | + parts := strings.SplitN(line, ":", 3) | |
| 170 | + if len(parts) < 3 { | |
| 171 | + return match{}, false | |
| 172 | + } | |
| 173 | + n, err := strconv.Atoi(parts[1]) | |
| 174 | + if err != nil { | |
| 175 | + return match{}, false | |
| 176 | + } | |
| 177 | + return match{path: displayPath(root, parts[0]), line: n, text: capLine(strings.TrimSpace(parts[2]))}, true | |
| 178 | + } | |
| 179 | + | |
| 180 | + func grepWalk(ctx context.Context, root, base string, in GrepArgs, re *regexp.Regexp) ([]match, error) { | |
| 181 | + var matches []match | |
| 182 | + err := filepath.WalkDir(base, func(p string, d fs.DirEntry, err error) error { | |
| 183 | + if err != nil { | |
| 184 | + return nil | |
| 185 | + } | |
| 186 | + if ctx.Err() != nil { | |
| 187 | + return ctx.Err() | |
| 188 | + } | |
| 189 | + if d.IsDir() { | |
| 190 | + if p != base && shouldSkip(d.Name(), in.Glob) { | |
| 191 | + return fs.SkipDir | |
| 192 | + } | |
| 193 | + return nil | |
| 194 | + } | |
| 195 | + if in.Glob != "" { | |
| 196 | + rel, relErr := filepath.Rel(base, p) | |
| 197 | + if relErr != nil || !matchPath(in.Glob, rel) { | |
| 198 | + return nil | |
| 199 | + } | |
| 200 | + } | |
| 201 | + data, readErr := os.ReadFile(p) | |
| 202 | + if readErr != nil || isBinary(data) { | |
| 203 | + return nil | |
| 204 | + } | |
| 205 | + for i, line := range strings.Split(string(data), "\n") { | |
| 206 | + if re.MatchString(line) { | |
| 207 | + matches = append(matches, match{ | |
| 208 | + path: displayPath(root, p), | |
| 209 | + line: i + 1, | |
| 210 | + text: capLine(strings.TrimSpace(line)), | |
| 211 | + }) | |
| 212 | + } | |
| 213 | + } | |
| 214 | + return nil | |
| 215 | + }) | |
| 216 | + return matches, err | |
| 217 | + } | |
| 218 | + | |
| 219 | + // isBinary treats a NUL byte in the first block as the signal, which is what | |
| 220 | + // grep and ripgrep both do. | |
| 221 | + func isBinary(data []byte) bool { | |
| 222 | + if len(data) > 8000 { | |
| 223 | + data = data[:8000] | |
| 224 | + } | |
| 225 | + return bytes.IndexByte(data, 0) >= 0 | |
| 226 | + } | |
| 227 | + | |
| 228 | + func errorsAs(err error, target **exec.ExitError) bool { | |
| 229 | + e, ok := err.(*exec.ExitError) | |
| 230 | + if ok { | |
| 231 | + *target = e | |
| 232 | + } | |
| 233 | + return ok | |
| 234 | + } | |
| @@ -98,16 +98,26 @@ func splitLines(s string) []string { | ||
| 98 | 98 | return lines |
| 99 | 99 | } |
| 100 | 100 | |
| 101 | - // resolve anchors a relative path to root. The real path jail lands in Phase 6. | |
| 101 | + // resolve anchors a relative path to root, requiring one. The real path jail | |
| 102 | + // lands in Phase 6. | |
| 102 | 103 | func resolve(root, path string) (string, error) { |
| 103 | 104 | if path == "" { |
| 104 | 105 | return "", fmt.Errorf("path is required") |
| 105 | 106 | } |
| 106 | - if filepath.IsAbs(path) { | |
| 107 | - return filepath.Clean(path), nil | |
| 108 | - } | |
| 107 | + return resolveBase(root, path), nil | |
| 108 | + } | |
| 109 | + | |
| 110 | + // resolveBase is resolve for the search tools, where an omitted path means the | |
| 111 | + // working root rather than an error. | |
| 112 | + func resolveBase(root, path string) string { | |
| 109 | 113 | if root == "" { |
| 110 | 114 | root = "." |
| 111 | 115 | } |
| 112 | - return filepath.Join(root, path), nil | |
| 116 | + if path == "" { | |
| 117 | + return root | |
| 118 | + } | |
| 119 | + if filepath.IsAbs(path) { | |
| 120 | + return filepath.Clean(path) | |
| 121 | + } | |
| 122 | + return filepath.Join(root, path) | |
| 113 | 123 | } |
| @@ -0,0 +1,251 @@ | ||
| 1 | + package tools | |
| 2 | + | |
| 3 | + import ( | |
| 4 | + "context" | |
| 5 | + "encoding/json" | |
| 6 | + "os" | |
| 7 | + "path/filepath" | |
| 8 | + "strings" | |
| 9 | + "testing" | |
| 10 | + "time" | |
| 11 | + ) | |
| 12 | + | |
| 13 | + // searchRepo builds a small tree with the shapes that matter: nesting, a | |
| 14 | + // pruned directory, and a binary file. | |
| 15 | + func searchRepo(t *testing.T) string { | |
| 16 | + t.Helper() | |
| 17 | + dir := t.TempDir() | |
| 18 | + writeFile(t, dir, "go.mod", "module harness\n") | |
| 19 | + writeFile(t, dir, "cmd/harness/main.go", "package main\n\nfunc main() { run() }\n") | |
| 20 | + writeFile(t, dir, "internal/agent/loop.go", "package agent\n\nfunc Run() {}\n") | |
| 21 | + writeFile(t, dir, "internal/agent/loop_test.go", "package agent\n\nfunc TestRun(t *testing.T) {}\n") | |
| 22 | + writeFile(t, dir, "README.md", "# harness\n\nRun it.\n") | |
| 23 | + writeFile(t, dir, "node_modules/junk/index.js", "function run() {}\n") | |
| 24 | + writeFile(t, dir, ".git/config", "[core]\n\trun = true\n") | |
| 25 | + if err := os.WriteFile(filepath.Join(dir, "bin.dat"), []byte{0x00, 0x01, 'r', 'u', 'n'}, 0o644); err != nil { | |
| 26 | + t.Fatal(err) | |
| 27 | + } | |
| 28 | + return dir | |
| 29 | + } | |
| 30 | + | |
| 31 | + func call(t *testing.T, tool Tool, args any) string { | |
| 32 | + t.Helper() | |
| 33 | + raw, err := json.Marshal(args) | |
| 34 | + if err != nil { | |
| 35 | + t.Fatal(err) | |
| 36 | + } | |
| 37 | + out, err := tool.Execute(context.Background(), raw) | |
| 38 | + if err != nil { | |
| 39 | + t.Fatalf("%s: %v", tool.Name(), err) | |
| 40 | + } | |
| 41 | + return out | |
| 42 | + } | |
| 43 | + | |
| 44 | + func TestGlob(t *testing.T) { | |
| 45 | + dir := searchRepo(t) | |
| 46 | + tool := NewGlob(dir) | |
| 47 | + | |
| 48 | + tests := []struct { | |
| 49 | + name string | |
| 50 | + args GlobArgs | |
| 51 | + want []string | |
| 52 | + unwanted []string | |
| 53 | + }{ | |
| 54 | + { | |
| 55 | + name: "bare pattern matches at any depth", | |
| 56 | + args: GlobArgs{Pattern: "*.go"}, | |
| 57 | + want: []string{"cmd/harness/main.go", "internal/agent/loop.go", "internal/agent/loop_test.go"}, | |
| 58 | + unwanted: []string{"go.mod", "README.md"}, | |
| 59 | + }, | |
| 60 | + { | |
| 61 | + name: "pattern with a slash is anchored to the subtree", | |
| 62 | + args: GlobArgs{Pattern: "internal/**/*_test.go"}, | |
| 63 | + want: []string{"internal/agent/loop_test.go"}, | |
| 64 | + unwanted: []string{"cmd/harness/main.go"}, | |
| 65 | + }, | |
| 66 | + { | |
| 67 | + name: "path narrows the search", | |
| 68 | + args: GlobArgs{Pattern: "*.go", Path: "cmd"}, | |
| 69 | + want: []string{"cmd/harness/main.go"}, | |
| 70 | + unwanted: []string{"internal/agent/loop.go"}, | |
| 71 | + }, | |
| 72 | + { | |
| 73 | + name: "noise directories are pruned", | |
| 74 | + args: GlobArgs{Pattern: "*.js"}, | |
| 75 | + unwanted: []string{"node_modules"}, | |
| 76 | + }, | |
| 77 | + { | |
| 78 | + name: "exact name", | |
| 79 | + args: GlobArgs{Pattern: "go.mod"}, | |
| 80 | + want: []string{"go.mod"}, | |
| 81 | + }, | |
| 82 | + } | |
| 83 | + for _, tc := range tests { | |
| 84 | + t.Run(tc.name, func(t *testing.T) { | |
| 85 | + out := call(t, tool, tc.args) | |
| 86 | + for _, w := range tc.want { | |
| 87 | + if !strings.Contains(out, w) { | |
| 88 | + t.Errorf("missing %q in:\n%s", w, out) | |
| 89 | + } | |
| 90 | + } | |
| 91 | + for _, u := range tc.unwanted { | |
| 92 | + if strings.Contains(out, u) { | |
| 93 | + t.Errorf("unexpected %q in:\n%s", u, out) | |
| 94 | + } | |
| 95 | + } | |
| 96 | + }) | |
| 97 | + } | |
| 98 | + } | |
| 99 | + | |
| 100 | + func TestGlobNoMatchesSaysSoUsefully(t *testing.T) { | |
| 101 | + out := call(t, NewGlob(searchRepo(t)), GlobArgs{Pattern: "*.rs"}) | |
| 102 | + if !strings.Contains(out, "No files match") || !strings.Contains(out, "files scanned") { | |
| 103 | + t.Errorf("a miss should report the scan, got: %s", out) | |
| 104 | + } | |
| 105 | + } | |
| 106 | + | |
| 107 | + func TestGlobSortsNewestFirst(t *testing.T) { | |
| 108 | + dir := searchRepo(t) | |
| 109 | + newest := filepath.Join(dir, "internal/agent/loop.go") | |
| 110 | + if err := os.Chtimes(newest, timeNow(), timeNow()); err != nil { | |
| 111 | + t.Fatal(err) | |
| 112 | + } | |
| 113 | + out := call(t, NewGlob(dir), GlobArgs{Pattern: "*.go"}) | |
| 114 | + if first := strings.SplitN(out, "\n", 2)[0]; first != "internal/agent/loop.go" { | |
| 115 | + t.Errorf("first result = %q, want the most recently modified file", first) | |
| 116 | + } | |
| 117 | + } | |
| 118 | + | |
| 119 | + func TestGlobErrors(t *testing.T) { | |
| 120 | + tool := NewGlob(searchRepo(t)) | |
| 121 | + for _, tc := range []struct { | |
| 122 | + name, want string | |
| 123 | + args GlobArgs | |
| 124 | + }{ | |
| 125 | + {name: "no pattern", want: "pattern is required", args: GlobArgs{}}, | |
| 126 | + {name: "bad path", want: "no such directory", args: GlobArgs{Pattern: "*.go", Path: "nowhere"}}, | |
| 127 | + } { | |
| 128 | + t.Run(tc.name, func(t *testing.T) { | |
| 129 | + raw, _ := json.Marshal(tc.args) | |
| 130 | + if _, err := tool.Execute(context.Background(), raw); err == nil || !strings.Contains(err.Error(), tc.want) { | |
| 131 | + t.Errorf("error = %v, want %q", err, tc.want) | |
| 132 | + } | |
| 133 | + }) | |
| 134 | + } | |
| 135 | + } | |
| 136 | + | |
| 137 | + func TestGrep(t *testing.T) { | |
| 138 | + dir := searchRepo(t) | |
| 139 | + tests := []struct { | |
| 140 | + name string | |
| 141 | + args GrepArgs | |
| 142 | + want []string | |
| 143 | + unwanted []string | |
| 144 | + }{ | |
| 145 | + { | |
| 146 | + name: "finds a symbol with path and line number", | |
| 147 | + args: GrepArgs{Pattern: `func Run\(\)`}, | |
| 148 | + want: []string{"internal/agent/loop.go:3: func Run() {}"}, | |
| 149 | + }, | |
| 150 | + { | |
| 151 | + name: "glob narrows to a file type", | |
| 152 | + args: GrepArgs{Pattern: "Run", Glob: "*.md"}, | |
| 153 | + want: []string{"README.md"}, | |
| 154 | + unwanted: []string{".go:"}, | |
| 155 | + }, | |
| 156 | + { | |
| 157 | + name: "case sensitive by default", | |
| 158 | + args: GrepArgs{Pattern: "RUN"}, | |
| 159 | + unwanted: []string{".go:", ".md:"}, | |
| 160 | + }, | |
| 161 | + { | |
| 162 | + name: "ignore_case", | |
| 163 | + args: GrepArgs{Pattern: "RUN", IgnoreCase: true}, | |
| 164 | + want: []string{"README.md"}, | |
| 165 | + }, | |
| 166 | + { | |
| 167 | + name: "noise directories and binaries are skipped", | |
| 168 | + args: GrepArgs{Pattern: "run"}, | |
| 169 | + unwanted: []string{"node_modules", ".git/config", "bin.dat"}, | |
| 170 | + }, | |
| 171 | + { | |
| 172 | + name: "path narrows the search", | |
| 173 | + args: GrepArgs{Pattern: "package", Path: "cmd"}, | |
| 174 | + want: []string{"cmd/harness/main.go"}, | |
| 175 | + }, | |
| 176 | + } | |
| 177 | + // Both engines must produce identical output, or eval numbers would depend | |
| 178 | + // on whether the machine has ripgrep installed. | |
| 179 | + for _, engine := range []string{"ripgrep", "walk"} { | |
| 180 | + t.Run(engine, func(t *testing.T) { | |
| 181 | + if engine == "walk" { | |
| 182 | + saved := ripgrep | |
| 183 | + ripgrep = "" | |
| 184 | + t.Cleanup(func() { ripgrep = saved }) | |
| 185 | + } else if ripgrep == "" { | |
| 186 | + t.Skip("ripgrep not installed") | |
| 187 | + } | |
| 188 | + tool := NewGrep(dir) | |
| 189 | + for _, tc := range tests { | |
| 190 | + t.Run(tc.name, func(t *testing.T) { | |
| 191 | + out := call(t, tool, tc.args) | |
| 192 | + for _, w := range tc.want { | |
| 193 | + if !strings.Contains(out, w) { | |
| 194 | + t.Errorf("missing %q in:\n%s", w, out) | |
| 195 | + } | |
| 196 | + } | |
| 197 | + for _, u := range tc.unwanted { | |
| 198 | + if strings.Contains(out, u) { | |
| 199 | + t.Errorf("unexpected %q in:\n%s", u, out) | |
| 200 | + } | |
| 201 | + } | |
| 202 | + }) | |
| 203 | + } | |
| 204 | + }) | |
| 205 | + } | |
| 206 | + } | |
| 207 | + | |
| 208 | + func TestGrepEnginesAgree(t *testing.T) { | |
| 209 | + if ripgrep == "" { | |
| 210 | + t.Skip("ripgrep not installed") | |
| 211 | + } | |
| 212 | + dir := searchRepo(t) | |
| 213 | + withRG := call(t, NewGrep(dir), GrepArgs{Pattern: "func"}) | |
| 214 | + | |
| 215 | + saved := ripgrep | |
| 216 | + ripgrep = "" | |
| 217 | + defer func() { ripgrep = saved }() | |
| 218 | + withWalk := call(t, NewGrep(dir), GrepArgs{Pattern: "func"}) | |
| 219 | + | |
| 220 | + if withRG != withWalk { | |
| 221 | + t.Errorf("engines disagree:\nripgrep:\n%s\nwalk:\n%s", withRG, withWalk) | |
| 222 | + } | |
| 223 | + } | |
| 224 | + | |
| 225 | + func TestGrepNoMatchIsNotAnError(t *testing.T) { | |
| 226 | + for _, engine := range []string{"ripgrep", "walk"} { | |
| 227 | + t.Run(engine, func(t *testing.T) { | |
| 228 | + if engine == "walk" { | |
| 229 | + saved := ripgrep | |
| 230 | + ripgrep = "" | |
| 231 | + t.Cleanup(func() { ripgrep = saved }) | |
| 232 | + } else if ripgrep == "" { | |
| 233 | + t.Skip("ripgrep not installed") | |
| 234 | + } | |
| 235 | + out := call(t, NewGrep(searchRepo(t)), GrepArgs{Pattern: "zzzznotpresent"}) | |
| 236 | + if !strings.Contains(out, "No matches") { | |
| 237 | + t.Errorf("want an explicit no-match message, got %q", out) | |
| 238 | + } | |
| 239 | + }) | |
| 240 | + } | |
| 241 | + } | |
| 242 | + | |
| 243 | + func TestGrepInvalidRegexIsReported(t *testing.T) { | |
| 244 | + raw, _ := json.Marshal(GrepArgs{Pattern: "func ("}) | |
| 245 | + _, err := NewGrep(searchRepo(t)).Execute(context.Background(), raw) | |
| 246 | + if err == nil || !strings.Contains(err.Error(), "invalid regular expression") { | |
| 247 | + t.Errorf("error = %v, want a regex error the model can fix", err) | |
| 248 | + } | |
| 249 | + } | |
| 250 | + | |
| 251 | + func timeNow() time.Time { return time.Now().Add(time.Hour) } | |