IStarry

一次全库只读审查:134KB 报告、P0×4 / P1×16,以及审查方自己的 4 处更正

v1.5.0 发布后我做了一次全库只读审查:不许改代码、只在副本上实测、逐条可核实,交回 134KB 报告。记录 4 个 P0、16 个 P1、两个治理机制(生产库保护铁律、已知欠账豁免)、审查方自己的 4 处更正,以及一份诚实的未完成清单。

IStarry

10 min read

一次全库只读审查:134KB 报告、P0×4 / P1×16,以及审查方自己的 4 处更正

0. 审查设计:只读、真实数据、逐条可核实

2026-09-17,v1.5.0 刚发布,我把整个仓库交出去做了一次全库只读审查——审查由另一个执行者承担,指令只有一句话,原文记录在审查报告的第一行(docs/review-v1.5.md:4):

状态:审查完成;未修改任何代码(用户指令:"先不要修改代码,只审查")

这不是客套。这次审查的全部价值,都建立在三条约束上;少一条,交回来的就会是一份"看起来很像审查"的散文。

约束一:只读。 审查期间不碰任何生产代码,实测只在 .tmp/ 下的副本上进行;报告末尾自证两处关键库哈希(交付库 B50615C1… / 根库 440E3D04…)审查前后一致、git status 为 0 改动(:75)。为什么排第一?因为审查一动手,报告就从"证据"退化成了"改动"——你再也分不清"这行本来是对的"还是"它刚被改对了"。

约束二:真实数据画像。 静态读码只能回答"这段代码在干什么",回答不了"它是不是在空转"。所以审查把交付产物 + 生产库副本在独立端口起起来,对真实 2148 台数据发请求(:6:64-75)——第 3 节会看到,这一条直接改变了三条结论的性质。

约束三:逐条可核实。 报告用两种证据标记(:11): = 本次实测或亲读代码确认; = 子代理报告、已抽查关键引用(抽查结论写入正文)。方法上是三路并行子代理分区深查(导入链 / 前端 / 测试与交付),但结论逐条回读代码复核后才采信:6)——第 9 节会看到,子代理的结论被更正了两处。

还有一条隐含约束:审查方要披露自己的副作用——报告专设一节写自己制造的垃圾(4 个一次性目录 + 3 个测试残留文件),并写明"均已删除并复原"(:77-80);同一节还顺手把"测试往源码目录写文件"记成了一条缺陷。

测量工具也要被审查。报告 §6 的耗时只取服务端 gin 日志,因为用 Invoke-WebRequest 客户端计时得到 500–850 ms,与服务端 106 ms 差了近 5 倍,换 curl.exe 后才吻合(:631)。


1. 交回来的是什么:134KB、P0×4 / P1×16

体量先给个可核对的数:v1.6.0 那次交付提交里,这份报告是 134,808 字节git cat-file -s f37d0b2:docs/review-v1.5.md)——按十进制 KB 记就是标题里的 134KB;此后因一处规则号勘误又长了几行,现工作区为 135,103 字节。

结论规模(:8):

