pi-lens
Version:
Real-time code feedback for pi — LSP, linters, formatters, type-checking, structural analysis & booboo
660 lines (657 loc) • 23.3 kB
JavaScript
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
};