calldiff
§ 00 · Source-level Observatory

calldiff 给 Agentic Review 看调用流变化

calldiff 把 git diff 的文本变化提升成 who-calls-whom 的调用栈差异,适合审查 AI agent 改动后的控制流重排。它不是语言服务器,而是用 Tree-sitter 做跨语言 AST 提取,再把函数体中的调用、分支和 JSX 组件组织成可 diff 的树。

★ 302
Stars
85
Files
22
Languages
2026-08-10
Last commit
git snapshot → list source files → Tree-sitter extractor → FunctionIndex → call tree / reach path → ASCII + JSON output
§ 01 · ARCHITECTURE

从 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 层。

▸系统拓扑

CLI args ├─ diff → resolve git snapshots → load before/after indexes → infer entries → diff trees ├─ tree → resolve one snapshot → load index → expand entry tree └─ reach → resolve one snapshot → load index → collect paths to target

▸模块清单

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。

CLI
incur 命令、格式选择、CTA 与 token flags。
Git snapshot
工作区、commit、路径过滤和 blob 批量读取。
AST extractors
每种语言输出统一 FunctionInfo / CallStep。

▸类型契约

src/types.ts:12 把展开后的节点固定为 CallNode,src/types.ts:29 则把函数体中的原始动作表达成 CallStep。这个二段模型很重要:extractor 只负责“函数内发生了哪些调用或分支”,calltree 层再决定如何沿 FunctionIndex 继续展开。

src/types.ts · L1-79TypeScript
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}
深度阅读 · AST 提取与语言 registry
§ 02 · CORE MODULES

核心模块的职责边界

▸run.ts 是编排器,不直接理解语法

src/run.ts:64 的 loadIndex 只拿文件列表和 source,调用 src/extract.ts:53 的缓存提取,然后通过 src/extract.ts:92 生成 FunctionIndex。它不关心 TypeScript、Python 或 Rust 的 AST 细节,因此新增语言不会污染 diff/tree/reach 主流程。

src/run.ts · L64-103TypeScript
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 的展开能力。

src/calltree.ts · L46-113TypeScript
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 时顺着新控制流阅读。

src/diff.ts · L62-109TypeScript
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
§ 03 · KEY FLOW

三种命令共享同一套索引能力

▸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。

src/run.ts · L143-225TypeScript
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 路径。这个命令对“某次改动是否仍能触达副作用函数”很有用。

深度阅读 · Git 快照与命令运行器
§ 04 · TESTS

测试覆盖围绕调用语义而不是 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 的边界。

test/callstack-diff.test.ts · L3-67TypeScript
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 下的所有可达路径。

test/reach.test.ts · L51-90TypeScript
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  });
§ 05 · RISKS

技术亮点之外,仍要看清边界

▸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 视角下的源文件,不会自动理解构建产物或框架生成的隐式调用。

Generated · 2026-08-11Source · tanishqkancharla/calldiff