calldiff
§ · Nested detail page

AST 提取与语言 registry 深度解析

这一页拆解 calldiff 如何从文件扩展名进入 Tree-sitter grammar,再把不同语言统一成 FunctionInfo 和 CallStep。

§ 01 · REGISTRY

语言注册表先解决路由

▸扩展名到 extractor

src/languages/registry.ts:28 把 TypeScript、Python、Go、Rust 等 extractor 组成数组,src/languages/registry.ts:54 再构造 extension map,src/languages/registry.ts:69 的 detectLanguage 因此只需要按 extname 查表。这个设计让 src/extract.ts:34 不必包含任何语言分支。

src/languages/registry.ts · L28-78TypeScript
28const extractors: LanguageExtractor[] = [
29  typescriptExtractor,
30  typescriptreactExtractor,
31  javascriptExtractor,
32  javascriptreactExtractor,
33  pythonExtractor,
34  goExtractor,
35  rustExtractor,
36  javaExtractor,
37  rubyExtractor,
38  cExtractor,
39  cppExtractor,
40  csharpExtractor,
41  phpExtractor,
42  kotlinExtractor,
43  swiftExtractor,
44  scalaExtractor,
45  luaExtractor,
46  elixirExtractor,
47  bashExtractor,
48  haskellExtractor,
49  zigExtractor,
50  solidityExtractor,
51  ocamlExtractor,
52];
53
54const byExtension = new Map<string, LanguageExtractor>();
55for (const extractor of extractors) {
56  for (const ext of extractor.extensions) {
57    byExtension.set(ext.toLowerCase(), extractor);
58  }
59}
60
61export function listSupportedExtensions(): string[] {
62  return [...byExtension.keys()].sort();
63}
64
65export function listSupportedLanguages(): string[] {
66  return extractors.map((e) => e.id);
67}
68
69export function detectLanguage(file: string): LanguageExtractor | null {
70  const ext = extname(file).toLowerCase();
71  return byExtension.get(ext) ?? null;
72}
73
74export function getExtractor(id: string): LanguageExtractor | null {
75  return extractors.find((e) => e.id === id) ?? null;
76}
77
78export { extractors };

▸grammar cache 处理 native 包差异

src/languages/grammars.ts:104 先尝试本地依赖,失败后落到 src/languages/grammars.ts:128 的 cache 目录,并在 src/languages/grammars.ts:132 用 npm install 拉 grammar。src/languages/grammars.ts:92 的 INSTALL_SPEC 则说明某些 grammar 需要 pin 版本才能稳定 require。

src/languages/grammars.ts · L92-154TypeScript
92/** Packages that need a pinned install spec (latest breaks createRequire). */
93const INSTALL_SPEC: Record<string, string> = {
94  "tree-sitter-c-sharp": "tree-sitter-c-sharp@0.23.1",
95  // 0.4+ is ESM-with-TLA; 0.2.0 is CJS and loads via createRequire.
96  "@tree-sitter-grammars/tree-sitter-lua":
97    "@tree-sitter-grammars/tree-sitter-lua@0.2.0",
98};
99
100/**
101 * Install an npm grammar package into the shared cache if missing, then require it.
102 * Reuses the cache across CLI invocations.
103 */
104export function loadGrammarPackage(npmPackage: string): GrammarModule {
105  // Prefer the app's own dependency when present (e.g. tree-sitter-typescript).
106  try {
107    const localRequire = createRequire(import.meta.url);
108    try {
109      return localRequire(npmPackage) as GrammarModule;
110    } catch (err) {
111      const code = (err as NodeJS.ErrnoException)?.code;
112      const msg = err instanceof Error ? err.message : String(err);
113      if (
114        code === "ERR_REQUIRE_ASYNC_MODULE" ||
115        msg.includes("top-level await")
116      ) {
117        const entry = localRequire.resolve(npmPackage);
118        const packageRoot = join(entry, "..", "..");
119        const binding = loadNativeBinding(packageRoot);
120        if (binding) return binding;
121      }
122      throw err;
123    }
124  } catch {
125    // fall through to cache
126  }
127
128  const cacheDir = grammarCacheDir();
129  if (!packageInstalled(cacheDir, npmPackage)) {
130    ensureCachePackageJson(cacheDir);
131    const installSpec = INSTALL_SPEC[npmPackage] ?? npmPackage;
132    execFileSync(
133      "npm",
134      [
135        "install",
136        "--prefix",
137        cacheDir,
138        "--no-save",
139        "--no-fund",
140        "--no-audit",
141        "--legacy-peer-deps",
142        installSpec,
143      ],
144      {
145        stdio: ["ignore", "pipe", "pipe"],
146        env: process.env,
147      },
148    );
149  }
150
151  const require = createRequire(join(cacheDir, "package.json"));
152  const packageRoot = join(cacheDir, "node_modules", npmPackage);
153  return requireGrammar(require, npmPackage, packageRoot);
154}
§ 02 · TYPESCRIPT

