UNPKG

pi-lens

Version:

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

326 lines (325 loc) • 13 kB
/** * Cross-file call graph — Sections 1, 2 & 3 of issue #154. * * Builds a bidirectional function-level call graph by resolving symbol * references across files. Provides BFS impact analysis with severity tiers. * Uses the Symbol/SymbolRef data produced by TreeSitterSymbolExtractor and * persists the result with per-file mtime tracking for incremental sessions. */ import * as fs from "node:fs"; import * as path from "node:path"; import { getProjectDataDir } from "./file-utils.js"; const CACHE_VERSION = 3; function severityForDepth(depth) { if (depth === 1) return "WillBreak"; if (depth === 2) return "MayBreak"; return "Review"; } /** * BFS upstream through the callers map from `startKey`. * * Returns all symbols that would be affected if `startKey` changes, * classified by severity: * depth 1 → WillBreak (direct callers) * depth 2 → MayBreak (callers of callers) * depth 3+ → Review (transitive) * * @param maxDepth Limit traversal depth (default 3 — Review tier cutoff). * @param minWeight Only follow edges with weight ≥ this threshold (default 0.1). * Filters out highly ambiguous name resolutions. */ export function impact(graph, startKey, maxDepth = 3, minWeight = 0.1) { const results = []; const visited = new Set([startKey]); const queue = [{ key: startKey, depth: 0 }]; // Build a weight lookup from edges for filtering const edgeWeightMap = new Map(); for (const edge of graph.edges) { const edgeKey = `${edge.calleeKey}→${edge.callerKey}`; const existing = edgeWeightMap.get(edgeKey) ?? 0; edgeWeightMap.set(edgeKey, Math.max(existing, edge.weight)); } while (queue.length > 0) { const item = queue.shift(); if (item.depth >= maxDepth) continue; const directCallers = graph.callers.get(item.key); if (!directCallers) continue; for (const callerKey of directCallers) { if (visited.has(callerKey)) continue; // Check edge weight — skip highly ambiguous resolutions const edgeKey = `${item.key}→${callerKey}`; const weight = edgeWeightMap.get(edgeKey) ?? 1.0; if (weight < minWeight) continue; visited.add(callerKey); const depth = item.depth + 1; results.push({ symbolKey: callerKey, depth, severity: severityForDepth(depth) }); queue.push({ key: callerKey, depth }); } } // Sort by depth then symbolKey for stable output return results.sort((a, b) => a.depth - b.depth || a.symbolKey.localeCompare(b.symbolKey)); } /** * Format an impact result set as a compact human-readable summary. * Example: "handleToolResult (WillBreak) → handleAgentEnd (MayBreak) → 3 Review callers" */ export function formatImpact(results, projectRoot) { if (results.length === 0) return ""; const willBreak = results.filter((r) => r.severity === "WillBreak"); const mayBreak = results.filter((r) => r.severity === "MayBreak"); const review = results.filter((r) => r.severity === "Review"); const parts = []; const label = (r) => { const name = r.symbolKey.includes(":") ? r.symbolKey.split(":").pop() ?? r.symbolKey : r.symbolKey; const file = r.symbolKey.includes(":") ? r.symbolKey.split(":").slice(0, -1).join(":").replace(projectRoot, "").replace(/^[/\\]/, "") : ""; return file ? `${name} (${file})` : name; }; if (willBreak.length > 0) { parts.push(willBreak.slice(0, 3).map((r) => `${label(r)} ⚠ WillBreak`).join(", ")); } if (mayBreak.length > 0) { parts.push(mayBreak.slice(0, 2).map((r) => `${label(r)} MayBreak`).join(", ")); } if (review.length > 0) { parts.push(`${review.length} Review caller${review.length === 1 ? "" : "s"}`); } return parts.join(" → "); } // ── Stdlib / builtin noise filter ───────────────────────────────────────────── /** * Common stdlib / builtin names that appear in call sites but never resolve * to project-defined symbols. Filtering them cuts noise significantly. */ const STDLIB_NAMES = new Set([ // JS/TS "console", "Math", "Object", "Array", "String", "Number", "Boolean", "Promise", "Error", "Map", "Set", "WeakMap", "WeakSet", "JSON", "Date", "RegExp", "Symbol", "BigInt", "parseInt", "parseFloat", "isNaN", "isFinite", "setTimeout", "clearTimeout", "setInterval", "clearInterval", "fetch", "URL", "URLSearchParams", "Buffer", "process", "require", // Python "print", "len", "range", "list", "dict", "str", "int", "float", "bool", "open", "isinstance", "issubclass", "type", "super", "hasattr", "getattr", "setattr", "enumerate", "zip", "map", "filter", "sorted", "reversed", // Go "fmt", "log", "os", "io", "err", "make", "append", "len", "cap", "copy", "close", "delete", "panic", "recover", "new", // Rust "println", "eprintln", "print", "eprint", "vec", "Some", "None", "Ok", "Err", "Box", "Rc", "Arc", "String", "Vec", "HashMap", "HashSet", "format", // Java/Kotlin "System", "println", "toString", "equals", "hashCode", "Objects", // Generic "new", "this", "self", "super", "nil", "null", "undefined", "true", "false", ]); // ── Core resolution ──────────────────────────────────────────────────────────── /** * Build def index: symbol name → list of SymbolKeys that define it. * Exported symbols and all symbols are indexed; the ambiguity weight * discounts edges when many files define the same name. */ function buildDefIndex(allSymbols) { const index = new Map(); for (const [, symbols] of allSymbols) { for (const sym of symbols) { if (!sym.name) continue; const key = `${sym.filePath}:${sym.name}`; const existing = index.get(sym.name) ?? []; if (!existing.includes(key)) existing.push(key); index.set(sym.name, existing); } } return index; } /** * Find the enclosing function/method for a ref at `refLine` using a * "last start-line before ref" heuristic. Returns the symbol whose * start line is closest to (and not after) the ref's line. */ function findEnclosingSymbol(symbols, refLine) { let best; for (const sym of symbols) { if (sym.line <= refLine && (sym.kind === "function" || sym.kind === "method")) { if (!best || sym.line > best.line) best = sym; } } return best; } /** * Build the function-level call graph from extracted symbols and refs. * * Two passes: * 1. Index all defs by name across all files. * 2. For each ref, resolve to cross-file defs; find enclosing caller. */ export function buildCallGraph(allSymbols, allRefs) { const defIndex = buildDefIndex(allSymbols); const callees = new Map(); const callers = new Map(); const inDegree = new Map(); const edges = []; let unresolvedRefs = 0; let totalRefs = 0; for (const [callerFile, refs] of allRefs) { const callerSymbols = allSymbols.get(callerFile) ?? []; for (const ref of refs) { totalRefs++; // ref.symbolId is "filePath:name" from the extractor; we only need the name. const refName = ref.symbolId.split(":").pop() ?? ref.symbolId; if (STDLIB_NAMES.has(refName) || !refName) continue; const defs = defIndex.get(refName); if (!defs || defs.length === 0) { unresolvedRefs++; continue; } // Only cross-file refs are interesting for the call graph. const crossFileDefs = defs.filter((d) => !d.startsWith(`${callerFile}:`)); if (crossFileDefs.length === 0) continue; const weight = 1.0 / crossFileDefs.length; // Enclosing function is the caller; fall back to file-level key. const enclosing = findEnclosingSymbol(callerSymbols, ref.line); const callerKey = enclosing ? `${callerFile}:${enclosing.name}` : `file:${callerFile}`; for (const calleeKey of crossFileDefs) { const calleeFile = calleeKey.split(":").slice(0, -1).join(":"); const calleeSymbol = calleeKey.split(":").pop() ?? calleeKey; // Bidirectional maps (deduplicated by Set). const callerCallees = callees.get(callerKey) ?? new Set(); callerCallees.add(calleeKey); callees.set(callerKey, callerCallees); const calleeCallers = callers.get(calleeKey) ?? new Set(); calleeCallers.add(callerKey); callers.set(calleeKey, calleeCallers); // Weighted in-degree accumulation. inDegree.set(calleeKey, (inDegree.get(calleeKey) ?? 0) + weight); edges.push({ callerFile, callerSymbol: enclosing?.name, callerKey, calleeFile, calleeSymbol, calleeKey, weight, }); } } } return { callees, callers, inDegree, edges, unresolvedRefs, totalRefs, builtAt: new Date().toISOString(), }; } // ── Persistence ──────────────────────────────────────────────────────────────── function cacheFilePath(cwd) { return path.join(getProjectDataDir(cwd), "cache", "call-graph.json"); } function metaFilePath(cwd) { return path.join(getProjectDataDir(cwd), "cache", "call-graph.meta.json"); } /** * Persist the call graph to disk with per-file mtime tracking. * On the next session-start, stale files can be identified without a full rebuild. */ export function saveCallGraph(cwd, graph, fileMtimes) { const cacheFile = cacheFilePath(cwd); const metaFile = metaFilePath(cwd); try { fs.mkdirSync(path.dirname(cacheFile), { recursive: true }); const persisted = { version: CACHE_VERSION, builtAt: graph.builtAt, fileMtimes: Object.fromEntries(fileMtimes), edges: graph.edges, callees: [...graph.callees.entries()].map(([k, v]) => [k, [...v]]), callers: [...graph.callers.entries()].map(([k, v]) => [k, [...v]]), inDegree: [...graph.inDegree.entries()], }; fs.writeFileSync(cacheFile, JSON.stringify(persisted), "utf-8"); fs.writeFileSync(metaFile, JSON.stringify({ savedAt: new Date().toISOString(), edgeCount: graph.edges.length }), "utf-8"); } catch { // Non-fatal — next session rebuilds from scratch. } } /** * Load the persisted call graph from disk. * Returns undefined if the cache is missing, version-mismatched, or corrupt. */ export function loadCallGraph(cwd) { const cacheFile = cacheFilePath(cwd); try { const raw = JSON.parse(fs.readFileSync(cacheFile, "utf-8")); if (raw.version !== CACHE_VERSION) return undefined; return { graph: { callees: new Map(raw.callees.map(([k, v]) => [k, new Set(v)])), callers: new Map(raw.callers.map(([k, v]) => [k, new Set(v)])), inDegree: new Map(raw.inDegree), edges: raw.edges, unresolvedRefs: 0, totalRefs: 0, builtAt: raw.builtAt, }, fileMtimes: new Map(Object.entries(raw.fileMtimes)), }; } catch { return undefined; } } /** * Returns the set of file paths whose mtime has changed since the cache was saved. * Files not in the mtime map are treated as new (stale). */ export function staleFiles(fileMtimes, currentFiles) { return currentFiles.filter((f) => { const cached = fileMtimes.get(f); if (cached === undefined) return true; // new file try { return fs.statSync(f).mtimeMs !== cached; } catch { return true; // deleted or unreadable } }); } /** * Read current mtimes for a set of files. */ export function readMtimes(files) { const mtimes = new Map(); for (const f of files) { try { mtimes.set(f, fs.statSync(f).mtimeMs); } catch { // skip } } return mtimes; }