首页
/ CodeGraph 1500 回归夹具 payroll-go 剖析:复现 explore 预算被生成 CRUD 抢占的缺陷

CodeGraph 1500 回归夹具 payroll-go 剖析:复现 explore 预算被生成 CRUD 抢占的缺陷

2026-09-06 11:45:56作者:温玫谨Lighthearted

本文围绕 codegraph 仓库中的 payroll-go 回归夹具(__tests__/fixtures/payroll-go/)展开。它是一个纯合成的 Go 工资服务,专门用来复现 GitHub issue #1500 的仓库形态——生成的 CRUD 层与真正干活的、手写的 use-case 并排共存,从而导致 codegraph_explore 的固定字节输出预算被"同名但无关"的生成代码抢占。读完本篇,你能理解这个夹具为什么要长成现在这样(目录结构、三条回归特性)、它的断言与基线数据如何设计,以及如何用探测脚本与 vitest 把它锁成一条不会静默回归的防线。

一、夹具要锁住的是什么缺陷

codegraph_explore 有一个固定的字节信封(由 getExploreOutputBudget() 决定,硬上限 25K,避免宿主把结果外置落盘)。问题在于:这份额外字节如何在多个文件间切分,是由一条横跨 handleExplore 的门槛、档位与上限链条决定的。在一个"生成 CRUD 与手写工作流并存"的仓库里,一个不点名任何具体符号的架构问题(例如 "how does payroll cycle create and calculate payslips?")会把信封砸在只靠同名撞上的生成层上,而不是真正回答问题的、那个被裁剪的、较大的手写文件上。

payroll-go 就是把这种形态永久固化下来的夹具。正如 夹具 README 所说:

This tree is a fixture, not a program. It never compiles or runs — it exists to be indexed. Keep it valid, idiomatic Go anyway: the extractor's output is the whole point.

关键前提:它从不编译、从不运行,存在的唯一目的就是被索引go.mod 声明模块 github.com/example/payroll-svcgo 1.22(见 go.mod),但整棵树的意义在于"提取器输出的内容",而非可执行程序。

二、目录结构与关键调用链

夹具复刻了"报告者仓库"的骨架:HTTP 入口 → 手写 use-case 工作流 → 持久化,旁边挂着一整层生成的 CRUD 与 DTO。完整结构(继承自 README 并对照真实源码):

cmd/payrolld/main.go                        装配 service
internal/transport/httpapi/                 HTTP 入口 → use-case
internal/usecase/payroll/                   ← 答案所在。手写工作流:
  cycle.go                                    runPayrollCycleAll(227 行,整文件)
  payslip_builder.go                          BuildPayslip —— 真正的工资计算
  prorate.go
internal/domain/payroll/payslip.go          手写领域类型
internal/store/payslipstore/store.go        真正的 Upsert
internal/platform/clock/clock.go

internal/gen/fkit/payroll/                  ← 噪声所在。生成的 CRUD,文件名极其普通:
  payslip.go        CreatePayslip, GetPayslip, UpdatePayslip, 还有一个第二个 BuildPayslip
  payroll_cycle.go  CreatePayrollCycle, PayrollCycleCreateRequest, …
  store.go          第二个 Upsert
  calculate.go      CalculatePayrollCycleTotals, CalculatePayslipNet, …
  dto.go
