/** * Grep tool — regex content search across the workspace (audit D5-4). * * Hand-rolled fs walk (no deps), schema-compatible with the Claude SDK's Grep * (the pi system prompt literally instructs "use the tool grep" for memory * recall). Skips VCS/build/dependency directories, binary-ish and oversized * files; output is `path:line:text`, capped so a broad pattern can't flood * the context. */ import fs from 'fs'; import path from 'path'; import type { PiTool } from './types.js'; import { safeResolve, displayPath } from './path-safety.js'; const SKIP_DIRS = new Set(['.git', 'node_modules', 'dist', 'dist-bloby', 'build', '.cache', '.next', 'coverage', '.venv', '__pycache__']); const MAX_FILE_BYTES = 1024 * 1024; // skip files > 1 MB const MAX_MATCHES = 200; const MAX_LINE_CHARS = 250; const MAX_FILES_SCANNED = 20_000; // Tiny glob→RegExp for the `glob` filter (basename or path patterns). // Single-pass tokenizer — sequential .replace() passes corrupt the regex // metacharacters they insert (the `?` of an emitted non-capturing group would // itself be glob-translated on the next pass). function globBodyToSource(pattern: string): string { let out = ''; for (let i = 0; i < pattern.length; i++) { const c = pattern[i]; if (c === '*') { if (pattern[i + 1] === '*') { if (pattern[i + 2] === '/') { out += '(?:.*/)?'; i += 2; } else { out += '.*'; i += 1; } } else { out += '[^/]*'; } } else if (c === '?') { out += '[^/]'; } else if (c === '{') { // Brace set {a,b,c} → (?:a|b|c) — claude-style globs use these // routinely (*.{ts,tsx}); alternates are translated recursively. let depth = 1; let j = i + 1; while (j < pattern.length && depth > 0) { if (pattern[j] === '{') depth++; else if (pattern[j] === '}') depth--; if (depth > 0) j++; } if (depth === 0) { const inner = pattern.slice(i + 1, j); const parts: string[] = []; let buf = ''; let d = 0; for (const ch of inner) { if (ch === '{') d++; if (ch === '}') d--; if (ch === ',' && d === 0) { parts.push(buf); buf = ''; } else buf += ch; } parts.push(buf); out += `(?:${parts.map(globBodyToSource).join('|')})`; i = j; } else { out += '\\{'; } } else if (c === '[') { // Character class — pass through unescaped when closed ([ab]c.md). const j = pattern.indexOf(']', i + 1); if (j > i + 1) { const cls = pattern.slice(i + 1, j); out += `[${cls[0] === '!' ? '^' + cls.slice(1) : cls}]`; i = j; } else { out += '\\['; } } else if ('.+^$}()|]\\'.includes(c)) { out += '\\' + c; } else { out += c; } } return out; } export function globToRegExp(pattern: string): RegExp { return new RegExp(`(?:^|/)${globBodyToSource(pattern)}$`); } function* walk(root: string, signal: AbortSignal | undefined, scan: { truncated: boolean }): Generator { const stack = [root]; let scanned = 0; while (stack.length > 0) { if (signal?.aborted) return; const dir = stack.pop()!; let entries: fs.Dirent[]; try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { continue; } for (const e of entries) { if (e.isDirectory()) { if (!SKIP_DIRS.has(e.name)) stack.push(path.join(dir, e.name)); } else if (e.isFile()) { if (++scanned > MAX_FILES_SCANNED) { // Signal the cut so callers never report a confident false negative // ("No matches found" over a half-scanned tree — review D-TOOLS-2). scan.truncated = true; return; } yield path.join(dir, e.name); } } } } export const grepTool: PiTool = { name: 'Grep', description: 'Search file contents with a regular expression. Returns matching lines as path:line:text. ' + 'Searches the whole workspace by default; narrow with `path` and/or a `glob` filename filter.', inputSchema: { type: 'object', properties: { pattern: { type: 'string', description: 'Regular expression to search for (JavaScript syntax).' }, path: { type: 'string', description: 'File or directory to search in (default: workspace root).' }, glob: { type: 'string', description: 'Filename filter, e.g. "*.ts" or "src/**/*.tsx".' }, '-i': { type: 'boolean', description: 'Case-insensitive search.' }, }, required: ['pattern'], }, async run(input, ctx) { const pattern = typeof input?.pattern === 'string' ? input.pattern : ''; if (!pattern) return { output: 'pattern is required.', isError: true }; let re: RegExp; try { re = new RegExp(pattern, input?.['-i'] || input?.case_insensitive ? 'i' : ''); } catch (err: any) { return { output: `Invalid regular expression: ${err.message}`, isError: true }; } let root: string; try { root = safeResolve(ctx.cwd, typeof input?.path === 'string' && input.path.trim() ? input.path : '.'); } catch (err: any) { return { output: err.message, isError: true }; } const globRe = typeof input?.glob === 'string' && input.glob.trim() ? globToRegExp(input.glob.trim()) : null; let stat: fs.Stats; try { stat = fs.statSync(root); } catch { return { output: `Path not found: ${displayPath(ctx.cwd, root)}`, isError: true }; } if (stat.isFile() && stat.size > MAX_FILE_BYTES) { return { output: `File too large to grep (${Math.round(stat.size / 1024)} KB > ${MAX_FILE_BYTES / 1024} KB limit): ${displayPath(ctx.cwd, root)}. Use Bash (grep/rg) for oversized files.`, isError: true, }; } const scan = { truncated: false }; const files = stat.isFile() ? [root] : Array.from(walk(root, ctx.signal, scan)); const matches: string[] = []; let truncated = false; outer: for (const file of files) { if (ctx.signal?.aborted) break; if (globRe && !globRe.test(file.split(path.sep).join('/'))) continue; let st: fs.Stats; try { st = fs.statSync(file); } catch { continue; } if (st.size > MAX_FILE_BYTES) continue; let content: string; try { content = fs.readFileSync(file, 'utf-8'); } catch { continue; } if (content.includes('')) continue; // binary-ish const lines = content.split('\n'); for (let i = 0; i < lines.length; i++) { if (re.test(lines[i])) { const text = lines[i].length > MAX_LINE_CHARS ? `${lines[i].slice(0, MAX_LINE_CHARS)}…` : lines[i]; matches.push(`${displayPath(ctx.cwd, file)}:${i + 1}:${text}`); if (matches.length >= MAX_MATCHES) { truncated = true; break outer; } } } } const scanNote = scan.truncated ? `\n\n[Scan stopped at ${MAX_FILES_SCANNED} files — narrow the path; unscanned files may contain matches]` : ''; if (matches.length === 0) return { output: 'No matches found.' + scanNote }; const tail = truncated ? `\n\n[Truncated at ${MAX_MATCHES} matches — narrow the pattern or path]` : ''; return { output: matches.join('\n') + tail + scanNote }; }, };