CodeGraph 生成文件检测:从路径约定到内容 Banner 的双信号设计(CG-5)
在 CodeGraph 这类预构建代码知识图谱中,"这个文件是不是工具生成的"直接决定了符号消歧、文件排序和 explore 预算分配的准确性。本文围绕设计文档 generated-file-detection.md 展开:解释为什么仅靠文件命名约定(.pb.go、.g.dart 等)漏掉了 Go 生态中最主流的生成文件形态,CodeGraph 如何把内容 Banner 检测在索引期一次性判定并持久化到 files.generated 字段(schema v9),以及如何通过有界查询、部分索引和精度优先的标记表,把索引期开销压到不可测量。读完后,你将掌握一套可复用的"路径信号 + 内容信号 + 索引期持久化"检测架构,以及其背后的精度/召回权衡和迁移策略。
一、问题:路径约定在 Go 世界里是盲的
CodeGraph 最初的生成文件判定函数 isGeneratedFile 只认路径。它匹配的是 <basename>.<tool>.<ext> 这一类命名约定——.pb.go、_grpc.pb.go、.g.dart、_pb2.py 等,绝大多数代码生成工具的输出确实长这样,这在对 cosmos-sdk 的审计中是够用的。
但它对 Go 不够。因为 Go 的生成文件约定是内容标记,而不是文件名标记:
// Code generated by <tool>. DO NOT EDIT.
这条 Banner 由 go generate 规范化,被 gofmt、golangci-lint 和 GitHub linguist 遵循,protoc-gen-go、mockgen、sqlc、ent、wire、stringer 以及各类内部生成器都会原样输出它。issue #1500 正是这个场景:一个 Go 单体仓库里,FKIT 生成的 CRUD 代码放在普通命名的文件(payroll.go)中,与手写的 workflow use-case 并排放在同一目录。路径上没有任何线索能把它暴露出来,于是整个代码库里的"生成文件降权"对它完全失效。
缺口有多大:client-go 实测
设计文档在 kubernetes/client-go 的浅克隆上做了测量(2,453 个 Go 文件):
| 信号 | 被标记文件数 |
|---|---|
| 基准真值(grep 前 60 行内的标准 Banner) | 2,001 |
路径约定(isGeneratedFile) |
0 |
内容 Banner(hasGeneratedHeader) |
2,001 —— 0 误报、0 漏报 |
也就是说该仓库 82% 的代码都是普通文件名的生成代码,而路径检查一个都没看到。这不是长尾情况,是主流情况。这组数据在测试文件 generated-detection.test.ts 的头部注释中被原样保留,作为后续修改标记表时的回归契约。
二、设计:索引期判定,DB 中读取
核心原则一句话概括:在索引期决定,存到文件记录上,读取时从 DB 拿。explore 等请求路径永远不应该为每次查询去读文件头。
实现分布在四个层面(均见源码):
isGeneratedFile(path)— 保持不变。 纯路径、纯函数、同步、零开销,可以放心在排序比较器里调用,服务于没有数据库句柄的调用方。完整的路径模式表见 generated-detection.ts 中的GENERATED_PATTERNS,覆盖 Go(protobuf/gRPC/pulsar/mockgen)、TypeScript/JavaScript(Apollo、Prisma、Hasura、ts-proto 等,包括.min.mjs压缩包)、Python(_pb2.py)、C++、C#、Java、Swift、Dart(build_runner/freezed/json_serializable)、Rust 等后缀约定。hasGeneratedHeader(content)— 内容信号。 判定文件头部是否携带可识别的生成 Banner(见下文算法细节)。detectGeneratedFile(path, content)— 两者取并集。 这就是索引器持久化的那个值,在 extraction/index.ts 的文件存储路径上被调用:每次文件变更重索引时重新判定(Banner 被编辑加入或移除都会在下次 sync 中反映),且在"文件未变更则早退"之后计算,所以未触碰的文件不付出任何代价。- 持久化与查询。 schema.sql 的
files表新增generated INTEGER NOT NULL DEFAULT 0(schema v9),并配一个部分索引:
-- idx_files_generated is PARTIAL: the generated set is a small minority of any
-- repo, so a lookup that intersects a bounded candidate list with it stays
-- proportional to the generated files, not to the repo.
CREATE INDEX IF NOT EXISTS idx_files_generated ON files(path) WHERE generated = 1;
部分索引的意义在于:查询代价正比于生成文件这个少数集,而不是整个仓库。
对外暴露的查询 API 是 QueryBuilder.generatedPredicateFor(paths)(见 queries.ts)和 CodeGraph.generatedFilePredicate(paths)(index.ts):先做一次有界探查,之后每次比较都是 O(1),且结果与路径检查取并集。实际消费方遍布所有排序路径——MCP 工具层的 explore 结果排序(tools.ts)、CLI 查询(codegraph.ts)、上下文格式化(context/index.ts)、主导文件/路由文件选取(getDominantFile/getTopRouteFile/getRoutingManifest)等。
为什么用有界查询,而不是缓存一个集合
每个消费方手里本来就持有一个很短的候选列表——一个排序后的文件组、一页 FTS 结果、一个 LIMIT 20 的聚合。把这个列表与部分索引相交,不需要物化整个仓库的生成文件集合,更重要的是不需要任何缓存去失效:一次排序调用不可能吐出"上一次 sync 早已推翻的判定"。
替代方案(惰性物化一个"全部生成路径"的 Set)则必须在每次文件写入时失效,而且会在只读查询池的 worker 上产生陈旧数据——换来的收益不过是一条亚毫秒查询的时间。queries.ts 中 getGeneratedPathsAmong 的注释把这个权衡写得很直白:分块 SELECT path FROM files WHERE generated = 1 AND path IN (...),只针对候选列表做部分索引探查。
三、内容检测算法:三重围栏,精度优先
误报会静默地在每一条排序路径里把手写代码降级,所以标记表是精度优先的,扫描也被三重围栏约束住(实现见 generated-detection.ts):
围栏 1:只看头部窗口
HEADER_SCAN_CHARS = 8192 字符 / HEADER_SCAN_LINES = 60 行。这个宽度对"build tag + Apache-2.0 许可证前言压在 Banner 上方"是宽裕的,又紧到足以保证生成器自己的源码——它把 Banner 作为字符串常量放在函数体里——不会被误标记。测试用例明确验证了这一点:
// 80 行填充之后才出现的 Banner —— 不算 Banner
expect(hasGeneratedHeader(`${filler}\n// Code generated by foo. DO NOT EDIT.\n...`)).toBe(false);
// 20 行填充之后的 Banner —— 在窗口内,能抓到
expect(hasGeneratedHeader(`${shortFiller}\n// Code generated by foo. DO NOT EDIT.\n...`)).toBe(true);
围栏 2:标记必须出现在注释行上
Banner 必须位于带注释引导符(//、#、--、<!--、%、;、'、!、(*、{-、"""、'''、=begin、<# 等)的行上,或者位于一个已打开的块注释(/* */、<!-- -->、"""、'''、=begin、<# #>)内部。模块用一个小型状态机在窗口内追踪块注释的打开/关闭:
const COMMENT_LEADER =
/^\s*(?:\/\/|\/\*+|\*+\/?|#+|--+|<!--|%+|;+|'|!|\(\*|\{-|"""|'''|=begin|<#|@rem\b|rem\b)/i;
const BLOCK_DELIMS: ReadonlyArray<{ open: string; close: string }> = [
{ open: '/*', close: '*/' },
{ open: '<!--', close: '-->' },
{ open: '"""', close: '"""' },
{ open: "'''", close: "'''" },
{ open: '=begin', close: '=end' },
{ open: '<#', close: '#>' },
];
生成器总是把 Banner 作为注释输出;要求这一点,就排除了仅仅"包含这些词"的标识符和字符串字面量——测试里 const banner = "Code generated by tool. DO NOT EDIT."; 这种裸语句断言为 false。
围栏 3:标记本身足够严格
标记表 GENERATED_CONTENT_PATTERNS 中每条都有明确的"为什么需要这个限定词",例如:
- Go 标准 Banner:
/\bcode generated\b.{0,200}?\bdo not edit\b/i—— 对应// Code generated by <tool>. DO NOT EDIT.; automatically generated单独出现不算——它是散文("该表在运行时自动生成");必须带by/from/with且后跟do not edit/modify/change才算 Banner;DO NOT EDIT单独出现不算——它只是风格指令;与生成声明配对时才算;@generated标记:JS/TS 生态的约定(Relay、GraphQL codegen、protobuf-es/Buf),带守卫防止foo@generated、@@generated误匹配;generated by X by running …双 by 从句:Wrangler 等 CLI 工具的形状("Generated by Wrangler by running `wrangler types`")。单纯的 "generated by" 是普通散文,不足以判定,必须同时点名工具并给出复现指令——这排除了 "报告由运行夜间任务生成" 这类单 by 从句的散文;.NET 的`:Roslyn、WinForms 设计器、T4 模板的输出。
每个标记在 generated-detection.test.ts 中都有对应的真实生成器输出用例(22 个正例,覆盖 Go/protoc Java/protoc Python/C#/Relay/Thrift/OpenAPI/FlatBuffers/bindgen/ANTLR/Wrangler/YAML/SQL/HTML 等),以及 9 个精度反例(生成器自己的源码、散文、邮箱地址含 @generated、无生成声明的 "DO NOT EDIT" 等)。测试注释说明:每条正例是"生成器真实输出的原文,不是转述"——如果某条正则被收窄,导致它失效的那个用例会按名字失败。
模块不标记自己
一个精巧的自洽约束:该模块内部引用的 Banner 字符串字面量刻意放在 8,192 字符头部窗口之下,因此检测器不会把 generated-detection.ts 自己分类为生成文件。generated-detection.test.ts 直接读入该源文件并断言 hasGeneratedHeader(self) === false——如果有人把模式表往上挪,测试失败,而不是仓库静默降级自己的文件。
四、迁移:DDL only,天然无法回填
v9 迁移(见 migrations.ts)只做 DDL:加列 + 建部分索引,任何规模的库上都是瞬时完成。它没有也无法回填,原因是结构性的:flag 派生自文件内容,而 files 表存的是内容哈希,不是字节,迁移阶段根本看不到文件内容。
后果与兜底设计是配套的:
- 迁移后的存量行保持
generated = 0,直到下次全量索引重新提取; - 由于所有读取方都把 flag 与路径检查取并集,一个未回填的数据库保留的恰好是 #1500 之前的行为,而不是回退——新信号只"加"路径信号,从不覆盖它;
sync会随着文件逐个变更把它逐步修复;- 这正是 CHANGELOG 中写明"需要重新索引才能启用新检测"的原因。
迁移还处理了一个幂等细节:SQLite 的 ALTER TABLE 没有 IF NOT EXISTS,所以先查 PRAGMA table_info(files) 再决定加不加列——对从当前 schema.sql 直接建库的数据库(列已存在)重跑迁移时不会报错。
五、成本:验收标准是"不可测量的索引期回归"
设计文档给出的验收门槛是"索引期没有可测量的成本回归",实现上靠两层:
- 廉价预过滤。所有标记都含词干 "generat",所以一条未锚定的
/generat/i扫描在任何分行发生之前就拒绝掉几乎所有手写文件。而String.prototype.slice在长字符串上产生的是 V8 的切片视图而非拷贝,快路径零分配。 - 实测数据。
-
微基准(
detectGeneratedFile扫整个语料,5 轮):client-go 上 4.6 µs/文件(2,453 文件、14.2 MB、82% 生成率——这是最坏情况,因为预过滤通过、完整行扫描真正跑起来了);本仓库src上 7.3 µs/文件。 -
端到端
codegraph init(client-go,n=3 交替组,当前构建 vs. 同一构建但内容扫描被 stub 掉):组 三次运行(s) 中位数 含内容检测 5.66, 5.73, 5.89 5.73 纯路径基线 5.52, 5.76, 5.88 5.76 两组在运行间互相交叉——差异在逐次运行噪声之内。
-
六、这个任务刻意没有改什么
生成文件状态仍然是同分时的稳定 tiebreak,位置不变:explore 的文件排序、findSymbolMatches、findAllSymbols、搜索结果格式化、getDominantFile/getTopRouteFile/getRoutingManifest、上下文格式化。一个原始分更高的生成文件依然排在手写文件前面——它只是相关度提示,不是硬过滤:生成节点仍在图中、仍然可达,只在存在同名的真实实现时排到最后。把生成状态升级为强负向信号是后续任务 CG-10 的范围,本任务通过让信号正确且可用为它解除了阻塞。
端到端验证用一个双文件 Go 包完成(对应测试 explore-allocation-1500.test.ts 与 generated-flag-index.test.ts):生成文件 payroll.go 和手写文件 workflow.go 都定义了 ProcessPayroll——flag 置位时手写文件排第一;在同一索引中清掉 flag(即 #1500 之前行为)则生成文件排第一。
小结
这套方案给出的可复用经验有三条:
- 判定下沉到索引期。内容类信号(需要读文件)在解析阶段算一次并持久化,查询路径只读 DB,代价正比于数据中真实的少数集(部分索引);
- 有界查询优于缓存集合。候选列表 + 部分索引探查,换来零缓存失效逻辑和只读 worker 上的强一致性;
- 精度优先的启发式必须被测试钉死。每条正则对应一条"生成器真实输出"的正例和一组"更松的表会误报"的反例,外加"模块不标记自己"的自洽测试,让后续任何收窄/放宽都会以具名失败的方式出现。
相关延伸阅读:预算分配的姊妹文档 explore-budget-allocation.md(CG-4 的计量工具,本文档是其前置条件)。
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 StartedRust0624
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