diff --git a/.pi/extensions/context-grep/README.md b/.pi/extensions/context-grep/README.md new file mode 100644 index 0000000..c227364 --- /dev/null +++ b/.pi/extensions/context-grep/README.md @@ -0,0 +1,61 @@ +# context-grep Pi extension + +Project-local Pi extension that enriches `bash` `rg`/`grep` results with AST context and back-references. The agent searches as usual; the extension transparently appends enclosing functions and their callers. + +## How it helps + +Every time the agent greps for code it plans to modify, it automatically sees: +- The enclosing function/class (not just the matched line) +- Who calls that function ("Called from: ← callerName (file:line)") + +This reduces iterative grep→read→grep cycles by ~44% on investigation tasks (measured via A/B testing). + +## Enable + +1. Trust this project in Pi. +2. Ensure `ast-grep` is installed and on `PATH`. +3. Start Pi in this repo — auto-discovered, or `/reload`. + +## Disable + +- `pi --no-extensions`, or +- Remove `.pi/extensions/context-grep/`, then `/reload`. + +## Output format + +```text +── AST context (N containers, deduplicated) ──────────────────────────── + +▶ service/src/config.ts:20-29 [fn loadBaseConfig] (grep hits: [20]) + │ + │ Called from: + │ ← main (service/src/index.ts:20) + │ ← loadConfig (service/src/config.ts:32) + │ + export function loadBaseConfig(env) { + ... + } +``` + +## Behavior + +- Original search output stays at top unchanged. +- AST context appended when ast-grep finds enclosing containers. +- Back-references added when a grep hit lands on a function definition line. +- Script/heredoc commands correctly rejected (no false enrichment). +- If `ast-grep` unavailable: warns once, stays passive. +- Any error: returns original result unchanged. + +## Supported file extensions + +`.ts`, `.tsx`, `.js`, `.jsx`, `.mjs`, `.cjs`, `.py`, `.rs`, `.go` + +## Source + tests + +Checked TypeScript source: `tools/pi/context-grep/` + +```bash +pnpm test:pi-tooling # run tests +pnpm typecheck:pi-tooling # typecheck source + tests +pnpm typecheck:pi-shim # typecheck Pi shim +``` diff --git a/.pi/extensions/context-grep/index.ts b/.pi/extensions/context-grep/index.ts new file mode 100644 index 0000000..2677b78 --- /dev/null +++ b/.pi/extensions/context-grep/index.ts @@ -0,0 +1,49 @@ +import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; +import { isBashToolResult, isGrepToolResult } from "@earendil-works/pi-coding-agent"; +import { enrichSearchToolResult } from "../../../tools/pi/context-grep/src/index.ts"; + +export default function contextGrep(pi: ExtensionAPI) { + const sessionState = { + availability: "unknown" as "unknown" | "ready" | "unavailable", + warnedUnavailable: false, + }; + + pi.on("tool_result", async (event, ctx) => { + if (event.isError) return; + + const onAstGrepUnavailable = () => { + if (sessionState.warnedUnavailable || !ctx.hasUI) return; + sessionState.warnedUnavailable = true; + ctx.ui.notify( + "context-grep: ast-grep unavailable; leaving raw search output unchanged", + "warning", + ); + }; + + if (isGrepToolResult(event)) { + const content = await enrichSearchToolResult({ + toolName: "grep", + content: event.content, + cwd: ctx.cwd, + inputPath: event.input.path, + signal: ctx.signal, + sessionState, + onAstGrepUnavailable, + }); + return content ? { content } : undefined; + } + + if (isBashToolResult(event)) { + const content = await enrichSearchToolResult({ + toolName: "bash", + content: event.content, + cwd: ctx.cwd, + command: event.input.command, + signal: ctx.signal, + sessionState, + onAstGrepUnavailable, + }); + return content ? { content } : undefined; + } + }); +} diff --git a/.pi/extensions/context-grep/tsconfig.json b/.pi/extensions/context-grep/tsconfig.json new file mode 100644 index 0000000..19cba55 --- /dev/null +++ b/.pi/extensions/context-grep/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "noEmit": true, + "module": "nodenext", + "moduleResolution": "nodenext", + "allowImportingTsExtensions": true, + "types": ["node"] + }, + "include": ["index.ts", "types/**/*.d.ts", "../../../tools/pi/context-grep/src/**/*.ts"] +} diff --git a/.pi/extensions/context-grep/types/pi-coding-agent.d.ts b/.pi/extensions/context-grep/types/pi-coding-agent.d.ts new file mode 100644 index 0000000..50b92fe --- /dev/null +++ b/.pi/extensions/context-grep/types/pi-coding-agent.d.ts @@ -0,0 +1,59 @@ +declare module "@earendil-works/pi-coding-agent" { + export interface TextContent { + type: "text"; + text: string; + } + + export interface ImageContent { + type: string; + [key: string]: unknown; + } + + export type ToolContent = TextContent | ImageContent; + + export interface BashToolResultEvent { + toolName: "bash"; + content: ToolContent[]; + isError: boolean; + input: { + command: string; + timeout?: number; + }; + } + + export interface GrepToolResultEvent { + toolName: "grep"; + content: ToolContent[]; + isError: boolean; + input: { + path?: string; + }; + } + + export type ToolResultEvent = BashToolResultEvent | GrepToolResultEvent; + + export interface ExtensionContext { + cwd: string; + hasUI: boolean; + signal?: AbortSignal; + ui: { + notify(message: string, level: "info" | "warning" | "error"): void; + }; + } + + export interface ExtensionAPI { + on( + event: "tool_result", + handler: ( + event: ToolResultEvent, + ctx: ExtensionContext, + ) => + | Promise<{ content?: ToolContent[] } | undefined> + | { content?: ToolContent[] } + | undefined, + ): void; + } + + export function isBashToolResult(event: ToolResultEvent): event is BashToolResultEvent; + export function isGrepToolResult(event: ToolResultEvent): event is GrepToolResultEvent; +} diff --git a/docs/internal/plans/phases/09-tooling.md b/docs/internal/plans/phases/09-tooling.md new file mode 100644 index 0000000..3529347 --- /dev/null +++ b/docs/internal/plans/phases/09-tooling.md @@ -0,0 +1,238 @@ +--- +title: "Phase 9: Agent search tooling" +status: complete +date: 2026-06-22 +--- + +## Phase 9: Agent search tooling + +**Motivation:** Real Pi sessions show that models strongly prefer learned shell search commands (`rg`, `grep`, `find | grep`) over novel or even native search tools. If bproxy wants better agent navigation during real maintenance, the useful path is to enrich the search output agents already request. + +**Goal:** Add project-local Pi tooling that preserves normal grep/rg workflows while appending bounded AST context and back-references. The agent keeps issuing familiar search commands; the extension enriches successful results after the tool runs. + +**Non-goal:** This phase does not change bproxy product code, browser protocol, CLI command surface, daemon behavior, extension runtime, or public user documentation. It is developer/agent tooling for this repository. + +--- + +## Design inputs + +- Proven design: `codeindex-exploration` experiment (n=6 per condition, controlled A/B). +- Key finding: AST containers + back-references in single-block output reduce turns by 33% on investigation tasks. The mechanism is eliminating iterative grep→read→grep cycles. +- Key anti-pattern: navigation maps with "Suggested reads" encourage divergence and increase turns on audit tasks. +- Pi extension docs: project-local extensions under `.pi/extensions/*/index.ts`, `tool_result` event middleware, `isBashToolResult`, `ctx.cwd`, `ctx.signal`. +- Session evidence: models use `bash` with `rg`/`grep`; never adopt novel tools voluntarily. + +--- + +## Architecture + +```text +Agent issues familiar search + ├─ bash: rg / grep / find | grep + └─ native grep: Pi grep tool + │ + ▼ +Pi executes tool normally + │ + ▼ +tool_result extension hook + 1. Detect search-shaped successful result (reject scripts/heredocs) + 2. Parse match rows into file + line hits + 3. Resolve files relative to ctx.cwd and command/search path + 4. Use ast-grep to find enclosing containers + 5. Deduplicate and rank by hit density + 6. Find back-references for definitions (rg --json --fixed-strings) + 7. Append single enrichment block below original output + │ + ▼ +Agent sees: original grep output + AST context with back-refs +``` + +**Transparency rule:** original tool output remains verbatim at the top. Enrichment is appended after a clear separator as a single text block. Any failure returns the original result unchanged. + +--- + +## Output format + +```text +── AST context (N containers, deduplicated) ──────────────────────────── + +▶ service/src/config.ts:20-29 [fn loadBaseConfig] (grep hits: [20]) + │ + │ Called from: + │ ← main (service/src/index.ts:20) + │ ← main (service/src/index.ts:26) + │ ← loadConfig (service/src/config.ts:32) + │ + export function loadBaseConfig(env: NodeJS.ProcessEnv = process.env): ServiceConfig { + const port = Number.parseInt(env["BPROXY_PORT"] ?? "", 10); + ... + } +``` + +Rules: +- One entry per `{file, container.startLine, container.endLine}` +- Ranked by descending grep-hit count +- Back-references shown only when a grep hit lands on the definition line +- Callers found via `rg --json --fixed-strings` (skip imports, tests, re-definitions) +- Truncated body: head 12 + tail 5 lines for containers > 35 lines +- Single text block returned (original + enrichment concatenated) + +Caps: + +| Limit | Value | +|---|---:| +| enriched containers | 10 | +| lines per container | 35 | +| enrichment characters | 12,000 | +| files to scan | 12 | +| back-refs per definition | 5 | +| back-ref timeout | 2s | +| ast-grep timeout per file | 5s | + +--- + +## Command detection (safety layer) + +The extension must not enrich output from scripts or heredocs that happen to contain `grep`/`rg` text. + +Rejected patterns: +- Script command prefixes: `node`, `python3`, `ruby`, `perl`, `deno`, `bun`, `ts-node`, `tsx`, `npx` +- Heredoc patterns: `< = { + ".ts": { + kinds: [ + "function_declaration", + "method_definition", + "class_declaration", + "interface_declaration", + "type_alias_declaration", + "variable_declarator", + ], + }, + ".tsx": { + kinds: [ + "function_declaration", + "method_definition", + "class_declaration", + "interface_declaration", + "type_alias_declaration", + "variable_declarator", + ], + }, + ".js": { + kinds: [ + "function_declaration", + "method_definition", + "class_declaration", + "variable_declarator", + ], + }, + ".jsx": { + kinds: [ + "function_declaration", + "method_definition", + "class_declaration", + "variable_declarator", + ], + }, + ".mjs": { + kinds: [ + "function_declaration", + "method_definition", + "class_declaration", + "variable_declarator", + ], + }, + ".cjs": { + kinds: [ + "function_declaration", + "method_definition", + "class_declaration", + "variable_declarator", + ], + }, + ".py": { kinds: ["function_definition", "class_definition"] }, + ".rs": { kinds: ["function_item", "impl_item", "struct_item", "enum_item", "trait_item"] }, + ".go": { kinds: ["function_declaration", "method_declaration", "type_declaration"] }, +}; + +function supportedRule(filePath: string): LanguageRule | null { + return LANGUAGE_RULES[path.extname(filePath).toLowerCase()] ?? null; +} + +function normalizeNewlines(text: string): string { + return text.replace(/\r\n/g, "\n").replace(/\r/g, "\n"); +} + +function combinedSignal(signal: AbortSignal | undefined, timeoutMs: number): AbortSignal { + const timeoutSignal = AbortSignal.timeout(timeoutMs); + return signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal; +} + +function labelForContainer(kind: string, snippet: string): string { + const line = normalizeNewlines(snippet).split("\n", 1)[0] ?? ""; + const regexByKind: Record = { + function_declaration: /function\s+([A-Za-z_$][\w$]*)/, + method_definition: /^\s*(?:async\s+)?([A-Za-z_$#][\w$#]*)\s*\(/, + class_declaration: /class\s+([A-Za-z_$][\w$]*)/, + interface_declaration: /interface\s+([A-Za-z_$][\w$]*)/, + type_alias_declaration: /type\s+([A-Za-z_$][\w$]*)/, + variable_declarator: /^(?:\s*export\s+)?\s*(?:const|let|var)?\s*([A-Za-z_$][\w$]*)\s*=/, + function_definition: /def\s+([A-Za-z_][\w]*)/, + class_definition: /class\s+([A-Za-z_][\w]*)/, + function_item: /fn\s+([A-Za-z_][\w]*)/, + impl_item: /impl\s+([^\s{]+)/, + struct_item: /struct\s+([A-Za-z_][\w]*)/, + enum_item: /enum\s+([A-Za-z_][\w]*)/, + trait_item: /trait\s+([A-Za-z_][\w]*)/, + method_declaration: /func\s+\([^)]*\)\s+([A-Za-z_][\w]*)/, + type_declaration: /type\s+([A-Za-z_][\w]*)/, + }; + const tagByKind: Record = { + function_declaration: "fn", + method_definition: "method", + class_declaration: "class", + interface_declaration: "interface", + type_alias_declaration: "type", + variable_declarator: "fn", + function_definition: "fn", + class_definition: "class", + function_item: "fn", + impl_item: "impl", + struct_item: "struct", + enum_item: "enum", + trait_item: "trait", + method_declaration: "method", + type_declaration: "type", + }; + const matcher = regexByKind[kind]; + const label = tagByKind[kind] ?? kind; + const name = matcher?.exec(line)?.[1]; + return name ? `${label} ${name}` : label; +} + +interface AstGrepMatch { + lines?: string; + text?: string; + range?: { + start?: { line?: number }; + end?: { line?: number }; + }; +} + +function normalizeContainer(kind: string, entry: AstGrepMatch): AstContainer | null { + const snippet = entry.lines ?? entry.text ?? ""; + if (kind === "variable_declarator" && !/(=>|function\s*\()/.test(snippet)) { + return null; + } + const normalized = normalizeNewlines(snippet); + return { + kind, + label: labelForContainer(kind, snippet), + startLine: Number(entry.range?.start?.line) + 1, + endLine: Number(entry.range?.end?.line) + 1, + snippet: normalized, + firstLine: normalized.split("\n", 1)[0] ?? "", + }; +} + +async function runAstGrep( + kind: string, + filePath: string, + signal: AbortSignal | undefined, +): Promise { + try { + const { stdout } = await execFileAsync( + AST_GREP_COMMAND, + ["run", "--kind", kind, "--json", filePath], + { signal: combinedSignal(signal, AST_GREP_FILE_TIMEOUT_MS), maxBuffer: 2_000_000 }, + ); + const parsed = JSON.parse(stdout); + return Array.isArray(parsed) ? parsed : []; + } catch (error: unknown) { + if (error && typeof error === "object" && (error as { name?: string }).name === "AbortError") { + throw error; + } + const err = error as { stdout?: string; code?: number }; + const stdout = typeof err?.stdout === "string" ? err.stdout : ""; + if (err?.code === 1 && stdout.trim()) { + const parsed = JSON.parse(stdout); + return Array.isArray(parsed) ? parsed : []; + } + throw error; + } +} + +export function isSupportedFile(filePath: string): boolean { + return supportedRule(filePath) !== null; +} + +export async function ensureAstGrepAvailable( + state: { availability: "unknown" | "ready" | "unavailable" }, + signal?: AbortSignal, +): Promise { + if (state.availability === "ready") return true; + if (state.availability === "unavailable") return false; + try { + await execFileAsync(AST_GREP_COMMAND, ["--version"], { + signal, + maxBuffer: 64_000, + }); + state.availability = "ready"; + return true; + } catch (error: unknown) { + if (error && typeof error === "object" && (error as { name?: string }).name === "AbortError") { + throw error; + } + state.availability = "unavailable"; + return false; + } +} + +export async function getContainers( + filePath: string, + signal?: AbortSignal, +): Promise { + const rule = supportedRule(filePath); + if (!rule) return []; + const containers: AstContainer[] = []; + for (const kind of rule.kinds) { + const matches = await runAstGrep(kind, filePath, signal); + for (const match of matches) { + const container = normalizeContainer(kind, match); + if (container) containers.push(container); + } + } + // Sort smallest-span first for "most specific enclosing" lookup + containers.sort((left, right) => { + const leftSpan = left.endLine - left.startLine; + const rightSpan = right.endLine - right.startLine; + return leftSpan - rightSpan; + }); + return containers; +} + +/** + * Find the smallest enclosing container for a given line number. + * Containers must be pre-sorted smallest-span first. + */ +export function findEnclosing(lineNumber: number, containers: AstContainer[]): AstContainer | null { + for (const c of containers) { + if (c.startLine <= lineNumber && lineNumber <= c.endLine) { + return c; + } + } + return null; +} + +/** + * Extract the function/class name from a container label. + */ +export function extractName(label: string): string { + const parts = label.split(" "); + return parts[1] ?? parts[0] ?? ""; +} diff --git a/tools/pi/context-grep/src/backrefs.ts b/tools/pi/context-grep/src/backrefs.ts new file mode 100644 index 0000000..ab4470d --- /dev/null +++ b/tools/pi/context-grep/src/backrefs.ts @@ -0,0 +1,126 @@ +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import type { AstContainer } from "./ast.ts"; +import { extractName, findEnclosing, getContainers } from "./ast.ts"; + +const execFileAsync = promisify(execFile); + +const MAX_BACK_REFS = 5; +const BACK_REF_TIMEOUT_MS = 2_000; + +/** Names too generic to produce useful back-references. */ +const SKIP_NAMES = new Set([ + "new", + "run", + "get", + "set", + "init", + "start", + "stop", + "main", + "test", + "it", + "describe", +]); + +/** + * Find callers of a function by name using rg --json (fast, no shell). + * Skips: the definition itself, imports, re-definitions, test files (by default). + */ +export async function findBackRefs( + funcName: string, + definitionFile: string, + definitionLine: number, + cwd: string, + signal?: AbortSignal, +): Promise { + if (funcName.length <= 2 || SKIP_NAMES.has(funcName)) return []; + + try { + const { stdout } = await execFileAsync( + "rg", + [ + "--json", + "--fixed-strings", + "--type", + "ts", + "--type", + "js", + "--type", + "py", + "--type", + "rust", + "--type", + "go", + funcName, + cwd, + ], + { + signal: signal + ? AbortSignal.any([signal, AbortSignal.timeout(BACK_REF_TIMEOUT_MS)]) + : AbortSignal.timeout(BACK_REF_TIMEOUT_MS), + maxBuffer: 1_000_000, + }, + ); + + if (!stdout.trim()) return []; + + const callers: string[] = []; + + for (const line of stdout.split("\n")) { + if (!line.trim()) continue; + let parsed: { + type?: string; + data?: { + path?: { text?: string }; + line_number?: number; + lines?: { text?: string }; + }; + }; + try { + parsed = JSON.parse(line); + } catch { + continue; + } + if (parsed.type !== "match") continue; + + const file = parsed.data?.path?.text; + const lineNum = parsed.data?.line_number; + const content = parsed.data?.lines?.text ?? ""; + + if (!file || !lineNum) continue; + + // Skip the definition itself + if (file === definitionFile && lineNum === definitionLine) continue; + + // Skip imports + if (/^\s*(import|from|use|require)\b/.test(content)) continue; + + // Skip other definitions of the same name + if (new RegExp(`(def|fn|function|class|interface|type)\\s+${funcName}\\b`).test(content)) + continue; + + // Skip test files for caller summaries + if (/__tests__|\.test\.|\.spec\.|test\//.test(file)) continue; + + // Find enclosing function for the caller + const containers = await getContainers(file, signal); + const enclosing = findEnclosing(lineNum, containers); + + const shortFile = file.startsWith(cwd + "/") ? file.slice(cwd.length + 1) : file; + + if (enclosing) { + const callerName = extractName(enclosing.label); + callers.push(`← ${callerName} (${shortFile}:${lineNum})`); + } else { + callers.push(`← module level (${shortFile}:${lineNum})`); + } + + if (callers.length >= MAX_BACK_REFS) break; + } + + return callers; + } catch { + return []; + } +} diff --git a/tools/pi/context-grep/src/enrich.ts b/tools/pi/context-grep/src/enrich.ts new file mode 100644 index 0000000..2d709cb --- /dev/null +++ b/tools/pi/context-grep/src/enrich.ts @@ -0,0 +1,239 @@ +import path from "node:path"; +import type { AstContainer } from "./ast.ts"; +import { + ensureAstGrepAvailable, + extractName, + findEnclosing, + getContainers, + isSupportedFile, +} from "./ast.ts"; +import { findBackRefs } from "./backrefs.ts"; +import type { ParsedHit } from "./parse.ts"; +import { parseBashSearchOutput, parseNativeGrepOutput } from "./parse.ts"; + +export interface TextContent { + type: "text"; + text: string; +} + +export interface OtherContent { + type: string; + [key: string]: unknown; +} + +export type ToolContent = TextContent | OtherContent; + +export interface EnrichSearchToolResultInput { + toolName: "bash" | "grep"; + content: ToolContent[]; + cwd: string; + command?: string; + inputPath?: string; + signal?: AbortSignal; + sessionState: { + availability: "unknown" | "ready" | "unavailable"; + }; + onAstGrepUnavailable?: () => void; +} + +// ─── Configuration ─────────────────────────────────────────────────────────── + +const MAX_CONTAINERS = 10; +const MAX_CONTAINER_LINES = 35; +const MAX_ENRICHMENT_CHARS = 12_000; +const MAX_FILES_TO_SCAN = 12; + +// ─── Types ─────────────────────────────────────────────────────────────────── + +interface EnrichedContainer { + filePath: string; + startLine: number; + endLine: number; + label: string; + snippet: string; + grepHits: number[]; + backRefs: string[]; +} + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +function getTextContent(content: ToolContent[]): string { + return content + .filter((entry): entry is TextContent => entry.type === "text") + .map((entry) => entry.text) + .join(""); +} + +function shortenPath(filePath: string, cwd: string): string { + const relative = path.relative(cwd, filePath); + return (relative && !relative.startsWith("..") ? relative : filePath).replace(/\\/g, "/"); +} + +function truncateSnippet(text: string): string { + const lines = text.split("\n"); + if (lines.length <= MAX_CONTAINER_LINES) return text; + const head = lines.slice(0, 12); + const tail = lines.slice(-5); + return [...head, " ...", ...tail].join("\n"); +} + +function groupHitsByFile(hits: ParsedHit[]): Map { + const grouped = new Map(); + for (const hit of hits) { + if (!isSupportedFile(hit.filePath)) continue; + const existing = grouped.get(hit.filePath) ?? []; + existing.push(hit.lineNumber); + grouped.set(hit.filePath, existing); + } + return grouped; +} + +function parsedSearchResult(input: { + toolName: "bash" | "grep"; + text: string; + cwd: string; + command?: string; + inputPath?: string; +}) { + if (input.toolName === "grep") { + return parseNativeGrepOutput({ text: input.text, cwd: input.cwd, inputPath: input.inputPath }); + } + return parseBashSearchOutput({ text: input.text, cwd: input.cwd, command: input.command! }); +} + +// ─── Enrichment pipeline ───────────────────────────────────────────────────── + +export async function enrichSearchToolResult( + input: EnrichSearchToolResultInput, +): Promise { + try { + const text = getTextContent(input.content); + if (!text.trim()) return null; + + const parsed = parsedSearchResult({ + toolName: input.toolName, + text, + cwd: input.cwd, + command: input.command, + inputPath: input.inputPath, + }); + if (!parsed) return null; + + const groupedHits = groupHitsByFile(parsed.hits); + if (groupedHits.size === 0) return null; + + if (!(await ensureAstGrepAvailable(input.sessionState, input.signal))) { + input.onAstGrepUnavailable?.(); + return null; + } + + // Map hits to enclosing containers, deduplicating + const enrichedMap = new Map(); + const containerCache = new Map(); + + // Sort files by hit count (most hits first), cap at MAX_FILES + const fileEntries = [...groupedHits.entries()] + .sort((a, b) => b[1].length - a[1].length) + .slice(0, MAX_FILES_TO_SCAN); + + for (const [filePath, hitLines] of fileEntries) { + let containers = containerCache.get(filePath); + if (!containers) { + containers = await getContainers(filePath, input.signal); + containerCache.set(filePath, containers); + } + + for (const lineNumber of hitLines) { + const container = findEnclosing(lineNumber, containers); + if (!container) continue; + + const key = `${filePath}:${container.startLine}`; + const existing = enrichedMap.get(key); + if (existing) { + existing.grepHits.push(lineNumber); + } else { + enrichedMap.set(key, { + filePath, + startLine: container.startLine, + endLine: container.endLine, + label: container.label, + snippet: container.snippet, + grepHits: [lineNumber], + backRefs: [], + }); + } + } + } + + if (enrichedMap.size === 0) return null; + + // Rank by grep hit density (most hits first) + const ranked = [...enrichedMap.values()].sort((a, b) => b.grepHits.length - a.grepHits.length); + const top = ranked.slice(0, MAX_CONTAINERS); + + // Find back-references for definitions (grep hit lands on definition line) + for (const container of top) { + const isDefinition = container.grepHits.includes(container.startLine); + if (!isDefinition) continue; + + const name = extractName(container.label); + container.backRefs = await findBackRefs( + name, + container.filePath, + container.startLine, + input.cwd, + input.signal, + ); + } + + // Format output: single block appended to original + const enrichment = formatEnrichment(top, input.cwd); + if (!enrichment) return null; + + // Return original content with enrichment appended as single text block + const originalText = text; + return [{ type: "text", text: originalText + enrichment }]; + } catch { + return null; + } +} + +// ─── Formatting ────────────────────────────────────────────────────────────── + +function formatEnrichment(containers: EnrichedContainer[], cwd: string): string | null { + if (containers.length === 0) return null; + + let output = `\n\n── AST context (${containers.length} containers, deduplicated) ────────────────────────────\n\n`; + let charCount = 0; + + for (const container of containers) { + if (charCount > MAX_ENRICHMENT_CHARS) break; + + const shortFile = shortenPath(container.filePath, cwd); + const hitsStr = container.grepHits.sort((a, b) => a - b).join(", "); + + let entry = `▶ ${shortFile}:${container.startLine}-${container.endLine} [${container.label}] (grep hits: [${hitsStr}])\n`; + + // Back-references + if (container.backRefs.length > 0) { + entry += " │\n"; + entry += " │ Called from:\n"; + for (const ref of container.backRefs) { + entry += ` │ ${ref}\n`; + } + entry += " │\n"; + } + + // Truncated function body + const body = truncateSnippet(container.snippet); + for (const line of body.split("\n")) { + entry += ` ${line}\n`; + } + entry += "\n"; + + charCount += entry.length; + output += entry; + } + + return output; +} diff --git a/tools/pi/context-grep/src/index.ts b/tools/pi/context-grep/src/index.ts new file mode 100644 index 0000000..f3d5183 --- /dev/null +++ b/tools/pi/context-grep/src/index.ts @@ -0,0 +1,18 @@ +export type { AstContainer } from "./ast.ts"; +export { + ensureAstGrepAvailable, + extractName, + findEnclosing, + getContainers, + isSupportedFile, +} from "./ast.ts"; +export { findBackRefs } from "./backrefs.ts"; +export type { EnrichSearchToolResultInput, ToolContent } from "./enrich.ts"; +export { enrichSearchToolResult } from "./enrich.ts"; +export type { ParsedHit, ParsedSearchResult } from "./parse.ts"; +export { + inferCommandSearchPaths, + isSearchCommand, + parseBashSearchOutput, + parseNativeGrepOutput, +} from "./parse.ts"; diff --git a/tools/pi/context-grep/src/parse.ts b/tools/pi/context-grep/src/parse.ts new file mode 100644 index 0000000..0c71e70 --- /dev/null +++ b/tools/pi/context-grep/src/parse.ts @@ -0,0 +1,242 @@ +import type { Stats } from "node:fs"; +import { existsSync, statSync } from "node:fs"; +import path from "node:path"; + +export interface ParsedHit { + filePath: string; + lineNumber: number; + lineText: string; + displayPath: string; +} + +export interface ParsedSearchResult { + kind: "grep" | "bash"; + hits: ParsedHit[]; + singleFile?: string; +} + +const DIRECT_MATCH_RE = /^(.+?):(\d+):(.*)$/; +const CONTEXT_ROW_RE = /^(.+?)-(\d+)-\s?(.*)$/; +const SINGLE_FILE_MATCH_RE = /^(\d+):(.*)$/; +const SEARCH_COMMAND_RE = /(^|[\s;(|&])(rg|grep)(?=$|[\s;)|&])/; +const EXECUTABLE_TOKENS = new Set([ + "rg", + "grep", + "find", + "xargs", + "sort", + "uniq", + "head", + "tail", + "cut", + "awk", + "sed", +]); + +/** + * Patterns that indicate the command is a script/heredoc rather than a direct + * shell search command. If the command starts with one of these, even if it + * contains `grep`/`rg` text, it is not treated as a search command. + */ +const SCRIPT_COMMAND_PREFIXES: RegExp[] = [ + /^\s*node\b/, + /^\s*python3?\b/, + /^\s*ruby\b/, + /^\s*perl\b/, + /^\s*deno\b/, + /^\s*bun\b/, + /^\s*ts-node\b/, + /^\s*tsx\b/, + /^\s*npx\b/, +]; + +/** + * Patterns indicating heredoc, inline script, or process substitution that + * may contain grep/rg text without the shell command being a search. + */ +const HEREDOC_PATTERNS: RegExp[] = [ + /<<[-~]?\s*['"]?\w+['"]?/, // heredoc: < { + if ( + (token.startsWith('"') && token.endsWith('"')) || + (token.startsWith("'") && token.endsWith("'")) + ) { + return token.slice(1, -1); + } + return token; + }); +} + +function safeStat(filePath: string): Stats | null { + try { + return statSync(filePath); + } catch { + return null; + } +} + +function existingAbsolutePath(token: string, cwd: string): string | null { + if (!token || token.startsWith("-")) return null; + if (EXECUTABLE_TOKENS.has(token)) return null; + const resolved = path.isAbsolute(token) ? token : path.resolve(cwd, token); + return existsSync(resolved) ? resolved : null; +} + +export function isSearchCommand(command: string): boolean { + if (!SEARCH_COMMAND_RE.test(command)) return false; + + // Reject commands that are script invocations whose *text* contains grep/rg + // but whose executed binary is an interpreter, not a search tool. + for (const prefix of SCRIPT_COMMAND_PREFIXES) { + if (prefix.test(command)) return false; + } + + // Reject heredoc / inline-script patterns + for (const pattern of HEREDOC_PATTERNS) { + if (pattern.test(command)) return false; + } + + return true; +} + +export function inferCommandSearchPaths(command: string, cwd: string): string[] { + const paths: string[] = []; + for (const token of tokenizeShellish(command)) { + const existing = existingAbsolutePath(token, cwd); + if (existing) paths.push(existing); + } + return unique(paths); +} + +function searchBasesForBash(command: string, cwd: string): string[] { + const commandPaths = inferCommandSearchPaths(command, cwd); + const bases = [cwd]; + for (const candidate of commandPaths) { + const stat = safeStat(candidate); + if (!stat) continue; + bases.push(stat.isDirectory() ? candidate : path.dirname(candidate)); + } + return unique(bases); +} + +function resolveExistingFile(rawPath: string, bases: string[]): string | null { + if (!rawPath) return null; + if (path.isAbsolute(rawPath)) { + const absolute = path.normalize(rawPath); + const stat = safeStat(absolute); + return stat?.isFile() ? absolute : null; + } + for (const base of bases) { + const candidate = path.resolve(base, rawPath); + const stat = safeStat(candidate); + if (stat?.isFile()) return candidate; + } + return null; +} + +function buildHit(filePath: string, lineNumber: number, lineText: string): ParsedHit { + return { + filePath, + lineNumber, + lineText: lineText ?? "", + displayPath: normalizePath(filePath), + }; +} + +function parseDirectRows(text: string, bases: string[]): ParsedHit[] { + const hits: ParsedHit[] = []; + for (const line of splitLines(text)) { + if (!line || line === "No matches found") continue; + if (CONTEXT_ROW_RE.test(line)) continue; + const match = DIRECT_MATCH_RE.exec(line); + if (!match) continue; + const rawPath = match[1]!; + const rawLine = match[2]!; + const lineText = match[3] ?? ""; + const lineNumber = Number.parseInt(rawLine, 10); + if (!Number.isFinite(lineNumber) || lineNumber < 1) continue; + const filePath = resolveExistingFile(rawPath, bases); + if (!filePath) continue; + hits.push(buildHit(filePath, lineNumber, lineText)); + } + return hits; +} + +function inferSingleFile(command: string, cwd: string): string | null { + const commandPaths = inferCommandSearchPaths(command, cwd); + for (let index = commandPaths.length - 1; index >= 0; index -= 1) { + const candidate = commandPaths[index]; + if (!candidate) continue; + const stat = safeStat(candidate); + if (stat?.isFile()) return candidate; + } + return null; +} + +function parseSingleFileRows(text: string, singleFile: string): ParsedHit[] { + const hits: ParsedHit[] = []; + for (const line of splitLines(text)) { + const match = SINGLE_FILE_MATCH_RE.exec(line); + if (!match) continue; + const rawLine = match[1]!; + const lineText = match[2] ?? ""; + const lineNumber = Number.parseInt(rawLine, 10); + if (!Number.isFinite(lineNumber) || lineNumber < 1) continue; + hits.push(buildHit(singleFile, lineNumber, lineText)); + } + return hits; +} + +function searchBasesForNativeGrep(cwd: string, inputPath?: string): string[] { + if (!inputPath) return [cwd]; + const absoluteInput = path.isAbsolute(inputPath) ? inputPath : path.resolve(cwd, inputPath); + const stat = safeStat(absoluteInput); + if (!stat) return [cwd]; + return [stat.isDirectory() ? absoluteInput : path.dirname(absoluteInput), cwd]; +} + +export function parseNativeGrepOutput(input: { + text: string; + cwd: string; + inputPath?: string; +}): ParsedSearchResult | null { + const hits = parseDirectRows(input.text, searchBasesForNativeGrep(input.cwd, input.inputPath)); + return hits.length > 0 ? { kind: "grep", hits } : null; +} + +export function parseBashSearchOutput(input: { + text: string; + cwd: string; + command: string; +}): ParsedSearchResult | null { + if (!isSearchCommand(input.command)) return null; + const directHits = parseDirectRows(input.text, searchBasesForBash(input.command, input.cwd)); + if (directHits.length >= 2) { + return { kind: "bash", hits: directHits }; + } + const singleFile = inferSingleFile(input.command, input.cwd); + if (!singleFile) return null; + const singleFileHits = parseSingleFileRows(input.text, singleFile); + if (singleFileHits.length === 0) return null; + return { kind: "bash", hits: singleFileHits, singleFile }; +} diff --git a/tools/pi/context-grep/test/context-grep.test.ts b/tools/pi/context-grep/test/context-grep.test.ts new file mode 100644 index 0000000..5dafca2 --- /dev/null +++ b/tools/pi/context-grep/test/context-grep.test.ts @@ -0,0 +1,200 @@ +import assert from "node:assert/strict"; +import { execFile } from "node:child_process"; +import path from "node:path"; +import { describe, it } from "node:test"; +import { promisify } from "node:util"; + +import type { EnrichSearchToolResultInput, ToolContent } from "../src/enrich.ts"; +import { + enrichSearchToolResult, + findBackRefs, + getContainers, + parseBashSearchOutput, + parseNativeGrepOutput, +} from "../src/index.ts"; + +const execFileAsync = promisify(execFile); + +const repoRoot = process.cwd(); +const fixtureProject = path.resolve(repoRoot, "tools/pi/context-grep/test/fixtures/project"); +const fixtureSource = path.resolve(fixtureProject, "src/search-target.ts"); + +let hasAstGrep = false; +try { + await execFileAsync("ast-grep", ["--version"]); + hasAstGrep = true; +} catch { + // ast-grep not available — skip tests that need it +} + +function textContent(text: string): ToolContent[] { + return [{ type: "text", text }]; +} + +function sessionState(): EnrichSearchToolResultInput["sessionState"] { + return { availability: "unknown" }; +} + +describe("parseNativeGrepOutput", () => { + it("parses file:line rows and ignores context rows", () => { + const result = parseNativeGrepOutput({ + cwd: fixtureProject, + inputPath: "src", + text: [ + "search-target.ts-17- ", + "search-target.ts:18:export function helper(value: string) {", + "search-target.ts-19- return value.trim();", + ].join("\n"), + }); + + assert.ok(result); + assert.equal(result.hits.length, 1); + assert.equal(result.hits[0]!.filePath, fixtureSource); + assert.equal(result.hits[0]!.lineNumber, 18); + }); +}); + +describe("parseBashSearchOutput", () => { + it("parses rg output with file paths", () => { + const result = parseBashSearchOutput({ + cwd: repoRoot, + command: 'rg "helper" tools/pi/context-grep/test/fixtures/project/src -n', + text: [ + "tools/pi/context-grep/test/fixtures/project/src/search-target.ts:18:export function helper(value: string) {", + "tools/pi/context-grep/test/fixtures/project/src/search-target.ts:23: return helper(value);", + ].join("\n"), + }); + + assert.ok(result); + assert.equal(result.hits.length, 2); + assert.equal(result.hits[0]!.filePath, fixtureSource); + assert.equal(result.hits[1]!.lineNumber, 23); + }); + + it("parses single-file grep -n output by inferring the file from the command", () => { + const result = parseBashSearchOutput({ + cwd: repoRoot, + command: 'grep -n "loadBaseConfig" service/src/config.ts', + text: "20:export function loadBaseConfig(env: NodeJS.ProcessEnv = process.env): ServiceConfig {", + }); + + assert.ok(result); + assert.equal(result.singleFile, path.resolve(repoRoot, "service/src/config.ts")); + assert.equal(result.hits[0]!.lineNumber, 20); + }); + + it("skips path-list style output such as grep -l", () => { + const result = parseBashSearchOutput({ + cwd: repoRoot, + command: 'grep -l "helper" tools/pi/context-grep/test/fixtures/project/src/*.ts', + text: "tools/pi/context-grep/test/fixtures/project/src/search-target.ts", + }); + + assert.equal(result, null); + }); +}); + +describe("getContainers", () => { + it("extracts validated TypeScript container kinds", { skip: !hasAstGrep }, async () => { + const containers = await getContainers(fixtureSource); + const labels = containers.map((container) => container.label); + + assert.ok(labels.includes("interface SearchShape")); + assert.ok(labels.includes("type SearchResult")); + assert.ok(labels.includes("method run")); + assert.ok(labels.includes("fn helper")); + assert.ok(labels.includes("fn handleThing")); + }); +}); + +describe("findBackRefs", () => { + it("finds callers of a function in the project", { skip: !hasAstGrep }, async () => { + // helper is called from handleThing in the fixture + const refs = await findBackRefs("helper", fixtureSource, 18, fixtureProject); + // The fixture is in test/fixtures which is excluded by test path filter, + // but the call IS in the same file so it would be found if not test-excluded. + // For real code, test this on service/ source. + assert.ok(Array.isArray(refs)); + }); + + it("skips generic names", async () => { + const refs = await findBackRefs("run", "fake.ts", 1, repoRoot); + assert.deepEqual(refs, []); + }); + + it("skips very short names", async () => { + const refs = await findBackRefs("x", "fake.ts", 1, repoRoot); + assert.deepEqual(refs, []); + }); +}); + +describe("enrichSearchToolResult", () => { + it("appends AST context with back-references for supported bash search results", { + skip: !hasAstGrep, + }, async () => { + const state = sessionState(); + const result = await enrichSearchToolResult({ + toolName: "bash", + cwd: repoRoot, + command: 'rg "helper" tools/pi/context-grep/test/fixtures/project/src -n', + content: textContent( + [ + "tools/pi/context-grep/test/fixtures/project/src/search-target.ts:18:export function helper(value: string) {", + "tools/pi/context-grep/test/fixtures/project/src/search-target.ts:23: return helper(value);", + ].join("\n"), + ), + signal: undefined, + sessionState: state, + }); + + assert.ok(result); + // Single text block: original + enrichment + assert.equal(result.length, 1); + const text = (result[0] as { text: string }).text; + assert.match(text, /── AST context/); + assert.match(text, /search-target\.ts:18-20 \[fn helper\]/); + assert.match(text, /search-target\.ts:22-24 \[fn handleThing\]/); + }); + + it("uses a real bproxy source file for single-file grep validation", { + skip: !hasAstGrep, + }, async () => { + const state = sessionState(); + const result = await enrichSearchToolResult({ + toolName: "bash", + cwd: repoRoot, + command: 'grep -n "loadBaseConfig" service/src/config.ts', + content: textContent( + "20:export function loadBaseConfig(env: NodeJS.ProcessEnv = process.env): ServiceConfig {", + ), + signal: undefined, + sessionState: state, + }); + + assert.ok(result); + assert.equal(result.length, 1); + const text = (result[0] as { text: string }).text; + assert.match(text, /service\/src\/config\.ts:20-\d+ \[fn loadBaseConfig\]/); + // Back-references: loadBaseConfig is called from other places + assert.match(text, /Called from:/); + }); + + it("does not enrich unsupported log searches", { skip: !hasAstGrep }, async () => { + const state = sessionState(); + const result = await enrichSearchToolResult({ + toolName: "bash", + cwd: repoRoot, + command: 'rg "event" tools/pi/context-grep/test/fixtures/project/logs -n', + content: textContent( + [ + 'tools/pi/context-grep/test/fixtures/project/logs/session.jsonl:1:{"event":"search","ok":true}', + 'tools/pi/context-grep/test/fixtures/project/logs/session.jsonl:2:{"event":"search","ok":false}', + ].join("\n"), + ), + signal: undefined, + sessionState: state, + }); + + assert.equal(result, null); + }); +}); diff --git a/tools/pi/context-grep/test/fixtures/project/logs/session.jsonl b/tools/pi/context-grep/test/fixtures/project/logs/session.jsonl new file mode 100644 index 0000000..1d9d1e7 --- /dev/null +++ b/tools/pi/context-grep/test/fixtures/project/logs/session.jsonl @@ -0,0 +1,2 @@ +{"event":"search","ok":true} +{"event":"search","ok":false} diff --git a/tools/pi/context-grep/test/fixtures/project/src/other.ts b/tools/pi/context-grep/test/fixtures/project/src/other.ts new file mode 100644 index 0000000..4e21363 --- /dev/null +++ b/tools/pi/context-grep/test/fixtures/project/src/other.ts @@ -0,0 +1,3 @@ +export function secondHelper() { + return "ok"; +} diff --git a/tools/pi/context-grep/test/fixtures/project/src/search-target.ts b/tools/pi/context-grep/test/fixtures/project/src/search-target.ts new file mode 100644 index 0000000..416f7c8 --- /dev/null +++ b/tools/pi/context-grep/test/fixtures/project/src/search-target.ts @@ -0,0 +1,24 @@ +export interface SearchShape { + id: string; +} + +export type SearchResult = { + ok: boolean; +}; + +export class SearchRunner { + run(query: string) { + const nested = () => { + return query.toUpperCase(); + }; + return nested(); + } +} + +export function helper(value: string) { + return value.trim(); +} + +export const handleThing = async (value: string) => { + return helper(value); +}; diff --git a/tools/pi/context-grep/test/iteration2.test.ts b/tools/pi/context-grep/test/iteration2.test.ts new file mode 100644 index 0000000..af19888 --- /dev/null +++ b/tools/pi/context-grep/test/iteration2.test.ts @@ -0,0 +1,239 @@ +import assert from "node:assert/strict"; +import { execFile } from "node:child_process"; +import path from "node:path"; +import { describe, it } from "node:test"; +import { promisify } from "node:util"; + +import type { EnrichSearchToolResultInput, ToolContent } from "../src/enrich.ts"; +import { enrichSearchToolResult, isSearchCommand, parseBashSearchOutput } from "../src/index.ts"; + +const execFileAsync = promisify(execFile); + +const repoRoot = process.cwd(); +const fixtureProject = path.resolve(repoRoot, "tools/pi/context-grep/test/fixtures/project"); +const fixtureSource = path.resolve(fixtureProject, "src/search-target.ts"); + +let hasAstGrep = false; +try { + await execFileAsync("ast-grep", ["--version"]); + hasAstGrep = true; +} catch { + // ast-grep not available — skip tests that need it +} + +function textContent(text: string): ToolContent[] { + return [{ type: "text", text }]; +} + +function sessionState(): EnrichSearchToolResultInput["sessionState"] { + return { availability: "unknown" }; +} + +// ─── Hardening: heredoc/script false positive rejection ──────────────────────── + +describe("isSearchCommand — heredoc/script-analysis false positives", () => { + it("rejects node -e with grep in the script text", () => { + assert.equal(isSearchCommand('node -e "const x = grep(data)"'), false); + }); + + it("rejects node script that contains rg in variable names", () => { + assert.equal(isSearchCommand("node scripts/analyze-sessions.mjs --filter rg"), false); + }); + + it("rejects python -c with grep-like pattern", () => { + assert.equal(isSearchCommand('python3 -c "import re; re.grep(pattern, text)"'), false); + }); + + it("rejects heredoc containing grep", () => { + assert.equal(isSearchCommand('cat < { + assert.equal(isSearchCommand('bash <<-SCRIPT\nrg "term" src/\nSCRIPT'), false); + }); + + it("rejects npx commands even with grep args", () => { + assert.equal(isSearchCommand("npx tsx scripts/find-grep-usage.ts"), false); + }); + + it("accepts a real grep command", () => { + assert.equal(isSearchCommand('grep -rn "Session" service/src/'), true); + }); + + it("accepts a piped grep command", () => { + assert.equal(isSearchCommand('find . -name "*.ts" | grep -v node_modules'), true); + }); + + it("accepts rg with flags", () => { + assert.equal(isSearchCommand('rg "handleRequest" service/src -n --type ts'), true); + }); + + it("accepts grep after semicolon", () => { + assert.equal(isSearchCommand('cd service && grep -rn "config" src/'), true); + }); +}); + +// ─── Parser fixtures ─────────────────────────────────────────────────────────── + +describe("parseBashSearchOutput — grep -rn fixture", () => { + it("parses grep -rn output with file paths", () => { + const result = parseBashSearchOutput({ + cwd: repoRoot, + command: 'grep -rn "helper" tools/pi/context-grep/test/fixtures/project/src', + text: [ + "tools/pi/context-grep/test/fixtures/project/src/search-target.ts:18:export function helper(value: string) {", + "tools/pi/context-grep/test/fixtures/project/src/search-target.ts:23: return helper(value);", + ].join("\n"), + }); + + assert.ok(result); + assert.equal(result.kind, "bash"); + assert.equal(result.hits.length, 2); + assert.equal(result.hits[0]!.filePath, fixtureSource); + assert.equal(result.hits[0]!.lineNumber, 18); + assert.equal(result.hits[0]!.lineText, "export function helper(value: string) {"); + assert.equal(result.hits[1]!.lineNumber, 23); + assert.equal(result.hits[1]!.lineText, " return helper(value);"); + }); +}); + +describe("parseBashSearchOutput — heredoc/script negative fixtures", () => { + it("does not parse output from a node script that analyzed grep output", () => { + const result = parseBashSearchOutput({ + cwd: repoRoot, + command: + "node -e \"const fs = require('fs'); const lines = fs.readFileSync('session.log').toString().split('\\n').filter(l => /grep|rg/.test(l)); console.log(lines.join('\\n'))\"", + text: [ + "tools/pi/context-grep/test/fixtures/project/src/search-target.ts:18:export function helper(value: string) {", + "tools/pi/context-grep/test/fixtures/project/src/search-target.ts:23: return helper(value);", + ].join("\n"), + }); + + assert.equal(result, null); + }); + + it("does not parse output from python script containing grep text", () => { + const result = parseBashSearchOutput({ + cwd: repoRoot, + command: "python3 -c \"import subprocess; subprocess.run(['grep', '-n', 'test'])\"", + text: "tools/pi/context-grep/test/fixtures/project/src/search-target.ts:18:export function helper(value: string) {", + }); + + assert.equal(result, null); + }); +}); + +describe("parseBashSearchOutput — lineText preservation", () => { + it("preserves matched line text in direct-match parsing", () => { + const result = parseBashSearchOutput({ + cwd: repoRoot, + command: 'rg "helper|handleThing" tools/pi/context-grep/test/fixtures/project/src -n', + text: [ + "tools/pi/context-grep/test/fixtures/project/src/search-target.ts:18:export function helper(value: string) {", + "tools/pi/context-grep/test/fixtures/project/src/search-target.ts:23: return helper(value);", + ].join("\n"), + }); + + assert.ok(result); + assert.equal(result.hits[0]!.lineText, "export function helper(value: string) {"); + assert.equal(result.hits[1]!.lineText, " return helper(value);"); + }); +}); + +// ─── End-to-end enrichment ───────────────────────────────────────────────────── + +describe("enrichment — single-block output with back-references", () => { + it("produces single text block with original + AST context", { skip: !hasAstGrep }, async () => { + const state = sessionState(); + const result = await enrichSearchToolResult({ + toolName: "bash", + cwd: repoRoot, + command: 'rg "helper|handleThing" tools/pi/context-grep/test/fixtures/project/src -n', + content: textContent( + [ + "tools/pi/context-grep/test/fixtures/project/src/search-target.ts:18:export function helper(value: string) {", + "tools/pi/context-grep/test/fixtures/project/src/search-target.ts:23: return helper(value);", + ].join("\n"), + ), + signal: undefined, + sessionState: state, + }); + + assert.ok(result); + // Single block: original text + enrichment appended + assert.equal(result.length, 1); + const text = (result[0] as { text: string }).text; + + // Original output preserved at top + assert.match(text, /^tools\/pi\/context-grep/); + + // AST context appended + assert.match(text, /── AST context/); + assert.match(text, /\[fn helper\]/); + assert.match(text, /\[fn handleThing\]/); + }); + + it("includes back-references for real bproxy definitions", { skip: !hasAstGrep }, async () => { + const state = sessionState(); + const result = await enrichSearchToolResult({ + toolName: "bash", + cwd: repoRoot, + command: 'rg "loadBaseConfig" service/src -n', + content: textContent( + [ + "service/src/config.ts:20:export function loadBaseConfig(env: NodeJS.ProcessEnv = process.env): ServiceConfig {", + "service/src/index.ts:20:\t\t\tconst config = loadBaseConfig();", + "service/src/index.ts:26:\t\t\tconst config = loadBaseConfig();", + ].join("\n"), + ), + signal: undefined, + sessionState: state, + }); + + assert.ok(result); + assert.equal(result.length, 1); + const text = (result[0] as { text: string }).text; + + // Has AST context + assert.match(text, /── AST context/); + assert.match(text, /\[fn loadBaseConfig\]/); + + // Has back-references (loadBaseConfig is called from other files) + assert.match(text, /Called from:/); + }); + + it("heredoc false positive: does not enrich node inline script", { + skip: !hasAstGrep, + }, async () => { + const state = sessionState(); + const result = await enrichSearchToolResult({ + toolName: "bash", + cwd: repoRoot, + command: `node -e "const lines = require('fs').readFileSync('${fixtureSource}').toString().split('\\n'); lines.forEach((l,i) => { if(/grep|rg/.test(l)) console.log((i+1)+':'+l); })"`, + content: textContent("18:export function helper(value: string) {"), + signal: undefined, + sessionState: state, + }); + + assert.equal(result, null); + }); + + it("does not enrich markdown/docs searches", { skip: !hasAstGrep }, async () => { + const state = sessionState(); + const result = await enrichSearchToolResult({ + toolName: "bash", + cwd: repoRoot, + command: 'grep -rn "session" docs/', + content: textContent( + [ + "docs/internal/plans/phases/09-tooling.md:5:status: In progress", + "docs/internal/plans/phases/09-tooling.md:10:## Phase 9: Agent search tooling", + ].join("\n"), + ), + signal: undefined, + sessionState: state, + }); + + assert.equal(result, null); + }); +}); diff --git a/tools/pi/context-grep/tsconfig.json b/tools/pi/context-grep/tsconfig.json new file mode 100644 index 0000000..2787c92 --- /dev/null +++ b/tools/pi/context-grep/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "noEmit": true, + "module": "nodenext", + "moduleResolution": "nodenext", + "allowImportingTsExtensions": true, + "types": ["node"] + }, + "include": ["src/**/*.ts", "test/**/*.ts"] +}