codegraph 回调边合成:用启发式合成边补齐观察者 / EventEmitter 动态分发的图断裂
Codegraph 是一个本地预索引代码知识图谱,为 Claude Code、Codex、Cursor 等 Agent 提供比直接读文件更省 token 的代码检索。但静态提取(tree-sitter 解析)天然看不见"分发器调用别处注册的回调"这类动态分发——于是像"一次状态更新如何到达屏幕渲染"这样的调用流在图中直接断链。本文基于仓库内的设计文档 docs/design/callback-edge-synthesis.md 与源码 src/resolution/callback-synthesizer.ts,完整讲解 codegraph 的回调/观察者边合成机制:它如何识别 registrar(注册器)/ dispatcher(分发器)/ registration site(注册点)三者并跨文件关联,如何合成 dispatcher → callback 边、如何控制精度、以及在真实仓库上的验证结果。
问题:静态解析留下的"动态分发洞"
先看文档给出的典型观察者模式样例(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 SITE(注册点)
运行时存在一条真实边 triggerUpdate → triggerRender,但静态分析看不到它:triggerUpdate 函数体内唯一的字面调用是匿名的 cb()。文档记录了实测结论——triggerUpdate 唯一的被调对象是 randomInteger,trace(triggerUpdate, triggerRender) 返回"无路径"。
要补齐这个洞,需要把三个分散在不同位置的角色关联起来:
- Registar:把回调存入共享存储的方法(
onUpdate); - Dispatcher:遍历该存储并逐个调用的方法(
triggerUpdate); - Registration site:实际把某个具名函数注册进去的调用行(
this.scene.onUpdate(this.triggerRender))。
为什么是"全图 pass"而不是 FrameworkResolver.resolve()
设计文档明确论证了架构选型:resolve(ref) 回答的是"这个具名引用指向谁",一次处理一个 ref。而回调边没有可解析的 ref(cb() 是匿名的),且需要跨文件、多点关联(registrar、registration、dispatcher 三处),所以它不适合放进按 ref 逐条解析的 resolver 框架,而是作为一个全图(whole-graph)pass,跑在基础引用解析完成、基础 calls 边全部落库之后。
这一点在编排层 src/resolution/index.ts 中可以验证:resolveAndPersistBatched() 在所有基础边持久化、边索引重建完成后调用 synthesizeCallbackEdges(),注释写明"Dynamic-edge synthesis: now that all base calls edges are persisted",并且该步骤是 best-effort——合成失败不会让整个索引失败。合成 pass 因此属于语言级机制(任意 OO 观察者都适用),放在 src/resolution/ 下,而不是 frameworks/ 目录。
同一覆盖工程的另一类机制:针对具名属性/描述符分发(如 django 的
self._iterable_class(...)),走的是claimsReference钩子(src/resolution/types.ts + src/resolution/index.ts 的预过滤)加FrameworkResolver.resolve()(django ORM resolver 在 src/resolution/frameworks/python.ts)。它能塞进resolve()是因为 ref 有名字。两条路线互补,同属动态分发覆盖工作。
Phase 1:字段观察者通道(fieldChannelEdges)
Phase 1 处理上面 Scene 那种"共享集合字段"的观察者。源码实现见 src/resolution/callback-synthesizer.ts 的 fieldChannelEdges(),五个阶段如下:
1. 按方法/函数名筛候选。 两条正则即文档中给出的模式:
// 源码中的实际常量(callback-synthesizer.ts L33-L39)
const REGISTRAR_NAME = /^(on[A-Z]\w*|subscribe|addListener|addEventListener|register|watch|listen|addCallback)$/;
const DISPATCHER_NAME = /(emit|trigger|notify|dispatch|fire|publish|flush)/i;
只有名字命中才会读文件做正文确认,避免全量切片的开销。
2. 用函数体确认角色。 通过 ctx.readFile 读文件并切片到节点的起止行:registrar 体内须含 this.<F>.add|push|set((见 registrarField());dispatcher 体内须含 for (… of [Array.from(]this.<F>) 加调用,或 this.<F>.forEach((见 dispatcherField())。这一步提取出共享字段名 F,是后续配对的钥匙。
3. 配对——对原始设计的一处偏离(DIVERGENCE)。 原设计按"同一个类"配对 registrar 和 dispatcher;实际实现改为同文件 + 同字段 F 配对(用文件作类的代理,因为可靠地拿到包含类更困难)。对"一个文件一个类"的常见形态成立,多类文件是已知的待改进点。源码中这一约束体现为 d.node.filePath === reg.node.filePath && d.field === reg.field。
4. 收集注册点。 对每个 registrar,取其入边 calls(queries.getIncomingEdges(registrar.id, ['calls'])),逐条到调用方的源文件、按边上的行号读出那一行,用正则 <registrarName>\s*\(\s*(?:this\.)?(\w+) 恢复实参名。这里是第二处偏离:原设计倾向用 tree-sitter 重解析,实际构建用正则(只支持具名引用——箭头函数/内联实参在此被漏掉,列入后续工作)。
5. 合成边。 对恢复出的实参名 getNodesByName(arg) 找到 method/function 节点,合成 dispatcher → fn 边,每个通道上限 MAX_CALLBACKS_PER_CHANNEL = 40。边带上完整的溯源元数据,源码里 registeredAt 注释说明了它的价值——Agent 解释调用流时第一件要做的事就是找到"回调在哪里被接上的",把它直接放进 metadata,node/trace 无需再多一轮 callers() + 读文件往返:
edges.push({
source: disp.node.id, target: fn.id, kind: 'calls', line: disp.node.startLine,
provenance: 'heuristic',
metadata: {
synthesizedBy: 'callback', via: reg.node.name, field: reg.field,
registeredAt: `${caller.filePath}:${e.line}`, // scene.onUpdate(this.triggerRender) 所在行
},
});
Phase 2:EventEmitter 通道(eventEmitterEdges)
Phase 2 处理字符串键的事件总线:bus.on('mount', handler) ↔ bus.emit('mount')。实现见 src/resolution/callback-synthesizer.ts:
- 文件导向扫描:遍历
ctx.getAllFiles(),先做子串预过滤(文件里没有.emit(/.on(/.once(/.addListener(等就直接跳过),再对全文跑两条正则:
// 源码中的实际正则(callback-synthesizer.ts L38-L39)
const ON_RE = /\.(?:on|once|addListener)\(\s*'"['"]\s*,\s*(?:function\s+(\w+)|(?:this\.)?(\w+))/g;
const EMIT_RE = /\.(?:emit|fire|dispatchEvent)\(\s*'"['"]/g;
- Dispatcher 定位:
emit('e')的包围函数——enclosingFn()在文件内所有 method/function/component 节点中找行号范围包含该匹配行的、起始行最靠后的(最紧的)一个。 - Handler 定位:
ON_RE捕获的 handler 名经getNodesByName解析成函数/方法节点。 - 按事件名字面量关联:
emitsByEvent与handlersByEvent两张按事件名分桶的表,同一事件名下的 dispatcher × handler 两两配对,合成dispatcher → handler边,metadata 记录event与registeredAt(注册点 file:line)。
精度护栏——第三处偏离: 原设计提议用接收者类型匹配;实际构建用事件扇出上限 EVENT_FANOUT_CAP = 6:某事件名超过 6 个 handler 或超过 6 个 dispatcher 就整体跳过。没有类型信息时,error/change 这类泛化事件名会制造大量错误连边,"宁可不连,不可错连"(skip rather than over-link)。
溯源标注:provenance 的设计取舍
Edge.provenance 是固定枚举('tree-sitter' | 'scip' | 'heuristic'),因此合成边统一标 provenance: 'heuristic' + metadata: { synthesizedBy: 'callback' | 'event-emitter' | …, via / event / field, registeredAt }。原设计中的独立 provenance 值 'callback-synthesis' 与 high/medium/low 置信度分级并未实现——替代品是三条硬性的精度护栏:
- 事件扇出上限(
EVENT_FANOUT_CAP = 6); - registrar 命名模式的唯一性要求(名字必须命中严格的
REGISTRAR_NAME正则); - 只链接具名 handler(
ON_RE只捕获function xxx/ 具名标识符,不捕获匿名箭头)。
这带来一个对使用者很重要的性质:合成边是**纯增量(additive)**的,从不替换静态边,工具侧可以随时按 provenance='heuristic' + metadata.synthesizedBy 过滤掉全部合成结果。
Phase 3:内联回调的提取改造(tree-sitter.ts)
Phase 2 在真实仓库上跑起来时发现真正的拦路虎不是关联算法,而是内联 handler 根本不是图节点:
bus.on('mount', function onmount() { /* … */ });
onmount 是嵌套在另一个函数体内的具名函数表达式。原实现里 visitFunctionBody 会直接"穿过"嵌套函数而不提取它们,于是 getNodesByName('onmount') 查无此节点,Phase 2 无目标可连。
修复位于共享遍历器 src/extraction/tree-sitter.ts:在函数体遍历中,当一个 body 子节点的类型属于该语言的 functionTypes、且 extractName 能提取出真实名字时,直接调用 extractFunction(node) 把它提取为独立节点(extractFunction 会顺带遍历它自己的函数体),然后 return。仅具名是关键限定——匿名箭头函数落回原有的默认递归,它们内部的调用仍归属外层函数。这个限定把节点膨胀控制住了:excalidraw 上只新增 3 个节点,无回归。
这一改造的意义超出了 EventEmitter 本身:任何内联的具名嵌套函数(回调 handler、局部辅助函数)从此都可被图中的边直接指向。
端到端验证结果
设计文档记录了在真实仓库上的实测(这也是判断"合成是否爆炸"的验收标准):
| 仓库 / 用例 | 结果 |
|---|---|
| excalidraw | 27,214 个节点中合成出 1 条边 triggerUpdate → triggerRender(正确的那条);trace(mutateElement, triggerRender) 从"无路径"变为 3 跳;节点数 9,286 → 9,289(Phase 3 仅 +3,无爆炸) |
| express | Phase 3 之后合成出 use → onmount,metadata 为 {event-emitter, event:"mount"}(onmount 现在被提取,位于 application.js:109) |
/tmp/cb-fixture/bus.js |
tick → handleRefresh、persist → handleSave(具名方法的 EventEmitter handler) |
| excalidraw / express | Phase 1 无回归;节点数稳定 |
复现步骤(文档原样保留,corpus 路径按本机情况替换):
npm run build
rm -rf /tmp/codegraph-corpus/excalidraw/.codegraph
( cd /tmp/codegraph-corpus/excalidraw && codegraph init -i )
# 查看合成边(provenance='heuristic',metadata.synthesizedBy 为 callback / event-emitter)
sqlite3 /tmp/codegraph-corpus/excalidraw/.codegraph/codegraph.db \
"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';"
# 端到端验证:合成边出现在 explore 的 Flow 段 + node trail
node scripts/agent-eval/probe-explore.mjs /tmp/codegraph-corpus/excalidraw "triggerUpdate triggerRender"
开发期探针脚本在 scripts/agent-eval/ 目录:probe-explore.mjs(相关源码 + 具名符号间的调用流)。需要注意文档 2026-06-01 的更新说明:codegraph_trace 和 codegraph_context 两个 MCP 工具后来被移除,codegraph_explore 成为唯一的呈现工具——它的 "Flow" 段(buildFlowFromNamedSymbols)和 codegraph_node 的 trail 会展示这些合成边;文档中出现的 trace(a, b) 记法现在表示"a→b 的流",用 codegraph_explore / probe-explore.mjs 验证。
实现现状:从两阶段到 ~30 个合成 pass 的注册表
值得指出的现状:设计文档描述的 Phase 1+2 只是 src/resolution/callback-synthesizer.ts 这个文件的起点。从源码结构看,该文件如今是一个动态分发合成的"聚合引擎",导出了一张 SYNTH_PASSES 注册表(见 callback-synthesizer.ts),除 fieldEdges、closureCollEdges、emitterEdges 三个与本文主题直接对应的 pass 外,还按语言门控挂载了大量同族机制,例如:
renderEdges(Reactthis.setState()→render)、flutterEdges(FluttersetState→build)、arkuiStateEdges/arkuiEmitter/arkuiRoutes(HarmonyOS ArkUI 状态属性、@ohos.events.emitter总线、router.pushUrl页面跳转);cppEdges(C++ 虚函数 override 桥接)、ifaceEdges(Java/Kotlin 等接口/抽象方法 → 实现类同名方法)、kotlinExpectActual(KMPexpect/actual链接)、goGrpcEdges、mybatisEdges(Java ↔ XML);- 框架级 dispatch synthesizer:
celeryEdges(Python)、springEdges(Java)、mediatrEdges(C#)、sidekiqEdges(Ruby)、laravelEdges(PHP)、thunkEdges(redux-thunk)、rtkEdges/piniaEdges/vuexEdges(Vue 生态)等。
编排逻辑(synthesizeCallbackEdges)上有几个工程细节值得注意:
- 语言门控:先用一次 files 表的
DISTINCT查询拿到项目实际出现的语言集合,某 pass 若依赖不存在的语言(如纯 C 内核上没有 Kotlin pass)直接跳过——注释提到 Kotlin pass 曾是在纯 C Linux 内核上 OOM 的元凶(issue #1212); - 顺序保障:Go 跨文件 method→type
contains边与 Go 隐式implements边必须先合成并落库,因为后续 Go 接口分发桥接会从 DB 读implements边; - 并行 fan-out:大仓库(≥150k ref)上复用 resolver worker 池把独立 pass 并行到只读 worker 上,worker 失败回退主线程重试;超过 150 万节点时 worker OOM 则跳过该 pass 以保索引存活;
- 协作式让出:每个 pass 循环中以固定间隔
await onYield(),让索引主线程的心跳得以发出,避免 liveness watchdog 在长 pass 尾部误杀进程(issue #1091/#850); - 进度上报:合成在引用解析进度条到 100% 之后执行,若没有独立进度上报,UI 会看似冻结在 "Resolving refs 100%",故每个完成的 pass 都会驱动自己的进度阶段。
剩余工作与已知边界
设计文档按优先级列出的后续方向(截至文档撰写时):
- 匿名箭头 handler:
on('e', () => foo())仍不产生边(Phase 3 有意不提取匿名箭头)。修复方向是"合成器穿透函数体"——解析箭头函数体,把dispatcher → (箭头体内的调用)连上。这是最大的剩余召回收益,覆盖最常见的现代回调形态。 - 接入
resolveAndPersist(增量同步):合成目前只在resolveAndPersistBatched(全量索引)中运行,增量重建不会刷新合成边。 - 接收者类型匹配:用
type_of边让x.emit('change')只在x、y同类型时才链到y.on('change', fn),从而放宽扇出上限。 - tree-sitter 实参恢复:替换字段通道 Stage 4 的正则,稳健支持箭头、多实参、换行调用。
- 单回调字段:
this.onChange = cb; … this.onChange()这种标量存储变体尚未实现。 - 全面精度/召回审计:跨整个语料库统计每仓库的合成边数,抽查,确认 EventEmitter 密集型仓库不爆炸。
- 测试与 CHANGELOG:
/tmp/cb-fixture/bus.js这个 fixture 是现成的 vitest 用例(临时文件,需移入__tests__/),另需为 Phase 3 的提取器与 django 侧 resolver 补测试。
边界与模型取舍(文档原意):
- 跨实例的过近似被接受——目标是可达性(reachability)而非实例级精度;
unregister/off被忽略(不撤销已合成的边); - 合成边纯增量——从不替换静态边;工具可按
provenance='heuristic'+metadata.synthesizedBy过滤。
小结:这套机制对 Agent 检索的价值
回调边合成解决的是"图能不能回答流程问题"的覆盖层问题,而非提示词或工具设计问题——文档在"Related work"一节引用了调研结论:让 Agent 用 codegraph 替代直接 Read 文件的杠杆是覆盖率(coverage),不是 prompting、hooks 或新工具。具体到本文:在 excalidraw 上,triggerUpdate → triggerRender 这一条合成边(2.7 万节点中仅此 1 条,精度极高)就使 trace(mutateElement, triggerRender) 从"无路径"变为 3 跳——Agent 通过 codegraph_explore 的 Flow 段和 codegraph_node 的 trail 即可拿到"状态更新 → 触发渲染"的完整链路,而不必逐个文件 Read 猜测。实现上,整个机制以高精度/低召回为设计原则(只链具名回调、按文件+字段配对、扇出上限熔断),合成边用 provenance: 'heuristic' 与 metadata.synthesizedBy 明确标出来源,保证静态事实与启发式事实在使用侧可区分、可过滤。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0622
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00