pi-lens
Version:
Real-time code feedback for pi — LSP, linters, formatters, type-checking, structural analysis & booboo
167 lines (166 loc) • 7.25 kB
JavaScript
import { normalizeMapKey } from "../path-utils.js";
import { findModuleForPath, getDownstreamModules, getModuleSourceFiles, } from "./workspace-modules.js";
function dedupe(items) {
return [...new Set(items)].sort((a, b) => a.localeCompare(b));
}
function filePathFromNode(graph, nodeId) {
return graph.nodes.get(nodeId)?.filePath;
}
function collectIncomingEdges(graph, nodeIds, kind) {
const edges = [];
for (const nodeId of nodeIds) {
for (const edge of graph.edgesByTo.get(nodeId) ?? []) {
if (edge.kind === kind)
edges.push(edge);
}
}
return edges;
}
const DEFAULT_IMPACT_RELATIONS = [
"calls",
"references",
"imports",
];
/**
* Transitive, depth-bounded impact of a file: "what depends on this, directly
* and indirectly". Unlike {@link computeImpactCascade} (one hop), this walks
* INCOMING edges (callers/referencers/importers) breadth-first up to `maxDepth`,
* returning each reached dependent with the depth and the relation that first
* reached it. Read-only graph traversal — the graphify-style symbol impact
* query, over the edges the review graph already carries (#162 mental model).
*/
export function computeTransitiveImpact(graph, seedFile, options) {
const normalized = normalizeMapKey(seedFile);
const maxDepth = Math.max(1, options?.maxDepth ?? 3);
const maxHits = Math.max(1, options?.maxHits ?? 200);
const relations = new Set(options?.relations ?? DEFAULT_IMPACT_RELATIONS);
// Seed from every symbol node in the file plus the file node itself (import
// edges point at the file node).
const seeds = [...(graph.symbolNodesByFile.get(normalized) ?? [])];
const fileNodeId = graph.fileNodes.get(normalized);
if (fileNodeId)
seeds.push(fileNodeId);
const visited = new Set(seeds);
let frontier = seeds.map((id) => ({ id, depth: 0 }));
const hits = [];
let maxDepthReached = 0;
for (let depth = 0; depth < maxDepth && frontier.length > 0; depth += 1) {
const next = [];
for (const node of frontier) {
for (const edge of graph.edgesByTo.get(node.id) ?? []) {
if (!relations.has(edge.kind))
continue;
if (visited.has(edge.from))
continue;
visited.add(edge.from);
const dependent = graph.nodes.get(edge.from);
const hitDepth = node.depth + 1;
maxDepthReached = Math.max(maxDepthReached, hitDepth);
hits.push({
symbol: dependent?.symbolName ?? "",
file: dependent?.filePath ?? "",
depth: hitDepth,
relation: edge.kind,
});
if (hits.length >= maxHits) {
return { seedFile: normalized, hits, truncated: true, maxDepthReached };
}
next.push({ id: edge.from, depth: hitDepth });
}
}
frontier = next;
}
return { seedFile: normalized, hits, truncated: false, maxDepthReached };
}
export function computeImpactCascade(graph, changedFile, moduleGraph) {
const normalizedFile = normalizeMapKey(changedFile);
const fileNodeId = graph.fileNodes.get(normalizedFile);
if (!fileNodeId) {
return {
filePath: normalizedFile,
changedSymbols: [],
directImporters: [],
directCallers: [],
neighborFiles: [],
riskFlags: [],
};
}
const changedSymbols = graph.changedSymbolsByFile.get(normalizedFile) ?? [];
const symbolNodeIds = (graph.symbolNodesByFile.get(normalizedFile) ?? []).filter((nodeId) => {
const symbolName = graph.nodes.get(nodeId)?.symbolName;
return (!changedSymbols.length ||
(symbolName && changedSymbols.includes(symbolName)));
});
const effectiveSymbolNodeIds = symbolNodeIds.length > 0
? symbolNodeIds
: (graph.symbolNodesByFile.get(normalizedFile) ?? []);
const importerFiles = dedupe((graph.edgesByTo.get(fileNodeId) ?? [])
.filter((edge) => edge.kind === "imports")
.flatMap((edge) => filePathFromNode(graph, edge.from) ?? []));
let callerFiles = dedupe(collectIncomingEdges(graph, effectiveSymbolNodeIds, "calls").flatMap((edge) => filePathFromNode(graph, edge.from) ?? []));
if (callerFiles.length === 0 &&
changedSymbols.length > 0 &&
importerFiles.length > 0) {
callerFiles = importerFiles;
}
// For non-jsts languages, import/call edges are absent but resolved
// `references` edges exist. Include them as supplemental neighbors.
const referenceFiles = dedupe(collectIncomingEdges(graph, effectiveSymbolNodeIds, "references").flatMap((edge) => filePathFromNode(graph, edge.from) ?? []));
let neighborFiles = dedupe([
...importerFiles,
...callerFiles,
...referenceFiles,
]).filter((candidate) => normalizeMapKey(candidate) !== normalizedFile);
// Module-level downstream expansion for monorepos
const downstreamModuleFiles = [];
if (moduleGraph) {
const changedModule = findModuleForPath(moduleGraph, normalizedFile);
if (changedModule) {
const downstream = getDownstreamModules(moduleGraph, changedModule.name);
for (const depName of downstream) {
const depMod = moduleGraph.modules.get(depName);
if (depMod) {
// Add representative source files from downstream modules
downstreamModuleFiles.push(...getModuleSourceFiles(depMod.root));
}
}
}
}
if (downstreamModuleFiles.length > 0) {
neighborFiles = dedupe([...neighborFiles, ...downstreamModuleFiles]);
}
const directImports = dedupe((graph.edgesByFrom.get(fileNodeId) ?? [])
.filter((edge) => edge.kind === "imports")
.flatMap((edge) => filePathFromNode(graph, edge.to) ?? []));
const riskFlags = new Set();
for (const nodeId of effectiveSymbolNodeIds) {
const node = graph.nodes.get(nodeId);
if (!node)
continue;
if (node.exported)
riskFlags.add("exported symbol changed");
const fanout = (graph.edgesByFrom.get(nodeId) ?? []).filter((edge) => edge.kind === "calls").length;
if (fanout >= 4)
riskFlags.add("high fanout");
const complexity = Number(node.metadata?.cyclomaticComplexity ?? 0);
if (complexity >= 8)
riskFlags.add("high complexity");
if (node.metadata?.isBoundaryWrapper)
riskFlags.add("boundary wrapper changed");
}
if (importerFiles.some((file) => directImports.includes(file))) {
riskFlags.add("cycle-adjacent file");
}
const riskFlagList = dedupe(riskFlags);
if (downstreamModuleFiles.length > 0) {
riskFlagList.push(`${downstreamModuleFiles.length} downstream module file(s)`);
}
return {
filePath: normalizedFile,
changedSymbols,
directImporters: importerFiles,
directCallers: callerFiles,
neighborFiles,
riskFlags: riskFlagList,
};
}