TypeScript extractor 的语义取舍

▸calleeKey 决定调用身份

src/languages/typescript.ts:76 的 calleeKey 是调用图质量的核心:identifier 直接返回函数名,this.method 会被归到当前 class,object.property 会生成 object.property。src/languages/typescript.ts:84 明确跳过 subscript expression,这避免把动态 key 误判成稳定调用。

src/languages/typescript.ts · L76-107TypeScript
76function calleeKey(node: SyntaxNode, className: string | null): string | null {
77  if (node.type === "identifier") return node.text;
78  if (node.type === "this") return className;
79
80  if (
81    node.type === "member_expression" ||
82    node.type === "subscript_expression"
83  ) {
84    if (node.type === "subscript_expression") return null;
85
86    const object = node.namedChild(0);
87    const property =
88      namedChildren(node).find(
89        (c) =>
90          c.type === "property_identifier" ||
91          c.type === "private_property_identifier",
92      ) ?? null;
93    if (!object || !property) return null;
94
95    const propName = property.text;
96    if (object.type === "this" && className) {
97      return `${className}.${propName}`;
98    }
99    if (object.type === "identifier") {
100      return `${object.text}.${propName}`;
101    }
102    if (className) return `${className}.${propName}`;
103    return propName;
104  }
105
106  return null;
107}

▸分支变成 CallStep,不被扁平化

src/languages/typescript.ts:160 识别 if_statement,src/languages/typescript.ts:174 写入 branch step,并在 src/languages/typescript.ts:184 到 src/languages/typescript.ts:220 保留 else-if / else 链。这样 render 时才知道这些是互斥路径,而不是连续调用。

