CodeGraph 1500 回归夹具 payroll-go 剖析:复现 explore 预算被生成 CRUD 抢占的缺陷
本文围绕 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-svc、go 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 生成的,可通过"路径"识别
夹具围绕这样一条链构建:runPayrollCycleAll → BuildPayslip → Upsert,从 POST /v1/payroll/cycles/{cycleID}/run 进入。这条链在源码里是逐跳可验证的:
- 路由注册:router.go 中
mux.HandleFunc("POST /v1/payroll/cycles/{cycleID}/run", h.RunCycle); - 处理器转调:payroll_handler.go 的
RunCycle调用h.svc.RunCycle(...); - 装配:main.go 中
store := payslipstore.New()、svc := payroll.NewService(store, clock.System{}); - 工作流主体:cycle.go 的
runPayrollCycleAll,对花名册里每个员工s.BuildPayslip(...)后s.store.Upsert(ctx, slip)(第 110 行)。
真正的"计算"发生在 payslip_builder.go 的 BuildPayslip:基本工资、加班、补贴,然后是扣款与税,全部以整型分(cents)累加,"没有任何一分钱会在两行之间消失"。这正是生成的 CRUD 层不会做的事——fkit 的 BuildPayslip 只是把字段在 DTO 与行之间搬运。
三、让它成为回归夹具的三条特性
README 把夹具的"含金量"归为三条特性。每一条都对应一个可被测试钉死的源码事实。
3.1 只有内容头能暴露的生成文件(#1500 的命门)
internal/gen/fkit/** 下的文件用的是普通文件名(payslip.go、store.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 刻意制造的同名冲突
BuildPayslip、Upsert、Store 各存在两份:一份生成、一份手写。生成层还在问题的每一个词上都撞名——CreatePayslip、PayrollCycleCreateRequest、CalculatePayrollCycleTotals、第二个 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.go 的 func (s *Store) Upsert(ctx, slip payroll.Payslip) error,对生成的 fkit/store.go 的 func (s *Store) Upsert(ctx, row PayslipRow) (PayslipRow, error)。测试 名称冲突断言 对 BuildPayslip、Upsert、Store 三个名字,各断言"既有生成版、也有手写版"(通过 getNodesByName 落到两个不同前缀的文件上)。
3.3 驱动渲染模式的大小分层
cycle.go 故意超过整文件窗口(227 行),从而落到"被裁剪的 cluster"渲染;生成的文件故意低于窗口,从而整文件下发。渲染模式由文件大小驱动,而不是由相关性驱动——这正是缺陷的力学。cycle.go 全文 227 行(已核实),runPayrollCycleAll 在其中跨越多行、逻辑密集,而 Service 类型几乎撑起整文件。
测试 大小断言 钉死了这一分裂:cycle.go 必须 > 220 行,而 fkit/payslip.go、fkit/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)
两条命令对应两层不同的门禁:
- vitest(仓库内,
src直跑):explore-allocation-1500.test.ts 分两半——"夹具形态"(今天绿色,钉住三条特性:生成/手写分裂、内容头识别、runPayrollCycleAll → BuildPayslip → Upsert端到端解析、大小分层)与"预算分配"(门槛)。夹具先被拷到临时目录、删掉可能混入的.codegraph索引、indexAll()后跑真实codegraph_explore,attributeSourceBytes把最终响应归因到每文件字节。 - 探测脚本(对已构建的
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.55、incidentalShareAtMost: 0.25、topFileGroup: "answer"、mustDeliverBytes 必须含 cycle.go 与 payslip_builder.go,且响应必须含三枚"只对手写链唯一"的针(runPayrollCycleAll、func (s *Service) BuildPayslip、s.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/README.md
- 完整夹具源码:
internal/usecase/payroll/{cycle.go,payslip_builder.go}(答案层)、internal/gen/fkit/payroll/{payslip.go,store.go,calculate.go,dto.go}(噪声层)、internal/store/payslipstore/store.go(真正的持久化) - 预算分配与 CG-4/CG-6/CG-10/CG-12/CG-14/CG-21 全记录:explore-budget-allocation.md(payroll-go 即其中的"CG-6 回归夹具 #1")
- 生成文件识别(路径 + 内容头双信号):generated-detection.ts
- 夹具声明与三阶段实测数据:allocation-fixtures.json
- 确定性份额探测(对构建产物 + CG-4 诊断):probe-allocation.mjs
- vitest 回归门槛与形态钉死:explore-allocation-1500.test.ts
适用前提:payroll-go 是合成夹具,
go.mod指向虚构模块github.com/example/payroll-svc,从不编译运行;所有"字节/占比"数字都绑定在特定构建与索引状态上(allocation-fixtures.json中每个数字都带measuredOn日期与对应 CG 节点)。引用这些数据时,应把它读作"夹具被构建要捕捉的失败基线"与"逐步修复后的实测演进",而非可脱离该构建环境外推的通用指标。
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 StartedRust0623
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