类别数量在哪
P0(交付级 / 数据安全级)4§3(:99
P116§4(:190
冗余与死代码30+ 处§5(:323
文档与现实不符9 处§8(:447,表内 #1–#9 共 9 行)

这四行里有一处必须交代的自相矛盾:报告的结论速览写"文档与现实不符 8 处":8),而 §8 的表里实际是 9 行、附录 K.4 也按 9 处收口(:1415)——审查报告自己犯了它正在审查的那类错误(详见 §9.1)。

那 16 条 P1 覆盖数据安全与权限、业务口径与功能缺口、交付与文档、测试与卫生四类(:194:443),说明这次审查不是"挑代码风格"。

清单拿到手之后,我没有按"代码难看程度"排序,而是按证据失效程度排:① 真实数据上必然失败且文档声称已验证(清空重导 500);② 数据落点错误(131072 B 空库事故类型);③ 大面积功能不可达(85% 记录不可见);④ 安全写入路径(CSRF 201);⑤ 口径不一致(列表能搜到、导出空表)。①②③ 的共同点是"测试全绿但功能不可用"——它们消耗的是团队对测试与文档的信任,而信任一旦失效,后面所有结论都要打问号。代价是这类缺陷的修复通常只有几行(P0-3 两行、P0-4 一行),但必须配套补"能红的用例"与守卫断言,否则会以另一种形态复发——本次 P0-3 的复发就被新增的 limit 字面量断言拦住了。


2. 真实数据画像:先分清"空转"与"业务事实"

这一节是整篇审查里最容易搞错的地方。只看代码,你会得出"班组功能是空转的死功能""逾期功能从未生效"这类结论;只有把真实库打开数一遍,才分得清三种完全不同的"没数据"(:84-95):

指标实测值含义
设备总数2148与文档一致
在库 / 外借1815 / 333IN_TEAMMAINTENANCESCRAPPEDOTHER 全为 0
外借单333(全部 OUTSTANDING
预计归还日期全为空逾期能力当前无输入 → Dashboard 逾期恒 0 是预期,不是缺陷
班组字典0 条系统仍处测试阶段、正式数据尚未导入 → 禁止据此删改班组功能
/api/teams/equipment522,476 B / 2148 台全部落在"未分配"该页在当前数据下等同一份全量清单

正是这一页把审查从"疑似 30 处死代码"里救回来两条:班组为空、预计归还日期为空都是业务事实,用户随后在 2026-09-17 明确答复(AGENTS.md:106,即决策 22 的 D-5):班组为空"不是功能空转,禁止据此删改班组相关功能";预计归还日期为空"属于业务事实",不要求补录。

这里有一条分辨法——"功能没被使用"和"功能是空的"是三件不同的事

  1. 业务事实(预计归还日期全空)→ 能力长期无输入,但实现必须正确
  2. 阶段未到(班组字典 0 条)→ 数据还没导入,功能一个都不许删
  3. 真缺陷(操作审计只写不读)→ 留痕写进去了、界面也在承诺"已记审计/审计保留",却没有任何读取路由与页面:253)。

只有第三类要改。它被挑出来的方式是把"界面文案承诺"和"接口清单"对了一遍。


3. 三个 P0,各自一行命令就能复现

3.1 清空重导在真实库上必然 500(FK 787)

现象:107,交付 exe + 生产库副本):

POST /api/import/reset  {"confirm":true}
→ HTTP/1.1 500  {"error":{"code":500,"message":"清空业务数据失败: constraint failed: FOREIGN KEY constraint failed (787)"}}
清空前 /api/dashboard total = 2148      清空后 total = 2148(数据未损,但流程不可用)

根因:一个循环外键,而删除顺序沿用了单向假设:113-119):

位置内容
internal/database/migrations/V001_init.sql:64equipment.current_borrow_record_id REFERENCES borrow_record(id) → equipment 是 borrow_record 的子表
internal/database/migrations/V001_init.sql:101flow_record.borrow_record_id REFERENCES borrow_record(id) → flow_record 也是子表
internal/database/database.go:46DSN 注入 _pragma=foreign_keys(1) → 外键强制生效
internal/service/reset.go:39-68(修复前)删除顺序 = borrow_record → flow_record → equipment → import_batch ← 先删父表

真实库有 333 张外借单且 equipment/borrow_record 互相引用 → DELETE FROM borrow_record 必然违约。只有从未发生过外借的库才删得掉。

为什么现有测试全绿(本次最值得记录的一条)

测试预置数据实测
internal/service/reset_test.go:35-41(修复前)直接 db.Create(&BorrowRecord{…})不设 equipment.current_borrow_record_id,也不建引用它的 flow_record✅ PASS
internal/api/import_api_test.go:167-173(修复前)只建 1 台设备(零张外借单✅ reset 返回 200
docs/ui-redesign-plan.md:310把上面那个用例当作「清空重导需二次确认 = PASS ✅」的唯一证据❌ 证据无效

这不是"测试不够多",而是结构性错位:用例恰好避开了唯一会违约的那条引用路径。时序上不冤枉历史阶段——v1.1 Phase 10(09-09)执行清空重导时库里还没有外借单(外借是 v1.3 才补录),当时确实通过;此后没有阶段用"含外借引用的库"重验过 reset,文档却声称已重跑(AGENTS.md:116,已列入 §8)。

修法internal/service/reset.go:36-94,仍在原事务内):先断开循环——UPDATE equipment SET current_borrow_record_id = NULL WHERE current_borrow_record_id IS NOT NULL——再按子 → 父顺序删除 flow_record → borrow_record → equipment → import_batch

// 1) 断开循环外键:清掉 equipment → borrow_record 的引用占位。
//    不做这一步,下面删除 borrow_record 会被 equipment 的外键拒绝。
if err := tx.Model(&models.Equipment{}).
    Where("current_borrow_record_id IS NOT NULL").
    UpdateColumn("current_borrow_record_id", nil).Error; err != nil {
    return err
}

实机验证(副本 + 本次构建的 exe,端口 8096,:731):reset 500 → 200,返回 equipment_deleted=2148 / borrow_deleted=333 / flow_deleted=2481 / batch_deleted=2,字典保留 categories=8 / borrowers=19,自动备份已生成。一句经验:"删表顺序"这种知识,没被一条"带引用的库"用例钉住就一定会漂。

3.2 外借页每页取 50 却只渲染 10:约 283/333 条永不可见

现象:171-175):