src/languages/typescript.ts · L160-222TypeScript
160    if (type === "if_statement") {
161      const test =
162        childByType(node, "parenthesized_expression") ??
163        namedChildren(node).find((c) => c.type !== "else_clause") ??
164        null;
165      const kids = namedChildren(node);
166      const consequent =
167        kids.find(
168          (c) =>
169            c.type !== "parenthesized_expression" && c.type !== "else_clause",
170        ) ?? null;
171      const elseClause = childByType(node, "else_clause");
172      const cond = test ? condText(test) : "";
173
174      steps.push({
175        type: "branch",
176        key: branchKey("if", cond),
177        label: test ? `if (${condText(test)})` : "if",
178        ...locFromNode(file, test ?? node),
179        children: consequent
180          ? collectStatements(file, statementsOf(consequent), className)
181          : [],
182      });
183
184      let current = elseClause;
185      while (current) {
186        const inner = current.namedChild(0);
187        if (!inner) break;
188
189        if (inner.type === "if_statement") {
190          const elseTest =
191            childByType(inner, "parenthesized_expression") ?? null;
192          const elseKids = namedChildren(inner);
193          const elseConsequent =
194            elseKids.find(
195              (c) =>
196                c.type !== "parenthesized_expression" &&
197                c.type !== "else_clause",
198            ) ?? null;
199          const elseCond = elseTest ? condText(elseTest) : "";
200          steps.push({
201            type: "branch",
202            key: branchKey("else-if", elseCond),
203            label: elseTest ? `else if (${condText(elseTest)})` : "else if",
204            ...locFromNode(file, elseTest ?? current),
205            children: elseConsequent
206              ? collectStatements(file, statementsOf(elseConsequent), className)
207              : [],
208          });
209          current = childByType(inner, "else_clause");
210          continue;
211        }
212
213        steps.push({
214          type: "branch",
215          key: branchKey("else", ""),
216          label: "else",
217          ...locFromNode(file, current),
218          children: collectStatements(file, statementsOf(inner), className),
219        });
220        break;
221      }
222      return;

▸JSX 组件也作为调用处理

src/languages/typescript.ts:109 把 PascalCase JSX 和 member expression 当成组件调用,src/languages/typescript.ts:239 到 src/languages/typescript.ts:276 还会把 attribute 和 children 里的调用挂到组件节点下面。这让 React/TSX 的 UI flow 可以进入同一个 call tree。

src/languages/typescript.ts · L239-310TypeScript
239    } else if (type === "jsx_element") {
240      const opening = childByType(node, "jsx_opening_element");
241      const childNodes = namedChildren(node).filter(
242        (c) =>
243          c.type !== "jsx_opening_element" && c.type !== "jsx_closing_element",
244      );
245      const fromAttrs: CallStep[] = [];
246      if (opening) {
247        for (const attr of namedChildren(opening)) {
248          if (
249            attr.type === "jsx_attribute" ||
250            attr.type === "jsx_expression"
251          ) {
252            fromAttrs.push(...collectStatements(file, [attr], className));
253          }
254        }
255      }
256      const nested = [
257        ...fromAttrs,
258        ...collectStatements(file, childNodes, className),
259      ];
260      if (opening) {
261        const key = jsxCalleeKey(opening);
262        if (key) {
263          if (nested.length > 0) {
264            steps.push({
265              type: "call",
266              key,
267              ...locFromNode(file, opening),
268              children: nested,
269            });
270          } else {
271            addCall(key, opening);
272          }
273          return;
274        }
275      }
276      for (const step of nested) steps.push(step);
277      return;
278    } else if (type === "jsx_self_closing_element") {
279      const attrNodes = namedChildren(node).filter(
280        (c) => c.type === "jsx_attribute" || c.type === "jsx_expression",
281      );
282      const nested = collectStatements(file, attrNodes, className);
283      const key = jsxCalleeKey(node);
284      if (key) {
285        if (nested.length > 0) {
286          steps.push({
287            type: "call",
288            key,
289            ...locFromNode(file, node),
290            children: nested,
291          });
292        } else {
293          addCall(key, node);
294        }
295      } else {
296        for (const step of nested) steps.push(step);
297      }
298      return;
299    }
300
301    for (const child of namedChildren(node)) {
302      walkExpr(child);
303    }
304  };
305
306  for (const stmt of statements) {
307    walkExpr(stmt);
308  }
309
310  return steps;
§ 03 · CONTRACT

统一输出契约

file extension → extractor → grammar → FunctionInfo[] → FunctionIndex → CallNode tree

src/languages/typescript.ts:325 的 functionFromParts 统一填充 key、label、steps 和 exported,src/languages/typescript.ts:388 的 handleClass 则把 method、constructor、public field arrow function 映射到同一套 FunctionInfo。这个收敛点保证 src/calltree.ts:107 可以不区分函数来源。

