CodeGraph 的 explore 输出管线修复实录:让 Agent 指名的大文件尾部符号必定渲染(CG-38)

发布时间:2026/9/5 19:46:01
CodeGraph 的 explore 输出管线修复实录:让 Agent 指名的大文件尾部符号必定渲染(CG-38) 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发出查询指名queueMessageL1087和flushQueuedMessagesL1102——无论是裸符号串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 proseAgent 指名的符号之间连成的调用链namedNodeIdsAgent 指名符号的节点 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 Setstring(), namedNodeIds: new Setstring(preciseNamedIds), uniqueNamedNodeIds: new Setstring([...uniqueNamedNodeIds].filter((id) preciseNamedIds.has(id))), spineCallSites: new Mapstring, number(), });原来两处return EMPTY的位置L2708 的2符号分支和 L2823 的主闸门都改成了return identityOnly()。关键约束是限定在 shape-precise token 上——与 gather 路径使用的同一个测试L2601-L2602const 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 引入的focusLinespine 的下一跳调用点只是此前从未被告知指名定义的存在。修复后src/mcp/tools.ts#L5281-L5354它接受一个focusLines列表spine 调用点加上所有 importance ≥ 9 的成员定义行上限 6 个由focusLinesOf构造L5364-L5372const 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 lineroom与covered的结转。windowToCeiling的文档注释也完整记录了这次事故L5271-L5279head fill 是源码顺序的所以大文件尾部一个指名定义否则总是超 ceiling 渲染丢弃的第一样东西——正是 Agent 按名字要的那段 span被它没要过的文件头填充内容挤掉了。记账缺口找到、测了、刻意不发布这是本修复中最有方法论价值的一节。shrinkCluster的适配测试用裸源码 span度量slice().join(\n).length而渲染时会在每个块外加contextPadding、给每行加行号前缀。在报告文件上这个估算低估约 60%16.5K 记账26.3K 实际渲染。团队构建并实测了一个精确投影前缀和行成本镜像buildSection执行的合并 填充——结论是它更差因此不发布仓库main宽松记账精确记账精确 用完剩余django20,71920,74720,747excalidraw19,70419,60619,606okhttp18,65118,76618,766tokio21,58221,42421,555gin11,95212,08212,082alamofire11,84911,84911,849probe-allocation4 PASSpayroll-go FAILpayroll-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 本身确认机制真实generated1generated0.d.tsgraphScore0.18750.75maxGraph0.32970.75gatemax 的 6%0.01980.0450进入排名的文件数32rank #1 的额度9,1008,166传导链是rankPenalty缩放fileGraphScorefileGraphScore决定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 bagabsentboth renderprose点名符号absentboth rendersymbols 诱饵 interfaceabsentboth renderprose未点名符号absentboth renderFixture 层面tests/fixtures/tail-render-ts3 种查询形态共 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 9focusLines均分预留避免贪心在低一层复现同一 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),仅供参考