web/src/BorrowsPage.tsx:62-67(修复前)   params.set('limit','50');  params.set('offset', String((page-1)*50))
web/src/BorrowsPage.tsx:283-286          pagination={{ current: page, pageSize: PAGE_SIZE_LIST /*=10*/, ... }}

一行命令复现:552-555):

(Invoke-WebRequest "$b/api/borrows?limit=50&offset=0" | ConvertFrom-Json)  # total=333, items=50
(Invoke-WebRequest "$b/api/borrows?limit=50&offset=50" | ConvertFrom-Json) # 首条 id=283

服务端老老实实返回 50 条,页面只渲染 10 条;翻页时 offset 按 50 跳,于是第 1 页窗口里的第 11–50 条永远没有机会出现——报告的估算结论是 约 283/333 条(85%)看不到,且伴随跳号:175)。

根因不是分页算法,是"两个数字各写一遍":请求侧写死 50,渲染侧引用共享常量 PAGE_SIZE_LIST(10)。它们本来必须同源,却分别被写成了字面量。

为什么守卫脚本没拦住scripts/check-web-conventions.ps1:40-41(修复前)只断言不出现 pageSize:\s*(10|20) 字面量——它抓的是渲染侧的硬编码,而这次的违规在请求侧limit:'50'

修法web/src/BorrowsPage.tsx:55-59):两侧改用同一个常量,并留下这段注释:

// 【v1.5 审查 P0-3 修复】limit/offset 必须与 Table 的 pageSize 同源:
// 原实现写死 50,而 Table 分页为 PAGE_SIZE_LIST(10) → 服务端返回 50 条、
// 前端只渲染 10 条,第 11–50 条永远不可见(333 条外借单中约 283 条看不到)。
params.set('limit', String(PAGE_SIZE_LIST));
params.set('offset', String((page - 1) * PAGE_SIZE_LIST));

实机验证:730):offset 0/10/20/30 各返回 10 条,首末 id 依次 333-324 / 323-314 / 313-304 / 303-29440 条去重后仍 40 条(连续、无跳号、无重复)。

比修复更值钱的动作是给守卫脚本加了一条断言limit 不得使用数字字面量(scripts/check-web-conventions.ps1:78-90)。现在这条断言是 PASS 的,且带一个自清理机制——如果有人把豁免清单改长,脚本同样 FAIL

3.3 「全不选」按钮反向全选

现象:182-186):

ImportDetailPage.tsx:668(修复前)  <Button size="small" onClick={() => setAllCompanies(true)}>全不选</Button>
ImportDetailPage.tsx:287            const setAllCompanies = (checked) => { … next[b.name] = checked …; setCompanySel(next); }

点「全不选」→ 全部外借方被选中 → plannedCount 变成 214 台 → 「确认补录」按钮变为可用。写入仍有二次确认与范围清单兜底(所以没有升级成数据损坏),但这是明确的功能反向:按钮在执行它名字的反面。

修法 1 行web/src/ImportDetailPage.tsx:660):

<Button size="small" onClick={() => setAllCompanies(false)}>
  全不选
