@handbooks/analyzer
v1.2.0
Published
Multi-language static call-graph extraction via tree-sitter (WASM) — no LLM
Readme
@handbooks/analyzer
English · 中文
指向一个目录,拿回一张带类型的调用图。不用 LLM,不联网,不需要本地编译——解析器是 WebAssembly。
这是什么
@handbooks/analyzer 是 Handbook 工具链的静态分析引擎——
而且它单独拿出来用也很有价值。给它一个源码根目录,不管代码是什么语言写的,
它都返回同一套与语言无关的 IR:
- 每个函数和方法,带文件、行范围、签名、装饰器、参数类型, 以及它读写的实例属性;
- 每个命名类型 —— class、interface、struct、record、enum、trait、别名 —— 连同它声明本身的行号范围,覆盖范围见下文 类型抽取;
- 每条调用边,通过
self/this、属性类型、参数类型标注、import 和继承解析出来; - 每个边界调用 —— 你的代码离开自己、进入第三方库的地方;
- 每个未解析的调用,被分类并隔离到单独的产物里,而不是猜一个。
因为它是确定性的,同样的输入永远产出同样的图。你可以 diff 两张图、把一张提交进仓库, 或者在测试里对它断言。
安装
pnpm add @handbooks/analyzer没有安装后编译步骤。语法以 .wasm 文件形式随包发布。
快速上手
import {
registerBuiltinAdapters,
discoverAll,
getAdapter,
buildGraph,
writeGraphArtifacts,
} from '@handbooks/analyzer';
registerBuiltinAdapters();
const root = '/path/to/repo';
const byLanguage = discoverAll(root); // { typescript: [...], python: [...] }
const analyses = [];
for (const [lang, files] of Object.entries(byLanguage)) {
analyses.push(await getAdapter(lang).analyze(files, root));
}
const result = buildGraph(
{ functions: analyses.flatMap((a) => a.functions), edges: analyses.flatMap((a) => a.edges) },
{ sourceRoot: root, scannedFiles: Object.values(byLanguage).flat(), language: 'multi', defaultExt: '' },
);
console.log(result.stats); // { functions, edgesKept, edgesDropped }
writeGraphArtifacts(result, './out');或者,用命令行——同一件事,一行:
handbook analyze --source /path/to/repo --work work/myrepo会落到磁盘上的东西
| 文件 | 内容 |
| -------------------- | -------------------------------------------------------------------------- |
| graph.json | 图本体:元数据、带出入度的节点、边、逐类的 self 属性索引、解析出的类型声明 |
| functions.csv | 全部函数,平铺 —— 给 grep、给表格、或者快速看一眼是否合理 |
| graph.dot | Graphviz。dot -Tsvg graph.dot -o graph.svg |
| dropped-calls.json | 按类别归档的未解析调用,带原始调用文本和行号 |
| scan-coverage.json | 读不了、解析不了、或解析出语法错误的文件 |
支持的语言
完整层 —— 手写适配器。类型驱动的调用解析、继承成员、逐属性状态追踪、语句跨度:
| 语言 | 扩展名 |
| ----------------------------- | -------------------------------------------------------- |
| Python | .py |
| TypeScript*(含 JavaScript)* | .ts .tsx .js .jsx .mjs .cjs |
| Go | .go |
| Rust | .rs |
| Java | .java |
| C# | .cs |
| C/C++ | .c .h .cpp .cc .cxx .c++ .hpp .hh .hxx |
| Ruby | .rb .rake .gemspec |
| PHP | .php .phtml |
| Swift | .swift |
| Dart | .dart |
| Solidity | .sol |
| Shell | .sh .bash |
通用层 —— 一个配置驱动的引擎,每种语言一份声明式规格。文件与函数清单精确, 调用关系尽力而为:
Kotlin(.kt .kts)· Scala(.scala .sc)· Zig(.zig)· Objective-C(.m)·
OCaml(.ml)
保真度是声明出来的,而且会传到下游
每个适配器都必须公布自己实际能交付什么:
readonly capabilities: AdapterCapabilities = {
tier: 'full',
callTypes: ['self_method', 'self_attr_method', 'param_method', 'internal_func', /* … */],
selfAttrs: true,
statementSpans: true,
typeKinds: ['class', 'enum', 'interface'], // 空数组 = 明确声明"不抽取类型"
};阶段 1 把它逐语言记进图的元数据,渲染器再把它写进手册总览。 两层产出的 IR 看起来一模一样,所以没有这个声明,读者就会把通用层的调用边 当成 Python 级别的事实。把话说出来,就是全部的意义。
register.test.ts 会把每个已注册的适配器跑在一份 fixture 仓库上,
再双向比对声明与实际产出:少声明和多声明都会让构建失败。
类型抽取是逐语言声明的
类型抽取是局部的,而且这个边界是声明出来的。typeKinds 列出适配器真正解析的种类;
空数组是一句肯定的声明——"它不解析任何类型"。
| 语言 | 是否抽取 | 映射 |
| ------------------------------------------------ | -------- | ------------------------------------------------------------------------------------------------------- |
| TypeScript | 是 | class(含 abstract)· interface · enum(含 const enum)· alias(type X = …) |
| Python | 是 | class |
| Go | 是 | struct · interface · alias(type A = B)· other(定义类型,如 type Celsius float64) |
| Rust | 是 | struct · enum · trait · alias(type)· other(union) |
| Java | 是 | class · interface · enum · record · other(@interface) |
| C# | 是 | class · interface · struct · record(含 record struct)· enum · other(delegate) |
| C/C++ | 是 | class · struct · enum(含 enum class)· alias(using X = …、typedef)· other(union) |
| Ruby | 是 | class · other(module——一个关键字两种用途,见下) |
| PHP | 是 | class · interface · trait · enum |
| Swift | 是 | class(含 actor)· struct · enum · interface(protocol)· alias(typealias) |
| Dart | 是 | class · enum · trait(mixin)· alias(typedef)· other(extension type) |
| Solidity | 是 | class(contract)· interface · struct · enum · other(library、type X is Y) |
| Shell | 否 | 该语言没有命名类型 |
| Kotlin、Scala、Zig、Objective-C、OCaml(通用层) | 否 | 靠模式匹配而非精确解析,见下 |
通用层故意不抽取类型。两层产出的 IR 长得一样,所以一行模式匹配来的类型
与精确解析的类型在下游无从分辨——而这正是保真度声明要防的事。
那五种语言继续用标注为推导所得的 class-derived 兜底。
已覆盖语言内部仍有的缺口,写在这里,好让"查不到"不被当成"不存在":
- TypeScript:
namespace内部的类型不会产出(扫描是对顶层声明的平铺遍历)。 - Python:
class Color(Enum)记为class。判定enum的唯一依据是一个基类名, 而任何模块都能定义它、任何 import 都能改名,所以不做这个推断。 - Ruby:
module归入other,既不是trait也不是interface。一个关键字干两件 不相干的事——命名空间(包住一个文件里所有类的module Demo)和带实现的 mixin (include Comparable)——而声明本身并不说明是哪一件。写在describe/do块里的class/module完全不产出:扫描不进 DSL 块,和它不记录define_method是同一个理由。 - Swift、Dart:
extension不声明类型,因此不产出行。它的name是别处(通常是另一个 文件)声明的类型,产出一行就会把读者指到 extension 而不是声明处。extension 的成员 仍然记在被扩展的类型上。 - C/C++:前向声明(
class Fwd;)不产出行——它不声明任何成员,而在常见的头文件/源文件 拆分里它甚至不是那个文件。函数体内的using别名不进索引(遍历不进函数体)。 - Swift、C++、Ruby:函数体内声明的类型不产出,同样的理由。
- Solidity:
event和自定义error不产出行。两者都有名字,但都不是类型: 既不能标注变量,也不能被继承。 - 所有带注解的语言(C#、Java、Swift、Dart、Python):跨度从前置的注解/特性开始,
因为语法的声明节点就是从那里开始的。于是一段很长的注解可能把类型自己的名字挤出被截断的
signature——在flutter/packages上实测 58/7212 行,在 Newtonsoft.Json 上 4/1756 行。 - 所有语言:常量、变量和宏完全不进索引。同名的重载集合或同名不同泛型元数的家族共用
一个 id,因此只有第一行留存(
Func<T>/Func<T1,T2>→ 一个Func), 和函数用的 id 模型一致。
词表是封闭的(class interface struct record enum trait alias other),
和 FILE_ROLES 一样。前七个都装不下的构造归入 other,而不是塞进"最像"的那个桶——
并且 TypeNode.signature 保留原样书写的声明,所以 other 从不丢掉语言本身的关键字。
对于不抽取类型的适配器,手册的 agent 产物会退化为一行 class-derived:
跨度取该类方法的 min..max,并明确标注为推导所得;
agent/index.md 会点名哪些语言进了索引、哪些没有。
两个如实说明的注意点
- Swift:随包的语法在 V8 ≥ 13 上会让进程 abort(Node 24 上实测 5/5 必挂,
Node 21 正常,而且十九种语法里只有它这样)。所以适配器在这种运行时上会
在发现阶段直接拒绝,并给出解决办法
node --liftoff-only, 而不是把你整次运行一起带走。 - Shell:含
case语句的脚本会被跳过,因为那个语法会抛异常——它的外部扫描器 import 了env.isalpha,而当前锁定的web-tree-sitter动态链接器没有提供它。case极其常见,所以实际上大多数非平凡脚本都会被跳过:在nvm上实测, 6 个文件、122 个函数全部落空。适配器是完整层的,但 Shell 的覆盖不是, 除非上游修好那个语法。扫描日志会点明原因,而不是让你自己去猜。
两者都会通过 logger 在扫描时报告。任何东西都不会被悄悄丢掉。
API
适配器与注册表
registerBuiltinAdapters(): void // 幂等;启动时调一次
registerAdapter(name, factory): void // 注册你自己的
getAdapter(name): LanguageAdapter // 抛错时会列出全部已注册语言
availableLanguages(): string[]
adapterForFile(relPath): LanguageAdapter | undefined // 最长扩展名优先
discoverAll(root, logger?): Record<string, string[]> // 先认领的适配器留住这个文件
discoverByExtension(root, exts, extraSkipDirs?, filter?): string[]COMMON_SKIP_DIRS 是所有适配器共同遵守的跳过列表:.git、node_modules、vendor、
target、build、dist、out、__pycache__、.venv、.idea、.vscode、
.handbook-patches 等等。
适配器契约
interface LanguageAdapter {
readonly name: string;
readonly extensions: readonly string[];
readonly capabilities: AdapterCapabilities; // 必填,含 `typeKinds` —— 见上
discover(sourceRoot: string): string[];
analyze(files, sourceRoot, options?): Promise<ModuleAnalysis>;
statementSpans?(filePath, qualname): Promise<Array<[number, number]> | undefined>;
}整个接口就这么多。 实现它,registerAdapter 一下,下游每个阶段原封不动就能工作。
构图
buildGraph(analysis, options): BuildGraphResult
// 划分保留/丢弃的边、标注出入度、
// 为「被引用但没有显式定义」的构造函数合成节点
writeGraphArtifacts(result, outDir): void
functionsCsv(graph): string
graphDot(graph): string
categorizeDropped(calleeId): string
dedupeFunctionsById(functions): FunctionNode[] // 后定义者胜导航包(NavPack)
buildNavPack(graph, options?): NavPack
renderOrientation(nav, options?): string
allFileDescriptors(graph, nav): NavFileDescriptor[]一张图的紧凑、适合喂给 LLM 的摘要——入口点、目录汇总、枢纽函数—— pipeline 用它来合成骨架,从而不必把整张图塞进提示词。
加一门语言
通用层(通常够用):在 src/generic.ts 的 GENERIC_LANGUAGES 里加一条
GenericLanguageSpec——语法名、扩展名、表示「函数」「类」「调用」的节点类型,
以及限定名怎么拼。不需要新依赖:上面列出的语言的语法已经随 tree-sitter-wasms 一起发布。
完整层:在 src/adapters/ 下实现 LanguageAdapter,声明诚实的 capabilities,
然后在 src/register.ts 里注册。
无论哪种,都要把显示名加进文档漂移测试——已注册的语言如果没出现在 README 里,构建就会失败。 之前那份列表正是这么落后了六种语言的。
设计说明
- 类型是调用图的兄弟,不是图里的第三种节点。
graph.nodes是调用图的顶点集 —— 里面每一个都可能是一条边的端点 —— 所以类型放在graph.types。 现有十三处代码都在遍历graph.nodes并追问"这是函数吗"; 多一种节点只会让这十三处"记得问才对",而忘记问的代价是把一个类型渲染成可调用的东西。 - 类型的跨度要么是解析出来的,要么就不存在。
TypeNode.lineStart在 schema 层就是正数。 适配器如果只能读出类型名、读不出位置,那就什么都不产出: 过期的路径打不开、过期的名字 grep 不到,而编造的行号范围会悄无声息地指向错误的代码。 - 两遍分析。 第一遍收集定义并建立类型索引;第二遍带着这些索引走调用点。
这正是
self.attr.method()和param.method()能被解析出来的原因。 - 「未解析」是一个类别,不是一次猜测。 定位不到的调用带着原始文本和行号进
dropped-calls.json。猜一个,就会给所有下游消费者塞进一批 看起来和真边一样可信的假边。 - 读不了的文件要被记录下来,而不是被抹掉。 同一条规矩往上抬一层:读不了的文件、
语法解析器拒绝的文件、以及解析出语法错误的文件,都会落进
scan-coverage.json。 前两类还会被排除在graph.metadata.scannedFiles之外,这样下游就不可能把一个 解析器压根没读过的文件,说成是「一个零函数的文件」。 - 一个坏掉的适配器不能搞垮发现流程。
discoverAll会捕获单个适配器的失败、 记日志,然后继续跑其余的。 web-tree-sitter锁死在~0.25.10。 0.26 改了 WASM ABI,加载不了随包的语法。 这个锁是刻意的,不要放宽。
测试
pnpm --filter @handbooks/analyzer test每个测试都解析真实的源码 fixture——没有 mock 出来的语法树,因为 mock 的树 证明不了任何关于语法的事。
