调用树展开与结构 diff 深度解析
这一页拆 calltree / infer / diff:从 FunctionIndex 递归展开,到反向边推断受影响入口,再用 LCS 对齐前后调用树。
调用树先解决递归展开
▸display label 与 loc 分离
src/calltree.ts:5 用 FunctionIndex 拿展示 label,找不到时才补一个 key();src/calltree.ts:58 则区分根节点定义位置和子节点调用位置。这一差异让 --locs 输出符合 LSP Call Hierarchy 的 fromRanges 思路。
5function displayCallLabel(key: string, index: FunctionIndex): string { 6 const info = index.get(key); 7 if (info) return info.label; 8 return key.includes("(") ? key : `${key}()`; 9} 10 11function expandSteps( 12 steps: CallStep[], 13 index: FunctionIndex, 14 depth: number, 15 maxDepth: number, 16 visiting: Set<string>, 17): CallNode[] { 18 return steps.map((step) => { 19 if (step.type === "branch") { 20 return { 21 key: step.key, 22 label: step.label, 23 kind: "branch" as const, 24 ...pickLoc(step), 25 children: expandSteps( 26 step.children, 27 index, 28 depth, 29 maxDepth, 30 visiting, 31 ), 32 }; 33 } 34 return expandCall( 35 step.key, 36 index, 37 depth, 38 maxDepth, 39 visiting, 40 step.children, 41 step, 42 ); 43 }); 44} 45
▸visiting 防止循环展开
src/calltree.ts:72 发现当前 key 已在 visiting 中时,会返回带循环标记的节点;src/calltree.ts:86 到 src/calltree.ts:93 则只在进入函数体时 add/delete。这个设计避免递归函数把 CLI 输出拖成无限树。
46function expandCall( 47 key: string, 48 index: FunctionIndex, 49 depth: number, 50 maxDepth: number, 51 visiting: Set<string>, 52 inlineChildren?: CallStep[], 53 callSite?: { file?: string; line?: number; endLine?: number }, 54): CallNode { 55 const label = displayCallLabel(key, index); 56 const info = index.get(key); 57 58 // Root uses the definition start line; every other node uses the call-site in the parent. 59 const loc = 60 depth === 0 && info?.line != null 61 ? pickLoc({ file: info.file, line: info.line }) 62 : pickLoc(callSite); 63 64 if (depth >= maxDepth) { 65 return { key, label, kind: "call", ...loc, children: [] }; 66 } 67 68 if (!info && !inlineChildren?.length) { 69 return { key, label, kind: "call", ...loc, children: [] }; 70 } 71 72 if (info && visiting.has(key)) { 73 // Still expand call-site JSX children; they are not a re-entry into `key`'s body. 74 const callSiteChildren = inlineChildren?.length 75 ? expandSteps(inlineChildren, index, depth + 1, maxDepth, visiting) 76 : []; 77 return { 78 key, 79 label: `${label} ⇄`, 80 kind: "call", 81 ...loc, 82 children: callSiteChildren, 83 }; 84 } 85 86 if (info) visiting.add(key); 87 const bodyChildren = info 88 ? expandSteps(info.steps, index, depth + 1, maxDepth, visiting) 89 : []; 90 const callSiteChildren = inlineChildren?.length 91 ? expandSteps(inlineChildren, index, depth + 1, maxDepth, visiting) 92 : []; 93 if (info) visiting.delete(key); 94 95 return { 96 key, 97 label, 98 kind: "call", 99 ...loc, 100 children: [...bodyChildren, ...callSiteChildren], 101 }; 102} 103 104/** 105 * Expand a function into a nested call tree by following known definitions. 106 */ 107export function buildCallTree( 108 entryKey: string, 109 index: FunctionIndex, 110 maxDepth: number, 111): CallNode { 112 const resolved = resolveEntry(entryKey, index) ?? entryKey; 113 return expandCall(resolved, index, 0, maxDepth, new Set());
入口推断从反向边开始
▸findAffected 把变化向调用者传播
src/infer.ts:12 构建 callee→callers 反向边,src/infer.ts:28 从 before/after 函数 shape 变化出发,用 BFS 传播到上游调用者。这样即使底层 helper 改了,src/infer.ts:80 也能推断真正值得展示的 exported entrypoint。
12function addReverseEdges( 13 reverse: Map<string, Set<string>>, 14 index: FunctionIndex, 15): void { 16 for (const [caller, fn] of index) { 17 for (const callee of flattenCallKeys(fn.steps)) { 18 let callers = reverse.get(callee); 19 if (!callers) { 20 callers = new Set(); 21 reverse.set(callee, callers); 22 } 23 callers.add(caller); 24 } 25 } 26} 27 28function findAffected( 29 before: FunctionIndex, 30 after: FunctionIndex, 31 maxDepth: number, 32): Set<string> { 33 const keys = new Set([...before.keys(), ...after.keys()]); 34 const reverse = new Map<string, Set<string>>(); 35 addReverseEdges(reverse, before); 36 addReverseEdges(reverse, after); 37 38 const distance = new Map<string, number>(); 39 const queue: string[] = []; 40 41 for (const key of keys) { 42 if (functionShape(before.get(key)) === functionShape(after.get(key))) { 43 continue; 44 } 45 distance.set(key, 0); 46 queue.push(key); 47 } 48 49 for (let i = 0; i < queue.length; i += 1) { 50 const callee = queue[i]!; 51 const nextDistance = distance.get(callee)! + 1; 52 if (nextDistance > maxDepth) continue; 53 54 for (const caller of reverse.get(callee) ?? []) { 55 const previous = distance.get(caller); 56 if (previous !== undefined && previous <= nextDistance) continue; 57 distance.set(caller, nextDistance); 58 queue.push(caller); 59 } 60 } 61 62 return new Set(distance.keys()); 63}
▸explicit entry 优先,fallback 再兜底
src/infer.ts:86 明确用户指定 entry 时不再猜;没有 entry 时,src/infer.ts:96 优先筛 affected exported 函数,src/infer.ts:105 才回退到非 exported 受影响函数。这个顺序让输出更像 review summary,而不是内部 helper 清单。
80export function inferEntries( 81 before: FunctionIndex, 82 after: FunctionIndex, 83 explicit: string[], 84 maxDepth: number, 85): string[] { 86 if (explicit.length > 0) { 87 const entries: string[] = []; 88 for (const entry of explicit) { 89 const key = resolveEntry(entry, after) ?? resolveEntry(entry, before); 90 if (!key) throw new Error(`Entrypoint not found: ${entry}`); 91 if (!entries.includes(key)) entries.push(key); 92 } 93 return entries; 94 } 95 96 const affected = [...findAffected(before, after, maxDepth)].filter( 97 (key) => !key.startsWith("new "), 98 ); 99 const exported = affected.filter((key) => 100 Boolean(before.get(key)?.exported || after.get(key)?.exported), 101 ); 102 const entries = changedKeys(exported, before, after, maxDepth); 103 if (entries.length > 0) return entries; 104 105 const checked = new Set(exported); 106 const fallback = affected.filter((key) => !checked.has(key)); 107 return changedKeys(fallback, before, after, maxDepth); 108}
结构 diff 如何保持可读顺序
▸LCS 对齐 children
src/diff.ts:62 建 dp 表,src/diff.ts:82 之后按 before/after key 走 LCS。相同节点递归 diff,不同节点标 added/removed;这让 refactor 后的新树顺序被保留,同时仍能显示删除的旧调用。
1import { pickLoc } from "./loc.js"; 2import type { CallNode, DiffNode } from "./types.js"; 3 4/** 5 * Structural tree diff keyed by call identity. 6 * Children are aligned with LCS so output order stays close to the "after" tree. 7 */ 8export function diffTrees(before: CallNode, after: CallNode): DiffNode { 9 return diffNode(before, after); 10} 11 12function diffNode(before: CallNode | null, after: CallNode | null): DiffNode { 13 if (before && after) { 14 return { 15 key: after.key, 16 label: after.label, 17 kind: after.kind ?? before.kind, 18 ...pickLoc(after.file ? after : before), 19 status: "same", 20 children: diffChildren(before.children, after.children), 21 }; 22 } 23 24 if (after) { 25 return { 26 key: after.key, 27 label: after.label, 28 kind: after.kind, 29 ...pickLoc(after), 30 status: "added", 31 children: after.children.map(markTree("added")), 32 }; 33 } 34 35 if (before) { 36 return { 37 key: before.key, 38 label: before.label, 39 kind: before.kind, 40 ...pickLoc(before), 41 status: "removed", 42 children: before.children.map(markTree("removed")), 43 }; 44 } 45 46 throw new Error("diffNode called with no trees"); 47} 48 49function markTree(status: "added" | "removed") { 50 return function mark(node: CallNode): DiffNode { 51 return { 52 key: node.key, 53 label: node.label, 54 kind: node.kind, 55 ...pickLoc(node), 56 status, 57 children: node.children.map(mark), 58 }; 59 }; 60} 61 62function diffChildren(before: CallNode[], after: CallNode[]): DiffNode[] { 63 const n = before.length; 64 const m = after.length; 65 const dp: number[][] = Array.from({ length: n + 1 }, () => 66 Array.from({ length: m + 1 }, () => 0), 67 ); 68 69 for (let i = n - 1; i >= 0; i--) { 70 for (let j = m - 1; j >= 0; j--) { 71 if (before[i]!.key === after[j]!.key) { 72 dp[i]![j] = dp[i + 1]![j + 1]! + 1; 73 } else { 74 dp[i]![j] = Math.max(dp[i + 1]![j]!, dp[i]![j + 1]!); 75 } 76 } 77 } 78 79 const result: DiffNode[] = []; 80 let i = 0; 81 let j = 0; 82 while (i < n && j < m) { 83 if (before[i]!.key === after[j]!.key) { 84 result.push(diffNode(before[i]!, after[j]!)); 85 i += 1; 86 j += 1; 87 } else if (dp[i + 1]![j]! >= dp[i]![j + 1]!) { 88 result.push(diffNode(before[i]!, null)); 89 i += 1; 90 } else { 91 result.push(diffNode(null, after[j]!)); 92 j += 1; 93 } 94 } 95 while (i < n) { 96 result.push(diffNode(before[i]!, null)); 97 i += 1; 98 } 99 while (j < m) { 100 result.push(diffNode(null, after[j]!)); 101 j += 1; 102 } 103 return result; 104} 105 106export function treeHasChanges(node: DiffNode): boolean { 107 if (node.status !== "same") return true; 108 return node.children.some(treeHasChanges); 109}
测试直接覆盖输出形状
▸helper refactor 样例
test/callstack-diff.test.ts:3 的测试非常贴近产品定位:原来三个调用被重构到 PiService.getServices,期望输出同时显示删除的旧子调用和新增 helper 的嵌套结构。它验证了 src/diff.ts:49 的 markTree 与 src/render.ts:121 的 renderDiff 能正确配合。
3test("refactors calls into a helper, preserves if/else branch labels", ({ 4 expectCallstack, 5}) => { 6 expectCallstack( 7 ` 8 export class PiService { 9 static createAgentSession(options: { sessionId?: string }) { 10 - AuthStorage.create(); 11 - new ModelRegistry(); 12 - createCodingTools(); 13 + const services = PiService.getServices(); 14 + services.boot(); 15 if (!options.sessionId) { 16 SessionManager.create(); 17 } else { 18 SessionManager.open(options.sessionId); 19 } 20 } 21 + 22 + static getServices() { 23 + SettingsManager.create(); 24 + AuthStorage.create(); 25 + new ModelRegistry(); 26 + createCodingTools(); 27 + return { boot() {} }; 28 + } 29 } 30 31 class AuthStorage { 32 static create() {} 33 } 34 35 class ModelRegistry { 36 constructor() {} 37 } 38 39 class SessionManager { 40 static create() {} 41 static open(_id: string) {} 42 } 43 + 44 + class SettingsManager { 45 + static create() {} 46 + } 47 48 function createCodingTools() {} 49 `, 50 "PiService.createAgentSession", 51 ).toEqual(` 52 PiService.createAgentSession(options) 53 - ├─ AuthStorage.create() 54 - ├─ new ModelRegistry() 55 - ├─ createCodingTools() 56 + ├─ PiService.getServices() 57 + │ ├─ SettingsManager.create() 58 + │ ├─ AuthStorage.create() 59 + │ ├─ new ModelRegistry() 60 + │ └─ createCodingTools() 61 + ├─ services.boot() 62 ├─ if (!options.sessionId) 63 └─ SessionManager.create() 64 └─ else 65 └─ SessionManager.open(_id) 66 `); 67});
图模型边界
▸key 冲突和动态派发
src/extract.ts:92 的 buildIndex 以 key 去重,src/calltree.ts:125 在多个后缀匹配时排序取一个。这个策略简单稳定,但跨文件同名函数、interface dispatch 或动态对象方法仍可能被简化。
另一个取舍在 entry 推断:src/infer.ts:41 从所有 key 比较函数 shape,src/infer.ts:49 用队列向上游扩散,src/infer.ts:96 再过滤掉 new constructor,src/infer.ts:102 最终只保留确实有树变化的 entry。它偏向 review 摘要,而不是完整 call graph dump。
92export function buildIndex(functions: FunctionInfo[]): FunctionIndex { 93 const index: FunctionIndex = new Map(); 94 for (const fn of functions) { 95 if (!index.has(fn.key)) { 96 index.set(fn.key, fn); 97 } 98 if (fn.key.endsWith(".constructor")) { 99 const className = fn.key.slice(0, -".constructor".length); 100 const newKey = `new ${className}`; 101 if (!index.has(newKey)) { 102 index.set(newKey, { ...fn, key: newKey, label: `new ${className}()` }); 103 } 104 } 105 } 106 return index; 107}