Build your own Claude Code
Reading the world·15 min

Grep

0 / 1
# grade this stage (from your course directory)
npx theinference test --stage grep --watch

Glob finds files by name; Grep finds them by content. Hand it a regex and a starting path and it scans every line of every file underneath, returning each hit as file:line:text — the same shape ripgrep prints, and the same shape the model has seen a thousand times in training, so it parses the results without being taught. This is the last read tool. After this, the model can locate the world before it changes anything.

The file:line:text match format
grep("foo", ".")   over   a.txt = "foo\nbar\nfoobar"   b.txt = "baz"

a.txt:1:foo
a.txt:3:foobar      (line 2 "bar" doesn't match; b.txt has no "foo")

What to build

Your task

Implement Tools.grep

Implement Tools.grep(pattern, path) in src/tools.ts:

  • Resolve path through the sandbox; error if it can't be resolved.
  • Compile pattern as a RegExp (treat it as a real regex, not a literal).
  • If path is a directory, walk it recursively, visiting entries in sorted order at each level; if it's a file, scan just that file.
  • For each file, split on \n and test each line; on a match push ${rel}:${lineNo}:${line} where rel is the path relative to the sandbox root and lineNo is 1-indexed.
  • Return the matches joined by newlines, in deterministic order.

The program.ts grep subcommand builds a sandbox from case.files and prints tools.grep(case.pattern, case.path).

src/tools.ts
grep(pattern: string, path: string): ToolOutput {
  // resolve path -> error if it can't resolve
  // compile pattern as a RegExp
  // walk dirs in SORTED order (or scan a single file); 1-indexed line numbers
  // push "${rel}:${lineNo}:${line}" per match; join with "\n"
}
Deterministic order: sort the walk, count lines from 1

Two non-determinism traps the grader catches. First, readdirSync returns entries in filesystem order — sort the names at each directory level so the file order is stable across machines. Second, lines are 1-indexed: the match on the first line is :1:, not :0:. Within a file, emit matches in file order (top to bottom). Get either wrong and the byte diff fails even when the set of matches is right.

`pattern` is a regex, not a literal

Compile with new RegExp(pattern) and test each line with re.test(line). That means foo matches the substring foo anywhere on the line (so foobar matches), and metacharacters like . or ^ behave as regex — that's intentional, it mirrors ripgrep's default. The fixture pattern foo hits foo on line 1 and foobar on line 3 of a.txt, and nothing in b.txt.

How it's graded

Process mode. The harness runs npx tsx program.ts grep fixtures/case.json and compares the printed JSON to fixtures/expected.json. The expected {"content":"a.txt:1:foo\na.txt:3:foobar"} pins format, line numbers, and order all at once — relative paths (not the temp prefix), 1-indexed lines, colons as separators, and the matched line text verbatim.