calldiff 给 Agentic Review 看调用流变化
calldiff 把 git diff 的文本变化提升成 who-calls-whom 的调用栈差异,适合审查 AI agent 改动后的控制流重排。它不是语言服务器,而是用 Tree-sitter 做跨语言 AST 提取,再把函数体中的调用、分支和 JSX 组件组织成可 diff 的树。
从 CLI 到调用树的六段管线
入口层在 src/cli.ts:119 定义 incur CLI,并把 diff/tree/reach 三个命令都收束到 src/run.ts:143、src/run.ts:228 和 src/run.ts:271。这个分层让命令参数、输出格式、CTA 和 token slicing 保持在 CLI 层,而真正的源码读取、索引构建和树渲染留给 run 层。
▸系统拓扑
▸模块清单
src/git.ts:181 和 src/git.ts:199 负责枚举工作区与 commit 文件;src/extract.ts:30 用语言 registry 加载 extractor;src/calltree.ts:107 展开递归调用树;src/diff.ts:62 用 LCS 对齐子节点;src/render.ts:47 把树变成 CLI 可读的 ASCII。
▸类型契约
src/types.ts:12 把展开后的节点固定为 CallNode,src/types.ts:29 则把函数体中的原始动作表达成 CallStep。这个二段模型很重要:extractor 只负责“函数内发生了哪些调用或分支”,calltree 层再决定如何沿 FunctionIndex 继续展开。
1export type CallNodeKind = "call" | "branch"; 2 3/** Source location as shown in editors: `file:line` or `file:line-line`. */ 4export interface SourceLoc { 5 file: string; 6 /** 1-based start line */ 7 line: number; 8 /** 1-based end line when the span covers multiple lines */ 9 endLine?: number; 10} 11 12export interface CallNode { 13 /** Stable identity used for matching across versions, e.g. "PiService.createAgentSession" */ 14 key: string; 15 /** Display label, e.g. "PiService.createAgentSession" or "if (!options.sessionId)" */ 16 label: string; 17 /** Branches omit the continuing │ rail so arms read as alternate paths */ 18 kind?: CallNodeKind; 19 /** 20 * Root: definition location. Children: call-site (or branch keyword) in the parent. 21 * Matching/diff keys ignore these fields. 22 */ 23 file?: string; 24 line?: number; 25 endLine?: number; 26 children: CallNode[]; 27} 28 29/** One step in a function body: a call, or a conditional branch with nested steps. */ 30export type CallStep = 31 | { 32 type: "call"; 33 key: string; 34 /** Call-expression span in the caller file. */ 35 file?: string; 36 line?: number; 37 endLine?: number; 38 /** Inline children (e.g. JSX component children at the call site). */ 39 children?: CallStep[]; 40 } 41 | { 42 type: "branch"; 43 key: string; 44 label: string; 45 /** Branch keyword / condition span. */ 46 file?: string; 47 line?: number; 48 endLine?: number; 49 children: CallStep[]; 50 }; 51 52export type DiffStatus = "same" | "added" | "removed"; 53 54export interface DiffNode { 55 key: string; 56 label: string; 57 status: DiffStatus; 58 kind?: CallNodeKind; 59 file?: string; 60 line?: number; 61 endLine?: number; 62 children: DiffNode[]; 63} 64 65export interface FunctionInfo { 66 /** Stable key: "foo" or "ClassName.method" or "ClassName.constructor" */ 67 key: string; 68 label: string; 69 file: string; 70 /** Ordered body steps (calls + if/else branches) */ 71 steps: CallStep[]; 72 exported: boolean; 73 /** Source span for change detection */ 74 start: number; 75 end: number; 76 /** 1-based definition line (derived from start/end + source) */ 77 line?: number; 78 endLine?: number; 79}
核心模块的职责边界
▸run.ts 是编排器,不直接理解语法
src/run.ts:64 的 loadIndex 只拿文件列表和 source,调用 src/extract.ts:53 的缓存提取,然后通过 src/extract.ts:92 生成 FunctionIndex。它不关心 TypeScript、Python 或 Rust 的 AST 细节,因此新增语言不会污染 diff/tree/reach 主流程。
64function loadIndex( 65 cwd: string, 66 snapshot: Snapshot, 67 pathFilters: string[], 68 cache: ExtractionCache = new Map(), 69): FunctionIndex { 70 const files = listSnapshotFiles(cwd, snapshot, pathFilters); 71 const extracted = new Map<string, FunctionInfo[]>(); 72 const extract = (file: SnapshotFile, source: string): void => { 73 try { 74 extracted.set(file.path, extractCached(file.path, source, cache)); 75 } catch (error) { 76 const message = error instanceof Error ? error.message : String(error); 77 console.error( 78 `warn: failed to parse ${file.path} @ ${snapshot.ref}: ${message}`, 79 ); 80 } 81 }; 82 83 if (snapshot.kind === "worktree") { 84 visitWorktreeFiles(cwd, files, extract); 85 } else { 86 const filesByOid = new Map<string, SnapshotFile[]>(); 87 for (const file of files) { 88 if (!file.oid) continue; 89 const matches = filesByOid.get(file.oid) ?? []; 90 matches.push(file); 91 filesByOid.set(file.oid, matches); 92 } 93 visitCommitBlobs(cwd, files, (oid, source) => { 94 for (const file of filesByOid.get(oid) ?? []) extract(file, source); 95 }); 96 } 97 98 const functions: FunctionInfo[] = []; 99 for (const file of files) { 100 functions.push(...(extracted.get(file.path) ?? [])); 101 } 102 return buildIndex(functions); 103}
▸calltree.ts 处理递归、循环和 call-site 位置
src/calltree.ts:46 的 expandCall 负责从 key 找到 FunctionInfo,根节点使用定义位置,子节点使用调用点位置;src/calltree.ts:72 用 visiting 集合阻止递归无限展开,并且保留 JSX inline children 的展开能力。
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());
▸diff.ts 用 LCS 保持 after 树顺序
src/diff.ts:62 对 before/after children 做最长公共子序列对齐,而不是简单按 key map 合并;这让新增、删除和移动后的输出尽量贴近“after”调用顺序,适合代码 review 时顺着新控制流阅读。
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}
三种命令共享同一套索引能力
▸diff:两棵索引树比较
src/run.ts:143 的 runDiff 先解析 from/to 快照,再分别通过 src/run.ts:166 和 src/run.ts:167 加载 before/after 索引。之后 src/infer.ts:80 会从受影响函数反推 exported entrypoints,最后 src/infer.ts:110 对每个 entry 调用 diffEntry。
143export function runDiff(options: DiffRunOptions = {}): DiffResult { 144 const cwd = options.cwd ?? process.cwd(); 145 const maxDepth = options.maxDepth ?? 12; 146 const entriesOpt = options.entries ?? []; 147 const color = options.color !== false; 148 const locs = options.locs === true; 149 150 assertGitRepo(cwd); 151 152 const { 153 from, 154 to, 155 paths: resolvedPaths, 156 } = resolveDiffSnapshotsAndPaths( 157 cwd, 158 options.from, 159 options.to, 160 options.paths ?? [], 161 ); 162 if (from.kind === "commit") verifyCommit(cwd, from.ref); 163 if (to.kind === "commit") verifyCommit(cwd, to.ref); 164 165 const extractionCache: ExtractionCache = new Map(); 166 const before = loadIndex(cwd, from, resolvedPaths, extractionCache); 167 const after = loadIndex(cwd, to, resolvedPaths, extractionCache); 168 extractionCache.clear(); 169 170 const entries = inferEntries(before, after, entriesOpt, maxDepth); 171 172 const fromLabel = describeSnapshot(from); 173 const toLabel = describeSnapshot(to); 174 175 if (entries.length === 0) { 176 const message = `No callstack changes between ${fromLabel} and ${toLabel}.`; 177 return { 178 mode: "diff", 179 from: fromLabel, 180 to: toLabel, 181 message, 182 trees: [], 183 ascii: message, 184 }; 185 } 186 187 const trees: DiffResult["trees"] = []; 188 const asciiParts: string[] = [ 189 `calldiff diff ${fromLabel} → ${toLabel}`, 190 "", 191 ]; 192 193 for (const entry of entries) { 194 const diff = diffEntry(entry, before, after, maxDepth); 195 if (!diff) continue; 196 const ascii = renderDiff(diff, { color, locs }); 197 trees.push({ 198 entry, 199 ascii: renderDiff(diff, { color: false, locs }), 200 tree: serializeDiffNode(diff), 201 }); 202 if (asciiParts.length > 2) asciiParts.push(""); 203 asciiParts.push(ascii); 204 } 205 206 if (trees.length === 0) { 207 const message = "No callstack changes for inferred entrypoints."; 208 return { 209 mode: "diff", 210 from: fromLabel, 211 to: toLabel, 212 message, 213 trees: [], 214 ascii: `${asciiParts[0]}\n\n${message}`, 215 }; 216 } 217 218 return { 219 mode: "diff", 220 from: fromLabel, 221 to: toLabel, 222 trees, 223 ascii: asciiParts.join("\n"), 224 }; 225}
▸tree:单快照展开
src/run.ts:228 的 runTree 更直,只有一个 snapshot:src/run.ts:236 解析 ref/path,src/run.ts:243 加载索引,src/run.ts:251 对 entry 展开调用树。它是用户定位某个入口全量调用关系的基础命令。
▸reach:从入口到目标的路径查询
src/run.ts:271 的 runReach 复用单快照索引,但多一步 src/run.ts:288 解析 target,再由 src/reach.ts:66 展开 entry tree 并收集命中的 root-to-target 路径。这个命令对“某次改动是否仍能触达副作用函数”很有用。
测试覆盖围绕调用语义而不是 UI
▸分支、private method、class field 都有断言
测试里最有价值的是把期望输出写成 ASCII 树。test/callstack-diff.test.ts:3 覆盖 helper refactor 和 if/else 标签,test/callstack-diff.test.ts:188 覆盖 private method,test/callstack-diff.test.ts:212 覆盖 class field arrow function,这些都直接对应 extractor 的边界。
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});
▸reach 单独验证多路径
test/reach.test.ts:52 断言同一个 target 可以从 if 分支和主路径各出现一次;这说明 reach 不是只返回第一个命中,而是要保留 alternate branch 下的所有可达路径。
51describe("reach paths", () => { 52 test("finds every path from entry to target across branches", () => { 53 const index = indexOf(source); 54 const paths = findReachPaths("runCheckout", "sendEmail", index, 12); 55 const ascii = paths.map((p) => 56 renderTree(p, { color: false, locs: false }), 57 ); 58 59 expect(ascii).toEqual([ 60 [ 61 "runCheckout()", 62 "└─ if (!reserved)", 63 " └─ notifyCustomer()", 64 " └─ sendEmail()", 65 ].join("\n"), 66 [ 67 "runCheckout()", 68 "└─ notifyCustomer()", 69 " └─ sendEmail()", 70 ].join("\n"), 71 ]); 72 }); 73 74 test("finds a single deep path to a leaf helper", () => { 75 const index = indexOf(source); 76 const paths = findReachPaths("runCheckout", "capture", index, 12); 77 expect(paths).toHaveLength(1); 78 expect(renderTree(paths[0]!, { color: false, locs: false })).toBe( 79 [ 80 "runCheckout()", 81 "└─ PaymentGateway.charge()", 82 " └─ capture()", 83 ].join("\n"), 84 ); 85 }); 86 87 test("returns empty when target is unreachable", () => { 88 const index = indexOf(source); 89 expect(findReachPaths("runCheckout", "missing", index, 12)).toEqual([]); 90 });
技术亮点之外,仍要看清边界
▸AST 不是类型系统
src/languages/typescript.ts:76 的 calleeKey 主要基于 identifier/member expression 推断调用 key;这能覆盖大量静态调用,但动态属性、依赖注入和运行时多态不会像 LSP 或类型检查器那样精确。README 也明确这是 syntactic AST-based 工具。
具体到 TypeScript,src/languages/typescript.ts:84 放弃 subscript expression,src/languages/typescript.ts:96 才把 this.method 归到 class,src/languages/typescript.ts:99 处理 object.property,src/languages/typescript.ts:102 又在 className 存在时做保守回退。这个策略稳,但不能恢复真正的运行时接收者。
▸语言数量带来维护压力
src/languages/registry.ts:28 一次注册 22 种 extractor,优势是覆盖广;代价是每种语言的语法边界都要靠测试守住。src/languages/grammars.ts:92 还需要为个别 grammar pin 安装规格,说明 native grammar 生态本身也会带来兼容成本。
运行时依赖同样不轻:src/languages/grammars.ts:104 会加载 grammar 包,src/languages/grammars.ts:128 决定 cache 目录,src/languages/grammars.ts:132 可能触发 npm install,src/languages/grammars.ts:151 再从 cache package.json 创建 require。离线环境要提前预热这些 grammar。
▸适合 review 辅助,不适合当唯一真相
calldiff 的强项是让 agentic review 快速看到调用流变化。最终判断仍要结合真实类型信息、测试和运行行为;尤其是 src/git.ts:156 的 isSourceFile 只按扩展名纳入文件,生成代码、配置驱动和框架魔法不会完整进入调用图。
快照层也有边界:src/git.ts:181 读取工作区 tracked 和 untracked 文件,src/git.ts:199 读取 commit 树,src/git.ts:230 做路径过滤,src/git.ts:242 返回 SnapshotFile 列表。它看的是 Git 视角下的源文件,不会自动理解构建产物或框架生成的隐式调用。