CodeGraph 的 explore 输出管线修复实录:让 Agent 指名的大文件尾部符号必定渲染(CG-38)
【免费下载链接】codegraphPre-indexed code knowledge graph, auto syncs on code changes, for Claude Code, Codex, Gemini, Cursor, OpenCode, AntiGravity, Kiro, CoPilot, and Hermes Agent — fewer tokens, fewer tool calls, 100% local项目地址: https://gitcode.com/GitHub_Trending/co0degr/codegraph
这篇技术实录围绕 CodeGraph 的codegraph_explore工具中一次真实的缺陷修复展开:当 Agent 按名字查询一个 1,400 多行文件尾部(L1087/L1102)的两个函数时,explore 返回的却是文件头部 L70 处一个同名词根的 interface。文档完整记录了两个相互独立的根因——named-symbol 身份信息被叙事一并丢弃、以及按源码顺序裁剪的 ceiling trim 总是先砍掉大文件末尾——及其修复方案、一个"测量后决定不发布"的记账改进、以及对 ranker 惩罚机制的排查结论。读完本文,你可以掌握一条完整的"保证型修复"路径:如何定位 explore 渲染管线的两级丢弃点、如何用identityOnly()与focusLines两个机制兑现"指名符号必须渲染"的承诺,以及如何用逐符号二值探针和几何镜像 fixture 把保证固化成常设回归门。
问题报告:文件拿了 rank #1,指名符号却一行没返回
缺陷场景是一个 1,414 行的 Svelte store。Agent 对codegraph_explore发出查询,指名queueMessage(L1087)和flushQueuedMessages(L1102)——无论是裸符号串(symbol bag)还是散文式提问——结果这两个函数从未出现在返回中。更反直觉的是:这两个函数所在的文件以 score 127 拿下 rank #1,占用了整个响应包络(envelope)的 67.3%。真正被渲染回来的,是 L70 处一个同名词根的QueuedMessageinterface。Agent 不得不退回到直接 Read 文件,才能找到它按名字问过的两个函数——而这恰恰是 explore 这个工具存在的意义所要防止的唯一结果。
CLAUDE.md 中"guarantee named symbols render(保证指名符号被渲染)"的承诺没有兑现,其底层机制是 importance-9 的 named-def 注入:当 Agent 指名一个符号时,其定义应被注入所属文件的 cluster 范围并排名 importance 9,从而在渲染竞争中优先存活。
排查结论(状态:已修复)是两个长期独立存在的缺陷,并且通过受控二分确认不是CG-24 系列的回归——固定索引、在每一次 epic 合并点上变动引擎版本的受控 bisect 显示,该症状在包括 epic 之前的每一个构建上都存在。
根因一:named-symbol 的身份(identity)随叙事一起被丢弃
机制背景:buildFlowFromNamedSymbols的两个输出
buildFlowFromNamedSymbols(位于 src/mcp/tools.ts)返回两个互不相关的东西:
- Flow 叙事文本(call path prose):Agent 指名的符号之间连成的调用链;
namedNodeIds:Agent 指名符号的节点 ID 集合。
下游正是靠第二个集合把指名的定义注入其所在文件的 cluster 范围并排名importance 9——这是"保证"的完整机制。
缺陷点:EMPTY把两个输出一起清零
修复前的最后一道闸门是:
if (!hasMain && synthLines.length === 0 && !boundaryText && !polyText) return EMPTY;EMPTY会同时把namedNodeIds清零。也就是说:只要指名符号恰好"没什么可打印的"(没有调用链、没有合成跳板、没有动态分发边界),整个保证就静默关闭了。而一个工厂里的两个兄弟闭包恰好就是这种情况:queueMessage和flushQueuedMessages互不调用(一个向共享数组 push,另一个 drain 它),于是没有 chain、没有 synthesized hop、没有 dispatch boundary——两个定义都失去了 importance 9。随后文件从头部开始渲染,L70 那个仅 6 行的 interface 就这样挤掉了 1,000 行之下的两个函数。
实测证据:在报告的查询上,flow.namedNodeIds是空的,而findAllSymbols对两个 token 各自精确解析到 1 个节点——解析本身没有错,错在身份集合被下游闸门丢弃。
修复:identityOnly()分离两个输出
修复把两个输出解耦。src/mcp/tools.ts 中的identityOnly()在没有任何可打印叙事时,仍然返回一个空文本但携带namedNodeIds的 Flow 结构:
const identityOnly = () => (preciseNamedIds.size === 0 ? EMPTY : { text: '', pathNodeIds: new Set<string>(), namedNodeIds: new Set<string>(preciseNamedIds), uniqueNamedNodeIds: new Set<string>([...uniqueNamedNodeIds].filter((id) => preciseNamedIds.has(id))), spineCallSites: new Map<string, number>(), });原来两处return EMPTY的位置(L2708 的<2符号分支和 L2823 的主闸门)都改成了return identityOnly()。
关键约束是限定在 shape-precise token 上——与 gather 路径使用的同一个测试(L2601-L2602):
const isPreciseToken = (x: string) => /[._$]|::|\//.test(x) || /[a-z][A-Z]/.test(x) || /^[A-Z]/.test(x);即 camelCase / PascalCase / snake_case / qualified 名称。之所以要加这个闸门:有叙事时,散文本身就是解析正确的佐证,该路径保持原样(所有 named id 都保留);而没有任何佐证时,只有无歧义的符号引用才配获得 importance 9——否则一个散文提问里恰好与某个 callable 精确同名的英文单词,也会错误地赚取 importance 9 的晋升。
根因二:ceiling trim 按源码顺序下刀
为什么恢复 importance 9 还不够
把 importance 9 恢复之后,符号仍然没有渲染出来——第二道丢弃点在这里。
shrinkCluster本身保住了它们:在报告查询上,shrink 的输出包含1022-1121这个块,完整覆盖两个目标函数。但 shrink 的输出实测为26,297 字符,超过 16,532 的 ceiling,于是windowToCeiling启动。它的旧行为是按源码顺序填充 part、遇到第一个超限就丢弃其后的一切:
shrunk: 101-107, 197-226, ..., 648-989, 1022-1121 (26,297) windowed: 101-107, 197-226, ..., 648-839 (16,532) ← 尾部消失按源码顺序裁剪的 trim,永远先牺牲大文件的末尾——而末尾恰恰是 Agent 指名符号最可能出现、且最不可能通过其他途径到达的位置。
修复:focusLines保护名单 + 均分预留
windowToCeiling其实早已有它需要的概念——CG-30 引入的focusLine(spine 的下一跳调用点),只是此前从未被告知"指名定义"的存在。修复后(src/mcp/tools.ts#L5281-L5354)它接受一个focusLines列表:spine 调用点加上所有 importance ≥ 9 的成员定义行,上限 6 个(由focusLinesOf构造,L5364-L5372):
const MAX_FOCUS_LINES = 6; const focusLinesOf = (c: ExploreCluster): number[] => { const named = c.members .filter((m) => m.importance >= 9) .sort((a, b) => b.importance - a.importance || a.start - b.start) .slice(0, MAX_FOCUS_LINES) .map((m) => m.start); return c.spineCallLine ? [c.spineCallLine, ...named] : named; };算法有两处关键设计:
- 优先尝试 full-ceiling 填充。只有当某条 focus line 确实未被头部覆盖时,才把填充空间压到 60%(即预留 40%,见 L5313-L5318 的
fill(Math.floor(ceiling * 0.6)))。这样一个头部已经触达 focus 的 cluster 能把整个 ceiling 留给源码内容——这本身就是对旧版"无条件预留"的改进。 - 预留份额在未被覆盖的 focus lines 之间均分并带结转(carry-forward),而不是按源码顺序贪心发放。贪心会在低一层复现同一个 bug:在散文查询上,4 条 focus line 全部解析成功,最早的 2 条吃掉了整份预留,
flushQueuedMessages再次被丢。均分逻辑见 L5325-L5345:每条 focus line 获得room / (pending.length - i)的份额,被跳过或窗口过小的份额返还给后续 focus line(room与covered的结转)。
windowToCeiling的文档注释也完整记录了这次事故(L5271-L5279):"head fill 是源码顺序的,所以大文件尾部一个指名定义,否则总是超 ceiling 渲染丢弃的第一样东西——正是 Agent 按名字要的那段 span,被它没要过的文件头填充内容挤掉了。"
记账缺口:找到、测了、刻意不发布
这是本修复中最有方法论价值的一节。
shrinkCluster的适配测试用裸源码 span度量(slice().join('\n').length),而渲染时会在每个块外加contextPadding、给每行加行号前缀。在报告文件上,这个估算低估约 60%(16.5K 记账,26.3K 实际渲染)。
团队构建并实测了一个精确投影(前缀和行成本,镜像buildSection执行的合并 + 填充)——结论是它更差,因此不发布:
| 仓库 | main(宽松记账) | 精确记账 | 精确 + 用完剩余 |
|---|---|---|---|
| django | 20,719 | 20,747 | 20,747 |
| excalidraw | 19,704 | 19,606 | 19,606 |
| okhttp | 18,651 | 18,766 | 18,766 |
| tokio | 21,582 | 21,424 | 21,555 |
| gin | 11,952 | 12,082 | 12,082 |
| alamofire | 11,849 | 11,849 | 11,849 |
probe-allocation | 4 PASS | payroll-go FAIL | payroll-go FAIL |
劣化的机制:精确记账停在"最后一个完整放得下的成员"处,释放出的字节结转到排名更低的文件。在payroll-go上,这从 rank #2 的答案文件cycle.go移走了 1,296 字符,放进 rank #5 的payslipstore/store.go,连带丢掉了runPayrollCycleAll里的s.store.Upsert(ctx, slip)调用——即查询中"create"那一半。
结论被写进了 shrinkCluster 的注释:这里的松弛(slack)在它所在的位置没有害处——bound()会把渲染精确钳制到 ceiling,多保留的成本是零字节。松弛不该做的是决定哪些成员存活——那是 ceiling trim 的职责,而 CG-38 修的正是让这次 trim 保护指名 span 而非按源码顺序下刀。注释里明确写给下一个读者,防止有人再来"修"它。
索引依赖线索:机制真实存在,但与本缺陷正交
issue 中最尖锐的线索是:把环境.d.ts标记为generated,似乎让一个无关文件的渲染变差了。用 CG-25 的方法(固定索引,只翻转files.generated那一行,把差异归因于 ranker 本身)确认机制真实:
generated=1 | generated=0 | |
|---|---|---|
.d.tsgraphScore | 0.1875 | 0.75 |
maxGraph | 0.3297 | 0.75 |
| gate(max 的 6%) | 0.0198 | 0.0450 |
| 进入排名的文件数 | 3 | 2 |
| rank #1 的额度 | 9,100 | 8,166 |
传导链是:rankPenalty缩放fileGraphScore,fileGraphScore决定maxGraph,而相关性门槛是maxGraph的 6%——所以对一个文件的惩罚确实会移动准入集合以及每个其他文件的额度。确认属实。
但它不是符号消失的原因:在 main 分支上,两种 flag 状态下符号都缺席(渲染分别止于 L316 / L381);修复后,两种状态下都在。分配(allocation)会移动,但保证不依赖它。这条结论由tests/explore-named-symbol-render.test.ts 的最后一个用例固定(pin)下来:该用例通过直接改写索引库的UPDATE files SET generated = ?切换 flag 状态,断言两种状态下两个指名定义都渲染。
修复结果与常设护栏
真实复现(queueMessageL1087 /flushQueuedMessagesL1102),覆盖全部查询形态:
| 查询形态 | main | 修复后 |
|---|---|---|
| symbol bag | absent | both render |
| prose,点名符号 | absent | both render |
| symbols + 诱饵 interface | absent | both render |
| prose,未点名符号 | absent | both render |
Fixture 层面(tests/fixtures/tail-render-ts),3 种查询形态共 7 个符号检查:main 上 7/7 失败,修复后 7/7 通过——每个 arm 连续 4 次运行结果确定一致。
常设护栏(standing bars)全部守住:
probe-allocation.mjs—— payroll-go / starved-cluster / dense-header / self-query 全部 PASSprobe-file-spend.mjs—— 无饥饿(starvation)标志probe-suite-envelope.mjs——六个仓库上与 main 字节级相同(20,719 / 19,704 / 18,651 / 21,582 / 11,952 / 11,849),文件数相同- 完整测试套件全绿
套件字节级相同这件事本身就是要点:focus 窗口只改变一个渲染已经超出 ceiling 之后的行为,而六个套件的查询没有一个越界——修复对健康路径零副作用。
检测工具:这套保证如何被持续测量
- scripts/agent-eval/probe-named-symbol.mjs —— 整个 CG-24 epic 缺失的那把尺子。它是逐符号、二值的:该符号的定义行是否出现在响应的渲染行中?光有名字什么都证明不了——名字无论函数体是否发出,都会出现在 section 头部的符号列表和调用点中。这正是该缺陷在整个 epic 的聚合探针下隐身的原因。任何期望符号缺席时退出码为 1,可直接用作 CI 门。
- tests/fixtures/tail-render-ts —— 几何镜像 fixture,复刻报告文件的空间结构:L70 的同词根诱饵 interface、L104 起跨约 92% 文件体量的工厂闭包(使所有符号合并成一个cluster)、L1088/L1096/L1102 的目标函数、外加一个 2,500 行的生成
.d.ts供 ranker 惩罚。由脚本生成——需要改动时改几何(目标行号、闭包跨度、诱饵位置),而不是逐行编辑。 - tests/explore-named-symbol-render.test.ts —— 常设门。它先断言 fixture 自身的形状(目标在 L1000 之后、闭包覆盖半文件以上、诱饵在 L100 之前、两个目标互不调用、
.d.ts超过 2,000 行),再断言渲染保证——如果 fixture 腐烂了,门本身毫无意义。
方法备注:baseline 的正确姿势
文档最后留了一条工具链层面的教训:git stash -- <path>建立的"baseline"回滚到的是HEAD,而不是main。如果分支上有一个 WIP commit,这个姿势会让你的改动与自身做对比,静默产生一个"main 上也通过"的纯虚构结果——本次排查中就发生过。正确做法是文件交换(git show main:<path> > <path>),这与该仓库内部笔记baseline-builds-use-fresh-file-swap对构建的要求一致。
小结
CG-38 的修复展示了"保证型"缺陷修复的完整闭环:
- 拆解两个独立丢弃点——身份集合被
EMPTY连带清零(语义层)与源码顺序裁剪(预算层),并意识到修一个不等于修另一个; - 修复带精度约束——
identityOnly()只放行 shape-precise token,避免散文英文单词误赚 importance 9;focusLines均分预留避免贪心在低一层复现同一 bug; - 对"更精确"的诱惑说不——精确记账实测更差就不发布,并把"不要修它"写进源码注释;
- 把保证固化——逐符号二值探针、几何镜像 fixture 与其形状自校验、字节级相同的回归基线,四层护栏让"指名符号必渲染"从一次修复变成一项可验证的常设承诺。
【免费下载链接】codegraphPre-indexed code knowledge graph, auto syncs on code changes, for Claude Code, Codex, Gemini, Cursor, OpenCode, AntiGravity, Kiro, CoPilot, and Hermes Agent — fewer tokens, fewer tool calls, 100% local项目地址: https://gitcode.com/GitHub_Trending/co0degr/codegraph
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考