package repo import ( "fmt" "os" "path/filepath" "regexp" "sort" "strings" "testing" ) /* 判定 `mails.status` 这个**行级**字段的读者清册(pi 2026-09-14 裁定 §1)。 背景:多读者场景里"某人读过"被写成整封"读过了",与按读者派生的 `CountUnread`/`ListInbox` 给出**另一个答案**。两种语义长期并存,迟早有人按行级那个来判。 所以不能只写一句"不要读它" —— 靠人记得的约束都会失效(这轮已用 `|| 'workspace'`、`catch {}`、`fillGap` 反复确认过)。 这条判据的形状:**新增一处读取 `mails.status` ⇒ 红**。 白名单里每一项都要写明**为什么它是正当的**;将来要么按同一个理由登记,要么迁移到 `mail_reads`(`readStateFor`/`unreadFor` 就是那个正确形状)。 行级字段目前**唯一正当的语义是 `archived`**(归档是全局的,不是按读者的)。 'read' 那个值**不再作为判据**(见 repo.go 里 unreadFor/readStateFor 的说明)。 */ func TestMailsStatusReadersAreRegistered(t *testing.T) { /* * 清册:**文件名 → 允许出现的次数**(+ 为什么正当)。 * * 记**次数**而不是"这个文件里有没有":后者挡不住"在已经允许的文件里再新增一处读取" * —— 而 pi 的要求是"**新增一处**读取 → 红"。次数变了就必须来改这张清册, * 于是每新增一处读取都要**当场面对"这处读取合不合规"**这个问题。 * 代价是合法改动也要来改数字:这是有意的(要的就是这个停顿)。 */ registered := map[string]int{ // archived 判定(2 处:unreadFor / readStateFor)+ GetMailByID 等查询带出行级列 "repo.go": 13, "thread.go": 1, "migrate.go": 4, // 一次性回填:历史 'read' → 折进 mail_reads } reasons := map[string]string{ "repo.go": "unreadFor/readStateFor 用 m.status 判 archived(正当);其余是查询带出行级列(欠账:应按读者派生)", "thread.go": "GetThread 带出 m.status(同一处欠账)", "migrate.go": "**全新安装/空库路径必须存在**:把历史 'read' 折进 mail_reads(措辞按 pi 2026-09-14 更正 —— 不是「跑过即结束」,否则下一个人会以为可以删)", } /* * 「正当」必须是**可判的约束**,不能是一句标注(pi 2026-09-14 裁定 §1)。 * * 按文件+计数登记挡得住"新增一处",挡不住"**已登记的那一处改变性质**": * `unreadFor`/`readStateFor` 之所以正当,是因为它们比的字面量是 `'archived'` * (**全局属性**:归档对所有人都归档),而不是 `'read'`(**per-reader 属性**)。 * 可这个"正当"原先只活在人的判断里 —— 明天有人让同一处也看 `status='read'`, * 计数不变、清册全绿,而语义塌回行级。 * * 所以这里把它判出来:**任何与 m.status 的比较,只允许比 'archived'**; * 出现与 'read'(或任何别的字面量)的比较 ⇒ 红。 */ compareRe := regexp.MustCompile(`\bm\.status\s*(?:=|<>|!=)\s*'([a-z_]+)'`) allowedLiteral := "archived" /* * 豁免必须**按文件 + 次数**(pi 2026-09-14 裁定 §2)。 * * 我在读侧清册上自己发现并修过这个洞("文件里有没有"挡不住"在已允许的文件里再新增一处"), * 但豁免这一格又退了一格:只按文件放行 ⇒ 明天有人在同一个迁移文件里新加一处 * `WHERE m.status='read'`,豁免直接吞掉它、没有任何东西会红。 * 所以豁免也记**上限次数**,多一处即红 —— 与读侧清册同形,理由字段照留。 */ allowedCompareExempt := map[string]struct { max int why string }{ "migrate.go": {2, "迁移回填:把历史 'read' 折进 mail_reads —— 它**必须**读那个遗留值(全新安装/空库路径也要有)。上限 2 处,多一处即红"}, } exemptSeen := map[string]int{} writeRe := regexp.MustCompile(`UPDATE\s+mails\s+SET\s+status`) readRe := regexp.MustCompile(`\bm\.status\b|mails\.status`) // 从包目录往上看**整个 internal/ 树**(repo / db / handler 都要在同一张清册里, // 否则"新增读者"可以躲在别的包里不被发现) root := filepath.Join("..", "..", "internal") found := map[string]int{} var writes []string var badCompare []string err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error { if err != nil || info.IsDir() || !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") { return nil } b, err := os.ReadFile(path) if err != nil { return err } src := string(b) base := filepath.Base(path) if n := len(readRe.FindAllString(src, -1)); n > 0 { found[base] = n } for i, line := range strings.Split(src, "\n") { for _, m := range compareRe.FindAllStringSubmatch(line, -1) { if m[1] == allowedLiteral { continue } /* * 唯一豁免:**迁移回填**。它的职责就是读那个遗留值(把历史 'read' 折进 * mail_reads),所以这里比 'read' 不但正当,而且是必须的。 * 豁免也做成**可判的**:按文件登记 + 写明理由(不是"凡是迁移都放行")。 */ if ex, ok := allowedCompareExempt[base]; ok { exemptSeen[base]++ if exemptSeen[base] <= ex.max { continue } badCompare = append(badCompare, fmt.Sprintf("%s:%d **超出豁免上限**(%s 这类比较上限 %d 处,理由:%s)—— %s", base, i+1, base, ex.max, ex.why, strings.TrimSpace(line))) continue } badCompare = append(badCompare, fmt.Sprintf("%s:%d 把 m.status 与 '%s' 比较 —— %s", base, i+1, m[1], strings.TrimSpace(line))) } } if readRe.MatchString(src) || writeRe.MatchString(src) { for i, line := range strings.Split(src, "\n") { if writeRe.MatchString(line) { writes = append(writes, base+":"+fmt.Sprint(i+1)+" "+strings.TrimSpace(line)) } } } return nil }) if err != nil { t.Fatal(err) } var problems []string for f, n := range found { want, ok := registered[f] if !ok { problems = append(problems, f+"(新增文件,"+fmt.Sprint(n)+" 处读取)") continue } if n != want { dir := "多了" if n < want { dir = "少了" } problems = append(problems, fmt.Sprintf("%s:清册记 %d 处,实际 %d 处(%s %d)—— 为什么:%s", f, want, n, dir, abs(n-want), reasons[f])) } } for f := range registered { if _, ok := found[f]; !ok { problems = append(problems, f+"(清册里有、实际已不用 ⇒ 清册过期,删掉该条)") } } sort.Strings(problems) if len(problems) > 0 { t.Fatalf("`mails.status` 的读取清册对不上:\n %s\n\n"+ "行级字段唯一的正当语义是 `archived`(全局);'read' 必须按读者算(readStateFor/unreadFor)。\n"+ "新增一处读取就要**当场面对**这个问题:要么迁到 mail_reads,要么改清册的数字并写明理由。", strings.Join(problems, "\n ")) } if len(badCompare) > 0 { t.Fatalf("`m.status` 被拿去和 `'%s'` 以外的东西比较了(行级语义会因此塌回邮件级):\n %s\n\n"+ "`archived` 是**全局**属性(对所有人都归档),可以留在行级;`read`/`unread` 是**按读者**的,\n"+ "必须用 mail_reads 派生(见 readStateFor/unreadFor)。\n"+ "(唯一豁免是迁移回填,按文件登记在 allowedCompareExempt 里,且要写理由。)", allowedLiteral, strings.Join(badCompare, "\n ")) } /* * 写入点:**登记清册**(不是"允许它存在",而是"多一处就红")。 * * 第一次跑这条判据就抓到了第二处 —— `MarkAllInboxReadForSession`(整会话批量已读) * 里还有一句 `UPDATE mails SET status = 'read'`,我原先只看到 `MarkMailRead` 那一处。 * 这正是"新增即红"的价值:靠人 grep 会漏,靠判据不会。 * * 两处都**已登记为欠账**(见下面 debt 注释):行级 'read' 与按读者的派生是两种语义, * 收口方向是迁到 `mail_reads`(`markReadFor` 已经是那个正确形状), * 但在"详情/线程仍返回行级 status"这两处读者迁移之前不能只删写入 * —— 那会让 JSON 里的 status 永远停在 unread,是另一种错误事实。 */ registeredWrites := map[string]string{ "repo.go": "MarkMailRead(单封已读)+ MarkAllInboxReadForSession(整会话批量已读):行级 'read' 冗余,欠账待收口", } var unregisteredWrites []string for _, w := range writes { base := strings.SplitN(w, ":", 2)[0] if _, ok := registeredWrites[base]; !ok { unregisteredWrites = append(unregisteredWrites, w) } } if len(unregisteredWrites) > 0 { t.Fatalf("新增了 `UPDATE mails SET status` 的写入点,但没登记:\n %s\n"+ "行级 status 与按读者的 `mail_reads` 是**两种语义**:写它的地方每多一处,"+ "\"某人读过\"被写成\"读过了\"的机会就多一处。要么迁到 markReadFor,要么登记理由。", strings.Join(unregisteredWrites, "\n ")) } if len(writes) < 2 { t.Fatalf("清册里登记了两处写入,实际只找到 %d 处 —— 是不是有人已经收口了?"+ "那就把这条清册和 docs 里的欠账一起更新(别让登记变成过期解释)", len(writes)) } } func abs(n int) int { if n < 0 { return -n } return n }