</Button>

这条修复的验收方式是:本环境没有浏览器,只做了逻辑级验证plannedCount 依赖链与按钮 disabled: plannedCount === 0),并明确写下"UI 点击观感待用户实机验收"(:732)——没测过的地方就说没测过


4. 安全类:那个 201 Created

这一条是我在整份报告里看得最不舒服的,因为它不是"代码写得丑",而是系统真的被写进去了数据

现象与一行命令复现:198-199:547-550):

curl.exe -s -i -X POST "$b/api/borrowers" -H "Content-Type: text/plain;charset=UTF-8" `
  -H "Origin: http://evil.example" --data-binary "@body.json"
#   → HTTP/1.1 201 Created  {"id":20,"name":"CSRF-PROBE-IGNORE"}   (borrowers 19 → 20)

根因ginShouldBindJSON 不校验 Content-Type;项目也没有 CORS 中间件、没有 Origin/Referer 校验:203)。于是恶意网页可以用 fetch(url, {mode:'no-cors', body:'{"confirm":true}'}) 发起无预检的简单请求,直接命中状态变更接口——系统按决策 5 无登录体系,这就是残留的 CSRF 面。

修法(新增 internal/api/guard.go,两道防线,:38-76):

  1. Origin 校验:带 Origin 且与本次请求不同源 → 403。缺失 Origin 视为本机脚本/curl,放行——浏览器跨站请求必带 Origin
  2. 内容类型校验POST/PUT/PATCH 必须是 application/json;把"无预检的简单请求"变成"必须预检",而我们不返回任何 CORS 响应头 → 跨站预检必然失败。

有三处细节比"加中间件"本身更值得抄:

细节一:multipart/form-data 必须收窄到具体路径。 multipart/form-data 本身也是 CORS 安全内容类型,全局放行等于没堵(internal/api/guard.go:11-18):

// multipartPaths 允许 multipart/form-data 的接口 —— **仅**这两个 Excel 上传通道。
var multipartPaths = map[string]bool{
	"/api/import/parse":               true,
	"/api/import/borrow-detail/parse": true,
}

细节二:DELETE 有意豁免 Content-Type 校验——因为前端删除请求不带 body 因而没有 Content-Type,强校验会打断功能(实机 DELETE /api/teams/999999404 而不是 415:853)。

细节三:比提示词更严,而且被显式追认。 提示词原文写"GET 不受限制",实现却对 GET 也校验 Origin——因为 GET /api/export/flow 会写审计行,放行跨站 GET 等于留一条可被恶意页面触发的写路径(AGENTS.md:113,D-8)。用户在 2026-09-18 选择「选项 A:保留严格实现」。实现比提示词更严不必然是错,但必须被显式追认,不能让它悄悄变成事实。

实机验证的完整结果(:845-857):text/plain → 415、跨站 Origin → 403、无 Content-Type → 415、合法 application/json → 200、真实台账 multipart 上传 → 200、非上传接口 multipart → 415。


5. 并发类:430/468 → 0/468 → 1200/1200

「恢复备份」是这个系统里最危险的操作:它要在运行中替换整个数据库文件。修复前它有三个问题(:861-863):① restoreBusy 只挡"恢复 vs 恢复",挡不住"恢复 vs 普通请求",而 s.DB = newDB 与并发读者构成数据竞争;② 只替换 equipment.db不管 -wal/-shm/-journal;③ 任何一步失败后 s.DB 仍是已 Close 的句柄——此后所有请求永久 5xx。

先红:把缺陷测出来。 新增 internal/api/restore_race_test.go:8 个 goroutine 持续 GET /api/equipment?limit=5,主线程执行恢复(:875-881):

修复前:restore_race_test.go:117: 恢复期间有 430/468 个并发请求返回 5xx(期望被锁保护、全部成功) → --- FAIL
修复后:--- PASS(0/468 5xx)

后绿:读写锁 + 附属文件清理 + 失败必回退。 Serversync.RWMutexdbGate 中间件每个请求持读锁(注册在最前,internal/api/api.go:54),Restore 在替换窗口持写锁;新增 service.RemoveSidecarFiles 清理 -wal/-shm/-journal——必须在 Close 之后、替换之前调用,因为残留的 -journal 是热日志,SQLite 下次打开会拿它回滚刚换上去的新文件:869);失败路径改为"原库重开,实在打不开才回滚到恢复前快照",并且不再把系统留在"句柄已关闭"状态

取舍(有意):恢复现在会等待在途请求结束,而不是让它们失败——单机单用户下最坏只是多等几百毫秒,反之则是永久 5xx。

实机并发验证:890-892):8 路并发 GET 共 1200 次,其间执行真实恢复(HTTP 200,172 ms):1200/1200 全部 200,非 200 = 0;恢复后 total=2148 不变。

顺带一提:-race 还捕获了一个既有的数据竞争——logger.Get() 的惰性初始化(if l == nil { l = log.New(...) })在多个 goroutine 首次并发打日志时会竞争;该包此前没有测试,本次新增首个测试并自证非空绿(临时装回旧写法 → WARNING: DATA RACE:904-906)。

环境摩擦也照实记:跑 -race 必须用交付锁定工具链 GOTOOLCHAIN=go1.20.14 + CGO_ENABLED=1(仅测试),因为本机 Go 1.26 的 race 运行时不兼容 mingw 8.1.0(exit status 0xc0000139:883-885)。


6. 口径类:列表能搜到、导出空表;以及 08:00 的日界

这一类缺陷有一个共同特征:系统内部不自相矛盾地错了——每一个页面单独看都对,放在一起才露馅。

(1)列表能搜到,导出是空表:226-237)。修复前的实测:

q=SPX-1600(1)   列表 total=2    导出 6525 B   ← 6525 B 正是"零行基准"
q=SPX-1600        列表 total=2    导出 6731 B
q=ZZZZZZZZ(零行基准)            导出 6525 B

用户在台账里用带 (n) 后缀的显示编号搜到 2 台设备,点「导出设备台账」拿到的是空表:235)。根因是筛选条件构造有 4 份实现,字段集已经开始漂移,而 display_no(n) 剥离只在其中 1 份里做了。

修法是收敛成唯一来源:新增 internal/service/filter.goExportFilter 直接改为 EquipmentFilter类型别名internal/service/filter.go:33-34),让"导出与列表必须共用"从一句约定变成编译期事实

// ExportFilter 是 EquipmentFilter 的别名:导出与列表必须共用同一筛选定义。
type ExportFilter = EquipmentFilter

文件头部同时列出了有意保留的三处差异(班组页、外借页、流转导出各自有独立语义),并写明"不是漂移"(internal/service/filter.go:22-25)。这一笔很重要:收敛不等于全都合并——分不清"重复"和"刻意差异"的重构,会把三种正确的语义压成一种错的。

实机验证(:979-985):q=SPX-1600(1) 列表 2 条 / 导出 2 行;全角与半角括号、前后空格、小写、q+status 组合、不存在类别、未知状态共 8 组,列表 total 与导出 xlsx 行数逐一相等

(2)逾期判定在东八区 08:00 的日界错位:264-281)。修复前有两套互斥实现:Dashboard 与外借页筛选拿完整时间戳比(到期当天任意时刻都算逾期),而显示天数用 time.Now().Truncate(24*time.Hour)Truncate 作用在绝对时间上,东八区会把"今天"截到当地 08:00——于是当地 08:00 之前,同一行会被 status=OVERDUE 筛出来、却显示「外借中(0 天)」;08:00 之后才显示「逾期 1 天」。同一 handler 内筛选口径与字段口径不一致,Dashboard 与外借页对"逾期"的定义也不同,而且两侧都是 0 测试

修法不是"改一处",而是让老算法变成永久证据。新增 internal/service/overdue.go 作为唯一实现(口径 = expected_return_date日期部分 < 今天;OverdueCutoff 取当地 00:00,OverdueDays 用两个当地 00:00 相减,不用 Truncate),同时把修复前的算法原样实现进一个测试用例internal/service/overdue_test.go:56-88):

if at0030 == at0830 {
    t.Fatalf("修复前的算法本应时刻相关(00:30=%d,08:30=%d),若相等说明本用例的前提失效", at0030, at0830)
}
t.Logf("修复前:00:30 → %d 天、08:30 → %d 天(同一行两个答案);修复后两处均为 1 天", at0030, at0830)

实测输出就是标题里那句话:修复前:00:30 → 1 天、08:30 → 2 天(同一行两个答案);修复后两处均为 1 天:993,用固定 +08:00 时区,与运行机器时区无关)。这是全篇最想推荐的一个手法:不要把坏代码删掉,把它钉成一条会失败的断言——删掉之后只剩一句注释,下一个人(或下一个 AI)很容易"顺手优化"回去。


7. 「先红后绿」:先证明测试真的会红

这次审查修复最有纪律的一点:几乎每一处修复都先构造一个失败的用例。原因很朴素——测试全绿不是证据,除非你知道它红起来长什么样。

这一条在项目里升级成了一个明确要求(AGENTS.md:104,决策 22 的 D-3):真实数据文件不在仓库根目录时,硬编码真实文件的用例改为 skip,但必须打印缺失原因、登记清单,并自证"未掩盖真实失败"。注意这个自证的方向:不是"证明测试能过",而是"构造一个与 fixture 无关的真实失败,确认测试仍然变红"。

Phase 0 做了四组自证(:656-669):

自证做法结果
A(守卫不掩盖真实失败)放入版式不符的假台账(把外借明细文件改名冒充)→ 跑 importer 用例5 个用例全部 FAIL(中文提示正确);删除文件后恢复 SKIP
B(非跳过分支真的执行)备份根库 → 临时以交付库覆盖 → 跑真实库对账用例PASSequipment 2148 ↔ Sheet1 2148;flow_record 2481 ↔ Sheet2 2481
C(新增断言有效)临时新增两个违规文件(web/src/__tmp_selfproof.tsxscripts/__tmp_selfproof.ps1)→ 跑约定脚本5 条 FAIL 且均点名临时文件;删除后 exit 0
D(空值语义)复刻 Test-Metric* 做单元级验证$null -le 1 = True(缺陷复现)→ Test-MetricLe $null 1 = False(已修)

自证 D 值得一提:ui-review.ps1 里的 $d.overflowX -le 1 在指标缺失($null)时——PowerShell 中 $null -le 1 为 True——会把"无横向溢出"误判 PASS:443):这类 bug 只会在出问题的时候假装没问题

同样的纪律贯穿后半程:

  • restore_race_test.go:先红 430/468 → 后绿 0/468(第 5 节);
  • parse_bounds_test.go:47:解析循环只走到 R466,超出的行既不导入也不报错(静默丢数据)。先红实测"问题清单为 []"→ 后绿产出 BLOCK 级 V12 问题(:1276-1280);
  • overdue_test.go:56:把老算法写成永久证据(第 6 节);
  • logger 首个测试:临时装回旧写法 → WARNING: DATA RACE(第 5 节)。

还有一个"反面自证"值得记:守卫本身也会让人误判TestImportRunReviewGate 受 fixture 守卫保护(台账不在根目录即 skip),若只看 go test ./... 全绿,会误以为这条边界已被覆盖。按 D-4 把真实台账临时放回根目录实跑,才看到这条边界的真相(:914-928):

[GIN] POST /api/import/run    422     1.6ms   ← 未确认任何 REVIEW
[GIN] POST /api/import/run    422     3.7ms   ← 只确认 1 项(新增边界)
    import_api_test.go:107: 部分确认用例已执行:57 项 REVIEW 中只确认 1 项 → 422

57 项 REVIEW 中只确认 1 项 → 422。这条边界此前从未被执行过。 而"新增一条边界"这件事本身也补了兜底:用例加了 else 分支打印"本次解析只产生 N 项 REVIEW,部分确认用例未执行",避免 ≤1 项时静默假绿:930)。


8. 治理升级:生产库保护铁律与「已知欠账」豁免

审查清单修完会过期,制度不会。这次审查留下两条可复用的机制。

机制一:生产库保护铁律。 任何实测只在副本上进行;阶段报告必须附两个基线库哈希。所以你在报告里反复看到同一个句式(:1082:944:1311):

生产库保护(铁律) | 全程只用副本:交付库 B50615C1…、根库 440E3D04…(131,072 B)哈希与阶段前一致

它解决的是一个很具体的问题:执行审查的一方很愿意"帮你验证一下",而验证动作可能直接写在真实数据上。铁律把代价前置了——先复制,再动手。它也确实挡住了事:release/preview-v1.4/ 里躺着与交付库逐字节相同的生产库副本 + 18 份真实备份(:1338:1439-1440),清理时被整体移出交付目录(27 files / 56,204,006 B → 0)——生产库副本不得留在交付目录里

机制二:「已知欠账」豁免机制scripts/check-web-conventions.ps1:19-35)。约定守卫的常见失败模式是"告警太多,最后没人看",这个机制用两条互锁断言解决:

$KnownDebtSticky = @()
$KnownDebtLimit = @()
...
Assert '无新增文件使用 limit 数字字面量' ($newLimit.Count -eq 0) (Join-Sorted $newLimit)
Assert 'limit 欠账清单与实际违规集合一致(修好后请移除豁免)' `
  ((Join-Sorted $limitViolators) -eq (Join-Sorted $KnownDebtLimit)) ...

