pi 2026-09-14 三条(他接受了我对"恒红=相位错"的反驳,但指出 Skip 带来的两处漏洞)。
1. **Skip 必须进余额、条件必须是测量**:
- **条件**:跳过与否由 `measureMailStatusDebt` **实测**(详情路径的 status 是否真的
等于按读者派生),不是常量、不是"我们还没迁完"这种没人会更新的事实;
- **余额**:`docs/DEBTS.json` 是**唯一登记**,Go 侧判据 `TestDebtLedgerMatchesMeasurement`
**自己测量**后与登记比对(第一版我让余额由另一条判据写入 ⇒ **排序依赖**,
Go 同包内按源文件顺序跑,登记那条先跑就读到 0 —— 排序依赖是隐蔽的假绿,已抽成自足函数);
- **可见性**:`go test` 跑通时**不打印包的输出**,我第一版把余额打在 TestMain 里,
常态运行一个字都看不见 —— 正是 pi 说的"不显形"。所以常态可见的那份打在
electron 套件的 RESULT 行:`RESULT phase=install static=5 debts=7
(static-criteria:5,mails-status-derived:1,gesture-semantics:1) probe=ok`。
2. **豁免从"按文件"改成"按文件 + 次数"**:`migrate.go` 这类比较**上限 2 处**(附理由),
多一处即红。我在读侧清册上自己修过这个洞,豁免那格却退了一格 —— pi 指出得对。
3. **三笔欠账合成一处**:原先各自表达(`RESULT static=5` / `t.Skip` 无余额 /
文档里的到期前提无余额),**没有一处能一眼看全**。现在统一登记在 `docs/DEBTS.json`
(id / 余额 / 到期前提 / 判据位置),两端读同一份:Go 侧比对实测,electron 侧打进 RESULT 行。
还清那天:登记要跟着清 —— 不清则由 `TestDebtLedgerMatchesMeasurement` 报
"**欠账已还清**,但登记还记着 N"(还清是可测事件,这正是那条判据存在的意义)。
214 lines
9.3 KiB
Go
214 lines
9.3 KiB
Go
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
|
||
}
|