src/languages/typescript.ts · L325-454TypeScript
325function functionFromParts(
326  file: string,
327  key: string,
328  label: string,
329  params: SyntaxNode | null,
330  body: SyntaxNode | null,
331  exported: boolean,
332  start: number,
333  end: number,
334  className: string | null,
335): FunctionInfo {
336  const paramsLabel = getParamsLabel(params);
337  return {
338    key,
339    label: `${label}${paramsLabel}`,
340    file,
341    steps: collectStepsFromBody(file, body, className),
342    exported,
343    start,
344    end,
345  };
346}
347
348function handleFunctionNode(
349  file: string,
350  node: SyntaxNode,
351  name: string | null,
352  exported: boolean,
353  className: string | null,
354  functions: FunctionInfo[],
355) {
356  if (!name) return;
357  const key = className ? `${className}.${name}` : name;
358  const params = childByType(node, "formal_parameters");
359  const body =
360    childByType(node, "statement_block") ??
361    namedChildren(node).find(
362      (c) =>
363        c.type !== "formal_parameters" &&
364        c.type !== "type_parameters" &&
365        c.type !== "type_annotation" &&
366        c.type !== "identifier" &&
367        c.type !== "accessibility_modifier" &&
368        c.type !== "async" &&
369        c.type !== "readonly",
370    ) ??
371    null;
372
373  functions.push(
374    functionFromParts(
375      file,
376      key,
377      key,
378      params,
379      body,
380      exported,
381      node.startIndex,
382      node.endIndex,
383      className,
384    ),
385  );
386}
387
388function handleClass(
389  file: string,
390  node: SyntaxNode,
391  exported: boolean,
392  functions: FunctionInfo[],
393) {
394  const nameNode =
395    childByType(node, "type_identifier") ?? childByType(node, "identifier");
396  const className = nameNode?.text ?? null;
397  if (!className) return;
398
399  const body = childByType(node, "class_body");
400  if (!body) return;
401
402  for (const element of namedChildren(body)) {
403    if (element.type === "method_definition") {
404      const keyNode =
405        childByType(element, "property_identifier") ??
406        childByType(element, "private_property_identifier") ??
407        childByType(element, "computed_property_name");
408      const methodName = keyNode?.text ?? null;
409      const isConstructor = methodName === "constructor";
410      if (!methodName) continue;
411
412      const accessibility = childByType(element, "accessibility_modifier");
413      const methodExported = exported || accessibility?.text === "public";
414
415      const params = childByType(element, "formal_parameters");
416      const fnBody = childByType(element, "statement_block");
417      const key = isConstructor
418        ? `${className}.constructor`
419        : `${className}.${methodName}`;
420      const label = isConstructor ? `new ${className}()` : key;
421
422      functions.push(
423        functionFromParts(
424          file,
425          key,
426          label,
427          params,
428          fnBody,
429          methodExported,
430          element.startIndex,
431          element.endIndex,
432          className,
433        ),
434      );
435    }
436
437    if (element.type === "public_field_definition") {
438      const keyNode = childByType(element, "property_identifier");
439      const value =
440        childByType(element, "arrow_function") ??
441        childByType(element, "function_expression");
442      if (keyNode && value) {
443        handleFunctionNode(
444          file,
445          value,
446          keyNode.text,
447          exported,
448          className,
449          functions,
450        );
451      }
452    }
453  }
454}
§ 04 · TESTS

测试如何压住 extractor 边界

▸private 与 class field

test/callstack-diff.test.ts:188 证明 private method 会进入调用树,test/callstack-diff.test.ts:212 证明 class field arrow function 也能被跟踪。它们分别对应 src/languages/typescript.ts:404 的 private_property_identifier 和 src/languages/typescript.ts:437 的 public_field_definition 分支。

test/callstack-diff.test.ts · L188-234TypeScript
188test("indexes and expands #private methods", ({ expectCallstack }) => {
189  expectCallstack(
190    `
191      export class Vault {
192        open() {
193          this.#unlock();
194        }
195        #unlock() {
196          prep();
197    +     audit();
198        }
199      }
200      function prep() {}
201    + function audit() {}
202    `,
203    "Vault.open",
204  ).toEqual(`
205      Vault.open()
206      └─ Vault.#unlock()
207         ├─ prep()
208    +    └─ audit()
209  `);
210});
211
212test("follows class field arrow functions", ({ expectCallstack }) => {
213  expectCallstack(
214    `
215      export class Runner {
216        start() {
217          this.helper();
218        }
219        helper = () => {
220          work();
221    +     extra();
222        };
223      }
224      function work() {}
225    + function extra() {}
226    `,
227    "Runner.start",
228  ).toEqual(`
229      Runner.start()
230      └─ Runner.helper()
231         ├─ work()
232    +    └─ extra()
233  `);
234});
§ 05 · RISKS

Extractor 的边界

▸动态调用仍是盲区

src/languages/typescript.ts:84 跳过 subscript expression 是正确的保守选择,但也意味着 registry[ name ]()、依赖注入容器和框架装饰器不会被完整展开。这个工具适合 review call flow,不适合替代类型系统。

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