回调合成 · 补上静态解析看不见的边
tree-sitter 只能看到"这里有一次调用",却无法跨文件回答"它到底调的是谁",更看不懂观察者/事件发射器这类动态分发。CodeGraph 的 callback-synthesizer.ts 用一个整图 pass 把分散在三处(注册器、注册点、分发器)的线索关联起来,合成出运行时真实存在、但静态不可见的 dispatcher → callback 边。本页基于设计文档 docs/design/callback-edge-synthesis.md(状态:SHIPPED)拆解。
那个洞:分发器调的是注册进来的回调
考虑 excalidraw 里 Scene 的观察者模式——注册器把回调塞进一个共享集合,分发器遍历这个集合逐个调用:
class Scene {
private callbacks = new Set<Callback>();
onUpdate(cb: Callback) { this.callbacks.add(cb); } // 注册器 REGISTRAR
triggerUpdate() { for (const cb of this.callbacks) cb(); } // 分发器 DISPATCHER
}
this.scene.onUpdate(this.triggerRender); // 注册点 REGISTRATION
运行时的边 triggerUpdate → triggerRender 静态根本不存在:triggerUpdate 里唯一的字面调用是匿名的 cb()。文档记录的实测——triggerUpdate 唯一的被调者是 randomInteger,trace(triggerUpdate, triggerRender) 返回无路径。这就是"一次更新如何到达屏幕"这种流在图里断掉的地方。
为什么是整图 pass,而不是 FrameworkResolver.resolve()
resolve(ref) 回答的是"这个命名引用指向谁",一次一个 ref。但回调边没有 ref 可解(cb() 是匿名的),而且需要跨文件、多点关联(注册器、注册点、分发器三者)。所以它是 base resolution 之后跑的整图 pass,语言级(任何 OO 观察者通用),住在 src/resolution/callback-synthesizer.ts,而不在 frameworks/ 下。
self._iterable_class(...))——走的是另一条路:resolution/types.ts 的 claimsReference 钩子 + index.ts 预过滤 + 一个 FrameworkResolver.resolve()(frameworks/python.ts 里的 django ORM 解析器)。因为那里的 ref 是命名的,恰好能塞进 resolve()。两类是同一覆盖工作的两半。Phase 1 · 字段观察者通道(fieldChannelEdges)
第一阶段处理"回调存进 this.<字段> 再被遍历调用"这一类,分五步:
- ① 按名取候选:注册器名匹配
^(on[A-Z]\w*|subscribe|addListener|addEventListener|register|watch|listen|addCallback)$;分发器名包含(emit|trigger|notify|dispatch|fire|publish|flush)。 - ② 按体确认(
ctx.readFile+ 切节点行):注册器体里有this.<F>.add|push|set(;分发器体里有for (… of [Array.from(]this.<F>)后接调用,或this.<F>.forEach(。 - ③ 配对(与设计的偏离):原设计按类配对;实际实现按 同文件 + 同字段 F 配对(用文件当类的代理——可靠拿到包含类太难)。对常见的"一文件一类"成立,多类文件待重访。
- ④ 找注册点:
queries.getIncomingEdges(registrar.id, ['calls']),对每条入边读 caller 在该行的源码,用正则回收实参<registrarName>\s*\(\s*(?:this\.)?(\w+)。偏离:设计想用 tree-sitter 重解析,实现用正则——只认命名引用(箭头/内联实参会漏)。 - ⑤ 合成
dispatcher → fn(getNodesByName(arg)取 method|function)。每通道封顶MAX_CALLBACKS_PER_CHANNEL = 40。
Phase 2 · EventEmitter 通道(eventEmitterEdges)
第二阶段处理 .on('mount', …) / .emit('mount') 这种按事件名字面量关联的发射器模式,是面向文件的扫描(ctx.getAllFiles() + readFile,先用 .emit( / .on( 子串预过滤):
// 监听端:捕获事件名 + handler 名(具名函数或 this.method)
ON_RE = /\.(?:on|once|addListener)\(\s*['"]([^'"]+)['"]\s*,\s*(?:function\s+(\w+)|(?:this\.)?(\w+))/
// 发射端:捕获事件名
EMIT_RE = /\.(?:emit|fire|dispatchEvent)\(\s*['"]([^'"]+)['"]/
分发器取 emit('e') 所在的包围函数(enclosingFn 找到包含该行的最紧 function/method/component 节点),handler 取 getNodesByName 的 on-handler 名,按事件名字面量关联后合成 dispatcher → handler。
精度(与设计的偏离):原设计想用接收者类型匹配;实现改用事件扇出上限 EVENT_FANOUT_CAP = 6——超过 6 个 handler 或 dispatcher 的事件直接跳过,因为 error / change 这种通用名在没有类型信息时会过度连边。
Phase 3 · 让内联命名 handler 变成可链接节点
前两个阶段都要 getNodesByName(handlerName) 才能连边——但如果 handler 是写在函数体里的具名嵌套函数,原本根本不会成为图里的节点。于是 src/extraction/tree-sitter.ts 的 visitFunctionBody 被改为抽取命名的嵌套函数,让这些内联具名 handler 也变成 linkable 的节点。合成是在 resolution/index.ts 的 resolveAndPersistBatched() 末尾、base 边落库之后调用 synthesizeCallbackEdges() 完成的。
provenance:合成边如何标记,精度如何保证
Edge.provenance 是固定枚举 'tree-sitter' | 'scip' | 'heuristic',所以所有合成边统一用 provenance: 'heuristic',再在 metadata.synthesizedBy 里记是 'callback' 还是 'event-emitter',以及把它接起来的那个站点。这意味着合成边可被 SQL 一句筛出,也能在任何穿过它的流里被标注出来。
select s.name||' → '||t.name||' '||coalesce(e.metadata,'')
from edges e
join nodes s on e.source=s.id
join nodes t on e.target=t.id
where e.provenance='heuristic';
精度是这套方法的命门。文档记录:在 excalidraw 上,整图只合成出 1 条边(就是正确的那条),27k 节点里没有误连;Phase 3 之后节点数仅 +3,没有爆炸。MAX_CALLBACKS_PER_CHANNEL=40 和 EVENT_FANOUT_CAP=6 这两个上限,正是用来在"补洞"和"过度连边"之间卡住平衡点的。
这些合成边由统一的查询工具 codegraph_explore 浮现——它的 "Flow" 区(buildFlowFromNamedSymbols)和 codegraph_node 的 trail 会把它们显示出来。(注:codegraph_trace / codegraph_context 两个工具已于 2026-06-01 移除,codegraph_explore 成为唯一的浮现入口。)
另一半:命名引用与框架路由
回调合成补的是匿名动态分发;对命名引用,resolver 走常规路径:import → 源文件(含 tsconfig path 别名、cargo workspace 成员)、call → 定义(靠 import 解析 + 名称匹配)、继承 extends/implements。在此之上,框架感知会识别 web 框架的路由文件,发出 route 节点并用 references 边连到处理函数/控制器——于是查一个 view 的 callers 就能浮现绑定它的 URL 模式。
src/resolution/frameworks/ 下有 21 个框架解析器。文档化的路由形状覆盖:
| 框架 | 识别的形状 |
|---|---|
| Django | path()/re_path()/url()/include()(CBV .as_view()、点路径) |
| Flask / FastAPI | @app.route(methods=[…]) / @app.get、@router.post |
| Express / NestJS | app.get+中间件链 / @Controller+@Get、GraphQL/WS 订阅 |
| Laravel / Rails | Route::resource()、Controller@action / get '/x', to: |
| Spring / ASP.NET | @GetMapping 等 / [HttpGet("/x")] 特性 |
| Gin·chi·gorilla / Axum·actix·Rocket | r.GET / HandleFunc / .route("/x", get(handler)) |
| Vapor / Drupal | app.get("x", use:) / *.routing.yml + hook_* |
| React Router / SvelteKit | 路由 component 节点 |
解析器目录里还有 cargo-workspace、expo-modules、react-native、swift-objc(跨语言桥,对应 __tests__/swift-objc-bridge-resolver.test.ts)、fabric、play、astro/vue/svelte 等——跨语言桥接边同样落 provenance:'heuristic' 并在 metadata.synthesizedBy 打标(如 swift-objc-bridge、rn-event-channel)。
意义:tree-sitter 搞不定的,才是 resolver 的价值所在
抽取层(tree-sitter)解决的是"这里有什么",真正难的是"它连到谁"——尤其当连接关系藏在运行时的动态分发里。CodeGraph 的解法不是更聪明的 AST,而是对每一类动态分发各写一个有界、可标记、可复现的合成器:观察者/EventEmitter(匿名,整图 pass)、React re-render(setState→render)、JSX child(render→子组件)、Django ORM 描述符(命名,走 resolve)。每条合成边都带 heuristic provenance 和接线站点,既补上了 agent 追"一次更新如何到屏幕"所需的那几跳,又用上限把误连压到"27k 里 1 条"的精度。这正是 codegraph "确定性、可进 git" 承诺能延伸到动态语义的关键一层。