diff --git a/src/lib/init/tools/glob.ts b/src/lib/init/tools/glob.ts index 91886a07c..453e3eb15 100644 --- a/src/lib/init/tools/glob.ts +++ b/src/lib/init/tools/glob.ts @@ -1,131 +1,60 @@ -import path from "node:path"; +/** + * Init-wizard `glob` tool adapter. + * + * Thin wrapper over `collectGlob` from `src/lib/scan/`. Historically + * this file contained a `rg --files → git ls-files → fs walk` fallback + * chain with ~150 LOC of subprocess plumbing; all replaced by the + * pure-TS scanner from PR #791. This adapter: + * + * 1. Sandboxes the user-supplied `params.path` via `safePath` (once, + * since it's shared across all patterns). + * 2. Runs each pattern as a separate `collectGlob` call — the wire + * contract returns one result row per pattern, with its own + * `truncated` flag. `collectGlob` accepts a `patterns` array but + * unions them, which would lose per-pattern attribution. + * 3. Passes each pattern's `files` + `truncated` straight through. + */ + +import { collectGlob } from "../../scan/index.js"; import type { GlobPayload, ToolResult } from "../types.js"; -import { - isGitRepo, - resolveSearchTarget, - spawnSearchProcess, - walkFiles, -} from "./search-utils.js"; +import { safePath } from "./shared.js"; import type { InitToolDefinition } from "./types.js"; const MAX_GLOB_RESULTS = 100; -async function rgGlobSearch(opts: { - cwd: string; - pattern: string; - target: string; - maxResults: number; -}): Promise<{ files: string[]; truncated: boolean }> { - const { stdout, exitCode } = await spawnSearchProcess( - "rg", - ["--files", "--hidden", "--glob", opts.pattern, opts.target], - opts.cwd - ); - - if (exitCode === 1 || (exitCode === 2 && !stdout.trim())) { - return { files: [], truncated: false }; - } - if (exitCode !== 0 && exitCode !== 2) { - throw new Error(`ripgrep failed with exit code ${exitCode}`); - } - - const lines = stdout.split("\n").filter(Boolean); - const truncated = lines.length > opts.maxResults; - const files = lines - .slice(0, opts.maxResults) - .map((filePath) => path.relative(opts.cwd, filePath)); - return { files, truncated }; -} - -async function gitLsFiles(opts: { - cwd: string; - pattern: string; - target: string; - maxResults: number; -}): Promise<{ files: string[]; truncated: boolean }> { - const { stdout, exitCode } = await spawnSearchProcess( - "git", - ["ls-files", "--cached", "--others", "--exclude-standard", opts.pattern], - opts.target - ); - - if (exitCode !== 0) { - throw new Error(`git ls-files failed with exit code ${exitCode}`); - } - - const lines = stdout.split("\n").filter(Boolean); - const truncated = lines.length > opts.maxResults; - const files = lines - .slice(0, opts.maxResults) - .map((filePath) => - path.relative(opts.cwd, path.resolve(opts.target, filePath)) - ); - return { files, truncated }; -} - -async function fsGlobSearch(opts: { - cwd: string; +type PatternResult = { pattern: string; - searchPath: string | undefined; - maxResults: number; -}): Promise<{ files: string[]; truncated: boolean }> { - const target = resolveSearchTarget(opts.cwd, opts.searchPath); - const files: string[] = []; - - for await (const rel of walkFiles(opts.cwd, target, opts.pattern)) { - files.push(rel); - if (files.length > opts.maxResults) { - break; - } - } - - const truncated = files.length > opts.maxResults; - if (truncated) { - files.length = opts.maxResults; - } - return { files, truncated }; -} - -async function globSearch(opts: { - cwd: string; - pattern: string; - searchPath: string | undefined; - maxResults: number; -}): Promise<{ files: string[]; truncated: boolean }> { - const target = resolveSearchTarget(opts.cwd, opts.searchPath); - const resolvedOpts = { ...opts, target }; - - try { - return await rgGlobSearch(resolvedOpts); - } catch { - if (isGitRepo(opts.cwd)) { - try { - return await gitLsFiles(resolvedOpts); - } catch { - // fall through to filesystem search - } - } - return await fsGlobSearch(opts); - } -} + files: string[]; + truncated: boolean; +}; /** * Find files matching one or more glob patterns. + * + * Patterns run in parallel via `Promise.all` — preserves the + * concurrency shape of the pre-PR implementation. */ export async function glob(payload: GlobPayload): Promise { const maxResults = payload.params.maxResults ?? MAX_GLOB_RESULTS; - const results = await Promise.all( + + // Validate the optional subpath once before spawning per-pattern + // calls — a single throw aborts the whole payload, which matches + // the registry's sandbox-reject contract. + if (payload.params.path !== undefined) { + safePath(payload.cwd, payload.params.path); + } + + const results: PatternResult[] = await Promise.all( payload.params.patterns.map(async (pattern) => { - const { files, truncated } = await globSearch({ + const { files, truncated } = await collectGlob({ cwd: payload.cwd, - pattern, - searchPath: payload.params.path, + patterns: pattern, + path: payload.params.path, maxResults, }); return { pattern, files, truncated }; }) ); - return { ok: true, data: { results } }; } diff --git a/src/lib/init/tools/grep.ts b/src/lib/init/tools/grep.ts index 08f2d65d3..defbc9da0 100644 --- a/src/lib/init/tools/grep.ts +++ b/src/lib/init/tools/grep.ts @@ -1,285 +1,108 @@ -import fs from "node:fs"; -import path from "node:path"; -import { MAX_FILE_BYTES } from "../constants.js"; -import type { GrepPayload, ToolResult } from "../types.js"; -import { - isGitRepo, - resolveSearchTarget, - spawnSearchProcess, - walkFiles, -} from "./search-utils.js"; +/** + * Init-wizard `grep` tool adapter. + * + * Thin wrapper over `collectGrep` from `src/lib/scan/`. Historically + * this file contained a `rg → git grep → fs walk` fallback chain with + * ~300 LOC of subprocess-spawn plumbing; that was all replaced by the + * pure-TS scanner shipped in PR #791. This adapter now just: + * + * 1. Sandboxes the user-supplied `search.path` via `safePath`. + * 2. Forwards each `GrepSearch` to `collectGrep` with the wire-level + * constants (`maxResults`, `maxLineLength`) plumbed through. + * 3. Strips the `absolutePath` field from each `GrepMatch` before + * returning — the Mastra wire contract has never included it. + * 4. Catches `ValidationError` from `compilePattern` so a bad regex + * from the agent surfaces as an empty result for that search + * (rather than taking down the whole payload). + */ + +import { ValidationError } from "../../errors.js"; +import { collectGrep } from "../../scan/index.js"; +import type { GrepPayload, GrepSearch, ToolResult } from "../types.js"; +import { safePath } from "./shared.js"; import type { InitToolDefinition } from "./types.js"; const MAX_GREP_RESULTS_PER_SEARCH = 100; const MAX_GREP_LINE_LENGTH = 2000; -const GREP_LINE_RE = /^(.+?):(\d+):(.*)$/; -type GrepMatch = { path: string; lineNum: number; line: string }; - -function truncateMatchLine(line: string): string { - if (line.length <= MAX_GREP_LINE_LENGTH) { - return line; - } - return `${line.substring(0, MAX_GREP_LINE_LENGTH)}…`; -} +/** Per-match shape on the wire — no `absolutePath`, by contract. */ +type WireGrepMatch = { path: string; lineNum: number; line: string }; -function limitMatches( - matches: T[], - maxResults: number -): { matches: T[]; truncated: boolean } { - const truncated = matches.length > maxResults; - if (truncated) { - matches.length = maxResults; - } - return { matches, truncated }; -} +type SearchResult = { + pattern: string; + matches: WireGrepMatch[]; + truncated: boolean; +}; -function parseRgGrepOutput( +/** + * Run one `GrepSearch`. Throws if `safePath` rejects `search.path`; + * caller (`grep`) hoists the throw to the registry's error path. + */ +async function runOneSearch( cwd: string, - stdout: string, + search: GrepSearch, maxResults: number -): { matches: GrepMatch[]; truncated: boolean } { - const lines = stdout.split("\n").filter(Boolean); - const matches: GrepMatch[] = []; - - for (const line of lines.slice(0, maxResults)) { - const firstSep = line.indexOf("|"); - if (firstSep === -1) { - continue; - } - - const filePart = line.substring(0, firstSep); - const rest = line.substring(firstSep + 1); - const secondSep = rest.indexOf("|"); - if (secondSep === -1) { - continue; - } - - const lineNum = Number.parseInt(rest.substring(0, secondSep), 10); - const text = truncateMatchLine(rest.substring(secondSep + 1)); - matches.push({ path: path.relative(cwd, filePart), lineNum, line: text }); +): Promise { + // Validate the subpath against the sandbox. `safePath` throws on + // escape attempts — the scan engine explicitly trusts its `path` + // input (see `src/lib/scan/types.ts::GrepOptions.path`), so the + // adapter is the correct place to enforce sandboxing. We only need + // the validation side effect; `collectGrep` takes a cwd-relative + // subpath, so we pass `search.path` through unchanged afterward. + if (search.path !== undefined) { + safePath(cwd, search.path); } - return { - matches, - truncated: lines.length > maxResults, - }; -} - -async function rgGrepSearch(opts: { - cwd: string; - pattern: string; - target: string; - include: string | undefined; - maxResults: number; -}): Promise<{ matches: GrepMatch[]; truncated: boolean }> { - const args = [ - "-nH", - "--no-messages", - "--hidden", - "--field-match-separator=|", - "--regexp", - opts.pattern, - ]; - if (opts.include) { - args.push("--glob", opts.include); - } - args.push(opts.target); - - const { stdout, exitCode } = await spawnSearchProcess("rg", args, opts.cwd); - if (exitCode === 1 || (exitCode === 2 && !stdout.trim())) { - return { matches: [], truncated: false }; - } - if (exitCode !== 0 && exitCode !== 2) { - throw new Error(`ripgrep failed with exit code ${exitCode}`); - } - - return parseRgGrepOutput(opts.cwd, stdout, opts.maxResults); -} - -function compilePattern(pattern: string): RegExp | null { try { - return new RegExp(pattern); - } catch { - return null; - } -} - -async function readSearchableFile(absPath: string): Promise { - try { - const stat = await fs.promises.stat(absPath); - if (stat.size > MAX_FILE_BYTES) { - return null; - } - return await fs.promises.readFile(absPath, "utf-8"); - } catch { - return null; - } -} - -function findRegexMatches( - relPath: string, - content: string, - regex: RegExp, - maxResults: number -): GrepMatch[] { - const matches: GrepMatch[] = []; - const lines = content.split("\n"); - - for (let i = 0; i < lines.length; i += 1) { - const line = lines[i] ?? ""; - regex.lastIndex = 0; - if (!regex.test(line)) { - continue; - } - - matches.push({ - path: relPath, - lineNum: i + 1, - line: truncateMatchLine(line), - }); - if (matches.length > maxResults) { - break; - } - } - - return matches; -} - -async function fsGrepSearch(opts: { - cwd: string; - pattern: string; - searchPath: string | undefined; - include: string | undefined; - maxResults: number; -}): Promise<{ matches: GrepMatch[]; truncated: boolean }> { - const target = resolveSearchTarget(opts.cwd, opts.searchPath); - const regex = compilePattern(opts.pattern); - if (!regex) { - return { matches: [], truncated: false }; - } - - const matches: GrepMatch[] = []; - for await (const rel of walkFiles(opts.cwd, target, opts.include)) { - if (matches.length > opts.maxResults) { - break; - } - - const absPath = path.join(opts.cwd, rel); - const content = await readSearchableFile(absPath); - if (!content) { - continue; - } - - matches.push( - ...findRegexMatches( - rel, - content, - regex, - opts.maxResults - matches.length + 1 - ) - ); - } - - return limitMatches(matches, opts.maxResults); -} - -function parseGrepOutput( - stdout: string, - maxResults: number, - pathPrefix?: string -): { matches: GrepMatch[]; truncated: boolean } { - const matches: GrepMatch[] = []; - for (const line of stdout.split("\n").filter(Boolean)) { - const match = line.match(GREP_LINE_RE); - if (!(match?.[1] && match[2] && match[3] !== undefined)) { - continue; - } - - matches.push({ - path: pathPrefix ? path.join(pathPrefix, match[1]) : match[1], - lineNum: Number.parseInt(match[2], 10), - line: truncateMatchLine(match[3]), + const { matches, stats } = await collectGrep({ + cwd, + pattern: search.pattern, + include: search.include, + path: search.path, + // `caseInsensitive` is the wire shape; the scan engine exposes + // the inverse (`caseSensitive`). Pass `undefined` when the + // caller didn't set it so the engine's default (rg-like, + // case-sensitive) takes effect. + caseSensitive: search.caseInsensitive === true ? false : undefined, + multiline: search.multiline, + maxResults, + maxLineLength: MAX_GREP_LINE_LENGTH, }); - if (matches.length > maxResults) { - break; + return { + pattern: search.pattern, + // Strip `absolutePath` — not part of the Mastra wire contract. + matches: matches.map((m) => ({ + path: m.path, + lineNum: m.lineNum, + line: m.line, + })), + truncated: stats.truncated, + }; + } catch (error) { + if (error instanceof ValidationError) { + // Malformed regex from the agent. Surface as an empty row for + // this search rather than aborting the whole payload — lets the + // agent retry with a corrected pattern. + return { pattern: search.pattern, matches: [], truncated: false }; } - } - - return limitMatches(matches, maxResults); -} - -async function gitGrepSearch(opts: { - cwd: string; - pattern: string; - target: string; - include: string | undefined; - maxResults: number; -}): Promise<{ matches: GrepMatch[]; truncated: boolean }> { - const args = ["grep", "--untracked", "-n", "-E", opts.pattern]; - if (opts.include) { - args.push("--", opts.include); - } - - const { stdout, exitCode } = await spawnSearchProcess( - "git", - args, - opts.target - ); - if (exitCode === 1) { - return { matches: [], truncated: false }; - } - if (exitCode !== 0) { - throw new Error(`git grep failed with exit code ${exitCode}`); - } - - const prefix = path.relative(opts.cwd, opts.target); - return parseGrepOutput(stdout, opts.maxResults, prefix || undefined); -} - -async function grepSearch(opts: { - cwd: string; - pattern: string; - searchPath: string | undefined; - include: string | undefined; - maxResults: number; -}): Promise<{ matches: GrepMatch[]; truncated: boolean }> { - const target = resolveSearchTarget(opts.cwd, opts.searchPath); - const resolvedOpts = { ...opts, target }; - - try { - return await rgGrepSearch(resolvedOpts); - } catch { - if (isGitRepo(opts.cwd)) { - try { - return await gitGrepSearch(resolvedOpts); - } catch { - // fall through to filesystem search - } - } - return await fsGrepSearch(opts); + throw error; } } /** * Search project files for one or more regex patterns. + * + * Searches run in parallel via `Promise.all` — preserves the + * concurrency shape of the pre-PR implementation. */ export async function grep(payload: GrepPayload): Promise { const maxResults = payload.params.maxResultsPerSearch ?? MAX_GREP_RESULTS_PER_SEARCH; const results = await Promise.all( - payload.params.searches.map(async (search) => { - const { matches, truncated } = await grepSearch({ - cwd: payload.cwd, - pattern: search.pattern, - searchPath: search.path, - include: search.include, - maxResults, - }); - return { pattern: search.pattern, matches, truncated }; - }) + payload.params.searches.map((search) => + runOneSearch(payload.cwd, search, maxResults) + ) ); - return { ok: true, data: { results } }; } diff --git a/src/lib/init/tools/search-utils.ts b/src/lib/init/tools/search-utils.ts deleted file mode 100644 index 0f857dd00..000000000 --- a/src/lib/init/tools/search-utils.ts +++ /dev/null @@ -1,146 +0,0 @@ -import { spawn as nodeSpawn } from "node:child_process"; -import fs from "node:fs"; -import path from "node:path"; -import { MAX_OUTPUT_BYTES } from "../constants.js"; -import { safePath } from "./shared.js"; - -const MAX_STDERR_CHUNKS = 64; - -export const SEARCH_SKIP_DIRS = new Set([ - "node_modules", - ".git", - "__pycache__", - ".venv", - "venv", - "dist", - "build", -]); - -type SearchProcessResult = { - stdout: string; - stderr: string; - exitCode: number; -}; - -/** - * Resolve an optional search path within the init sandbox. - */ -export function resolveSearchTarget( - cwd: string, - searchPath: string | undefined -): string { - return searchPath ? safePath(cwd, searchPath) : cwd; -} - -/** - * Spawn a search helper process, draining both stdout and stderr to avoid - * blocking when a child emits a large amount of diagnostics. - */ -export function spawnSearchProcess( - cmd: string, - args: string[], - cwd: string -): Promise { - return new Promise((resolve, reject) => { - const child = nodeSpawn(cmd, args, { - cwd, - stdio: ["ignore", "pipe", "pipe"], - timeout: 30_000, - }); - - const stdoutChunks: Buffer[] = []; - let stdoutBytes = 0; - child.stdout.on("data", (chunk: Buffer) => { - if (stdoutBytes >= MAX_OUTPUT_BYTES) { - return; - } - - const remaining = MAX_OUTPUT_BYTES - stdoutBytes; - stdoutChunks.push(chunk.subarray(0, remaining)); - stdoutBytes += Math.min(chunk.length, remaining); - }); - - const stderrChunks: Buffer[] = []; - child.stderr.on("data", (chunk: Buffer) => { - if (stderrChunks.length >= MAX_STDERR_CHUNKS) { - return; - } - - stderrChunks.push(chunk); - }); - - child.on("error", reject); - child.on("close", (code, signal) => { - if (signal) { - reject(new Error(`Process killed by ${signal} (timeout)`)); - return; - } - - resolve({ - stdout: Buffer.concat(stdoutChunks).toString("utf-8"), - stderr: Buffer.concat(stderrChunks).toString("utf-8"), - exitCode: code ?? 1, - }); - }); - }); -} - -/** - * Check whether a directory is a git repository. - */ -export function isGitRepo(dir: string): boolean { - try { - return fs.statSync(path.join(dir, ".git")).isDirectory(); - } catch { - return false; - } -} - -/** - * Minimal glob matcher supporting `*`, `**`, and `?`. - */ -export function matchGlob(name: string, pattern: string): boolean { - const re = pattern - .replace(/[.+^${}()|[\]\\]/g, "\\$&") - .replace(/\*\*/g, "\0") - .replace(/\*/g, "[^/]*") - .replace(/\0/g, ".*") - .replace(/\?/g, "."); - return new RegExp(`^${re}$`).test(name); -} - -/** - * Recursively walk a directory and yield file paths relative to the original - * cwd, skipping common dependency and build directories. - */ -export async function* walkFiles( - root: string, - base: string, - globPattern: string | undefined -): AsyncGenerator { - let entries: fs.Dirent[]; - try { - entries = await fs.promises.readdir(base, { withFileTypes: true }); - } catch { - return; - } - - for (const entry of entries) { - const full = path.join(base, entry.name); - const rel = path.relative(root, full); - - if (entry.isDirectory() && !SEARCH_SKIP_DIRS.has(entry.name)) { - yield* walkFiles(root, full, globPattern); - continue; - } - - if (!entry.isFile()) { - continue; - } - - const matchTarget = globPattern?.includes("/") ? rel : entry.name; - if (!globPattern || matchGlob(matchTarget, globPattern)) { - yield rel; - } - } -} diff --git a/src/lib/init/types.ts b/src/lib/init/types.ts index 62cfbe43d..182b3a58b 100644 --- a/src/lib/init/types.ts +++ b/src/lib/init/types.ts @@ -100,6 +100,23 @@ export type GrepSearch = { pattern: string; path?: string; include?: string; + /** + * Case-insensitive match. Default: false (case-sensitive, matching + * `rg`'s default). A leading `(?i)` inline flag in `pattern` has + * the same effect — callers can use either. + * + * No current Mastra server invocation sets this field; reserving + * it here means the server can start sending it without a CLI + * update. The underlying scan engine natively supports it. + */ + caseInsensitive?: boolean; + /** + * Multiline mode: when true (default), `^` and `$` match at line + * boundaries within the file — grep/rg semantics. When false, they + * anchor to the buffer start/end — strict JS `RegExp` semantics. + * Rarely needs to be set. + */ + multiline?: boolean; }; export type GrepPayload = { diff --git a/src/lib/scan/glob.ts b/src/lib/scan/glob.ts index 3dff5074e..73ec0e75b 100644 --- a/src/lib/scan/glob.ts +++ b/src/lib/scan/glob.ts @@ -17,10 +17,11 @@ * walker's default `hidden: true`. * - `**` spans directory boundaries. * - * This mirrors the behavior of the init wizard's existing FS-fallback - * glob (`src/lib/init/tools/search-utils.ts::matchGlob`) but uses - * picomatch's full grammar — extglobs (`+(a|b)`), braces (`{a,b}`), - * negation (`!pattern`), etc. + * The semantics match the init wizard's pre-PR-791 FS-fallback + * glob (a hand-rolled `matchGlob` in the now-deleted + * `src/lib/init/tools/search-utils.ts`) but use picomatch's full + * grammar — extglobs (`+(a|b)`), braces (`{a,b}`), negation + * (`!pattern`), etc. * * ### Cost model * diff --git a/test/lib/init/tools/search-tools.test.ts b/test/lib/init/tools/search-tools.test.ts index 69383f003..10d2008ec 100644 --- a/test/lib/init/tools/search-tools.test.ts +++ b/test/lib/init/tools/search-tools.test.ts @@ -1,3 +1,21 @@ +/** + * Wire-contract tests for the init-wizard `grep` / `glob` tools. + * + * Scope after PR #791/#PR-4: the tools are thin adapters over the + * pure-TS `collectGrep`/`collectGlob` from `src/lib/scan/`. The scan + * module has extensive unit + property coverage of its own; this file + * pins the adapter-level contract: + * + * - The Mastra wire shape (field names, nesting, per-pattern rows). + * - Subpath + include filter behavior — delegated to scan but + * exercised end-to-end via the `executeTool` entry point so a + * regression at the adapter layer surfaces here. + * - The sandbox guard — `safePath` at the adapter boundary must + * reject paths that escape the project root. + * - The `absolutePath` field MUST NOT appear on wire results — the + * adapter's job is to strip it before returning. + */ + import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import fs from "node:fs"; import path from "node:path"; @@ -24,22 +42,10 @@ function makeToolPayload(payload: Omit): ToolPayload { } as ToolPayload; } -function writeExecutable(filePath: string, content: string): void { - fs.writeFileSync(filePath, content, { mode: 0o755 }); -} - -function setPath(entries: string[]): void { - process.env.PATH = entries.join(path.delimiter); -} - -let savedPath: string | undefined; let testDir: string; -let helperBinDir: string; beforeEach(() => { - savedPath = process.env.PATH; testDir = fs.mkdtempSync(path.join("/tmp", "init-search-")); - helperBinDir = fs.mkdtempSync(path.join("/tmp", "init-search-bin-")); fs.writeFileSync( path.join(testDir, "app.ts"), @@ -58,9 +64,7 @@ beforeEach(() => { }); afterEach(() => { - process.env.PATH = savedPath; fs.rmSync(testDir, { recursive: true, force: true }); - fs.rmSync(helperBinDir, { recursive: true, force: true }); }); describe("search tools", () => { @@ -124,117 +128,97 @@ describe("search tools", () => { expect((empty.data as any).results[0].files).toHaveLength(0); }); - test("falls back to git-based grep and glob when rg is unavailable", async () => { - const realGit = Bun.which("git"); - expect(realGit).toBeString(); - - const initResult = Bun.spawnSync([realGit as string, "init"], { - cwd: testDir, - stdout: "ignore", - stderr: "ignore", - }); - expect(initResult.exitCode).toBe(0); - - writeExecutable( - path.join(helperBinDir, "git"), - `#!/bin/sh\nexec "${realGit}" "$@"\n` - ); - setPath([helperBinDir]); - - const grepResult = await executeTool( + test("rejects grep paths outside the init sandbox", async () => { + const result = await executeTool( makeToolPayload({ operation: "grep", cwd: testDir, - params: { searches: [{ pattern: "Sentry\\.init" }] }, - }), - makeContext(testDir) - ); - const globResult = await executeTool( - makeToolPayload({ - operation: "glob", - cwd: testDir, - params: { patterns: ["*.ts"] }, + params: { searches: [{ pattern: "test", path: "../../etc" }] }, }), makeContext(testDir) ); - expect(grepResult.ok).toBe(true); - expect((grepResult.data as any).results[0].matches.length).toBeGreaterThan( - 0 - ); - expect(globResult.ok).toBe(true); - expect((globResult.data as any).results[0].files).toContain("app.ts"); + expect(result.ok).toBe(false); + expect(result.error).toContain("outside project directory"); }); - test("falls back to filesystem search when rg and git are unavailable", async () => { - setPath([helperBinDir]); - - const grepResult = await executeTool( + test("grep result matches MUST NOT include absolutePath", async () => { + // The Mastra wire contract has never exposed `absolutePath`; the + // underlying `collectGrep` does return it on each `GrepMatch` + // (for local callers that want to re-open the file). The adapter + // is responsible for stripping it. A regression here would leak + // real filesystem paths into the agent's context window. + const result = await executeTool( makeToolPayload({ operation: "grep", cwd: testDir, - params: { searches: [{ pattern: "helper" }] }, - }), - makeContext(testDir) - ); - const globResult = await executeTool( - makeToolPayload({ - operation: "glob", - cwd: testDir, - params: { patterns: ["**/*.ts"] }, + params: { searches: [{ pattern: "Sentry" }] }, }), makeContext(testDir) ); - expect(grepResult.ok).toBe(true); - expect((grepResult.data as any).results[0].matches.length).toBeGreaterThan( - 0 - ); - expect(globResult.ok).toBe(true); - expect((globResult.data as any).results[0].files).toContain("src/index.ts"); + expect(result.ok).toBe(true); + const [firstRow] = (result.data as any).results; + expect(firstRow.matches.length).toBeGreaterThan(0); + for (const match of firstRow.matches) { + expect(Object.keys(match).sort()).toEqual(["line", "lineNum", "path"]); + expect("absolutePath" in match).toBe(false); + } }); - test("drains stderr during rg-based glob searches", async () => { - fs.writeFileSync(path.join(testDir, "src", "app.ts"), "export {};\n"); - writeExecutable( - path.join(helperBinDir, "rg"), - [ - "#!/bin/sh", - 'target="$5"', - "i=0", - 'while [ "$i" -lt 70000 ]; do', - " printf x >&2", - " i=$((i + 1))", - "done", - "printf '%s\\n' \"$target/src/app.ts\"", - ].join("\n") - ); - setPath([helperBinDir]); - + test("grep bad regex yields empty matches without crashing the payload", async () => { + // The adapter catches `ValidationError` from `compilePattern` + // and surfaces it as an empty per-search row, letting the + // agent retry with a corrected pattern instead of the whole + // payload failing. If this behavior regressed, the Mastra + // server would see `{ok: false, error: "Invalid grep pattern…"}` + // and almost certainly stop the wizard. const result = await executeTool( makeToolPayload({ - operation: "glob", + operation: "grep", cwd: testDir, - params: { patterns: ["**/*.ts"] }, + // Unclosed paren — `new RegExp("(unclosed")` throws. + params: { searches: [{ pattern: "(unclosed" }] }, }), makeContext(testDir) ); - expect(result.ok).toBe(true); - expect((result.data as any).results[0].files).toContain("src/app.ts"); + expect((result.data as any).results[0]).toEqual({ + pattern: "(unclosed", + matches: [], + truncated: false, + }); }); - test("rejects grep paths outside the init sandbox", async () => { - const result = await executeTool( + test("grep caseInsensitive flag enables case-insensitive matching", async () => { + // Regression test for the new wire field added in PR-4. The + // default is case-sensitive (matches rg), so "SENTRY" (all-caps) + // would normally return zero hits. With `caseInsensitive: true` + // the search picks up `Sentry` in the sandboxed fixture. + const caseSensitive = await executeTool( makeToolPayload({ operation: "grep", cwd: testDir, - params: { searches: [{ pattern: "test", path: "../../etc" }] }, + params: { searches: [{ pattern: "SENTRY" }] }, + }), + makeContext(testDir) + ); + const caseInsensitive = await executeTool( + makeToolPayload({ + operation: "grep", + cwd: testDir, + params: { + searches: [{ pattern: "SENTRY", caseInsensitive: true }], + }, }), makeContext(testDir) ); - expect(result.ok).toBe(false); - expect(result.error).toContain("outside project directory"); + expect(caseSensitive.ok).toBe(true); + expect((caseSensitive.data as any).results[0].matches).toHaveLength(0); + expect(caseInsensitive.ok).toBe(true); + expect( + (caseInsensitive.data as any).results[0].matches.length + ).toBeGreaterThan(0); }); });