UNPKG

pi-lens

Version:

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

660 lines (657 loc) • 23.3 kB
import { createRequire as __pilensCreateRequire } from "node:module"; const require = __pilensCreateRequire(import.meta.url); import { parseSymbolKey } from "./chunk-3MED75HT.js"; import { getProjectDataDir } from "./chunk-VN2AXLEX.js"; import { writeFileAtomic } from "./chunk-2EECBNC5.js"; import { normalizeMapKey, toProjectRelativePath } from "./chunk-Q5U6FMDI.js"; // dist/clients/call-graph.js import * as fs from "node:fs"; import * as path from "node:path"; var CALL_GRAPH_CACHE_VERSION = 5; function severityForDepth(depth) { if (depth === 1) return "WillBreak"; if (depth === 2) return "MayBreak"; return "Review"; } function impact(graph, startKey, maxDepth = 3, minWeight = 0.1) { const results = []; const visited = /* @__PURE__ */ new Set([startKey]); const queue = [ { key: startKey, depth: 0 } ]; const edgeWeightMap = /* @__PURE__ */ new Map(); for (const edge of graph.edges) { const edgeKey = `${edge.calleeKey}\u2192${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; const edgeKey = `${item.key}\u2192${callerKey}`; const weight = edgeWeightMap.get(edgeKey) ?? 1; 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 }); } } return results.sort((a, b) => a.depth - b.depth || a.symbolKey.localeCompare(b.symbolKey)); } function parseSymbolKey2(key, knownFilePath) { return parseSymbolKey(key, knownFilePath); } 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 parsed = parseSymbolKey2(r.symbolKey); const name = parsed.symbolName ?? r.symbolKey; const file = toProjectRelativePath(parsed.filePath, projectRoot); return file ? `${name} (${file})` : name; }; if (willBreak.length > 0) { parts.push(willBreak.slice(0, 3).map((r) => `${label(r)} \u26A0 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(" \u2192 "); } var STDLIB_NAMES = /* @__PURE__ */ 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", "cap", "copy", "close", "delete", "panic", "recover", "new", // Rust "eprintln", "eprint", "vec", "Some", "None", "Ok", "Err", "Box", "Rc", "Arc", "Vec", "HashMap", "HashSet", "format", // Java/Kotlin "System", "toString", "equals", "hashCode", "Objects", // Generic "this", "self", "nil", "null", "undefined", "true", "false" ]); function buildDefIndex(allSymbols) { const byName = /* @__PURE__ */ new Map(); const byId = /* @__PURE__ */ new Map(); for (const [, symbols] of allSymbols) { for (const sym of symbols) { if (!sym.name || !sym.id) continue; const key = sym.id; byId.set(key, sym); const existing = byName.get(sym.name) ?? []; if (!existing.includes(key)) existing.push(key); byName.set(sym.name, existing); } } return { byName, byId }; } function findEnclosingSymbol(symbols, refLine) { let best; for (const sym of symbols) { if (sym.line <= refLine && (sym.endLine === void 0 || refLine <= sym.endLine) && (sym.kind === "function" || sym.kind === "method")) { if (!best || sym.line > best.line) best = sym; } } return best; } function buildCallGraph(allSymbols, allRefs, inputCoverage) { const canonicalSymbols = /* @__PURE__ */ new Map(); for (const [filePath, symbols] of allSymbols) canonicalSymbols.set(normalizeMapKey(filePath), symbols); const defIndex = buildDefIndex(canonicalSymbols); const callees = /* @__PURE__ */ new Map(); const callers = /* @__PURE__ */ new Map(); const inDegree = /* @__PURE__ */ new Map(); const edgeByPair = /* @__PURE__ */ new Map(); let unresolvedRefs = 0; let totalRefs = 0; const hasInputCoverage = inputCoverage !== void 0; const coverage = inputCoverage ? { ...inputCoverage } : { totalEvidence: 0, callsEvidence: 0, referencesEvidence: 0, eligibleEvidence: 0, resolvedEvidence: 0, unresolvedEvidence: 0, typeOnlyEvidence: 0, unsupportedEvidence: 0, sameFileEvidence: 0, duplicateEvidence: 0, complete: false }; for (const [callerFile, refs] of allRefs) { const callerFileKey = normalizeMapKey(callerFile); const callerSymbols = canonicalSymbols.get(callerFileKey) ?? []; for (const ref of refs) { totalRefs++; if (!hasInputCoverage) { coverage.totalEvidence++; if (ref.evidenceKind === "references" || ref.referenceKind === "type") coverage.referencesEvidence++; else coverage.callsEvidence++; } if (ref.referenceKind === "type") { if (!hasInputCoverage) coverage.typeOnlyEvidence++; continue; } if (ref.evidenceKind && ref.referenceKind !== "call") { if (!hasInputCoverage) coverage.unsupportedEvidence++; continue; } const candidates = []; if (ref.targetId) { const callee = defIndex.byId.get(ref.targetId); if (!callee || ref.resolution === "name-only" || ref.resolution === "unresolved") { if (!hasInputCoverage) { unresolvedRefs++; coverage.unresolvedEvidence++; } continue; } candidates.push({ callee, resolution: ref.resolution ?? "exact", candidateCount: 1 }); } else { const refName = ref.symbolName ?? parseSymbolKey2(ref.symbolId, callerFile).symbolName ?? ""; if (STDLIB_NAMES.has(refName) || !refName) { if (!hasInputCoverage) coverage.unsupportedEvidence++; continue; } const defs = defIndex.byName.get(refName); if (!defs || defs.length === 0) { if (!hasInputCoverage) { unresolvedRefs++; coverage.unresolvedEvidence++; } continue; } const crossFileDefs = defs.map((id) => defIndex.byId.get(id)).filter((candidate) => candidate !== void 0 && normalizeMapKey(candidate.filePath) !== callerFileKey); if (crossFileDefs.length === 0) { if (!hasInputCoverage) coverage.unsupportedEvidence++; continue; } const resolution = crossFileDefs.length === 1 ? "exact" : "name-only"; for (const callee of crossFileDefs) { candidates.push({ callee, resolution, candidateCount: crossFileDefs.length }); } } if (ref.evidenceKind && candidates.some(({ resolution }) => resolution === "name-only")) { if (!hasInputCoverage) { unresolvedRefs++; coverage.unresolvedEvidence++; } continue; } const caller = ref.callerSymbolId ? defIndex.byId.get(ref.callerSymbolId) : void 0; const enclosing = caller ?? findEnclosingSymbol(callerSymbols, ref.line); const callerKey = enclosing?.id ?? `file:${callerFile}`; let producedEdge = false; let duplicateRef = false; for (const { callee, resolution, candidateCount } of candidates) { if (normalizeMapKey(callee.filePath) === callerFileKey) { coverage.sameFileEvidence++; if (hasInputCoverage) { coverage.eligibleEvidence--; coverage.resolvedEvidence--; } continue; } producedEdge = true; const pairKey = `${callerKey}\0${callee.id}`; const existing = edgeByPair.get(pairKey); if (existing) { existing.evidenceCount = (existing.evidenceCount ?? 1) + 1; existing.evidenceKind = existing.evidenceKind === ref.evidenceKind ? existing.evidenceKind : "mixed"; duplicateRef = true; continue; } const weight = ref.evidenceKind ? 1 : 1 / Math.max(1, candidateCount); const edge = { callerFile, callerSymbol: enclosing?.name, callerKey, callerKind: enclosing?.kind, callerLine: enclosing?.line, callerColumn: enclosing?.column, calleeFile: callee.filePath, calleeSymbol: callee.name, calleeKey: callee.id, calleeKind: callee.kind, calleeLine: callee.line, calleeColumn: callee.column, evidenceKind: ref.evidenceKind, resolution, evidenceCount: 1, weight }; edgeByPair.set(pairKey, edge); const callerCallees = callees.get(callerKey) ?? /* @__PURE__ */ new Set(); callerCallees.add(callee.id); callees.set(callerKey, callerCallees); const calleeCallers = callers.get(callee.id) ?? /* @__PURE__ */ new Set(); calleeCallers.add(callerKey); callers.set(callee.id, calleeCallers); inDegree.set(callee.id, (inDegree.get(callee.id) ?? 0) + weight); } if (!hasInputCoverage && producedEdge) { coverage.eligibleEvidence++; coverage.resolvedEvidence++; if (duplicateRef) coverage.duplicateEvidence++; } if (hasInputCoverage && producedEdge && duplicateRef) coverage.duplicateEvidence++; } } if (hasInputCoverage) totalRefs = coverage.totalEvidence; const languagesKnownComplete = Object.values(coverage.languages ?? {}).every((status) => status === "complete"); coverage.complete = coverage.complete && hasInputCoverage && coverage.unsupportedEvidence === 0 && languagesKnownComplete && (allSymbols.size > 0 || coverage.totalEvidence > 0); return { callees, callers, inDegree, edges: [...edgeByPair.values()], unresolvedRefs: hasInputCoverage ? coverage.unresolvedEvidence : unresolvedRefs, totalRefs, coverage, builtAt: (/* @__PURE__ */ new Date()).toISOString() }; } 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"); } function saveCallGraph(cwd, graph, identity) { const cacheFile = cacheFilePath(cwd); const metaFile = metaFilePath(cwd); try { fs.mkdirSync(path.dirname(cacheFile), { recursive: true }); const persisted = { version: CALL_GRAPH_CACHE_VERSION, builtAt: graph.builtAt, reviewGraphVersion: identity.reviewGraphVersion, reviewGraphSignature: identity.reviewGraphSignature, 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()], totalRefs: graph.totalRefs, unresolvedRefs: graph.unresolvedRefs, coverage: graph.coverage }; writeFileAtomic(cacheFile, JSON.stringify(persisted)); writeFileAtomic(metaFile, JSON.stringify({ savedAt: (/* @__PURE__ */ new Date()).toISOString(), edgeCount: graph.edges.length })); } catch { } } function inferLegacyCoverage(edges) { let weightedTotalEvidence = 0; let weightedReferencesEvidence = 0; let weightedDuplicateEvidence = 0; for (const edge of edges) { const count = Number.isFinite(edge.evidenceCount) && (edge.evidenceCount ?? 0) > 0 ? Math.floor(edge.evidenceCount) : 1; const weight = Number.isFinite(edge.weight) && edge.weight >= 0 ? edge.weight : 1; weightedTotalEvidence += count * weight; weightedDuplicateEvidence += Math.max(0, count - 1) * weight; if (edge.evidenceKind === "references") weightedReferencesEvidence += count * weight; } const totalEvidence = Math.max(0, Math.round(weightedTotalEvidence)); const referencesEvidence = Math.max(0, Math.round(weightedReferencesEvidence)); const duplicateEvidence = Math.max(0, Math.round(weightedDuplicateEvidence)); return { totalEvidence, callsEvidence: totalEvidence - referencesEvidence, referencesEvidence, eligibleEvidence: totalEvidence, resolvedEvidence: totalEvidence, unresolvedEvidence: 0, typeOnlyEvidence: 0, unsupportedEvidence: 0, sameFileEvidence: 0, duplicateEvidence, // A legacy graph can still be queried, but it cannot prove that the old // extractor covered every source/reference kind. complete: false }; } function loadCoverage(rawCoverage, edges) { const inferred = inferLegacyCoverage(edges); return rawCoverage ? { ...inferred, ...rawCoverage } : inferred; } function finiteCount(value) { return typeof value === "number" && Number.isInteger(value) && value >= 0; } function countsMatch(left, right) { return Math.abs(left - right) <= 1e-9 * Math.max(1, Math.abs(left), Math.abs(right)); } function validatePersistedCallGraph(raw, coverage) { const nonEmptyString = (value) => typeof value === "string" && value.trim().length > 0; const enumValue = (value, values) => typeof value === "string" && values.includes(value); const edgeKinds = ["calls", "references", "mixed"]; const resolutions = [ "exact", "import", "receiver-type", "name-only", "unresolved" ]; if (!raw || raw.version !== CALL_GRAPH_CACHE_VERSION || !nonEmptyString(raw.builtAt)) return false; if (!nonEmptyString(raw.reviewGraphVersion) || !nonEmptyString(raw.reviewGraphSignature)) return false; if (raw.coverage !== void 0 && (!raw.coverage || typeof raw.coverage !== "object" || Array.isArray(raw.coverage))) return false; if (!Array.isArray(raw.edges) || !Array.isArray(raw.callees) || !Array.isArray(raw.callers) || !Array.isArray(raw.inDegree)) return false; const coverageValues = [ coverage.totalEvidence, coverage.callsEvidence, coverage.referencesEvidence, coverage.eligibleEvidence, coverage.resolvedEvidence, coverage.unresolvedEvidence, coverage.typeOnlyEvidence, coverage.unsupportedEvidence, coverage.sameFileEvidence, coverage.duplicateEvidence ]; if (typeof coverage.complete !== "boolean" || coverageValues.some((value) => !finiteCount(value))) return false; if (coverage.languages !== void 0 && (!coverage.languages || typeof coverage.languages !== "object" || Array.isArray(coverage.languages) || Object.entries(coverage.languages).some(([language, status]) => !nonEmptyString(language) || !enumValue(status, ["complete", "partial", "unavailable"])))) return false; if (coverage.callsEvidence + coverage.referencesEvidence !== coverage.totalEvidence) return false; if (coverage.resolvedEvidence > coverage.eligibleEvidence) return false; if (coverage.eligibleEvidence !== coverage.resolvedEvidence) return false; if (coverage.resolvedEvidence + coverage.unresolvedEvidence + coverage.typeOnlyEvidence + coverage.unsupportedEvidence + coverage.sameFileEvidence !== coverage.totalEvidence) return false; if (coverage.complete && coverage.unsupportedEvidence > 0) return false; if (coverage.complete && Object.values(coverage.languages ?? {}).some((status) => status !== "complete")) return false; const expectedCallees = /* @__PURE__ */ new Map(); const expectedCallers = /* @__PURE__ */ new Map(); const expectedInDegree = /* @__PURE__ */ new Map(); const pairKeys = /* @__PURE__ */ new Set(); let weightedEdgeEvidence = 0; let weightedDuplicateEvidence = 0; for (const edge of raw.edges) { if (!edge || typeof edge !== "object" || !nonEmptyString(edge.callerKey) || !nonEmptyString(edge.calleeKey) || !nonEmptyString(edge.callerFile) || !nonEmptyString(edge.calleeFile) || !nonEmptyString(edge.calleeSymbol) || typeof edge.weight !== "number" || !Number.isFinite(edge.weight) || edge.weight <= 0 || edge.weight > 1) return false; if (edge.callerSymbol !== void 0 && !nonEmptyString(edge.callerSymbol)) return false; if (edge.callerKind !== void 0 && !nonEmptyString(edge.callerKind)) return false; if (edge.calleeKind !== void 0 && !nonEmptyString(edge.calleeKind)) return false; if (edge.evidenceKind !== void 0 && !enumValue(edge.evidenceKind, edgeKinds)) return false; if (edge.resolution !== void 0 && !enumValue(edge.resolution, resolutions)) return false; if (edge.evidenceCount !== void 0 && (!finiteCount(edge.evidenceCount) || edge.evidenceCount < 1)) return false; const evidenceCount = edge.evidenceCount ?? 1; const callerIdentity = parseSymbolKey(edge.callerKey, edge.callerFile); const calleeIdentity = parseSymbolKey(edge.calleeKey, edge.calleeFile); if (normalizeMapKey(callerIdentity.filePath) !== normalizeMapKey(edge.callerFile) || normalizeMapKey(calleeIdentity.filePath) !== normalizeMapKey(edge.calleeFile) || calleeIdentity.symbolName !== void 0 && calleeIdentity.symbolName !== edge.calleeSymbol || callerIdentity.symbolName !== void 0 && edge.callerSymbol !== void 0 && callerIdentity.symbolName !== edge.callerSymbol) return false; const pair = `${edge.callerKey}\0${edge.calleeKey}`; if (pairKeys.has(pair)) return false; pairKeys.add(pair); weightedEdgeEvidence += evidenceCount * edge.weight; weightedDuplicateEvidence += (evidenceCount - 1) * edge.weight; const callees = expectedCallees.get(edge.callerKey) ?? /* @__PURE__ */ new Set(); callees.add(edge.calleeKey); expectedCallees.set(edge.callerKey, callees); const callers = expectedCallers.get(edge.calleeKey) ?? /* @__PURE__ */ new Set(); callers.add(edge.callerKey); expectedCallers.set(edge.calleeKey, callers); expectedInDegree.set(edge.calleeKey, (expectedInDegree.get(edge.calleeKey) ?? 0) + edge.weight); } if (!countsMatch(weightedEdgeEvidence, coverage.resolvedEvidence)) return false; if (!countsMatch(weightedDuplicateEvidence, coverage.duplicateEvidence)) return false; if (!countsMatch(coverage.totalEvidence, weightedEdgeEvidence + coverage.unresolvedEvidence + coverage.typeOnlyEvidence + coverage.unsupportedEvidence + coverage.sameFileEvidence)) return false; if (raw.totalRefs !== void 0 && !finiteCount(raw.totalRefs)) return false; if (raw.unresolvedRefs !== void 0 && !finiteCount(raw.unresolvedRefs)) return false; if (raw.totalRefs !== void 0 && raw.totalRefs !== coverage.totalEvidence) return false; if (raw.unresolvedRefs !== void 0 && raw.unresolvedRefs !== coverage.unresolvedEvidence) return false; const readAdjacency = (entries) => { const result = /* @__PURE__ */ new Map(); if (!Array.isArray(entries)) return void 0; for (const entry of entries) { if (!Array.isArray(entry) || !nonEmptyString(entry[0]) || !Array.isArray(entry[1]) || entry[1].some((key) => !nonEmptyString(key))) return void 0; const keys = entry[1]; if (new Set(keys).size !== keys.length || result.has(entry[0])) return void 0; result.set(entry[0], new Set(keys)); } return result; }; const actualCallees = readAdjacency(raw.callees); const actualCallers = readAdjacency(raw.callers); if (!actualCallees || !actualCallers || actualCallees.size !== expectedCallees.size || actualCallers.size !== expectedCallers.size) return false; const sameSets = (actual, expected) => { for (const [key, values] of expected) { const got = actual.get(key); if (!got || got.size !== values.size || [...values].some((value) => !got.has(value))) return false; } return true; }; if (!sameSets(actualCallees, expectedCallees) || !sameSets(actualCallers, expectedCallers)) return false; const actualInDegree = /* @__PURE__ */ new Map(); for (const entry of raw.inDegree) { if (!Array.isArray(entry) || !nonEmptyString(entry[0]) || typeof entry[1] !== "number" || !Number.isFinite(entry[1]) || entry[1] < 0 || actualInDegree.has(entry[0])) return false; actualInDegree.set(entry[0], entry[1]); } if (actualInDegree.size !== expectedInDegree.size) return false; for (const [key, value] of expectedInDegree) { if (actualInDegree.get(key) !== value) return false; } return true; } function loadCallGraph(cwd, expectedIdentity) { const cacheFile = cacheFilePath(cwd); try { const raw = JSON.parse(fs.readFileSync(cacheFile, "utf-8")); if (raw.version !== CALL_GRAPH_CACHE_VERSION) return void 0; if (typeof raw.reviewGraphVersion !== "string" || typeof raw.reviewGraphSignature !== "string") return void 0; const identity = { reviewGraphVersion: raw.reviewGraphVersion, reviewGraphSignature: raw.reviewGraphSignature }; if (expectedIdentity && (identity.reviewGraphVersion !== expectedIdentity.reviewGraphVersion || identity.reviewGraphSignature !== expectedIdentity.reviewGraphSignature)) return void 0; const coverage = loadCoverage(raw.coverage, raw.edges); if (!validatePersistedCallGraph(raw, coverage)) return void 0; 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: raw.unresolvedRefs ?? coverage.unresolvedEvidence, totalRefs: raw.totalRefs ?? coverage.totalEvidence, coverage, builtAt: raw.builtAt }, identity }; } catch { return void 0; } } export { CALL_GRAPH_CACHE_VERSION, impact, parseSymbolKey2 as parseSymbolKey, formatImpact, buildCallGraph, saveCallGraph, loadCallGraph };