From d25770ea2f7d9b316ced493db0e08e680785f048 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Mon, 14 Sep 2026 17:16:22 +0800 Subject: [PATCH] =?UTF-8?q?fix(=E6=AC=A0=E8=B4=A6):=20Skip=20=E8=BF=9B?= =?UTF-8?q?=E4=BD=99=E9=A2=9D=E4=B8=94=E6=9D=A1=E4=BB=B6=E5=BF=85=E9=A1=BB?= =?UTF-8?q?=E6=98=AF=E6=B5=8B=E9=87=8F=EF=BC=9B=E8=B1=81=E5=85=8D=E6=8C=89?= =?UTF-8?q?=E6=96=87=E4=BB=B6+=E6=AC=A1=E6=95=B0=EF=BC=9B=E4=B8=89?= =?UTF-8?q?=E7=AC=94=E6=AC=A0=E8=B4=A6=E5=90=88=E6=88=90=E4=B8=80=E5=A4=84?= =?UTF-8?q?=E5=8F=AF=E8=AF=BB=E4=BD=99=E9=A2=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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"(还清是可测事件,这正是那条判据存在的意义)。 --- client/electron/test/run-all.mjs | 18 ++- docs/DEBTS.json | 29 ++++ server/internal/repo/debt_registry_test.go | 152 ++++++++++++++++++ .../internal/repo/mail_status_derived_test.go | 67 ++++---- .../internal/repo/mail_status_readers_test.go | 27 +++- server/internal/repo/quota.go | 16 +- server/internal/repo/quota_test.go | 7 +- 7 files changed, 273 insertions(+), 43 deletions(-) create mode 100644 docs/DEBTS.json create mode 100644 server/internal/repo/debt_registry_test.go diff --git a/client/electron/test/run-all.mjs b/client/electron/test/run-all.mjs index 14402af..b74f090 100644 --- a/client/electron/test/run-all.mjs +++ b/client/electron/test/run-all.mjs @@ -23,6 +23,7 @@ */ import { prose } from './lib/read.mjs'; import { spawnSync } from 'node:child_process'; +import { readFileSync } from 'node:fs'; import { existsSync, readdirSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -506,7 +507,22 @@ if (skippedByPhase.length) { `—— 它们问的是"产物是不是从当前源码来的",安装相位问的是"已产出的产物能不能装";` + `安装相位判它只能读**产物自证**(见 deploy/install.sh 的前置检查)。`); } -console.log(`RESULT phase=${PHASE} static=${STATIC_ONLY.length} probe=${probeSummary}` + +/* + * 欠账余额打进**常态可见**的这一行(pi 2026-09-14 裁定 §1/§3): + * "跳过"若不可数就等于不显形 —— 而 `go test` 跑通时不打印包的输出, + * 所以 Go 那边的 Skip 余额必须在**这边**可见。两边读**同一个**登记(docs/DEBTS.json), + * 于是"三笔欠账一处看全"且有余额、有到期。 + */ +let debtsLine = ''; +try { + const led = JSON.parse(readFileSync(join(HERE, '..', '..', '..', 'docs', 'DEBTS.json'), 'utf8')); + const items = led.debts.map(d => `${d.id}:${d.count}`).join(','); + const total = led.debts.reduce((n, d) => n + d.count, 0); + debtsLine = ` debts=${total}(${items})`; +} catch (e) { + debtsLine = ` debts=登记读不到(${e.code || e.message})`; +} +console.log(`RESULT phase=${PHASE} static=${STATIC_ONLY.length}${debtsLine} probe=${probeSummary}` + (unknownProbes.length ? `(探针跑不了:${unknownProbes.join('、')} —— 已按到期处理)` : '(只能验形态的判据:到期前提成立就自动变红)')); diff --git a/docs/DEBTS.json b/docs/DEBTS.json new file mode 100644 index 0000000..1e5e32c --- /dev/null +++ b/docs/DEBTS.json @@ -0,0 +1,29 @@ +{ + "_": [ + "欠账的**单一登记**(pi 2026-09-14 裁定 §3):三笔类型不同、但必须能一眼看全。", + "为什么要一个文件:三笔原先各自表达(RESULT static=5 / t.Skip / 登记在文档里的到期前提),", + "没有一处能看全 —— 而『欠账不显形,就等于没有』;分散在多处的登记,审计时只会被找到一处就当全部。", + "两端都读这个文件:Go 侧判据断言自己的条目与**实测**一致(不许留一份手写的数字),", + "electron 套件把它打进 RESULT 行(那是常态可见的位置)。" + ], + "debts": [ + { + "id": "static-criteria", + "count": 5, + "due": "本工作区能装、能点设备(探针三值转 true 时自动变红)", + "where": "client/electron/test/run-all.mjs 的 STATIC_ONLY" + }, + { + "id": "mails-status-derived", + "count": 1, + "due": "详情/线程改为按读者派生(readStateFor)之后 —— 那时 mail_status_derived_test.go 从 Skip 转实跑", + "where": "server/internal/repo/mail_status_derived_test.go" + }, + { + "id": "gesture-semantics", + "count": 1, + "due": "P6 第 3 步:鸿蒙侧出现滑动手势代码时立即建(此前建 = 只有一端存在的假判据)", + "where": "docs/HARMONY-ALIGN-PLAN.md P6 段" + } + ] +} diff --git a/server/internal/repo/debt_registry_test.go b/server/internal/repo/debt_registry_test.go new file mode 100644 index 0000000..8e88721 --- /dev/null +++ b/server/internal/repo/debt_registry_test.go @@ -0,0 +1,152 @@ +package repo + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + "testing" +) + +/* +欠账的**单一余额**(pi 2026-09-14 裁定 §3)。 + +背景:到这轮为止已经有三笔不同类型、各自表达方式的欠账 —— +鸿蒙静态判据(`RESULT static=5`,有余额、有到期探针)、 +`mails.status` 行级派生化(`t.Skip`,**原先无余额**)、 +手势语义契约(登记的到期前提,**原先无余额**)。 +三笔都"可判",但**没有一处能一眼看全**;而"欠账不显形就等于没有"。 + +所以:**登记在一处、可打印**。Go 这一侧打印在 TestMain 收尾, +跨端的净值同时登记在 `docs/DEBTS.json`(两端都能读,见该文件)。 +*/ + +type debt struct { + ID string + Count int + Due string // 到期前提(什么时候该还清) + Where string // 判据在哪 +} + +var debts = map[string]debt{ + "mails-status-derived": { + ID: "mails-status-derived", + Count: 0, // 由 mail_status_derived_test.go **实测**后标 1(不许手写:常量余额 = 永久 Skip 的死法) + Due: "详情/线程改为按读者派生(readStateFor)之后 —— 那时 mail_status_derived_test.go 从 Skip 转实跑", + Where: "server/internal/repo/mail_status_derived_test.go", + }, + "static-criteria": { + ID: "static-criteria", + Count: 5, + Due: "本工作区能装、能点设备(探针三值转 true 时自动变红)", + Where: "client/electron/test/run-all.mjs(STATIC_ONLY)", + }, + "gesture-semantics": { + ID: "gesture-semantics", + Count: 1, + Due: "P6 第 3 步:鸿蒙侧出现滑动手势代码时立即建(此前建 = 只有一端存在的假判据)", + Where: "docs/HARMONY-ALIGN-PLAN.md P6 段", + }, +} + +// debtMark 由各条欠账的判据在"确认未清"时调用(余额来自**测量**,不是常量)。 +func debtMark(id string) { + d := debts[id] + d.Count = 1 + debts[id] = d +} + +type debtLedger struct { + Debts []struct { + ID string `json:"id"` + Count int `json:"count"` + Due string `json:"due"` + Where string `json:"where"` + } `json:"debts"` +} + +// loadDebts 读**那一份**登记(docs/DEBTS.json):两端的余额必须来自同一处。 +func loadDebts(t *testing.T) debtLedger { + t.Helper() + b, err := os.ReadFile(filepath.Join("..", "..", "..", "docs", "DEBTS.json")) + if err != nil { + t.Fatalf("读欠账登记 docs/DEBTS.json 失败:%v(登记丢了 = 欠账不显形)", err) + } + var l debtLedger + if err := json.Unmarshal(b, &l); err != nil { + t.Fatalf("欠账登记不是合法 JSON:%v", err) + } + return l +} + +/* +★ 登记与**实测**必须一致(pi 2026-09-14 裁定 §1:Skip 的条件要是测量结果,"跳过"要进余额)。 + +这条判据挡的是两种死法: +① 条件被写死:余额不是从测量来的,而是某人抄的数字 ⇒ 这里用 debtMark 的实测值比对; +② 跳过不可数:`go test` 对 Skip 是退出码 0、`--- SKIP` 只是一行输出 + + (而且**跑得通时 go test 根本不打印包的输出** —— 我第一次就把余额打在 TestMain 里, + 结果常态运行时一个字都看不见,正是"不显形")。所以可见的那份在 + electron 套件的 RESULT 行(它读同一个文件),这里保证两处**同源**。 +*/ +func TestDebtLedgerMatchesMeasurement(t *testing.T) { + l := loadDebts(t) + seen := map[string]bool{} + for _, d := range l.Debts { + seen[d.ID] = true + if d.Due == "" || d.Where == "" { + t.Fatalf("欠账 %s 必须写明到期前提与判据位置(否则它只是「存在」,不是「欠账」)", d.ID) + } + } + for _, id := range []string{"static-criteria", "mails-status-derived", "gesture-semantics"} { + if !seen[id] { + t.Fatalf("欠账登记里缺 %s —— 三笔必须同处登记,否则审计只会找到一处就当全部", id) + } + } + // 本包能实测的那一笔:**自己测**(不依赖别的测试先跑过 —— 排序依赖是隐蔽的假绿) + var want int + for _, d := range l.Debts { + if d.ID == "mails-status-derived" { + want = d.Count + } + } + outstanding, detail, derived := measureMailStatusDebt(t) + got := 0 + if outstanding { + got = 1 + } + if got != want { + if got == 0 { + t.Fatalf("**欠账已还清**(详情 %q 已等于按读者派生 %q),但 docs/DEBTS.json 还记着 %d —— "+ + "还清是可测事件,登记要跟着清(这正是这条判据存在的意义)", detail, derived, want) + } + t.Fatalf("mails-status-derived 的余额:登记说 %d,实测说 %d —— 登记与测量分叉了", want, got) + } +} + +// debtSummary 供 TestMain 打印:一处能看全的余额 + 到期前提。 +func debtSummary() string { + ids := make([]string, 0, len(debts)) + total := 0 + for id, d := range debts { + if d.Count > 0 { + ids = append(ids, id) + total += d.Count + } + } + if len(ids) == 0 { + return " debts=0(全部还清)" + } + sort.Strings(ids) + var b strings.Builder + fmt.Fprintf(&b, "\n======= 欠账余额 debts=%d =======", total) + for _, id := range ids { + d := debts[id] + fmt.Fprintf(&b, "\n · %s ×%d\n 判据:%s\n 到期:%s", d.ID, d.Count, d.Where, d.Due) + } + fmt.Fprintf(&b, "\n(三笔合在一处看全:分散登记时,审计只会找到一处就当全部)") + return b.String() +} diff --git a/server/internal/repo/mail_status_derived_test.go b/server/internal/repo/mail_status_derived_test.go index ca289cc..4cfafb0 100644 --- a/server/internal/repo/mail_status_derived_test.go +++ b/server/internal/repo/mail_status_derived_test.go @@ -13,58 +13,69 @@ import ( 而列表路径(`CountUnread`/`ListInbox`)给的是**按读者**派生的值 —— 同一个字段两种语义。 收口顺序是"先迁读者、再删写入"(先删写入会让 JSON 里的 status 永远是 unread,是另一种错误事实)。 -这条判据的形状(三态,与套件的探针同族): +形状(三态,与套件的探针同族): - - **欠账未清** → `t.Skip`,跳过信息里写明"还欠什么",**不假红也不假绿**; - - **欠账已清** → 开始**实跑**:JSON 里的 `status` 必须由 `mail_reads` 派生 - (对给定 reader 的取值等于派生死值),转绿即"这笔欠账还清了"的**可测事件**; - - 迁移做了一半(例如只改了 `GetMailByID`、漏了 `GetThread`)→ 红。 +- **欠账未清 ⇒ `t.Skip`**,跳过信息写明还欠什么(**不假红也不假绿**); +- **还清 ⇒ 实跑**并断言"详情路径的 status == 按读者派生的值",**转绿即可测事件**; +- 迁一半(只改 `GetMailByID`、漏 `GetThread`)⇒ 红。 -为什么不写成"现在就是红的":**恒红的门 = 挂在错误相位的门**(这轮已经为它付过学费, -见 `client/electron/test/CRITERIA.md` §11)。欠账要有余额、要有到期,也要有"还清即转绿"。 +为什么不写成"现在就是红的":**恒红的门 = 挂在错误相位的门** —— 这轮已经为它付过三次学费 +(`check-shared-libs`、`packaging`、`HOME`),见 `client/electron/test/CRITERIA.md` §11。 +改成 Skip 之后又必须补两件事(pi 2026-09-14 §1):**跳过条件必须是测量结果**(不是常量), +以及**"跳过"必须进可数的余额**(否则"不假红也不假绿"会变成"不显形")—— +余额登记在 `docs/DEBTS.json`(两端同源),常态可见的那份打在 electron 套件的 RESULT 行 +(`go test` 跑通时不打印包的输出,把余额只打在 TestMain 里等于没人看得见)。 */ -func TestMailStatusInJSONIsDerivedWhenDebtCleared(t *testing.T) { + +/* +measureMailStatusDebt:**自足**的测量 —— 谁调用谁测,不依赖"别的测试先跑过"。 + +第一版把债务标记写在 Skip 那一支、余额由另一条判据去读,于是产生**排序依赖** +(Go 在同一包内按源文件顺序跑,登记那条先跑就读到 0)。排序依赖是隐蔽的假绿来源, +所以把测量抽成一个函数,每条判据各自调用。 + +返回 outstanding = 详情路径与"按读者派生"是否**实测**不一致(不一致 = 欠账未清)。 +*/ +func measureMailStatusDebt(t *testing.T) (outstanding bool, detail, derived string) { + t.Helper() setupTestDB(t) ctx := context.Background() - id := seedMailTo(t, "alice", "bob") - - // 欠账是否还在:看详情路径返回的 status 与"按读者派生"是否可能不同 - // 手法:alice 未读时行级值仍是 'unread'(没写过的邮件),所以先让 bob 读掉这封 —— - // 一旦行级写入生效,alice 在详情里就会看到 'read',而列表路径(按读者)说 'unread'。 + // 让 bob 读掉:行级写入会把整封标 'read',而 alice 的按读者派生仍是 'unread' if err := MarkMailRead(ctx, id, "bob"); err != nil { t.Fatal(err) } - m, err := GetMailByID(ctx, id) if err != nil { t.Fatal(err) } - perReader, err := ListInbox(ctx, "alice", "all", 50) + list, err := ListInbox(ctx, "alice", "all", 50) if err != nil { t.Fatal(err) } - var listStatus string - for _, x := range perReader { + for _, x := range list { if x.ID == id { - listStatus = x.Status + derived = x.Status } } - if listStatus == "" { + if derived == "" { t.Fatalf("自检:列表路径里应当能看到这封邮件(id=%s)", id) } + return m.Status != derived, m.Status, derived +} - // 欠账判定的**唯一依据**是"详情路径是否已按读者派生"——所以先看它是否等于派生死值 - if m.Status != listStatus { - // 欠账还在:不假红,明确跳过并写清欠的是什么、以及还清后这条会开始实跑 - t.Skipf("欠账未清(本判据在还清那天会自动开始实跑并转绿):详情路径返回行级 status=%q,"+ +func TestMailStatusInJSONIsDerivedWhenDebtCleared(t *testing.T) { + outstanding, detail, derived := measureMailStatusDebt(t) + if outstanding { + // 欠账还在:**实测**(不是常量)⇒ 记进余额,然后再跳过 + debtMark("mails-status-derived") + t.Skipf("欠账未清(还清那天本判据自动转实跑并转绿):详情路径返回行级 status=%q,"+ "而按读者派生说 %q —— 差在 GetMailByID/GetThread 仍带出行级列;"+ - "迁完那两处(改用 readStateFor)后本判据会实跑。收口顺序:先迁读者、再删写入。", - m.Status, listStatus) + "迁完那两处(改用 readStateFor)后本判据会实跑。收口顺序:先迁读者、再删写入。"+ + "(余额见 docs/DEBTS.json;常态可见的那份在 electron 套件的 RESULT 行。)", detail, derived) } - // 已还清:断言更强的性质 —— 详情与列表必须一致(都对同一个 reader 派生) - if !strings.EqualFold(m.Status, listStatus) { - t.Fatalf("详情 %q 与列表 %q 不一致", m.Status, listStatus) + if !strings.EqualFold(detail, derived) { + t.Fatalf("详情 %q 与列表 %q 不一致", detail, derived) } } diff --git a/server/internal/repo/mail_status_readers_test.go b/server/internal/repo/mail_status_readers_test.go index 0e068db..71ae24e 100644 --- a/server/internal/repo/mail_status_readers_test.go +++ b/server/internal/repo/mail_status_readers_test.go @@ -59,10 +59,21 @@ func TestMailsStatusReadersAreRegistered(t *testing.T) { */ compareRe := regexp.MustCompile(`\bm\.status\s*(?:=|<>|!=)\s*'([a-z_]+)'`) allowedLiteral := "archived" - // 按文件登记的豁免(带理由);不在这张表里的文件与 'archived' 之外的字面量比较 ⇒ 红 - allowedCompareExempt := map[string]string{ - "migrate.go": "迁移回填:把历史 'read' 折进 mail_reads —— 它**必须**读那个遗留值,这是它的职责", + /* + * 豁免必须**按文件 + 次数**(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`) @@ -96,8 +107,14 @@ func TestMailsStatusReadersAreRegistered(t *testing.T) { * mail_reads),所以这里比 'read' 不但正当,而且是必须的。 * 豁免也做成**可判的**:按文件登记 + 写明理由(不是"凡是迁移都放行")。 */ - if why, ok := allowedCompareExempt[base]; ok { - _ = why + 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, diff --git a/server/internal/repo/quota.go b/server/internal/repo/quota.go index 64c50e6..c2c1aff 100644 --- a/server/internal/repo/quota.go +++ b/server/internal/repo/quota.go @@ -20,14 +20,14 @@ import ( // // `agents` 表这边只剩两样东西: // -// default_rounds —— 派给这个 Agent 的**新任务**默认多少个来回。 -// 不同 Agent 能力不同(跑测试的小工具 vs 重构整个模块), -// 默认值分开设才合理。 +// default_rounds —— 派给这个 Agent 的**新任务**默认多少个来回。 +// 不同 Agent 能力不同(跑测试的小工具 vs 重构整个模块), +// 默认值分开设才合理。 // -// used_rounds —— 纯统计,累计发信数。**不再拦任何请求。** -// 它原本是「终身额度」:跑满就得管理员手工重置才能再干活, -// 而 Agent 是长期在线的 —— 终身额度是错的工具。 -// 保留是因为「这个 Agent 一共发了多少信」本身有观测价值。 +// used_rounds —— 纯统计,累计发信数。**不再拦任何请求。** +// 它原本是「终身额度」:跑满就得管理员手工重置才能再干活, +// 而 Agent 是长期在线的 —— 终身额度是错的工具。 +// 保留是因为「这个 Agent 一共发了多少信」本身有观测价值。 // // 防止 Agent 用 `.new` 开一串新会话绕过预算,靠的是**新建会话速率限制** // (见 sessionRateLimiter),而不是终身额度。 @@ -169,7 +169,7 @@ type ForwardSource struct { } // LoadForwardSource 读取待转发的邮件,并校验转发者确实参与过该邮件 -//(收件人、发件人或被抄送方之一)。防止凭 mail_id 转发别人的邮件。 +// (收件人、发件人或被抄送方之一)。防止凭 mail_id 转发别人的邮件。 func LoadForwardSource(ctx context.Context, mailID uuid.UUID, actor string) (*models.Mail, error) { m, err := GetMailByID(ctx, mailID) if err != nil { diff --git a/server/internal/repo/quota_test.go b/server/internal/repo/quota_test.go index fca06b7..8600a30 100644 --- a/server/internal/repo/quota_test.go +++ b/server/internal/repo/quota_test.go @@ -2,6 +2,7 @@ package repo import ( "context" + "fmt" "os" "path/filepath" "testing" @@ -121,5 +122,9 @@ func TestListAgentStats(t *testing.T) { } func TestMain(m *testing.M) { - os.Exit(m.Run()) + code := m.Run() + // 欠账余额打在收尾:Skip 在 `go test` 里只是退出码 0 的一行输出, + // 不进余额就等于"不显形"(pi 2026-09-14 裁定 §1)。 + fmt.Println(debtSummary()) + os.Exit(code) }