internal/gen/fkit/employee/, timesheet/     更多生成的 CRUD
internal/gen/payrollpb/*.pb.go              生成的,可通过"路径"识别

夹具围绕这样一条链构建:runPayrollCycleAllBuildPayslipUpsert,从 POST /v1/payroll/cycles/{cycleID}/run 进入。这条链在源码里是逐跳可验证的:

  • 路由注册:router.gomux.HandleFunc("POST /v1/payroll/cycles/{cycleID}/run", h.RunCycle)
  • 处理器转调:payroll_handler.goRunCycle 调用 h.svc.RunCycle(...)
  • 装配:main.gostore := payslipstore.New()svc := payroll.NewService(store, clock.System{})
  • 工作流主体:cycle.gorunPayrollCycleAll,对花名册里每个员工 s.BuildPayslip(...)s.store.Upsert(ctx, slip)(第 110 行)。

真正的"计算"发生在 payslip_builder.goBuildPayslip:基本工资、加班、补贴,然后是扣款与税,全部以整型分(cents)累加,"没有任何一分钱会在两行之间消失"。这正是生成的 CRUD 层不会做的事——fkit 的 BuildPayslip 只是把字段在 DTO 与行之间搬运。

三、让它成为回归夹具的三条特性

README 把夹具的"含金量"归为三条特性。每一条都对应一个可被测试钉死的源码事实。

3.1 只有内容头能暴露的生成文件(#1500 的命门)

internal/gen/fkit/** 下的文件用的是普通文件名payslip.gostore.go),并携带 // Code generated by fkit v3.11.0. DO NOT EDIT. 头(见 fkit/payslip.go)。纯路径检测会漏掉它们——这正是 #1500 的情形,也是 CG-5 增加内容检测的原因。而 payrollpb/*.pb.go 则覆盖旁边那条"可通过路径识别"的通道,让两条通道同时被验证。

codegraph 用两个刻意分离的信号做生成文件识别(见 generated-detection.ts):

  • isGeneratedFile(filePath)——只看路径、纯同步,匹配 <basename>.<tool>.<ext> 约定(.pb.go_grpc.pb.go.g.dart_pb2.py 等);
  • hasGeneratedHeader(...)——扫文件头部的内容横幅。Go 的生成标记本就是内容约定而非文件名约定,所以一个叫 payroll.go 的文件对前者完全隐形——这就是 #1500。它在索引期只求值一次(内容此时本就在内存里),并把结果持久化为 files.generated

识别横幅是"精度优先"的白名单,Go 那条正是 go generate/gofmt/golangci-lint 共同遵守的 ^// Code generated .* DO NOT EDIT\.$ 约定(generated-detection.ts),"由 protoc-gen-go、mockgen、sqlc、ent、wire、stringer 逐字发出,也由 #1500 里像 FKIT 这样的内部生成器发出——而那个文件就叫 payroll.go,路径里什么都没有能暴露它的"。

测试 explore-allocation-1500.test.ts 对 7 个"内容头"文件逐个断言:isGeneratedFile(rel) 必须为 false(路径识别不到)、hasGeneratedHeader(source) 必须为 true、且索引后的 generated 标志为 true;再断言 internal/gen/payrollpb/payroll.pb.go 路径可识别。缺了这些,这个夹具就退化成一个 .pb.go 夹具,而不是 #1500 夹具。

3.2 刻意制造的同名冲突

BuildPayslipUpsertStore 各存在两份:一份生成、一份手写。生成层还在问题的每一个词上都撞名——CreatePayslipPayrollCycleCreateRequestCalculatePayrollCycleTotals、第二个 BuildPayslip、第二个 Upsert。一个"奖励偶然同名"的打分器,就会把 CRUD 路径顶出来。

对照两处 BuildPayslip 最能说明问题:

  • 手写版(payslip_builder.go):func (s *Service) BuildPayslip(ctx, cycle, employee, timesheet) (payroll.Payslip, error),是真正算工资的方法;
  • 生成版(fkit/payslip.go):func BuildPayslip(req PayslipCreateRequest) PayslipRow,注释明写"Field copy only — the generator has no knowledge of pay rules."(只做字段拷贝,生成器不懂工资规则)。

Upsert 同理:手写 payslipstore/store.gofunc (s *Store) Upsert(ctx, slip payroll.Payslip) error,对生成的 fkit/store.gofunc (s *Store) Upsert(ctx, row PayslipRow) (PayslipRow, error)。测试 名称冲突断言BuildPayslipUpsertStore 三个名字,各断言"既有生成版、也有手写版"(通过 getNodesByName 落到两个不同前缀的文件上)。

3.3 驱动渲染模式的大小分层

cycle.go 故意超过整文件窗口(227 行),从而落到"被裁剪的 cluster"渲染;生成的文件故意低于窗口,从而整文件下发。渲染模式由文件大小驱动,而不是由相关性驱动——这正是缺陷的力学。cycle.go 全文 227 行(已核实),runPayrollCycleAll 在其中跨越多行、逻辑密集,而 Service 类型几乎撑起整文件。

测试 大小断言 钉死了这一分裂:cycle.go 必须 > 220 行,而 fkit/payslip.gofkit/payroll_cycle.go 必须 < 220 行。注释直言:"工作流文件必须留在整文件窗口之上、生成的文件在其下,否则夹具就不再复现任何东西。" README 也强调:如果编辑这些文件,务必保住这一分裂(__tests__/explore-allocation-1500.test.ts 两边都钉着)。

四、断言:一个不点名任何符号的架构问题

夹具围绕的查询是:"how does payroll cycle create and calculate payslips?"——一个不点名任何回答符号的架构问题。预算应当集中到手写工作流上。README 记录了 CG-10 落地前(2026-08-03 基线)并没有做到这一点:

allocated(分配) delivered(实际下发)
手写层 48.4% 25.6%(全是领域类型)
生成 CRUD 39.9% 57.4%

cycle.go 被分配到单文件最大的一片(7,052 字符、30.6%),却下发为零——硬上限把它的整段裁掉了。payslip_builder.go(排名第 8)根本没渲染。于是 runPayrollCycleAll、手写版 BuildPayslip 和真正的 Upsert 从未到达 Agent

这里有两个刻意分开的信封数字(见 explore-budget-allocation.md):

  • allocated——渲染循环在最终硬上限裁剪前决定要发出的字节,是分配器自己的决定;
  • delivered——Agent 实际收到的字节。

只有当上限发生截断时二者才会背离;把二者混为一谈,就会漏掉"被丢掉的尾部文件"。

修复的演进(对照 allocation-fixtures.json

夹具声明里保存了 baseline / afterCG10 / afterCG12 三组实测下发占比,恰好串起整条修复链(payroll-go 属 19 个文件 → very-tiny 档,13,000 字符信封、19,500 硬上限):

文件(delivered 占比) baseline(CG-10 前) afterCG10 afterCG12
usecase/payroll/cycle.go 0.0 0.389 0.306
gen/fkit/payroll/payslip.go 0.307 0.0 0.0
gen/fkit/payroll/payroll_cycle.go 0.266 0.235 0.0
domain/payroll/payslip.go 0.256 0.226 0.212
usecase/payroll/payslip_builder.go 未渲染 0.151
store/payslipstore/store.go 0.118
  • CG-10(相关性打分):生成文件从 #1/#2 降到 #3/#4(类型加权 + 对生成文件的 0.3× 惩罚同时作用在分数与图上质量上),手写工作流占比升到 61.5%、生成层 23.5%。仍失败:payslip_builder.go 排 #6,而该档 maxFiles 只有 4。
  • CG-12(按分数比例分配 + 相对悬崖):每个文件的份额在渲染前就被"预留",低于最高权重 15% 的文件拿不到源码(只留路径/符号/行号,约 100 字符而非约 4,500,且不占 maxFiles 槽)。两个生成文件被"cliff 成指针",把手写 store 与 builder 的槽让出来;最终答案组 78.7%、生成层 0.0%func (s *Service) BuildPayslip 第一次到达 Agent。

按当前仓库状态,allocation-fixtures.json 顶部注释明确写着 "STATUS: BOTH FIXTURES PASS again as of CG-31",设计文档 也记录 CG-10 与 CG-12 分别关闭了两个夹具的"排序半边"与"字节切分半边",两者现在都通过,成为活着的回归。也就是说:README 里 probe "exits 1 today, by design" 描述的是缺陷尚开时的基线状态;在缺陷被 CG-10/CG-12 关闭后,探测脚本现以 0 退出、vitest 门槛成为常态绿色的活回归。

五、如何运行与验证

README 给出的最小复现路径(继承自原文档):

npm run build
node scripts/agent-eval/probe-allocation.mjs payroll-go   # 缺陷开时退出 1(by design)
npx vitest run __tests__/explore-allocation-1500.test.ts  # 缺陷开时绿色(by design)

两条命令对应两层不同的门禁

  1. vitest(仓库内,src 直跑)explore-allocation-1500.test.ts 分两半——"夹具形态"(今天绿色,钉住三条特性:生成/手写分裂、内容头识别、runPayrollCycleAll → BuildPayslip → Upsert 端到端解析、大小分层)与"预算分配"(门槛)。夹具先被拷到临时目录、删掉可能混入的 .codegraph 索引、indexAll() 后跑真实 codegraph_exploreattributeSourceBytes 把最终响应归因到每文件字节。
  2. 探测脚本(对已构建的 dist/,带 CG-4 诊断)probe-allocation.mjs 读取 CODEGRAPH_EXPLORE_DEBUG 的 JSONL 边车(所以测的是随产品发布的分配器,而非从 markdown 重新推导),把渲染文件按 allocation-fixtures.json 里声明的 answer/incidental 分组、校验声明的份额门槛。kind: "fixture" 是**密封(hermetic)**的——每次把夹具树拷到全新临时目录重新索引,同一构建跑两次字节完全一致。

payroll-go 的断言门槛(来自 allocation-fixtures.json):answerShareAtLeast: 0.55incidentalShareAtMost: 0.25topFileGroup: "answer"mustDeliverBytes 必须含 cycle.gopayslip_builder.go,且响应必须含三枚"只对手写链唯一"的针(runPayrollCycleAllfunc (s *Service) BuildPayslips.store.Upsert(ctx, slip))。vitest 侧还有 快照断言:不是门槛,而是把分裂形状记下,让未来的数字漂移在 diff 里显形,而不是静默翻转门槛。此外 CG-14 硬上限门槛 专门把 payroll-go 当作"上限压力样本":19 个文件落在 very-tiny 档、答案确实需要更多,渲染循环会把允许的超额花满(约 19.3K 对 19.5K 上限),只留约 1% 余量——这正是值得钉住的原因。

六、已知发现:链上的 Upsert 边解析到了生成的 store

README 明确记录了一个刻意未修的发现:runPayrollCycleAll 调用 s.store.Upsert(ctx, slip),其中 s.store*payslipstore.Store,但图把这条边解析到了 internal/gen/fkit/payroll/store.go——生成的 Store.Upsert——而非手写的。两个都定义了 Store.Upsert 的包之间,同名方法解析选错了接收者。

关键定性:这是解析缺陷,不是预算缺陷。它位于分配缺陷的上游——一条错误的边会把生成的 store 拉进子图、抬高它的分数。因此它被有意留在这里不修,归属于 CG-10 打分/同名方法解析那条线,而不是本夹具的职责。为了让"日后收紧解析器不会弄坏夹具",测试只断言工作流到达某个 Upsert(见 链解析断言workflow.some((n) => n.name === 'Upsert')),而不断言它必须是手写的。设计文档把 CG-10 的作用描述为"缓解症状"(生成的 store 在分数与图上质量上都被罚,从而不再挤掉真正的那个),而不去修解析 bug 本身。

七、延伸阅读与佐证路径

适用前提:payroll-go 是合成夹具go.mod 指向虚构模块 github.com/example/payroll-svc,从不编译运行;所有"字节/占比"数字都绑定在特定构建与索引状态上(allocation-fixtures.json 中每个数字都带 measuredOn 日期与对应 CG 节点)。引用这些数据时,应把它读作"夹具被构建要捕捉的失败基线"与"逐步修复后的实测演进",而非可脱离该构建环境外推的通用指标。

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