于是:新增欠账一律 FAIL(同类问题不能再溜进来);欠账修好后脚本反过来 FAIL,提醒你把豁免删掉(豁免清单既不会悄悄变大,也不会残留)。这次审查里它按设计工作了两次——Phase 1 修掉 limit 后必须把 BorrowsPage.tsx 从豁免里移除(:738),Phase 6 补完 18 处 sticky 后豁免清零、WARN 归零:1255)。

它还长出了一条自守卫scripts/*.ps1 必须带 UTF-8 BOM(:142-152)。起因是一次真实事故——编辑工具重写脚本时剥掉了 BOM,PowerShell 5.1 遂按 ANSI(GBK) 解析,中文乱码 → The string is missing the terminator → 脚本整体崩溃,表现为"exit 1 且无有效输出"(:739)。

我刚重新跑了一遍这个脚本:exit 0,19 条断言全 PASS,0 条 WARN,扫描 29 个 <Table>(本机实测,powershell -ExecutionPolicy Bypass -File scripts\check-web-conventions.ps1)。第 5 组断言扫描全部<Table>.tsx先剔注释再计数——旧实现只查 6 个文件、按 sticky 出现次数计数,注释里的 sticky 会让它误 PASS(:13-14)。


9. 诚实清单(全篇可信度的来源)

这一节是全篇最该认真读的部分。一份审查报告如果不写自己的边界,它的可信度就只剩下"看起来很像"。

9.1 这份报告自己出过的错

附录 C 的 3 条:627-631):① 逾期口径的形态写错过——子代理称"同一行 overdue_days 为 0",回读 internal/api/borrows.go:107-109 发现 if overdue < 1 { overdue = 1 } 兜底,天数至少为 1,真实分叉只在当地 08:00 之前,正文按亲验结论重写;② 严重度定错过——子代理把"发布脚本删除交付目录 backup/uploads"列为 P0,报告下调为 P1并降级成待确认事项;③ 计时工具用错过——首轮 Invoke-WebRequest 的耗时与服务端 gin 日志差近 5 倍,改用 curl.exe 后吻合。

附录 K.6 的 4 条(施行阶段,:1426-1441):

  1. lockfile 批量替换误伤依赖:改 web/package-lock.json 时正则误伤了依赖 resize-observer-polyfillversion 被改成 1.6.0 而 resolved 仍指 1.5.1),最终改为带上下文的两处精确编辑收口。教训:lockfile 禁止批量字符串替换。
  2. BOM 被剥掉两次:编辑工具重写 .ps1 会剥掉 UTF-8 BOM(第 8 节的断言 7.2 就是这么来的)。第一次由子代理告警(它不得改我负责的脚本,故只报告),第二次我自己又犯,随即补回。
  3. exe 版本校验是弱校验(假阳性):初版用"exe 文本含版本号字符串"判定,实测旧 exe 里也含 1.6.0(依赖库的版本串)。已改为断言"exe 内嵌本次前端产物 index-<hash>.js",版本号检查降级为仅打印
  4. 沿用了他人的口算数字:提示词里的"真实上传 Excel 约 26 MB""22 个 chrome-uireview-*"与实测不符(uploads 实测 87,882 B;约 26 MB 实为 18 份备份合计 26,099,712 B;chrome 目录实测 21 个)。已按实测更正。

另有两条报告与后续代码之间的漂移(第 8、9 条):§0 速览的"文档与现实不符 8 处"与 §8 的 9 行不一致(第 1 节已展开)——报告自己犯了它审查的那类错误;§7.1 的"全仓 0 个 t.Run 子测试"现已不成立(审查当时属实,:404;后续 Phase 2 新增的 cmd/equipment/anchor_test.go:24 用了表驱动子用例)——引用审查报告时要用它的日期,不要用它的现在时。

9.2 仍未完成的事

以下是 2026-09-19 发布 v1.6.0 时明确记录为"仍未完成"的项(:1417-1424),加上 AGENTS.md 末尾的目标电脑验收待办:

  1. Win7 SP1 实机回归——没有做。 需要一台 Win7 机器 + Chrome 109 / Firefox ESR 115,本机无法完成。Win7 兼容是目标与约束,不是实测结论。
  2. Chrome 109 离线安装包尚未放入 install/ 交付目录 install/ 实测为 0 个文件(:1420),离线包需人工放入。决策 11 承诺"随附离线安装包与安装说明"——承诺尚未兑现
  3. scripts/ui-review.ps1 -Mode assert 未执行:它会构建并起实例截图,与产物重建互相干扰;前置校验由子代理单独验证过,但完整断言未跑(:1421)。
  4. 18 处 sticky 与 ECharts 重绘只有静态/代码级证据,没有浏览器截图:1317-1318:1422)。原因照实写:需要拉起 exe + 真实库,而生产库保护铁律禁止在真实库上做视觉实测——两条规矩打架时选择了不动真实数据。吸顶表头,我只知道它"应该"吸顶。
  5. VACUUM INTO 的注释措辞未改internal/service/backup.go:16 注释称"WAL 安全",而 DSN 未设置 journal_mode=WALVACUUM INTO 本身是 WAL 安全的,故属措辞偏差:1423)。
  6. .gitignore 新增 *.xlsx/*.db 通配的副作用未加例外:将来入库"导入模板 / 种子库"需 git add -f 或加窄例外(:1424)。

再加一条我自己在写这篇时复核出来的:

  1. 文档内部对 <Table> 数量不一致(出处已更正)docs/review-v1.5.md:1190 写「扫描 29 个 <Table>」,而同一文件 :1247 写「30 个 <Table> 0 个缺 sticky」;AGENTS.md:140 写的也是 29。我按同一条正则实测当前是 29(scripts/check-web-conventions.ps1 第 5 组的输出即为 29),因此本篇正文只引用实测的 29,不引用 30。 (初稿曾把这处 30 误记为出自 AGENTS.md,2026-09-19 复核后更正为出自 docs/review-v1.5.md:1247。)

9.3 收尾:从第 1 篇的三条约束走到这里

第 1 篇写的是约束:交付物必须是一个 exe、目标机器只能跑 Chrome 109、没有登录体系、只有一个人加一个 AI——以及"不做什么比做什么更能决定项目成败"。

第 9 篇回头看,这次审查其实是同一套约束的自我检验,只是检验对象换成了这个项目自己的代码:

  • 只读 对应第 1 篇的边界意识:先定义不做什么,再谈做什么。审查不动代码,每条结论才还是证据。
  • 真实数据画像 对应"不猜测":"没有数据"和"功能是空的"不是一回事,这只能靠数真实库数出来,读代码读不出来。
  • 逐条可核实 + 自我更正 对应这个系列最想立住的东西:可信度不来自"我测过了",而来自"你可以按我给的命令自己复算一遍,包括复算出我哪里写错了"。

所以答案是:全库审查值得做,但得用对方式——只读、真实数据、逐条可核实,并且要求审查方如实写下自己没做到的部分。

最后给一个判断:一次审查的价值,取决于它敢不敢把"我错了"和"我没做完"也写进去。 这两样恰恰是整份报告里最有价值的部分——审查方把它自己的 4 处更正一并记在了报告里,没有藏起来。


Related Articles

Knowledge Relations