首页
/ CodeGraph 生成文件检测:从路径约定到内容 Banner 的双信号设计(CG-5)

CodeGraph 生成文件检测:从路径约定到内容 Banner 的双信号设计(CG-5)

2026-09-06 10:24:32作者:郁楠烈Hubert

在 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 等请求路径永远不应该为每次查询去读文件头。

实现分布在四个层面(均见源码):

  1. 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 等后缀约定。
  2. hasGeneratedHeader(content) — 内容信号。 判定文件头部是否携带可识别的生成 Banner(见下文算法细节)。
  3. detectGeneratedFile(path, content) — 两者取并集。 这就是索引器持久化的那个值,在 extraction/index.ts 的文件存储路径上被调用:每次文件变更重索引时重新判定(Banner 被编辑加入或移除都会在下次 sync 中反映),且在"文件未变更则早退"之后计算,所以未触碰的文件不付出任何代价。
  4. 持久化与查询。 schema.sqlfiles 表新增 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.tsgetGeneratedPathsAmong 的注释把这个权衡写得很直白:分块 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 直接建库的数据库(列已存在)重跑迁移时不会报错。

五、成本:验收标准是"不可测量的索引期回归"

设计文档给出的验收门槛是"索引期没有可测量的成本回归",实现上靠两层:

  1. 廉价预过滤。所有标记都含词干 "generat",所以一条未锚定的 /generat/i 扫描在任何分行发生之前就拒绝掉几乎所有手写文件。而 String.prototype.slice 在长字符串上产生的是 V8 的切片视图而非拷贝,快路径零分配。
  2. 实测数据
    • 微基准(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 的文件排序、findSymbolMatchesfindAllSymbols、搜索结果格式化、getDominantFile/getTopRouteFile/getRoutingManifest、上下文格式化。一个原始分更高的生成文件依然排在手写文件前面——它只是相关度提示,不是硬过滤:生成节点仍在图中、仍然可达,只在存在同名的真实实现时排到最后。把生成状态升级为强负向信号是后续任务 CG-10 的范围,本任务通过让信号正确且可用为它解除了阻塞。

端到端验证用一个双文件 Go 包完成(对应测试 explore-allocation-1500.test.tsgenerated-flag-index.test.ts):生成文件 payroll.go 和手写文件 workflow.go 都定义了 ProcessPayroll——flag 置位时手写文件排第一;在同一索引中清掉 flag(即 #1500 之前行为)则生成文件排第一。

小结

这套方案给出的可复用经验有三条:

  1. 判定下沉到索引期。内容类信号(需要读文件)在解析阶段算一次并持久化,查询路径只读 DB,代价正比于数据中真实的少数集(部分索引);
  2. 有界查询优于缓存集合。候选列表 + 部分索引探查,换来零缓存失效逻辑和只读 worker 上的强一致性;
  3. 精度优先的启发式必须被测试钉死。每条正则对应一条"生成器真实输出"的正例和一组"更松的表会误报"的反例,外加"模块不标记自己"的自洽测试,让后续任何收窄/放宽都会以具名失败的方式出现。

相关延伸阅读:预算分配的姊妹文档 explore-budget-allocation.md(CG-4 的计量工具,本文档是其前置条件)。

登录后查看全文
热门项目推荐
相关项目推荐