npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@handbooks/analyzer

v1.2.0

Published

Multi-language static call-graph extraction via tree-sitter (WASM) — no LLM

Readme

@handbooks/analyzer

English · 中文

指向一个目录,拿回一张带类型的调用图。不用 LLM,不联网,不需要本地编译——解析器是 WebAssembly。

npm no LLM languages


这是什么

@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 的树 证明不了任何关于语法的事。


Handbook 的一部分 · 架构 · 产物格式 · MIT