UNPKG

pi-lens

Version:

Real-time code feedback for pi — LSP, linters, formatters, type-checking, structural analysis & booboo

378 lines (377 loc) • 21.1 kB
/** * module_report + read_symbol tool definitions (#245). * * Agent-facing surface for the structured read-substitute flow: module_report * gives a navigable overview of a module (outline + signatures + who-uses-this + * ready-to-use read args); read_symbol returns one symbol's verbatim body; * read_enclosing maps a file+line search/diagnostic hit to the smallest enclosing * symbol/callback body. The exact-body tools wire the read-guard tie-in — a body * they return is recorded as a genuine read of that range (module_report * deliberately does not, since an outline is shape, not body). */ import * as path from "node:path"; import { Type } from "../clients/deps/typebox.js"; import { logLatency } from "../clients/latency-logger.js"; import { moduleReport, readEnclosing, readSymbol, renderCompactModuleReport, } from "../clients/module-report.js"; import { baseName, compactRenderResult } from "./render-compact.js"; function resolveFile(filePath, cwd) { return path.isAbsolute(filePath) ? filePath : path.resolve(cwd || ".", filePath); } function errorMessage(err) { return err instanceof Error ? err.message : String(err); } export function createModuleReportTool(getProjectRoot) { return { name: "module_report", label: "Module Report", description: "Structured, navigable overview of a source module — a token-efficient substitute for reading the whole file. Returns each symbol's name/kind/signature/line-range (plus a first-line `doc` summary when a doc comment is attached), important inline callbacks/closures/lambdas with stable handles, plus who-uses-this, risk flags, and ranked recommendedReads. To read a symbol's body: call read/read_symbol with offset=startLine, limit=endLine-startLine+1 on THIS report's `path` — those aren't repeated per symbol. Prefer this before a full read; then use read_symbol (or read) for the exact body you need.\n" + "Single mode: language-uniform tree-sitter outline + review-graph who-uses-this + inline executable extraction; degrades to outline-only when no cached graph is available. `semantic.source` reports whether graph data was used.\n" + 'Pass `blastRadius: true` to also get the cross-file blast radius — the transitive dependents of this module aggregated to ranked file `read` args ("if you change this, verify these files"). Read-only over the cached graph; omitted on a cold cache. Supersedes the standalone impact query.\n' + '`view: "compact"` returns a line-oriented text rendering (one line per symbol/callback, cheapest option) instead of JSON — same data, roughly a quarter of the token cost; use it for a quick skim. Default view returns JSON. An outline shows shape, not bodies — it does NOT count as having read a symbol\'s body for editing; use read_symbol for that.', promptSnippet: "Navigable file outline — a cheap substitute for reading a whole file", renderResult: compactRenderResult(({ details, args, isError }) => { const base = baseName(args.path) || "module"; if (isError || details?.available === false) { return `module_report ${base} — unavailable`; } const parts = [ `${details?.symbols ?? 0} symbols`, `${details?.exports ?? 0} exports`, ]; if (details?.callbacks) parts.push(`${details.callbacks} callbacks`); const view = details?.view && details.view !== "default" ? ` [${details.view}]` : ""; return `module_report ${base} ${parts.join(" · ")}${view}`; }), parameters: Type.Object({ path: Type.String({ description: "Absolute or workspace-relative path to the source file.", }), maxRefsPerSymbol: Type.Optional(Type.Number({ description: "Cap on who-uses-this entries per symbol (default 10).", })), focus: Type.Optional(Type.String({ description: "Optional task hint used only to rank recommendedReads (does not expand scope or trigger scans).", })), view: Type.Optional(Type.String({ enum: ["summary", "default", "compact"], description: "Payload tier. summary returns top-level entries/recommendedReads and section provenance with heavy callback/usedBy/blast-radius payloads omitted. compact (cheapest) returns a line-oriented TEXT rendering of the full report instead of JSON.", })), blastRadius: Type.Optional(Type.Boolean({ description: "Include the cross-file blast-radius section: transitive dependents aggregated to ranked file reads. Read-only over the cached graph (omitted when cold).", })), blastRadiusDepth: Type.Optional(Type.Number({ description: "Max hops for the blast-radius walk (default 3). Only used with blastRadius.", })), }), async execute(_toolCallId, params, _signal, _onUpdate, ctx) { // Resolve the file against the agent's cwd (sibling-tool convention); build // the review graph at the project root so cross-file who-uses-this is whole. const absFile = resolveFile(params.path, ctx.cwd); const cwd = getProjectRoot() || ctx.cwd || "."; let report; try { report = await moduleReport(absFile, cwd, { maxRefsPerSymbol: params.maxRefsPerSymbol, focus: params.focus, view: params.view, blastRadius: params.blastRadius, blastRadiusDepth: params.blastRadiusDepth, }); } catch (err) { return { content: [ { type: "text", text: `Module report failed for ${path.basename(absFile)}: ${errorMessage(err)}`, }, ], isError: true, details: { available: false }, }; } // view:"compact" renders the report as line-oriented text (roughly a // quarter of the JSON cost for the same info) instead of JSON. Every // other view returns compact (unindented) JSON — omitting indentation // alone saves ~30% on the wire without changing the schema. Tests use // JSON.parse for the JSON views, so they are agnostic to whitespace. const text = params.view === "compact" ? renderCompactModuleReport(report) : JSON.stringify(report); return { content: [{ type: "text", text }], isError: !report.available, details: { available: report.available, staleness: report.staleness, symbols: report.summary.symbols, exports: report.summary.exports, callbacks: report.callbacks.length, callbackSupport: report.callbackSupport, view: report.view ?? "default", }, }; }, }; } function recordReadCoverage(recordSymbolRead, result, phase) { if (!result.name || !result.kind || typeof result.startLine !== "number" || typeof result.endLine !== "number") { return false; } try { recordSymbolRead(result.path, { name: result.name, kind: result.kind, startLine: result.startLine, endLine: result.endLine, }); return true; } catch (err) { logLatency({ type: "phase", phase, filePath: result.path, durationMs: 0, metadata: { error: errorMessage(err) }, }); return false; } } export function createReadSymbolTool(getProjectRoot, recordSymbolRead) { return { name: "read_symbol", label: "Read Symbol", description: "Return the verbatim source of a single named symbol or module_report callback handle in a file — a targeted, cheap alternative to reading the whole file. Pair with module_report: module_report finds the symbol/callback handle, read_symbol shows its body. Unlike an outline, this delivers the actual lines, so it counts as having read that symbol for the read-before-edit guard. The returned body includes an attached doc comment when one exists. Accepts a dotted `Class.method` name to resolve a member directly, falling back to a plain top-level lookup when the qualifier doesn't resolve. A miss embeds the ~3 nearest symbol names in the file so a typo self-corrects without a second call. When multiple same-file symbols share a name (overloads, a type and a value sharing a name), the first is returned with an `ambiguous` note; pass `kind` to pick a specific one.", promptSnippet: "Read one symbol's body instead of the whole file", renderResult: compactRenderResult(({ details, args, isError, lineCount }) => { const base = baseName(args.path); if (isError || details?.found === false) { const sym = typeof args.symbol === "string" ? args.symbol : "?"; return `read_symbol "${sym}" ${base} — not found`; } const range = details?.startLine && details?.endLine ? `:${details.startLine}-${details.endLine} (${details.endLine - details.startLine + 1} lines)` : ` (${lineCount} lines)`; return `read_symbol ${details?.kind ?? ""} ${details?.name ?? ""} ${base}${range}`.replace(/\s+/g, " "); }), parameters: Type.Object({ path: Type.String({ description: "Absolute or workspace-relative path to the source file.", }), symbol: Type.String({ description: "Exact symbol name or callback handle to read (e.g. a function, class, type, or module_report callbacks[].name). Accepts a dotted `Class.method` name to resolve a member.", }), kind: Type.Optional(Type.String({ description: "Optional kind filter (e.g. function, interface, class) to disambiguate when multiple same-file symbols share the requested name. Omitting it returns the first match, same as today.", })), }), async execute(_toolCallId, params, _signal, _onUpdate, ctx) { const absFile = resolveFile(params.path, ctx.cwd); const cwd = getProjectRoot() || ctx.cwd || "."; let result; try { result = await readSymbol(absFile, params.symbol, cwd, { kind: params.kind, }); } catch (err) { return { content: [ { type: "text", text: `Read symbol failed for ${path.basename(absFile)}: ${errorMessage(err)}`, }, ], isError: true, details: { found: false }, }; } if (!result.found) { const warningSuffix = result.warnings?.length ? ` Warnings: ${result.warnings.join("; ")}` : ""; const suggestionSuffix = result.suggestions?.length ? ` Did you mean: ${result.suggestions.join(", ")}?` : " Use module_report to list available symbols."; const text = result.error ? `Could not inspect ${path.basename(absFile)}: ${result.error}${warningSuffix}` : `Symbol "${params.symbol}" not found in ${path.basename(absFile)}.${suggestionSuffix}${warningSuffix}`; return { content: [{ type: "text", text }], isError: true, details: { found: false, ...(result.error ? { error: result.error } : {}), ...(result.warnings ? { warnings: result.warnings } : {}), ...(result.suggestions ? { suggestions: result.suggestions } : {}), }, }; } // Read-substitute tie-in (#245): a readSymbol body IS a real read of that // range, so record it as read-guard coverage for the symbol. Keep the tool // response useful even if the guard hook itself fails; surface that fact in // details so callers know the returned body may not unlock a later edit. const readRecorded = recordReadCoverage(recordSymbolRead, result, "read_symbol_guard_error"); const ambiguityNote = result.ambiguous ? ` (${result.ambiguous.count} matches — returned the ${result.kind}; pass \`kind\` to disambiguate: ${result.ambiguous.kinds.join(", ")})` : ""; const header = `${result.kind} ${result.name}${ambiguityNote} ${path.basename(result.path)}:${result.startLine}-${result.endLine}`; const guardWarning = readRecorded ? "" : "\n\nWarning: read coverage recording failed; the returned body may not satisfy the edit guard."; return { content: [ { type: "text", text: `${header}${guardWarning}\n\n${result.source ?? ""}`, }, ], details: { found: true, name: result.name, kind: result.kind, startLine: result.startLine, endLine: result.endLine, readRecorded, ...(result.ambiguous ? { ambiguous: result.ambiguous } : {}), }, }; }, }; } export function createReadEnclosingTool(getProjectRoot, recordSymbolRead) { return { name: "read_enclosing", label: "Read Enclosing", description: "Return the verbatim source for the smallest useful symbol/callback enclosing a line in a file. Use after ast_grep_search, diagnostics, or LSP locations when you need exact body text without reading the whole file. Uses tree-sitter only — no LSP or graph build — and records read-guard coverage for the returned range.", promptSnippet: "Read the enclosing symbol or callback body for a line", renderResult: compactRenderResult(({ details, args, isError }) => { const base = baseName(args.path); if (isError || details?.found === false) { const ln = typeof args.line === "number" ? args.line : "?"; return `read_enclosing ${base}:${ln} — no enclosing symbol`; } const range = details?.startLine && details?.endLine ? `:${details.startLine}-${details.endLine}` : ""; return `read_enclosing ${details?.kind ?? ""} ${details?.name ?? ""} ${base}${range}`.replace(/\s+/g, " "); }), parameters: Type.Object({ path: Type.String({ description: "Absolute or workspace-relative path to the source file.", }), line: Type.Number({ description: "1-based line number inside the desired symbol/callback.", }), kinds: Type.Optional(Type.Array(Type.String(), { description: "Optional kind filter, e.g. function, method, callback, class, object_property_callback.", })), maxLines: Type.Optional(Type.Number({ description: "Optional maximum body size to return. Oversized matches obey onOversize.", })), onOversize: Type.Optional(Type.String({ enum: ["error", "slice", "outline"], description: "Behavior when the enclosing body exceeds maxLines. error (default) returns metadata only; slice returns a bounded partial read around line; outline returns nested symbols/callbacks with read handles.", })), aroundLine: Type.Optional(Type.Number({ description: "Maximum lines for onOversize=slice; defaults to maxLines, then 80.", })), }), async execute(_toolCallId, params, _signal, _onUpdate, ctx) { const absFile = resolveFile(params.path, ctx.cwd); const cwd = getProjectRoot() || ctx.cwd || "."; let result; try { result = await readEnclosing(absFile, params.line, cwd, { kinds: params.kinds, maxLines: params.maxLines, onOversize: params.onOversize, aroundLine: params.aroundLine, }); } catch (err) { return { content: [ { type: "text", text: `Read enclosing failed for ${path.basename(absFile)}:${params.line}: ${errorMessage(err)}`, }, ], isError: true, details: { found: false }, }; } if (!result.found) { const warningSuffix = result.warnings?.length ? ` Warnings: ${result.warnings.join("; ")}` : ""; const outlineSuffix = result.outline?.length ? `\n\nNested outline:\n${JSON.stringify(result.outline)}` : ""; const text = result.error ? `Could not read enclosing range in ${path.basename(absFile)}:${result.line}: ${result.error}${warningSuffix}${outlineSuffix}` : `No enclosing symbol/callback found in ${path.basename(absFile)}:${result.line}.${warningSuffix}`; return { content: [{ type: "text", text }], isError: true, details: { found: false, line: result.line, ...(result.name ? { name: result.name } : {}), ...(result.kind ? { kind: result.kind } : {}), ...(result.startLine ? { startLine: result.startLine } : {}), ...(result.endLine ? { endLine: result.endLine } : {}), ...(result.enclosingStartLine ? { enclosingStartLine: result.enclosingStartLine } : {}), ...(result.enclosingEndLine ? { enclosingEndLine: result.enclosingEndLine } : {}), ...(result.selection ? { selection: result.selection } : {}), ...(result.outline ? { outline: result.outline } : {}), ...(result.error ? { error: result.error } : {}), ...(result.warnings ? { warnings: result.warnings } : {}), }, }; } const readRecorded = recordReadCoverage(recordSymbolRead, result, "read_enclosing_guard_error"); const range = result.partial ? `${result.startLine}-${result.endLine} (partial of ${result.enclosingStartLine}-${result.enclosingEndLine})` : `${result.startLine}-${result.endLine}`; const header = `${result.kind} ${result.name} ${path.basename(result.path)}:${range}`; const guardWarning = readRecorded ? "" : "\n\nWarning: read coverage recording failed; the returned body may not satisfy the edit guard."; return { content: [ { type: "text", text: `${header}${guardWarning}\n\n${result.source ?? ""}`, }, ], details: { found: true, name: result.name, kind: result.kind, line: result.line, startLine: result.startLine, endLine: result.endLine, enclosingStartLine: result.enclosingStartLine, enclosingEndLine: result.enclosingEndLine, parentChain: result.parentChain, partial: result.partial, selection: result.selection, readRecorded, ...(result.warnings ? { warnings: result.warnings } : {}), }, }; }, }; }