docs: add P15-A and P15-B specs and plans, register both in ROADMAP
Two batches, one per repo, so they can run in parallel under the one-writer-per-repo rule: P15-A refactors crearte-server's Approve function, P15-B fixes a dark-mode gap in crearte's game loading screen plus two guard items. P15-A splits a 169-line function whose seven phases are mapped here with real line numbers — the base-tree mapping logged during P14 is void, since that batch moved the function into moderation.go. The spec's central constraint is that phases four and six look alike but must not be merged: four is an optimistic pre-check that runs unlocked before any object copy, six is a pessimistic re-check under a row lock after the copies have happened. They differ in four concrete ways (kind coverage, the NewVersion runtime and bundle checks, whether entities are created, and the error wrapping), so the apparent duplication is deliberate TOCTOU defence rather than removable redundancy. A measurement also overturned the survey's claim about helper shape: both an unexported method and a package-level function leave all seven P14 assertions green, because IsExported filtering and NumMethod's exported-only view put neither in scope. The choice is therefore about needing receiver fields, not about the guard. P15-B fixes a gap P13 left: its token work covered only the src/index.html entry and missed the second Vite entry, bootstrap, so the game loading screen hardcodes light values and dark-theme users see a white flash on every virtual game launch. The fix rides the hash channel bootstrap already parses rather than inventing a postMessage protocol, which would be async and so repaint light first — the very flash being removed. The spec records the trap that makes this easy to get wrong: GameHost builds targets in a computed, so injecting the reactive theme ref would make a theme switch recompute the iframe src and reload a game in progress; the non-reactive snapshot is required, and a guard leg plus a mutation exist solely to hold that line. It also records two pre-existing contrast violations found on the way, where inline style attributes override conforming stylesheet values with lower-contrast ones, and why noHardcodedColor cannot be extended to cover this file: it only collects .vue and blanks out style blocks, so widening its roots would scan nothing while looking like coverage. Every line number and quoted signature in both specs was checked against source before commit; two claims the survey had asserted from reasoning were measured instead, and one of them was wrong.
This commit is contained in:
@@ -74,3 +74,5 @@
|
|||||||
| P12 窄屏溢出修复与打磨批 | `docs/specs/2026-10-02-p12-narrow-viewport-overflow-design.md` | `docs/plans/2026-10-02-p12-narrow-viewport-overflow.md`(已执行,2026-10-02:crearte 0.25.0 / server 0.17.1 / deploy 0.7.1) |
|
| P12 窄屏溢出修复与打磨批 | `docs/specs/2026-10-02-p12-narrow-viewport-overflow-design.md` | `docs/plans/2026-10-02-p12-narrow-viewport-overflow.md`(已执行,2026-10-02:crearte 0.25.0 / server 0.17.1 / deploy 0.7.1) |
|
||||||
| P13 暗色模式 | `docs/specs/2026-10-03-p13-dark-mode-design.md` | `docs/plans/2026-10-03-p13-dark-mode.md`(已执行,2026-10-03:crearte 0.26.0) |
|
| P13 暗色模式 | `docs/specs/2026-10-03-p13-dark-mode-design.md` | `docs/plans/2026-10-03-p13-dark-mode.md`(已执行,2026-10-03:crearte 0.26.0) |
|
||||||
| P14 拆分 ContentService god object | `docs/specs/2026-10-03-p14-content-service-split-design.md` | `docs/plans/2026-10-03-p14-content-service-split.md`(已执行,2026-10-03:crearte-server `fe810dd` 0.18.0) |
|
| P14 拆分 ContentService god object | `docs/specs/2026-10-03-p14-content-service-split-design.md` | `docs/plans/2026-10-03-p14-content-service-split.md`(已执行,2026-10-03:crearte-server `fe810dd` 0.18.0) |
|
||||||
|
| P15-A 拆分 Approve 七阶段 | `docs/specs/2026-10-03-p15a-approve-phases-design.md` | `docs/plans/2026-10-03-p15a-approve-phases.md`(进行中,2026-10-03:crearte-server) |
|
||||||
|
| P15-B bootstrap 暗色 + 守卫补钉 + 死令牌清理 | `docs/specs/2026-10-03-p15b-bootstrap-dark-design.md` | `docs/plans/2026-10-03-p15b-bootstrap-dark.md`(进行中,2026-10-03:crearte) |
|
||||||
|
|||||||
@@ -0,0 +1,123 @@
|
|||||||
|
# P15-A 实现计划:拆分 `Approve` 169 行七阶段
|
||||||
|
|
||||||
|
- **spec(权威)**:`docs/specs/2026-10-03-p15a-approve-phases-design.md`
|
||||||
|
- **取证账本**:`crearte-server/.superpowers/sdd-p15a/survey.md`(控制者派发前落盘;P15 前期取证在 `crearte/.superpowers/sdd-p15/survey.md` §8.1)
|
||||||
|
- **仓**:`crearte-server`(**仅此一个**)· **base** `fe810dd`(0.18.0,master)· 分支 `chore/p15a-approve-phases`
|
||||||
|
> ⚠️ 分支前缀用 `chore/`:AGENTS.md 只允许 `{feat|fix|docs|chore}/` 四种(**`refactor/` 不允许**,P14 控制者曾写错)。纯结构重构归 `chore/`。
|
||||||
|
> ⚠️ inner repo 红线:**不得在 `master` 上提交**。`git commit` 前先 `git branch --show-current` 确认。
|
||||||
|
- **性质**:纯结构重构,**零行为变更**(spec D-G)
|
||||||
|
- **目标版本**:crearte-server **0.19.0** · wrapper **0.3.8**
|
||||||
|
- **派发**:**一个**实现者子代理做完 T1→T3(所有任务同动单一工作树与 git index,`sdd-parallel-dispatch` §1 一仓一写者)
|
||||||
|
- **并行**:P15-B 在 `crearte` 仓同时进行 → **不得触碰 `crearte/`、`crearte-deploy/`、wrapper 的任何文件**
|
||||||
|
|
||||||
|
> spec 与本计划冲突时 **spec 赢**。spec 全文(§0–§8)必读,尤其 **§1.2(④⑥不同形,禁止合并)**、§1.3(helper 形态实测)、§2 决策表 D-A…D-I、§3.2(逐阶段要求,含 6 处"逐字保留")、§5-T1(mutation a–h 及 (b) 的已知盲区警告)、§7 风险表、§8 纪律 10 条。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 任务表
|
||||||
|
|
||||||
|
| 任务 | 交付物 | 要求 | 完成判据 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **T1** | `internal/service/function_length_test.go`(**新增文件**,不改 `architecture_test.go`) | **RED 先行**(spec §5-T1)。三条断言:① `TestNoOversizedFunction`:`service/` 内非测试 `.go` 无任何函数 >100 行(阈值依据见 spec §0 普查:当前只有 `Approve` 169 超过,次大 83)② `TestApproveIsSmall`:`Approve` <60 行 ③ `TestApproveHelpersExist`:五个 helper 名存在——**必须用源码文本匹配,不能用 reflect**(spec §1.3:未导出标识符 reflect 不可见)。断言内须含"至少扫到 N 个 `.go` 文件"的前提检查(spec §5 mutation (e) 的教训) | RED 输出**逐字存证** `.superpowers/sdd-p15a/impl-evidence/t1-red.txt`,且**红的原因是断言失败而非编译错误**(与 P14 T1 的编译红形态**不同**:本批引用的都是既有类型)。三条各自的红须可分辨 |
|
||||||
|
| **T2** | `internal/service/moderation.go`(改) | 按 spec §3.1 骨架 + §3.2 逐阶段要求执行拆分:`loadApprovePlan`(①②③) / `precheckApprove`(④) / `copyApprovalObjects`(⑤,**收 `*approvePlan`**) / `applyApprovalInTx`(⑥,**包级函数**收 `tx repository.ContentRepository`) / `cleanupApprovalUploads`(⑦) + 包级私有 struct `approvePlan`(spec D-B 七字段)。**六处"逐字保留"**见 spec §3.2 的 ⚠️ 标注 | `go test ./... -count=1` **11 包全绿**;**既有 `*_test.go` 修改数 = 0**;`architecture_test.go` **修改行数 = 0**;P14 七断言 `-v` 全 PASS |
|
||||||
|
| **T3** | 字节级保真证明(加做项,P14 同款) | 写脚本对 base `fe810dd` 的 `Approve` 函数体与新树的 `Approve`+5 helper 做**语句级比对**(归一化缩进与接收者前缀),产出 `verbatim` / `changed`(逐条给理由)/ `missing`(**必须 0**) 三张清单。**脚本的三个已知坑见 spec §5-T3 的 ⚠️**(字符串字面量里的 `//` 被当注释、单行 `var` 须提前终止、意外值先怀疑自己的模式) | 三份清单存证 `.superpowers/sdd-p15a/impl-evidence/t3-verbatim.txt`;`missing = 0`;`changed` 每条有理由且都落在 spec §3.2 允许的范围内 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 验收(控制者独立复跑,不采信自报)
|
||||||
|
|
||||||
|
### 四门(dockerized Go,AGENTS.md 硬红线)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd crearte-server && docker run --rm -v "$PWD/src:/src" -w /src \
|
||||||
|
-v crearte_gomod:/go/pkg/mod -e GOCACHE=/gocache -v crearte_gocache:/gocache \
|
||||||
|
-e GOPROXY=https://goproxy.cn,direct -e GOSUMDB=sum.golang.google.cn \
|
||||||
|
golang:1.24-alpine sh -c 'gofmt -l . ; go vet ./... ; go build ./... ; go test ./... -count=1'
|
||||||
|
```
|
||||||
|
|
||||||
|
基线(spec §0,已实测):`gofmt -l .` 空 · `go vet` exit 0 · `go build` exit 0 · `go test ./... -count=1` **11 包 ok**(api ~11.0s 真库集成、service ~3.7s)。
|
||||||
|
|
||||||
|
⚠️ **host Go 1.18 不可用**;**必须带 `GOPROXY=https://goproxy.cn,direct`**(`proxy.golang.org` 本机不可达,默认下载**挂死并杀代理**);冷构建 1–3 min → `background: true` + 耐心 poll,**不要 sleep 循环、不要因没立刻返回就断定失败**。
|
||||||
|
⚠️ **`-count=1` 强制实跑**(禁缓存)。
|
||||||
|
⚠️ **落盘证据用 `setsid nohup` 脱离 + 宿主侧重定向**(容器只挂 `/src` 写不了 `.superpowers/`;exec session 一断容器就被杀)。
|
||||||
|
⚠️ **`grep -c` 输出 0 时 exit 1**,放 `&&` 链里会静默中断后续步骤(含 `git commit`)。
|
||||||
|
⚠️ **`pkill -f <pattern>` 会匹配到自己的命令行**(P14 控制者把自己 SIGKILL)→ 用 `[p]attern` 括号技巧。
|
||||||
|
|
||||||
|
### 结构指标(spec §5 表,交付时须报实测数字,**一律 `wc -l` 口径**)
|
||||||
|
|
||||||
|
| 指标 | 基线 | 目标 |
|
||||||
|
|---|---|---|
|
||||||
|
| `Approve` 行数 | **169** | **< 60** |
|
||||||
|
| `service/` 包内 >100 行的函数数 | **1** | **0** |
|
||||||
|
| `service/` 包内最大单函数行数 | **169** | **< 100**(次大者 83,故落点应在 83–100) |
|
||||||
|
| `moderation.go` 行数 | 298 | **不作指标**(spec D-H:helper 同文件,可能不降反升;报了即可) |
|
||||||
|
| 既有 `*_test.go` 修改文件数 | — | **0** |
|
||||||
|
| `architecture_test.go` 修改行数 | — | **0** |
|
||||||
|
| `Approve` 签名 | `(ctx context.Context, adminID, submissionID, note string) error` | **逐字不变** |
|
||||||
|
| 字节级比对 `missing` | — | **0** |
|
||||||
|
|
||||||
|
> ⚠️ **不要用 `len(text.split('\n'))` 数行数**:文件以换行结尾时它多算一个空段。P14 F5 就因此让控制者报出 83/299 而真值是 82/298,被任务级审查者抓到。`wc -l` 是本仓钉死的口径(P14 spec §5 RE-PIN)。
|
||||||
|
|
||||||
|
### mutation 抽查(spec §5-T1 的 a–h,控制者独立复现,不复用实现者结果)
|
||||||
|
|
||||||
|
**八条**,每条测"预期红 + 其余绿 + **非编译红** + 恢复证明":
|
||||||
|
|
||||||
|
| # | mutation | 预期 |
|
||||||
|
|---|---|---|
|
||||||
|
| (a) | helper 存在但 `Approve` 不调它、改为原地展开 | `TestApproveIsSmall` 红 |
|
||||||
|
| (b) | 构造一个 **>100 行**的 helper(⚠️ spec §5 已警告:把⑥整体 80 行搬进 `applyApprovalInTx` **不会红**,那是阈值 100 的已知盲区,不是守卫失效) | `TestNoOversizedFunction` 红 |
|
||||||
|
| (c) | 删掉一个 helper(内容合并进 `Approve`) | `TestApproveHelpersExist` 红 |
|
||||||
|
| (d) | helper 改名(如 `loadApprovePlan`→`loadApproveInputs`) | `TestApproveHelpersExist` 红(**(c) 的对照**:证明钉的是名字集合而非"存在任意 5 个函数") |
|
||||||
|
| (e) | 扫描范围改成空目录 | 红(防"扫 0 文件报 0 违规") |
|
||||||
|
| (f) | 阈值 100 → 1000 | 红(防阈值被放宽;断言须自证阈值字面量) |
|
||||||
|
| (g) | **给 `ModerationService` 加字段**(`clock func() time.Time`) | **P14 断言 6 `TestLineFields` 红**(跨批回归) |
|
||||||
|
| (h) | **在 `ContentService` 上加方法** | **P14 断言 5 红**,且**须同时测有名与匿名两种接收者形态**(P14 FR-1 教训:只测有名会漏掉 `func (*ContentService) M()`) |
|
||||||
|
|
||||||
|
⚠️ **`restore()` 只回滚 mutation 触及的文件,绝不用 `git checkout -- .`**(P14 控制者第 24 次自伤:全量回滚抹掉了未提交的守卫编辑,导致后四轮全在测没有修复的树、汇总报"绿=3")。每轮恢复后核 `git diff HEAD --exit-code -- <file>` 空 + 守卫文件 `func Test` 计数不变。
|
||||||
|
⚠️ **mutation 若产生编译错误,不算"守卫有牙"**(P14 控制者第 22 次错误:正则截断签名致编译红,却被报成"守卫拦住了遮蔽")。
|
||||||
|
|
||||||
|
### 行为不变的额外证据
|
||||||
|
|
||||||
|
- **既有 13 个测试函数就是行为守卫**(spec §1.6 表):`TestApproveConcurrentOnlyOneWins`(⑥行锁+重校)· `TestApproveSameWorkIDConcurrent`(④⑥的 `ErrWorkIDTaken`,断言文本含 `want ErrWorkIDTaken or ErrSubmissionConflict`)· `TestApproveMetadataChangeFeaturesSemantics`(⑥ 的 `Features` **三态语义**,用 `meta-absent` 与 `,"features":{}` 两个 payload 精确区分)· `TestApproveOptionalFieldsRoundTrip` · `TestMetadataChangeRuntimeImmutable` · `TestNewVersionMetadataChangeOwnership` · api 层 5 个 + 2 个真库发布链
|
||||||
|
- **错误文案逐字比对**:`service: load submission: %w` / `service: resolve submitter: %w` / **`service: approve precheck: %w`(两处,只在④)** / `service: copy bundle: %w` / `service: copy cover: %w`;以及 `ErrContentNotFound`(①) vs **`ErrSubmissionConflict`(⑥ 的 `GetByIDForUpdate` 未找到)** 这个**刻意的差异**(spec §3.2 ⑥)
|
||||||
|
- **两条 slog 逐字**:`slog.Error("approve: pending delete failed", "key", key, "error", err)`(⑦ helper 内)· `slog.Info("approve", "submission", …, "work", …, "kind", …, "admin", …)`(**留在 `Approve` 本体**,spec §3.2 ⑦)
|
||||||
|
- **`Approve` 只有一个非测试调用点** `handler/admin.go:62`(spec §1.7)→ helper 无须导出;签名变更会打破 `moderationPort` 满足性 → `serve.go` 编译红
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 边界(**不做**,spec §4)
|
||||||
|
|
||||||
|
- **不合并④⑥的 switch**(spec §1.2 四处实质差异 + 乐观/悲观语义 + 错误文案不同;被否方案 A/B)
|
||||||
|
- 不动 `Reject`/`Unpublish`/`Republish`/`UpdateWorkFeatures`/`ListQueue`(同文件其它方法)
|
||||||
|
- 不动 `deleteAccountLocked`(83)/`UpdateSubmission`(76)/`ValidateWorkFields`(75)/`ImportDir`(70)/`Run`(70)/`checkSubmissionRules`(66) —— 六个 >60 行函数各自独立成批(spec §4 挂账)
|
||||||
|
- 不新增/修改任何测试(既有测试即守卫,spec §2.1 被否方案 D)
|
||||||
|
- **不改 `architecture_test.go`**(P14 七断言)——若某条因新 helper 变红,**停下来报告**,不自行改守卫
|
||||||
|
- 不碰 `docs/`、`go.mod`/`go.sum`(实现者红线;记账由控制者做)
|
||||||
|
- 不碰其它三个仓
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 记账(控制者做,实现者禁动 `docs/`)
|
||||||
|
|
||||||
|
- `crearte-server/docs/CHANGELOG.md` 新增 **`## [0.19.0] - 2026-10-03`**(插 `## [0.18.0]` 前)
|
||||||
|
- wrapper `docs/CHANGELOG.md` 新增 **`## [0.3.8] - 2026-10-03`**(插 `## [0.3.7]` 前),小节 `### Done / 完成`;**与 P15-B 合并为一条还是分两条按合并时序定**
|
||||||
|
- `docs/ROADMAP.md`:第三波表新增 **P15-A 行** + 文档索引表新增一行(**哈希引 merge commit**,P12/P13/P14 惯例)
|
||||||
|
- **挂账新增**:六个 >60 行函数 · `TestNoOversizedFunction` 阈值 100 的已知盲区(80 行 helper 不被拦)
|
||||||
|
- 格式:**同条目英文行紧跟中文行、无空行**;不同条目**空一行**;小节标题双语(`### Changed / 变更`、`### Tests / 测试`、`### Context / 背景`)
|
||||||
|
- **纪律**:显式 `git add <file>`,**禁 `git add -A`**;wrapper 的 `IDENTITY.md`/`SOUL.md`/`USER.md` 三个未跟踪文件**绝不 stage**
|
||||||
|
- **合并**:CHANGELOG 改动提交到**特性分支** → `git checkout master` → `git merge --no-ff` → 记 merge 哈希 → 推送(**禁管道**)→ `git ls-remote origin master` **非空**对账(`-z` 检查,防空变量假 MATCH)→ 删分支
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 审查阶梯(P11–P14 惯例,不可省)
|
||||||
|
|
||||||
|
1. **实现者自报** + 证据落盘(RED 输出、四门、mutation、字节级比对)
|
||||||
|
2. **控制者独立核实**(全部自己跑,不采信自报数字;P14 控制者因此抓到实现者报告可信度高、也抓到自己 6 处错)
|
||||||
|
3. **任务级审查者**(只读,**自设 mutation 不照抄 spec 的 a–h**,审守卫恒真/恒假、跨任务缝隙、spec 自身问题)
|
||||||
|
- P13 先例:10 条自设 mutation 挖出 5 条 findings,**M1 抓到控制者自己写错两次的恒真守卫**
|
||||||
|
- P14 先例:抓到 **spec D-J 的守卫目的无断言覆盖** + **spec 里 positive control 的绕行路径** + 控制者的行数口径错
|
||||||
|
4. **控制者逐条裁定**(`sdd-pre-merge-review` §3:**未裁定的 note 阻塞合并**,"PASS with notes" 不等于门过了)
|
||||||
|
5. **全分支终审者**(只读;spec 全文自洽性 + **审控制者的裁定工作** + 独立复现 mutation)
|
||||||
|
- P11 先例:终审裁定过「**需修复后合并**」→ **不是橡皮章**
|
||||||
|
- P14 先例:终审在**第一轮修复自身**里找到洞(匿名接收者绕过源码扫描),并**推翻控制者两处已入库的全称声明**
|
||||||
|
6. **spec re-pin**(裁定后一次性批量改,**勿在审查者读 spec 期间改**=移动靶);**代码改了 spec 必须同步**(P14:正则字面量写进 spec,改代码后 spec 立刻不一致)
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
# P15-B 实现计划:bootstrap 加载屏暗色 + 对比度守卫补钉 + 死令牌清理
|
||||||
|
|
||||||
|
- **spec(权威)**:`docs/specs/2026-10-03-p15b-bootstrap-dark-design.md`
|
||||||
|
- **取证账本**:`crearte/.superpowers/sdd-p15/survey.md`(§2/§3/§5/§7/§8 全部实测;含"叉积当违规清单"与"三元互斥分支当同元素共现"两次自我纠错)
|
||||||
|
- **仓**:`crearte`(**仅此一个**)· **base** `f823195`(master)· 分支 `feat/p15b-bootstrap-dark-and-guard-pins`
|
||||||
|
> ⚠️ 分支前缀用 `feat/`(AGENTS.md 只允许 `{feat|fix|docs|chore}/`)。本批含用户可感知的暗色改动 → `feat/`;若实现者认为纯守卫部分占主体,**也不得改用 `refactor/`**(不在允许集内)。
|
||||||
|
> ⚠️ inner repo 红线:**不得在 `master` 上提交**。`git commit` 前先 `git branch --show-current` 确认。
|
||||||
|
- **性质**:用户可见改动(加载屏暗色)+ 守卫补强 + 死代码清理,**三者一批**(spec D-A…D-H)
|
||||||
|
- **目标版本**:crearte **0.27.0** · wrapper **0.3.8**
|
||||||
|
- **派发**:**一个**实现者子代理做完 T1→T4(所有任务同动 crearte 单一工作树与 git index,`sdd-parallel-dispatch` §1 一仓一写者)
|
||||||
|
- **并行**:P15-A 在 `crearte-server` 仓同时进行 → **不得触碰 `crearte-server/`、`crearte-deploy/`、wrapper 的任何文件**
|
||||||
|
|
||||||
|
> spec 与本计划冲突时 **spec 赢**。spec 全文(§0–§8)必读,尤其 **§1.1 的 🔴 架构陷阱(响应式注入会让运行中的游戏被重载)**、§1.3 的连带面表(5 文件)、§1.4(为何不能扩 `noHardcodedColor`)、§2.1 被否方案 A–F、§5-T1 的 10 腿清单 + ⚠️ 恒真审视三条、§8 纪律 12 条(前端 4 条加粗)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 关键路径约定(与后端不同,勿照搬 P15-A)
|
||||||
|
|
||||||
|
- **前端源码与 `package.json` 都在 `crearte/src/`,不在仓根**。所有 `npx`/`npm` 命令须 `cd crearte/src`。(P15 取证期控制者两次因 `cd` 层级写错路径而拿到假结果:`cd` 仓根却写 `app/...`(真根 `src/app/...`)致 grep 全失败输出 `bg-surface=0`;`ls ../e2e` 猜错致空输出。**任何"零命中"结论都要先证明扫描范围非空。**)
|
||||||
|
- **测试在宿主机跑 `npx vitest run`,不进容器**(P13 plan 第 58 行同款先例:`npx vitest run app/lib/tableOverflow.test.ts app/lib/tableScope.test.ts`)。`crearte/src/node_modules` 已装(232M)。AGENTS.md 的"一切在容器内"针对的是 compose 栈与应用服务,**不针对前端单测**;e2e 需要浏览器,同样在宿主机跑。
|
||||||
|
- **vitest 环境是 node**(`src/vite.config.ts` 的 `test` 段只有 `exclude`,**无 `environment` 键**)→ 源码级守卫可用 `fileURLToPath(new URL('../..', import.meta.url))` 读文件。**P13 教训:happy-dom 下该写法抛 `ERR_INVALID_URL_SCHEME`**;本批新增守卫**不得引入需要 DOM 的断言**。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 任务表
|
||||||
|
|
||||||
|
| 任务 | 交付物 | 要求 | 完成判据 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **T1** | `app/lib/bootstrapTheme.test.ts`(**新增**) | **RED 先行**(spec §5-T1)。**10 腿**:①pre-paint 脚本在 `<style>` 与 `<body>` 之前 ②判定序 hash→prefers→light(`indexOf` 比较,**先断言三者都 `> -1`**)③白名单校验 hash theme(只接受 `light`/`dark`)④IIFE 且只用 `var` ⑤**`GameHost.vue` 注入的是 `effectiveTheme()` 而非 `useTheme().theme.value`** ⑥bootstrap 暗色块六个 hex 与 `main.css` 暗色调色板**逐值一致**(字符串相等,**不得写成 `max(a,b) ≥ k`**)⑦bootstrap 亮色六个 hex **未被改动** ⑧`bootstrap/index.html` 内**不存在 inline `style` 里的 `color:`** ⑨**前提检查**(读到 ≥3 个文件且 bootstrap html 长度 >500)⑩`AA_PAIRS` 含 `['ink','surface']` 且 `REQUIRED_KEYS` 不含 `info`、长度 `toBe(11)` | RED 输出**逐字存证** `.superpowers/sdd-p15b/impl-evidence/t1-red.txt`,并**逐腿标注红/绿**:腿 1/2/5/6/8/10 须红,**腿 7/9 应当已绿**(它们是"不得回退"钉桩,不是新功能断言——spec §5-T1 明写这是有意的) |
|
||||||
|
| **T2** | `app/lib/contrast.test.ts`(改)+ `app/styles/main.css`(改) | 按 spec §3.2/§3.3:`AA_PAIRS` 插入 `['ink','surface','卡片/表格/表单底(bg-surface 62 处,58 靠 body 继承 text-ink)亮18.42/暗14.85']`(**不得改其它 10 对**);删 `main.css:12`(亮)与`:53`(暗) 两行 `--color-info`;`REQUIRED_KEYS` 删 `'info',`;四处 "12 令牌" 文案改 "11 令牌"(`:47,74,119,121`)。⚠️ **`LEGACY_KEYS`(`:197`)不动**(九键不含 `info`,已实测) | `npx vitest run` **80 文件 / 688+N 全绿**(N = T1 腿数 ≥10);`--color-info` 全仓命中 **0**;`AA_PAIRS` **11** 对;`REQUIRED_KEYS` **11** 项;`LEGACY_KEYS` **9** 项不动 |
|
||||||
|
| **T3** | `bootstrap/index.html`(改)+ `runtime/host/adapters.ts`(改)+ `runtime/host/GameHost.vue`(改) | 按 spec §3.1(a)–(e):head 内 `<style>` **之前**插 pre-paint 脚本(**逐字对齐 spec 给的代码块**,含 CSP 注释与 `catch` 兜底);`<style>` 加 `html[data-theme="dark"]` 覆盖块(**亮色值逐字不变**,`color-scheme: dark` 随块声明一次);**删两处 inline `color`**(`:25` 的 `#a3a3a3`、`:27` 的 `#f87171`,各保留 `font-size`);`resolveRuntimeTargets` 的 `opts` 加**可选**键 `theme?: 'light' \| 'dark'`,**仅当存在时**才写 `fragment.theme`;`GameHost.vue` 注入 **`effectiveTheme()`** 并写明陷阱注释 | `npx vitest run` 全绿;`npm run typecheck`(`vue-tsc --noEmit`)**零错**;**`adapters.test.ts` 4 处既有调用零修改仍绿**(验证 `theme` 是可选键);既有 `*.test.ts` 修改数 = **1**(仅 `contrast.test.ts`,须在报告显式声明) |
|
||||||
|
| **T4** | e2e / 产物验证 + mutation 自查 | 按 spec §5-T4:`npm run build` exit 0(⚠️ 它会类型检查 `e2e/*.spec.ts`,P13 spike 6 曾因此 `BUILD_EXIT=2`);`npm run e2e` **≥102+1skip** 不回退(`dark.spec.ts` 10 腿全绿);**新增一腿覆盖 hash 注入**(e2e 断言 iframe `src` hash 含 `theme=dark`;**若 fixture 无虚拟游戏可跑,改为 vitest 腿**用 `@vue/test-utils` 挂 `GameHost` 断言 `targets[0].url` 含 `theme=dark`——**必须有一腿覆盖**,不接受"fixture 不支持所以不测");**产物验证**:`dist/` 里 bootstrap HTML 含暗色块、**不含** `#a3a3a3` 与 `#f87171`,主应用 CSS 里 `--color-info` 零命中、`var(--color-*)` 计数与 P13 交付值 **75** 比对并**解释差异**;**mutation (a)–(j) 逐条**附输出 | 全部输出存证 `.superpowers/sdd-p15b/impl-evidence/`;产物 grep **用 `grep -F`**(spec §8-6:手写转义与带引号模式对 minified 产物假阴性) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 验收(控制者独立复跑,不采信自报)
|
||||||
|
|
||||||
|
### 测试与构建
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd crearte/src
|
||||||
|
npx vitest run # 基线 79 文件 / 688 测试 → 目标 80 / 688+N
|
||||||
|
npm run typecheck # vue-tsc --noEmit,零错
|
||||||
|
npm run build # 含 vue-tsc + vite build + build-runtime.mjs
|
||||||
|
npm run e2e # 基线 102+1skip(dark.spec.ts 10 腿)
|
||||||
|
```
|
||||||
|
|
||||||
|
基线(spec §0,已实测 2026-10-03 13:07):**vitest 79 文件 / 688 测试全绿,Duration 44.83s**——与 P13 交付值逐字相同(P14 未触及前端)。**e2e 102+1skip** 是 P13 交付值。
|
||||||
|
|
||||||
|
⚠️ `npm run build` 含 `vue-tsc --noEmit`,会**类型检查 `e2e/*.spec.ts`**;spike 期间若只想快速验产物可用 `npx vite build`(P13 纪律 5 的同款做法),但**交付验收必须跑完整 `npm run build`**。
|
||||||
|
|
||||||
|
### 结构指标(spec §5 表,交付时须报实测数字)
|
||||||
|
|
||||||
|
| 指标 | 基线 | 目标 |
|
||||||
|
|---|---|---|
|
||||||
|
| vitest 文件数 / 测试数 | **79 / 688** | **80 / 688+N**(N ≥ 10) |
|
||||||
|
| 既有 `*.test.ts` 修改文件数 | — | **1**(仅 `contrast.test.ts`,仅因 "12→11 令牌" 文案;**须在报告显式声明**) |
|
||||||
|
| `AA_PAIRS` 对数 | 10 | **11** |
|
||||||
|
| `REQUIRED_KEYS` 项数 | 12 | **11** |
|
||||||
|
| `LEGACY_KEYS` 项数 | 9 | **9**(不动) |
|
||||||
|
| `--color-info` 全仓命中 | 2(两处定义) | **0** |
|
||||||
|
| bootstrap 内 `localStorage` / `prefers-color-scheme` / `data-theme` 命中 | **0 / 0 / 0** | **0 / ≥1 / ≥1**(`localStorage` **仍须为 0**:源隔离,spec D-A) |
|
||||||
|
| bootstrap 内 inline `style` 的 `color:` 数 | 2 | **0** |
|
||||||
|
| `typecheck` / `build` | 0 / 0 | **0 / 0** |
|
||||||
|
| 主 e2e | 102+1skip | **≥102+1skip**(新增腿另计) |
|
||||||
|
|
||||||
|
### mutation 抽查(spec §5-T1 的 (a)–(j),控制者独立复现,不复用实现者结果)
|
||||||
|
|
||||||
|
**十条**,每条测"预期红 + 其余绿 + 恢复证明":
|
||||||
|
|
||||||
|
| # | mutation | 预期 |
|
||||||
|
|---|---|---|
|
||||||
|
| (a) | bootstrap 的 pre-paint `<script>` 移到 `<style>` 之后 | 腿 1 红 |
|
||||||
|
| (b) | 交换 hash theme 与 `prefers-color-scheme` 的判定先后 | 腿 2 红 |
|
||||||
|
| (c) | hash theme 校验放宽成 `hashTheme ? hashTheme : …` | 腿 3 红 |
|
||||||
|
| (d) | **`GameHost.vue` 改用 `useTheme().theme.value`** | 腿 5 红(**这条是 spec D-B 的牙**:响应式注入会让主题切换重载运行中的游戏) |
|
||||||
|
| (e) | 改 bootstrap 暗色的一个 hex | 腿 6 红 |
|
||||||
|
| (f) | 加回 `style="color:#a3a3a3"` | 腿 8 红 |
|
||||||
|
| (g) | 从 `AA_PAIRS` 删掉补钉的 `['ink','surface']` | 腿 10 红 |
|
||||||
|
| (h) | 把 `info` 加回 `REQUIRED_KEYS` | 腿 10 红 |
|
||||||
|
| (i) | **守卫的文件路径改成不存在的文件** | 腿 9 红(防"读 0 文件报 0 违规"的假信心) |
|
||||||
|
| (j) | **删掉 `main.css` 的暗色块**(模拟 P13 成果回退) | 腿 6 或 `contrast.test.ts` 的暗色腿红 |
|
||||||
|
|
||||||
|
⚠️ **`restore()` 只回滚 mutation 触及的文件,绝不用 `git checkout -- .`**(P14 控制者第 24 次自伤:全量回滚抹掉了未提交的守卫编辑,导致后四轮全在测没有修复的树、汇总报"绿=3")。每轮恢复后核 `git diff HEAD --exit-code -- <file>` 空 + 守卫文件腿数不变。
|
||||||
|
|
||||||
|
⚠️ **写守卫时注释里不要出现完整的 `bg-[#…]` / `text-[#…]` 字面量**(spec §8-8:**Tailwind v4 扫描全部源文件含 `.test.ts`**,注释里的类名字面量会被当候选、烧死 utility 进产物。P13 实现者用拼接修对一处,控制者八分钟后在自己裁定 commit 里重新引入,靠重建抓出)。
|
||||||
|
|
||||||
|
### 产物验证(P13 教训:源码级守卫绿 ≠ 产物正确)
|
||||||
|
|
||||||
|
- **必须验真实 `dist/`**,不得用运行时注入或 `APPLY.toString()`+`new Function` 序列化注入替代(P13 控制者勘查期两次栽在这两种无效手法上)
|
||||||
|
- 选择器/字符串 grep **用 `grep -F`**,不手写反斜杠转义(P13:`.backdrop\:bg-scrim` 手写转义对 minified 产物假阴性)
|
||||||
|
- **带引号的 grep 模式对 minified 产物也假阴性**(`grep 'bg-[#]'` 匹配不到压缩形态)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 边界(**不做**,spec §4)
|
||||||
|
|
||||||
|
- **不动 `themeBootstrap.test.ts`**(主应用 pre-paint 守卫 7 腿)——若某腿因本批变红,**停下来报告**
|
||||||
|
- **不扩 `noHardcodedColor.test.ts` 的扫描根**(spec §1.4 + 被否方案 D:`candidates()` 只收 `.vue`、`maskNonTemplate()` 会空白掉 `<style>` 块 → 扩根**扫不到任何东西**,只会制造"已覆盖"的假信心)
|
||||||
|
- **不动 `useTheme.ts`**(`effectiveTheme()` 已够用;改它会波及主应用 10 腿 e2e)
|
||||||
|
- **不动 `src/index.html`**(主应用入口,P13 交付)
|
||||||
|
- **不改 P13 的 CHANGELOG 0.26.0 条目与 P13 spec 原文**(spec D-C:改写会让"当时交付了什么"失真;改为新条目说明 + P13 spec 加 ⚠️ RE-PIN 标注块,那是控制者的记账动作)
|
||||||
|
- **不修 GameHost 亮色徽标 WCAG 3.26**(spec §4 挂账 + 被否方案 F:属**设计决策**,显而易见的一行修法已实测证伪——亮色 `accent` vs `accent-ink` 仅 **1.4943**,徽标与「加载失败」状态点同屏共存,改完会把两个语义压成一个视觉信号;ROADMAP `9965fd7` 已登记该约束)
|
||||||
|
- **不做第三态"恢复跟随系统"**(P13 spike 3 已证伪:header 在 @320px 最坏格无像素容纳带标签控件)
|
||||||
|
- 不新增任何令牌(只删 `--color-info`)
|
||||||
|
- 不碰 `crearte-server`、`crearte-deploy`、wrapper 的 `docs/`(实现者红线;记账由控制者做)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 记账(控制者做,实现者禁动 `docs/`)
|
||||||
|
|
||||||
|
- `crearte/docs/CHANGELOG.md` 新增 **`## [0.27.0] - 2026-10-03`**(插 `## [0.26.0]` 前)
|
||||||
|
- wrapper `docs/CHANGELOG.md` 新增 **`## [0.3.8] - 2026-10-03`**(插 `## [0.3.7]` 前),小节 `### Done / 完成`;**与 P15-A 合并为一条还是分两条按合并时序定**
|
||||||
|
- `docs/ROADMAP.md`:第三波表新增 **P15-B 行** + 文档索引表新增一行(**哈希引 merge commit**);挂账里**删掉「死令牌 `--color-info`」**(已处置)、**新增「bootstrap 内联脚本的 CSP nonce」**(与主应用同批)
|
||||||
|
- P13 spec 加 **⚠️ RE-PIN 标注块**(spec D-C:12→11 令牌),**不改写原文**
|
||||||
|
- 格式:**同条目英文行紧跟中文行、无空行**;不同条目**空一行**;小节标题双语
|
||||||
|
- **纪律**:显式 `git add <file>`,**禁 `git add -A`**;wrapper 的 `IDENTITY.md`/`SOUL.md`/`USER.md` 三个未跟踪文件**绝不 stage**
|
||||||
|
- **合并**:CHANGELOG 改动提交到**特性分支** → `git checkout master` → `git merge --no-ff` → 记 merge 哈希 → 推送(**禁管道**)→ `git ls-remote origin master` **非空**对账(`-z` 检查,防空变量假 MATCH)→ 删分支
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 审查阶梯(P11–P14 惯例,不可省)
|
||||||
|
|
||||||
|
1. **实现者自报** + 证据落盘(RED 逐腿标注、mutation 十条、产物验证)
|
||||||
|
2. **控制者独立核实**(全部自己跑,不采信自报数字)
|
||||||
|
3. **任务级审查者**(只读,**自设 mutation 不照抄 spec 的 (a)–(j)**,审守卫恒真/恒假、spec 自身问题、**特别是腿 5 这条架构陷阱断言是否真能防住响应式注入**)
|
||||||
|
- P13 先例:10 条自设 mutation 挖出 5 条 findings,**M1 抓到控制者自己写错两次的恒真守卫**(`max(a,b) ≥ 3` 互补下界 4.0621)
|
||||||
|
- P14 先例:抓到 **spec D-J 的守卫目的无断言覆盖** + **spec 里 positive control 的绕行路径** + 控制者行数口径错(`split` vs `wc -l`)
|
||||||
|
4. **控制者逐条裁定**(`sdd-pre-merge-review` §3:**未裁定的 note 阻塞合并**,"PASS with notes" 不等于门过了)
|
||||||
|
5. **全分支终审者**(只读;spec 全文自洽性 + **审控制者的裁定工作** + 独立复现 mutation)
|
||||||
|
- P11 先例:终审裁定过「**需修复后合并**」→ **不是橡皮章**
|
||||||
|
- P14 先例:终审在**第一轮修复自身**里找到洞(匿名接收者 `func (*ContentService) M()` 绕过源码扫描),并**推翻控制者两处已入库的全称声明**
|
||||||
|
6. **spec re-pin**(裁定后一次性批量改,**勿在审查者读 spec 期间改**=移动靶);**代码改了 spec 必须同步**(P14:正则字面量写进 spec,改代码后 spec 立刻不一致,构成"需修复后合并"的同类形态)
|
||||||
@@ -0,0 +1,353 @@
|
|||||||
|
# P15-A 设计:拆分 `Approve` 169 行七阶段(crearte-server)
|
||||||
|
|
||||||
|
- **日期**:2026-10-03
|
||||||
|
- **仓**:`crearte-server`(Go module 根在 `src/`)
|
||||||
|
- **分支**:`chore/p15a-approve-phases`(AGENTS.md:18 只允许 `{feat|fix|docs|chore}/`)
|
||||||
|
- **base**:`fe810dd`(P14 merge,master)
|
||||||
|
- **维度**:代码优雅(USER.md 六维度循环)
|
||||||
|
- **前置**:P14 已交付(`ContentService` 拆分 + 七条架构守卫)。**本批不得改动 `architecture_test.go`**,除非某条断言因新 helper 变红(见 §5-T1 的 mutation (g))。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## §0 基线(已实测,2026-10-03 12:50)
|
||||||
|
|
||||||
|
**四门**(dockerized `golang:1.24-alpine`,AGENTS.md 硬红线;host Go 1.18 不可用):`gofmt -l .` 空 · `go vet ./...` exit 0 · `go build ./...` exit 0 · `go test ./... -count=1` **11 包 ok**(`internal/api` ~11.0s 含真库集成、`internal/service` ~3.7s)。
|
||||||
|
|
||||||
|
**目标函数**:`internal/service/moderation.go:47-215` = **169 行**(`wc -l` 口径,P14 spec §5 已钉该口径)。
|
||||||
|
|
||||||
|
**`service/` 包内非测试文件的函数长度分布**(78 个函数,实测普查):
|
||||||
|
|
||||||
|
| 阈值 | 超过的函数数 | 清单 |
|
||||||
|
|---|---|---|
|
||||||
|
| >100 行 | **1** | `*ModerationService.Approve`(169) |
|
||||||
|
| >80 行 | 2 | + `*AccountService.deleteAccountLocked`(83) |
|
||||||
|
| >70 行 | 4 | + `*SubmissionService.UpdateSubmission`(76)、`ValidateWorkFields`(75) |
|
||||||
|
| >60 行 | 7 | + `*ImportService.ImportDir`(70)、`*CleanupService.Run`(70)、`*SubmissionService.checkSubmissionRules`(66) |
|
||||||
|
|
||||||
|
→ **`Approve` 是包内唯一 >100 行的函数**,且比次大者(83)大一倍。这就是本批的选型依据:不是"文件太长",是**单个函数承担了七件事**。
|
||||||
|
|
||||||
|
**行数口径**:一律 `wc -l`。**不要用 `len(text.split('\n'))`**——文件以换行结尾时后者多算一个空段(P14 F5:控制者与任务级审查者为此报出 83/299 与 82/298 两套数字)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## §1 取证(决定设计,全部实测)
|
||||||
|
|
||||||
|
### 1.1 七阶段边界(`moderation.go` 真实行号;P14 挂账里 base 树的 606–772 **已作废**)
|
||||||
|
|
||||||
|
| 阶段 | 行 | 内容 | 返回的错误 | 是否碰 `tx` |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| ① 加载 + 状态校验 + 解析 | 48–62 | `Submissions().GetByID` → `ErrNotFound` 映射 `ErrContentNotFound`;`sub.Status != Pending` → `ErrSubmissionConflict`;`ParseSubmissionEnvelope(sub.Payload)`;`p := env.Work` | `ErrContentNotFound` / `ErrSubmissionConflict` / envelope 解析错 | 否 |
|
||||||
|
| ② owner 查取 | 64–70 | **仅** `Kind == NewWork` 时 `s.users.GetByID(sub.SubmitterID)` | `service: resolve submitter: %w` | 否 |
|
||||||
|
| ③ 上传解析 | 72–85 | `BundleUploadID`/`CoverUploadID` 各 `Uploads().GetByID`,并校验 `up.ConsumedBy == sub.ID` | `ErrUploadUnavailable` | 否 |
|
||||||
|
| ④ **乐观预检** switch | 87–100 | `NewWork`:`Works().GetByID` 已存在 → `ErrWorkIDTaken`;`NewVersion`:`Versions().Get` 已存在 → `ErrVersionExists`;其它 kind 无 case | `ErrWorkIDTaken` / `ErrVersionExists` / **`service: approve precheck: %w`** | 否(用 `s.store`) |
|
||||||
|
| ⑤ 对象 Copy | 102–123 | `finalBundleKey = "bundles/"+sub.WorkID+"/"+p.Version+".bin"`;`finalCoverKey = "covers/"+sub.WorkID+"/"+coverUpload.SHA256+path.Ext(...)`;各 `s.objects.Copy` | `ErrUploadUnavailable`(`storage.ErrObjectNotFound`)/ `service: copy bundle|cover: %w` | 否(用 `s.objects`) |
|
||||||
|
| ⑥ **`WithTx` 事务** | 125–206 | `GetByIDForUpdate` 锁行 → **重校** `Status == Pending` → **悲观重检** switch(三分支)→ 建 Work / Version / Touch / UpdateMetadata → `MarkReviewed` | 见 §1.2 | **是**(`tx repository.ContentRepository`) |
|
||||||
|
| ⑦ pending 清理 + slog | 207–214 | 事务错误映射(`repository.ErrConflict` → `ErrSubmissionConflict`);`pendingKeys(bundleUpload, coverUpload)` 逐个 `s.objects.Delete`(**只 `slog.Error` 不返回**,best-effort);`slog.Info("approve", …)`;`return nil` | `ErrSubmissionConflict` / 裸 `err` | 否 |
|
||||||
|
|
||||||
|
**`WithTx` 签名**(`internal/repository/content.go:38`):`WithTx(ctx context.Context, fn func(ContentRepository) error) error`。
|
||||||
|
|
||||||
|
### 1.2 🔴 阶段④与阶段⑥的 switch **不同形,禁止合并**(本批最重要的约束)
|
||||||
|
|
||||||
|
两者都 `switch sub.Kind`、都查 `Works().GetByID` / `Versions().Get`、都可能返回 `ErrWorkIDTaken`/`ErrVersionExists`,故看上去可以抽成一个"重复性检查" helper 共用。**实测比对后确认不可合并**,四处实质差异:
|
||||||
|
|
||||||
|
| 差异 | 阶段④(87–100) | 阶段⑥(136–186) |
|
||||||
|
|---|---|---|
|
||||||
|
| **kind 覆盖** | 只有 `NewWork` / `NewVersion` 两个 case | 三个 case:`NewWork` / `NewVersion` / **`MetadataChange`** |
|
||||||
|
| **`NewVersion` 的额外校验** | 只查 `Versions().Get` 是否已存在 | **还查** `work.Runtime != model.RuntimeVirtual \|\| bundleUpload == nil` → `ErrSubmissionConflict`;且 `Versions().Create` 后**还调** `tx.Works().Touch` |
|
||||||
|
| **`NewWork` 的动作** | 只查冲突,不建实体 | `PayloadToWork(p)` + 设 `OwnerID`/`CoverKey` + `tx.Works().Create`(`ErrConflict` → `ErrWorkIDTaken`)+ 条件建 Version |
|
||||||
|
| **错误文案** | 非 `ErrNotFound` 的错误包成 **`service: approve precheck: %w`** | **裸返 `err`**,无包装 |
|
||||||
|
|
||||||
|
**语义差异**(不可合并的根本原因):④是**乐观快失败**——无锁、在**任何对象 Copy 之前**,失败时副作用为零;⑥是**悲观重检**——持 `SELECT … FOR UPDATE` 行锁、在对象**已 Copy 之后**,失败时须靠阶段⑦清理 pending 对象。合并成一个 helper 会让"检查"与"写入"的边界模糊,且**改变错误文案**(`service: approve precheck: …` 会消失或蔓延到事务内),属行为变更。
|
||||||
|
|
||||||
|
→ **裁定:④与⑥各自独立拆 helper,不共享代码。** 表面重复是**有意的双层校验**(TOCTOU 防护:预检减少无谓 Copy,重检保证正确性),不是可消除的冗余。spec 里写明这条,防实现者"顺手 DRY"。
|
||||||
|
|
||||||
|
### 1.3 helper 形态:**未导出方法与包级函数都不触架构守卫**(实测,推翻 P15 账本 §8.1 的说法)
|
||||||
|
|
||||||
|
账本原写"新 helper 应是包级 `func`,因为方法会改动 `expectedLineFields`/断言 2 的方法集并触发守卫红"。**这句是错的**,已用两轮 mutation 实测推翻:
|
||||||
|
|
||||||
|
| 探针 | 追加到 `moderation.go` 的声明 | `go build` | 七断言 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| A | `func (s *ModerationService) approvePrecheckLocked(ctx context.Context) error { return nil }`(未导出**方法**) | exit 0 | **7 PASS / 0 FAIL** |
|
||||||
|
| B | `func approvePrecheck(store repository.ContentStore, sub model.Submission) error { return nil }`(**包级**函数) | exit 0 | **7 PASS / 0 FAIL** |
|
||||||
|
|
||||||
|
**机制**:断言 2 `TestLineExportedMethods` 用 `m.IsExported()` 过滤,断言 4 遍历的 `reflect.Type.NumMethod()` **本身只暴露导出方法** → 未导出方法根本不在两条断言的视野内。断言 6 只看**字段**,不看方法。
|
||||||
|
|
||||||
|
→ **形态选择依据是"是否需要接收者字段",不是守卫约束**:
|
||||||
|
- 需要 `s.store` / `s.objects` / `s.users` 的 → **未导出方法**(体例:`account.go:78` 的 `func (s *AccountService) deleteAccountLocked(ctx, user) (DeletionReport, error)`,注释「共享内核,调用方已确认账号存在且未注销」,两处调用)
|
||||||
|
- 只需 `tx` + 已备好的数据 → **包级函数**(体例:`moderation.go:283` `versionFromUpload(workID, version, objectKey, up)`、`:290` `pendingKeys(uploads ...*model.Upload)`)
|
||||||
|
|
||||||
|
⚠️ **不得给 `ModerationService` 加字段**(断言 6 会红:`expectedLineFields["ModerationService"] = {objects, store, users}`);**不得在 `ContentService` 上加任何方法**(断言 5 源码扫描会红)。
|
||||||
|
|
||||||
|
### 1.4 `ContentStore` 内嵌 `ContentRepository`(决定事务 helper 的签名)
|
||||||
|
|
||||||
|
```go
|
||||||
|
// internal/repository/content.go:28-39
|
||||||
|
type ContentRepository interface {
|
||||||
|
Works() WorkRepository
|
||||||
|
Versions() WorkVersionRepository
|
||||||
|
Submissions() SubmissionRepository
|
||||||
|
Uploads() UploadRepository
|
||||||
|
Reactions() ReactionRepository
|
||||||
|
}
|
||||||
|
type ContentStore interface {
|
||||||
|
ContentRepository
|
||||||
|
WithTx(ctx context.Context, fn func(ContentRepository) error) error
|
||||||
|
}
|
||||||
|
```
|
||||||
|
→ **`ContentStore` 是 `ContentRepository` 的超集**。故收 `repository.ContentRepository` 的 helper **既能接 `s.store`(阶段④)也能接 `tx`(阶段⑥)**。但**本批刻意不用这个能力去合并④⑥**(见 §1.2);它只用于让事务体 helper 的签名收 `tx`。
|
||||||
|
|
||||||
|
### 1.5 `ParseSubmissionEnvelope` 的返回类型(决定 plan 结构体字段类型)
|
||||||
|
|
||||||
|
```go
|
||||||
|
// internal/service/content.go:54-60
|
||||||
|
type SubmissionEnvelope struct {
|
||||||
|
Work WorkPayload `json:"work"`
|
||||||
|
BundleUploadID string `json:"bundle_upload_id,omitempty"`
|
||||||
|
CoverUploadID string `json:"cover_upload_id,omitempty"`
|
||||||
|
}
|
||||||
|
func ParseSubmissionEnvelope(raw []byte) (SubmissionEnvelope, error) // 值返回,非指针
|
||||||
|
```
|
||||||
|
`Approve` 内 `p := env.Work`(`WorkPayload` 值)。**plan 结构体应持有 `p WorkPayload` 值而非 `env` 指针**——与现有代码逐字一致,避免引入 nil 判定分支(那会是行为变更)。
|
||||||
|
|
||||||
|
### 1.6 既有测试覆盖:**充分,不需要先补 characterization 测试**
|
||||||
|
|
||||||
|
`Approve` 有 5 个直接调用点(`internal/service/content_test.go:91,205,268,284,359`)与 13 个相关测试函数:
|
||||||
|
|
||||||
|
| 测试 | 覆盖的阶段/分支 |
|
||||||
|
|---|---|
|
||||||
|
| `content_test.go:18 TestApproveConcurrentOnlyOneWins` | ⑥ 的行锁 + 重校状态(并发只有一个赢) |
|
||||||
|
| `content_test.go:126 TestApproveSameWorkIDConcurrent` | ④/⑥ 的 `ErrWorkIDTaken`(断言文本:`loser error = %v, want ErrWorkIDTaken or ErrSubmissionConflict`) |
|
||||||
|
| `content_test.go:237 TestApproveMetadataChangeFeaturesSemantics` | ⑥ 的 `MetadataChange` 分支 + **`Features` 三态语义**(用 `meta-absent` 与 `,"features":{}` 两个 payload 精确区分 nil=保留 / 空 map=清空) |
|
||||||
|
| `content_test.go:293 TestApproveOptionalFieldsRoundTrip` | ①② 的 envelope 解析与字段往返 |
|
||||||
|
| `content_hosted_test.go:76 TestMetadataChangeRuntimeImmutable` | ⑥ 的 Runtime 不可变约束 |
|
||||||
|
| `namespace_test.go:132 TestNewVersionMetadataChangeOwnership` | ⑥ 的 OwnerID 归属 |
|
||||||
|
| `api/admin_test.go:83,131,149,197,231`(5 个) | HTTP 层:发布、Forbidden+DoubleApprove、同 slug 异命名空间、NewVersion、MetadataChange |
|
||||||
|
| `api/integration_test.go:27 TestFullPublishChainOnPostgres`、`api/hosted_postgres_test.go:28 TestHostedPublishChainOnPostgres` | ⑤⑥ 的完整发布链(真库) |
|
||||||
|
|
||||||
|
→ **既有测试就是本批的行为守卫**(与 P14 D-G 同构)。验收硬指标:**既有 `*_test.go` 修改数 = 0**。若实现者发现必须改测试,**停下来报告**而不是改(P14 §4 末条)。
|
||||||
|
|
||||||
|
### 1.7 调用面:`Approve` 只有一个非测试调用点
|
||||||
|
|
||||||
|
`internal/handler/admin.go:62`:`h.svc.Approve(c.Request.Context(), CurrentUser(c).ID, c.Param("id"), body.Note)`(经 `moderationPort` 窄接口)→ **新 helper 无须导出**,且 `Approve` 的**签名与错误语义必须逐字不变**(`moderationPort` 由 P14 断言 2 钉住 6 个方法名,签名变更会打破 `serve.go` 的接口满足性 → 编译红)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## §2 设计决策
|
||||||
|
|
||||||
|
| # | 决策点 | 裁定 | 依据 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **D-A** | 拆分粒度 | **按七阶段拆 5 个 helper**(①②③ 合为一个"输入装载"、④ 一个、⑤ 一个、⑥ 一个(内部再拆 3 个 kind 分支)、⑦ 一个),不逐行拆 | §1.1 的阶段边界是**语义边界**(副作用发生点),不是任意切点。①②③ 都只读、都产出 plan 的字段、失败时零副作用 → 合为一个 helper 不损失可读性;④⑤⑥⑦ 各自有独立副作用(预检/Copy/事务/清理)→ 必须分开 |
|
||||||
|
| **D-B** | plan 结构体 | 新增**包级私有 struct** `approvePlan`,字段:`sub model.Submission`、`p WorkPayload`、`owner model.User`、`bundleUpload *model.Upload`、`coverUpload *model.Upload`、`finalBundleKey string`、`finalCoverKey string` | 七阶段之间传递的就是这七样东西(实测自函数体)。用 struct 而非 7 个返回值:Go 的多返回值超过 4 个即难读,且 ⑤ 要往 plan 里写 `finalBundleKey`/`finalCoverKey` 供 ⑥⑦ 用 |
|
||||||
|
| **D-C** | helper 形态 | ①②③④⑤⑦ → **未导出方法**(需 `s.store`/`s.users`/`s.objects`);⑥ 事务体 → **包级函数** `applyApprovalInTx(ctx, tx repository.ContentRepository, plan approvePlan, adminID, note string) error`;⑥ 内三个 kind 分支 → **包级函数** | §1.3 实测两种形态都不触守卫;选择依据是"是否需要接收者字段"。事务体不碰 `s.*`(只用 `tx`)→ 包级函数更纯,且签名自证"事务内不得访问 store 外的东西"。体例分别同 `deleteAccountLocked` 与 `versionFromUpload` |
|
||||||
|
| **D-D** | ④与⑥**不合并** | 各自独立 helper,不共享代码 | §1.2:四处实质差异 + 乐观/悲观语义不同 + 错误文案不同。表面重复是**有意的 TOCTOU 双层校验** |
|
||||||
|
| **D-E** | 事务边界与 Copy 顺序 | **一行不动**:⑤ 的两次 `s.objects.Copy` 必须在 ⑥ `WithTx` **之外**(故 ⑦ 需要清理);⑥ 的全部写入必须在 `WithTx` **之内**;`MarkReviewed` 必须是事务内**最后一个**调用 | 改变顺序即行为变更:Copy 进事务会让长耗时 I/O 持锁;`MarkReviewed` 提前会让"实体建成但状态未改"的中间态可被并发观察到 |
|
||||||
|
| **D-F** | `Features` 三态语义 | `if p.Features == nil { work.Features = existing.Features }` **连注释一起搬**进 MetadataChange helper,**不得"简化"** | 该注释原文:「旧提交(features 采集上线前创建)的 payload 无 features 键:保留现值,避免审批通过时静默清空已发布作品的运行权限;显式 `{}` 才是清空」。三态(nil=保留 / 空 map=清空 / 有值=覆盖)由 `content_test.go:237` 精确钉住,简化即红 |
|
||||||
|
| **D-G** | 零行为变更 | 纯结构重构:**方法体逐字迁移**,只改接收者/参数传递与缩进。`Approve` 的签名、返回的每个错误值与错误文案、`slog` 的两条日志(键名与顺序)**全部逐字不变** | P14 同款契约。验收:既有测试零修改 + 四门绿 + 字节级方法体比对 |
|
||||||
|
| **D-H** | 结构指标钉什么 | **钉「最大单函数行数」,不钉文件行数** | helper 仍在 `moderation.go`(298 行)内,拆完文件**可能不降反升**。用文件行数当指标会逼实现者把 helper 塞进新文件,那是无意义的文件增殖 |
|
||||||
|
| **D-I** | 派发形态 | **一个实现者子代理**做完 T1→T3(同动 crearte-server 单一工作树与 git index) | AGENTS.md / `sdd-parallel-dispatch` §1「一仓一写者」;P12/P13/P14 同款。**P15-B 在 crearte 仓,与本批可并行**(两仓各自单写者) |
|
||||||
|
|
||||||
|
### §2.1 被否方案
|
||||||
|
|
||||||
|
| 方案 | 否决理由 |
|
||||||
|
|---|---|
|
||||||
|
| **A:把④⑥的 switch 抽成共享 helper(DRY)** | §1.2 实测四处差异 + 乐观/悲观语义不同。合并会改变错误文案(`service: approve precheck: %w` 消失或蔓延)与 `NewVersion` 分支的校验集 → **行为变更**,违反 D-G |
|
||||||
|
| **B:用 `repository.ContentRepository` 参数统一④⑥(机械上可行)** | §1.4 证明 `ContentStore` 内嵌 `ContentRepository`,故技术上能让一个 helper 同时接 `s.store` 与 `tx`。但"能"不等于"该":它把①的乐观预检与⑥的持锁重检伪装成同一件事,**读者无法从签名看出锁语义差别** → 比 A 更隐蔽的行为语义损失 |
|
||||||
|
| **C:把七阶段拆到七个新文件** | 文件增殖。`moderation.go` 298 行本身不超标(P14 目标 <300),拆文件不解决"单函数太长"这个真问题,且 D-H 已判定不用文件行数当指标 |
|
||||||
|
| **D:先补 characterization 测试再重构** | §1.6 实测既有 13 个测试函数已覆盖全部七阶段与三态语义(含并发、真库发布链)。补测试是**额外成本而非额外保障**,且会违反"既有 `*_test.go` 修改数 = 0"这个更硬的验收判据 |
|
||||||
|
| **E:本批一并拆 `deleteAccountLocked`(83) / `UpdateSubmission`(76)** | D-F「一批一个关注点」(P14 同款)。三个函数分属 account/submission/moderation 三条线,混在一批会让"行为不变"的验收判断面翻三倍。已挂账 |
|
||||||
|
| **F:把 `Approve` 改成状态机/策略模式** | 过度设计。七个阶段是**线性**的(无分支跳转、无回退),`switch sub.Kind` 已经是策略分派。引入状态机会把 169 行变成更多的行 + 一个新抽象层,且**改变错误的产生位置**(行为变更) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## §3 实现要求
|
||||||
|
|
||||||
|
### 3.1 目标形态(骨架,**签名为示意,实现者须按 §1.1 的真实类型逐字对齐**)
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (s *ModerationService) Approve(ctx context.Context, adminID, submissionID, note string) error {
|
||||||
|
plan, err := s.loadApprovePlan(ctx, submissionID) // ①②③
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := s.precheckApprove(ctx, plan); err != nil { // ④(乐观、无锁、Copy 之前)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := s.copyApprovalObjects(ctx, plan); err != nil { // ⑤(写 plan.finalBundleKey/finalCoverKey)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
err = s.store.WithTx(ctx, func(tx repository.ContentRepository) error {
|
||||||
|
return applyApprovalInTx(ctx, tx, plan, adminID, note) // ⑥
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, repository.ErrConflict) {
|
||||||
|
return ErrSubmissionConflict
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
s.cleanupApprovalUploads(ctx, plan) // ⑦(best-effort,只 slog.Error)
|
||||||
|
slog.Info("approve", "submission", submissionID, "work", plan.sub.WorkID, "kind", plan.sub.Kind, "admin", adminID)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
⚠️ **注意 `copyApprovalObjects` 必须能写回 plan**:`plan` 是值类型,故该方法签名须为 `(ctx context.Context, plan *approvePlan) error`(收指针),或返回新的 plan。**选前者**——与 ⑤ 原地修改 `finalBundleKey`/`finalCoverKey` 的现有语义一致,且避免"返回新 plan 但调用方忘了接"的静默丢值。
|
||||||
|
|
||||||
|
### 3.2 逐阶段要求
|
||||||
|
|
||||||
|
**①②③ `loadApprovePlan`**(未导出方法):
|
||||||
|
- 逐字搬 48–85 行。返回 `(approvePlan, error)`。
|
||||||
|
- 三处错误映射逐字保留:`errors.Is(err, repository.ErrNotFound)` → `ErrContentNotFound`;`sub.Status != model.SubmissionStatusPending` → `ErrSubmissionConflict`;`fmt.Errorf("service: load submission: %w", err)`;`fmt.Errorf("service: resolve submitter: %w", err)`;两处 `ErrUploadUnavailable`(含 `up.ConsumedBy != sub.ID` 判定)。
|
||||||
|
- ② 的 `if sub.Kind == model.SubmissionKindNewWork` 条件**必须保留**(其它 kind 不查 owner,`plan.owner` 为零值;⑥ 的 `NewWork` 分支才用 `owner.ID`)。
|
||||||
|
- ③ 的 `bundleUpload = &up` 取地址语义**必须保留**(`up` 是循环内局部变量,取其地址是本函数刻意的写法;改成存值会让 `plan.bundleUpload != nil` 判定失效)。
|
||||||
|
|
||||||
|
**④ `precheckApprove`**(未导出方法):
|
||||||
|
- 逐字搬 87–100 行。**保持用 `s.store`**(不是 `tx`)。
|
||||||
|
- 错误文案 `fmt.Errorf("service: approve precheck: %w", err)` **逐字保留**(两处)。
|
||||||
|
- **不得**增加 `MetadataChange` case(④ 现在没有,加了会改变 `MetadataChange` 提交的失败时机)。
|
||||||
|
|
||||||
|
**⑤ `copyApprovalObjects`**(未导出方法,收 `*approvePlan`):
|
||||||
|
- 逐字搬 102–123 行。key 拼接公式**逐字保留**(`"bundles/"+sub.WorkID+"/"+p.Version+".bin"`、`"covers/"+sub.WorkID+"/"+coverUpload.SHA256+ext`,`ext := path.Ext(coverUpload.ObjectKey)`)。
|
||||||
|
- `if bundleUpload != nil` / `if coverUpload != nil` 的**条件 Copy 语义保留**(无上传时 `finalXxxKey` 保持 `""`,⑥ 的 `MetadataChange` 分支靠 `if finalCoverKey != ""` 判定是否覆盖)。
|
||||||
|
- `storage.ErrObjectNotFound` → `ErrUploadUnavailable` 的映射逐字保留;`service: copy bundle|cover: %w` 两条文案逐字保留。
|
||||||
|
|
||||||
|
**⑥ `applyApprovalInTx`**(包级函数):
|
||||||
|
- 搬 126–205 行(`WithTx` 闭包体)。签名收 `(ctx, tx repository.ContentRepository, plan approvePlan, adminID, note string) error`。
|
||||||
|
- **`GetByIDForUpdate` 锁行 + 重校 `Status == Pending` 必须在最前**(125–134),且 `errors.Is(err, repository.ErrNotFound)` → `ErrSubmissionConflict`(**注意与①不同**:① 映射 `ErrContentNotFound`,⑥ 映射 `ErrSubmissionConflict`——这个差异是刻意的,事务内消失意味着并发删除,语义是冲突而非未找到)。
|
||||||
|
- 三个 kind 分支各自拆包级函数(`createWorkInTx` / `createVersionInTx` / `applyMetadataChangeInTx`),或保留为一个 switch——**由实现者按可读性定**,但 `switch` 的分支顺序与 `MarkReviewed` 在末尾的位置**不得变**。
|
||||||
|
- `MetadataChange` 分支的 `Features` 三态**连注释一起搬**(D-F)。
|
||||||
|
- 事务内错误**裸返**,不加 `service: approve precheck` 之类包装(§1.2)。
|
||||||
|
|
||||||
|
**⑦ `cleanupApprovalUploads`**(未导出方法):
|
||||||
|
- 搬 212–213 行。`for _, key := range pendingKeys(plan.bundleUpload, plan.coverUpload)` + `s.objects.Delete` + `if err != nil && !errors.Is(err, storage.ErrObjectNotFound) { slog.Error("approve: pending delete failed", "key", key, "error", err) }` **逐字保留**。
|
||||||
|
- **不得返回 error**(best-effort 语义:清理失败不能让已成功的审批变失败)。
|
||||||
|
- ⚠️ `slog.Info("approve", …)` 与 `return nil` **留在 `Approve` 本体**,不进 helper——helper 名是 `cleanup`,把成功日志塞进去会让职责不清。
|
||||||
|
|
||||||
|
### 3.3 不得触碰
|
||||||
|
|
||||||
|
- `architecture_test.go`(P14 的七条守卫)——除非 mutation (g) 证明某条因新 helper 变红,那时**停下来报告**,不要自行改守卫。
|
||||||
|
- `moderationPort`(`handler/admin.go`)与 `serve.go`。
|
||||||
|
- `versionFromUpload` / `pendingKeys` 的**签名**(可调用,不可改)。
|
||||||
|
- 任何 `*_test.go`。
|
||||||
|
- `docs/`、`go.mod`/`go.sum`(AGENTS.md 红线:实现者不得碰)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## §4 边界
|
||||||
|
|
||||||
|
| 项 | 在范围内 | 不在范围内 |
|
||||||
|
|---|---|---|
|
||||||
|
| 函数 | `Approve` 及其拆出的 helper | `Reject`/`Unpublish`/`Republish`/`UpdateWorkFeatures`/`ListQueue`(同文件其它方法)、`deleteAccountLocked`(83)、`UpdateSubmission`(76) |
|
||||||
|
| 文件 | `internal/service/moderation.go` | 其它 service 文件(除非编译需要,须报告) |
|
||||||
|
| 行为 | 零变更(D-G) | 任何错误文案/日志键名/错误产生时机的调整 |
|
||||||
|
| 测试 | 无(既有测试即守卫) | 新增测试、修改既有测试 |
|
||||||
|
| 守卫 | 无 | 改 `architecture_test.go` |
|
||||||
|
|
||||||
|
**挂账**(本批发现但刻意不做):`deleteAccountLocked`(83 行,account 线)、`UpdateSubmission`(76)、`ValidateWorkFields`(75)、`ImportDir`(70)、`CleanupService.Run`(70)、`checkSubmissionRules`(66) —— 六个 >60 行函数,各自独立成批。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## §5 测试计划
|
||||||
|
|
||||||
|
### T1 —— 结构守卫(**RED 先行**):扩展或新增函数长度断言
|
||||||
|
|
||||||
|
P14 的七条断言钉的是**类型形态**,没有一条钉**函数长度**。本批的验收核心是"`Approve` 从 169 行降到 <60",若没有机械守卫,下一个贡献者可以把它加回去而无人拦截。
|
||||||
|
|
||||||
|
**新增 `internal/service/function_length_test.go`**(**新文件,不改 `architecture_test.go`**):
|
||||||
|
1. **`TestNoOversizedFunction`**:解析 `internal/service/` 下所有非测试 `.go`,统计每个 `func` 的行数(`^func ` 起,括号深度归零止),断言**没有任何函数 >100 行**。
|
||||||
|
- **阈值 100 的依据(实测,非拍脑袋)**:§0 普查显示当前包内 >100 行的函数**只有 `Approve`(169) 一个**,次大是 83 → 阈值 100 在**修前恰好红一条**(`Approve`)、**修后全绿**,且给次大者(83)留 17 行余量不至于误伤。
|
||||||
|
- ⚠️ **不得用 60 当阈值**:那会让 `deleteAccountLocked`(83)、`UpdateSubmission`(76)、`ValidateWorkFields`(75)、`ImportDir`(70)、`Run`(70)、`checkSubmissionRules`(66) 六个**本批范围外**的既有函数立刻报红,把"重构 `Approve`"变成"重构整个包"(违反 D-F/E)。
|
||||||
|
2. **`TestApproveIsSmall`**:断言 `Approve` 本身 **<60 行**(本批的直接目标;169 → <60)。
|
||||||
|
3. **`TestApproveHelpersExist`**:断言 §3.1 骨架里的 helper 名存在(`loadApprovePlan`/`precheckApprove`/`copyApprovalObjects`/`applyApprovalInTx`/`cleanupApprovalUploads`)——防实现者只把代码挪进一个匿名闭包了事。
|
||||||
|
- ⚠️ **这条断言的实现方式必须是源码文本匹配**(`os.ReadDir` + 正则),**不能用 reflect**:未导出函数在 reflect 里不可见(§1.3 同一机制)。
|
||||||
|
|
||||||
|
**RED 相位要求**:T1 在 T2 之前提交时,`go test ./internal/service/ -run 'TestNoOversizedFunction|TestApproveIsSmall|TestApproveHelpersExist'` 必须**红**,且红的原因是**断言失败而非编译错误**(`Approve` 169 行 > 100、helper 名不存在)。**这与 P14 T1 的 RED 形态不同**(P14 是 `undefined: CatalogService` 编译红)——本批 T1 引用的都是既有类型,故必须是断言红。实现者须把 RED 输出**逐字存证**到 `.superpowers/sdd-p15a/impl-evidence/t1-red.txt`。
|
||||||
|
|
||||||
|
**mutation 自查(实现者必做并附输出,每条测"预期红 + 其余绿 + 非编译红 + 恢复证明")**:
|
||||||
|
- (a) 把 `Approve` 的某阶段**内联回去**(helper 存在但 `Approve` 不调它,改为原地展开)→ `TestApproveIsSmall` 必须红
|
||||||
|
- (b) 给某个 helper 塞代码使 `Approve` 仍 <60 但**某函数 >100**(例如把⑥的三个 kind 分支全塞进 `applyApprovalInTx` 而不拆)→ `TestNoOversizedFunction` 必须红
|
||||||
|
> ⚠️ **(b) 的可行性须先实测**:若⑥整体(126–205 = 80 行)搬进 `applyApprovalInTx` 而不再拆,该函数是 80 行 < 100 → **mutation (b) 在这个具体形态下不会红**。这是**阈值 100 的已知盲区**,不是守卫失效:它钉的是"不再有 169 行的巨函数",不是"每个 helper 都短"。实现者须在报告里写明这一点,**不得声称 (b) 覆盖了所有塞代码形态**。若要在 (b) 上取得真红,须构造 >100 行的 helper(例如把 ④⑥ 两个 switch 都塞进一个 helper)。
|
||||||
|
- (c) 删掉一个 helper(把其内容合并进 `Approve`)→ `TestApproveHelpersExist` 必须红
|
||||||
|
- (d) 把 helper 名改成别的(如 `loadApproveInputs`)→ `TestApproveHelpersExist` 必须红(**这条是 (c) 的对照**:证明断言钉的是名字集合而非"存在任意 5 个函数")
|
||||||
|
- (e) 把 `TestNoOversizedFunction` 的扫描范围改成一个空目录 → 必须红(**防"扫 0 文件也报 0 违规"的假信心**,P13 审查者 M5 的同族教训;断言内须含"至少扫到 N 个 `.go` 文件"的前提检查)
|
||||||
|
- (f) 把阈值从 100 改成 1000 → 必须红(同 (e),防阈值被放宽;断言须自证阈值字面量)
|
||||||
|
- (g) **给 `ModerationService` 加字段**(如 `clock func() time.Time`)→ P14 的断言 6 `TestLineFields` 必须红(**跨批回归检查**:证明本批新增文件没有削弱 P14 守卫)
|
||||||
|
- (h) **在 `ContentService` 上加方法** → P14 的断言 5 必须红(同 (g),且**须同时测有名与匿名接收者两种形态**——P14 FR-1 的教训:只测有名会漏掉 `func (*ContentService) M()`)
|
||||||
|
|
||||||
|
每次 mutation 后**只回滚触及的文件**(`git checkout -- <file>`,**绝不用 `git checkout -- .`**——P14 控制者第 24 次自伤:全量回滚会抹掉未提交的守卫编辑,导致后续各轮全在测没有修复的树),并核 `git diff` 空 + 守卫文件 `func Test` 计数不变。
|
||||||
|
|
||||||
|
### T2 —— 执行拆分(T1 由红转绿)
|
||||||
|
|
||||||
|
按 §3 执行。**完成后 `go test ./...` 必须 11 包全绿且既有测试文件零修改**。
|
||||||
|
|
||||||
|
### T3 —— 字节级方法体保真证明(加做项,P14 同款)
|
||||||
|
|
||||||
|
写脚本对 base 树(`fe810dd`)的 `Approve` 函数体与新树的 `Approve` + 5 个 helper 做**语句级比对**:base 的每一行(归一化缩进与接收者前缀后)必须在新树中出现,且**顺序保持**(阶段①→⑦ 的相对顺序不得变)。产出三张清单:`verbatim`(逐字一致)、`changed`(刻意变更,须逐条给理由)、`missing`(**必须为 0**)。
|
||||||
|
|
||||||
|
⚠️ **脚本的已知坑(P14 两位审查者都栽过)**:
|
||||||
|
- 提取声明块时**不能"剥行注释后数括号"**——字符串字面量里的 `//`(如 `strings.HasPrefix(k, "https://")`)会被当注释起点吃掉右括号。须用**状态机分词器**跟踪 `"` / `` ` `` / `'` / `//` / `/* */`,把字面量与注释内容替换为**等长空格**后再数括号(P14 任务级审查者 F6 的修法)。
|
||||||
|
- **单行 `var X = errors.New(...)` 须有提前终止分支**,否则会把后续声明并入同一块(P14 F6 的残留局限)。
|
||||||
|
- 若工具给出意外值(如某方法报 CHANGED),**先怀疑自己的模式**,不要据此指控实现者(P14 F6:第一版工具误报 `CoverURL` 为 CHANGED,若照它写报告会产生一条完全虚假的 critical finding)。
|
||||||
|
|
||||||
|
### 四门验收
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd crearte-server && docker run --rm -v "$PWD/src:/src" -w /src \
|
||||||
|
-v crearte_gomod:/go/pkg/mod -e GOCACHE=/gocache -v crearte_gocache:/gocache \
|
||||||
|
-e GOPROXY=https://goproxy.cn,direct -e GOSUMDB=sum.golang.google.cn \
|
||||||
|
golang:1.24-alpine sh -c 'gofmt -l . ; go vet ./... ; go build ./... ; go test ./... -count=1'
|
||||||
|
```
|
||||||
|
⚠️ **`-count=1` 强制实跑**(禁缓存)。⚠️ **落盘证据用 `setsid nohup` 脱离 + 宿主侧重定向**(容器只挂 `/src` 写不了 `.superpowers/`;exec session 一断容器就被杀——P14 实现者与任务级审查者都踩过)。⚠️ **`grep -c` 输出 0 时 exit 1**,放 `&&` 链里会静默中断后续步骤(含 `git commit`)。⚠️ **`pkill -f <pattern>` 会匹配到自己的命令行**(P14 控制者把自己 SIGKILL)→ 用 `[p]attern`。
|
||||||
|
|
||||||
|
### 结构指标(交付时须报实测数字,`wc -l` 口径)
|
||||||
|
|
||||||
|
| 指标 | 基线 | 目标 |
|
||||||
|
|---|---|---|
|
||||||
|
| `Approve` 行数 | **169** | **< 60** |
|
||||||
|
| `service/` 包内 >100 行的函数数 | **1** | **0** |
|
||||||
|
| `service/` 包内最大单函数行数 | **169** | **< 100**(实测次大者 83,故目标应落在 83–100 之间) |
|
||||||
|
| `moderation.go` 行数 | 298 | **不作指标**(D-H:helper 同文件,可能不降反升;报了即可,不设目标) |
|
||||||
|
| 既有 `*_test.go` 修改文件数 | — | **0** |
|
||||||
|
| `architecture_test.go` 修改行数 | — | **0** |
|
||||||
|
| `Approve` 的签名 | `(ctx context.Context, adminID, submissionID, note string) error` | **逐字不变** |
|
||||||
|
| 字节级比对 `missing` | — | **0** |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## §6 记账
|
||||||
|
|
||||||
|
- `crearte-server/docs/CHANGELOG.md` 新增 **`## [0.19.0] - 2026-10-03`**(插 `## [0.18.0]` 前)。格式:**同条目英文行紧跟中文行、无空行**;不同条目**空一行**;小节标题双语(`### Changed / 变更`、`### Tests / 测试`、`### Context / 背景`)。
|
||||||
|
- wrapper `docs/CHANGELOG.md` 新增 **`## [0.3.8] - 2026-10-03`**(插 `## [0.3.7]` 前),小节 `### Done / 完成`。
|
||||||
|
- `docs/ROADMAP.md`:第三波表新增 **P15-A 行**(P14 行之后)+ 文档索引表新增一行。**哈希引 merge commit**(P12/P13/P14 惯例),记账 commit 另列。
|
||||||
|
- **挂账新增**:六个 >60 行函数(`deleteAccountLocked` 83 / `UpdateSubmission` 76 / `ValidateWorkFields` 75 / `ImportDir` 70 / `CleanupService.Run` 70 / `checkSubmissionRules` 66)· `TestNoOversizedFunction` 阈值 100 的已知盲区(80 行的 helper 不会被拦,见 §5 mutation (b) 的警告)。
|
||||||
|
- **`go.mod` / 任何 version 文件不 bump**。
|
||||||
|
- **纪律**:显式 `git add <file>`,**禁 `git add -A`**。wrapper 的 `IDENTITY.md`/`SOUL.md`/`USER.md` 三个未跟踪文件**非本批产物,绝不 stage**。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## §7 风险与缓解
|
||||||
|
|
||||||
|
| 风险 | 缓解 |
|
||||||
|
|---|---|
|
||||||
|
| **实现者"顺手 DRY"合并④⑥的 switch** | §1.2 已写明四处实质差异 + 语义差别(乐观/悲观)+ 错误文案差别;D-D 明令禁止;T3 字节级比对会暴露合并(base 的 `service: approve precheck: %w` 文案会消失或移位);既有 `TestApproveSameWorkIDConcurrent` 断言的错误集合会变 |
|
||||||
|
| **`plan` 值传递导致 ⑤ 的 `finalXxxKey` 静默丢失** | §3.1 已点名:`copyApprovalObjects` 必须收 `*approvePlan`。`TestApproveOptionalFieldsRoundTrip` 与 api 层发布链测试会抓到(key 为空 → 作品无 bundle) |
|
||||||
|
| **② 的 `owner` 零值被误当成"未加载"** | §3.2 已写明 `if sub.Kind == NewWork` 条件必须保留;`namespace_test.go:132 TestNewVersionMetadataChangeOwnership` 会抓到 OwnerID 错误 |
|
||||||
|
| **③ 的 `bundleUpload = &up` 取地址语义被改成存值** | §3.2 已点名;改了会让 `plan.bundleUpload != nil` 恒真或恒假 → ⑤⑥⑦ 全链路错,api 层发布链测试必红 |
|
||||||
|
| **⑦ 的 `slog.Info` 被搬进 cleanup helper** | §3.2 已明令留在 `Approve` 本体(职责清晰)。日志键名/顺序变更由 D-G 约束;若实现者搬了,代码审查阶段抓 |
|
||||||
|
| **新守卫 `TestNoOversizedFunction` 写成恒真/恒假** | mutation (e)(f) 双向验证(改扫描范围→红、改阈值→红)+ P13/P14 教训:`max(a,b) ≥ k` 是恒真高发区、`NumMethod()==0` 是恒假形态。**本批新增镜像形态须防:断言里若含"至少扫到 N 个文件"的前提检查,N 写太小会让前提恒成立而主体断言失去意义** |
|
||||||
|
| **未导出 helper 让 reflect 类断言失效** | §1.3 已实测:未导出方法不在断言 2/4 视野内 → 这是**特性不是缺陷**(重构私有实现不该触守卫)。但 T1.3 的 `TestApproveHelpersExist` 因此**必须用源码文本匹配而非 reflect** |
|
||||||
|
| **本批削弱 P14 守卫** | mutation (g)(h) 跨批回归检查(加字段→断言 6 红;加组合根方法→断言 5 红,**且须测有名与匿名两种接收者形态**,P14 FR-1 教训) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## §8 实现纪律(P11–P14 累计教训,逐条来自真实事故)
|
||||||
|
|
||||||
|
1. **不推断,只实测。** 任何"应该是/大概/按惯例"都要跑一条命令确认。P14 控制者在勘查期与裁定期共犯 28 次断言/grep/锚点/区间/路径/正则错误。
|
||||||
|
2. **断言或 grep 返回意外值时,先怀疑自己的模式,别先宣布缺陷。** P14 任务级审查者的第一版比对脚本误报 `CoverURL` 为 CHANGED(字符串字面量里的 `//` 被当注释起点);控制者五次因这条纪律避免了指控子代理的假缺陷。
|
||||||
|
3. **修守卫必须两方向都验**:对合法值不误红 + mutation 下不误绿。P13 控制者只验前者,交付了数学恒真的断言(`max(填充侧,边框侧) ≥ 3`,互补两侧下界 = √cr(paper,ink) = 4.0621 > 3,50653 采样暴力验证)。
|
||||||
|
4. **全称声明需要全称范围的证据。** P14 控制者写"断言 5 是唯一能抓遮蔽的手段"被终审推翻(匿名接收者 `func (*ContentService) M()` 绕过);写"两层覆盖完整"被推翻(孤儿私有方法)。**写"唯一/全部/任何"之前先枚举形态**(有名/匿名接收者、值/指针、导出/未导出)。
|
||||||
|
5. **恒假断言与恒真断言同样无价值,且恒假更容易因"看起来严格"而通过审查。** P14 控制者 spike 否决的 `NumMethod()==0`(嵌入 `*T` 使方法进值+指针两个方法集,合法树上已是 22)就是恒假形态。
|
||||||
|
6. **管道会吞掉真退出码。** `go test ./... | tail -30; echo $?` 取的是 `tail` 的退出码。P14 终审者踩过(`full_exit=0` 而实际套件 FAIL)。
|
||||||
|
7. **`|| echo "(zero)"` 这类兜底文本在 glob 失败时会伪装成"测量结果为零"。** P14 终审者 R0 轮整轮结论无证据支撑,自查后在真仓用绝对路径重跑;控制者的 `bg-surface=0` 同形态(`cd` 到仓根却写 `app/...`,真根是 `src/app/...`)。**任何"零命中"结论都要先证明扫描范围非空。**
|
||||||
|
8. **长时命令落盘证据用 `setsid nohup` 脱离 + 宿主侧重定向。** exec session 一断容器就被杀,P14 实现者的 `t3-gates.txt` 首跑只写 1/12 包。
|
||||||
|
9. **`restore()` 只回滚 mutation 触及的文件,绝不用 `git checkout -- .`**;每轮恢复后核验守卫编辑仍在(`func Test` 计数),否则 FATAL 终止——让脚本在被自毁时大声失败,而不是静默产出"未按预期"的假结论。
|
||||||
|
10. **测试被迫修改 = 设计失败的信号。** 若必须改既有测试,**停下来报告**而不是改。本批的验收硬指标就是"既有 `*_test.go` 修改数 = 0"。
|
||||||
@@ -0,0 +1,421 @@
|
|||||||
|
# P15-B 设计:bootstrap 加载屏暗色 + 对比度守卫补钉 + 死令牌清理(crearte)
|
||||||
|
|
||||||
|
- **日期**:2026-10-03
|
||||||
|
- **仓**:`crearte`(前端;`package.json` 与全部源码在 **`src/`**,不是仓根)
|
||||||
|
- **分支**:`feat/p15b-bootstrap-dark-and-guard-pins`(AGENTS.md 只允许 `{feat|fix|docs|chore}/`;本批含用户可感知的暗色改动,故用 `feat/`)
|
||||||
|
- **base**:`f823195`(master,P13 交付后的 AGENTS.md 不变量 commit)
|
||||||
|
- **维度**:用户体验 / UI 交互 + 代码优雅(USER.md 六维度循环)
|
||||||
|
- **并行**:P15-A 在 `crearte-server` 仓,两仓各自单写者 → **可同时派发**(`sdd-parallel-dispatch` §1)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## §0 基线(已实测,2026-10-03 13:07)
|
||||||
|
|
||||||
|
**vitest**:`Test Files 79 passed (79)` · `Tests 688 passed (688)` · Duration 44.83s。
|
||||||
|
命令:`cd crearte/src && npx vitest run`。**与 P13 交付时报的 688/79 逐字相同** → P14 未触及前端,且此后无新增测试。这是本批的比对基线。
|
||||||
|
|
||||||
|
`bootstrap/host-origin.test.ts (3 tests)` 已在 79 个文件里 → **`bootstrap/` 目录已有测试先例**,新增测试文件不属破例。
|
||||||
|
|
||||||
|
**vitest 环境**:`src/vite.config.ts` 的 `test` 段只有 `exclude: [...configDefaults.exclude, 'e2e/**']`,**无 `environment` 键** → 默认 **node**。这是 `contrast.test.ts` / `noHardcodedColor.test.ts` / `themeBootstrap.test.ts` 能用 `fileURLToPath(new URL('../..', import.meta.url))` 读源码文件的前提(**P13 教训:happy-dom 下会抛 `ERR_INVALID_URL_SCHEME`**)。本批新增的守卫**必须沿用同款写法**,不得引入需要 DOM 的断言。
|
||||||
|
|
||||||
|
**e2e**:`src/e2e/`,`playwright.config.ts` / `.noauth.` / `.stack.` 三份配置都是 `testDir: './e2e'`。`dark.spec.ts` **10 腿**(P13 交付),其中 `:100` 是 **pre-paint 归因腿**:「Vue 包被拦截时 `data-theme` 仍为 dark(证明是内联脚本而非挂载后设置)」。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## §1 三项改动的取证
|
||||||
|
|
||||||
|
### 1.1 改动①:bootstrap 游戏加载屏对暗色用户「白闪」
|
||||||
|
|
||||||
|
**现象**:`src/bootstrap/index.html`(虚拟模式游戏的加载屏,`<title>游戏加载中…</title>` + 进度条 + 状态行 + 错误区)把整套配色**硬编码为亮色值**,且 `localStorage` / `prefers-color-scheme` / `data-theme` / `@media` **四项全部零命中**。暗色用户启动虚拟游戏时,会在暗色页面里看到一块亮色矩形。
|
||||||
|
|
||||||
|
**它确实是用户面**(非死代码):
|
||||||
|
- `src/vite.config.ts:21` 把 `bootstrap/index.html` 注册为名为 `bootstrap` 的**第二个 Vite 构建入口**
|
||||||
|
- `runtime/sw/router.ts:1` `export const BOOTSTRAP_PATH = '/__bootstrap'`;`sw/index.ts:211,218` 有 `redirect-bootstrap` 与离线兜底
|
||||||
|
- `runtime/host/adapters.ts:38`:`targets.push({ mode: 'virtual', url: \`${origin}/__bootstrap#${hash.toString()}\`, origin })`
|
||||||
|
- `useGameFrame.test.ts`:virtual 模式 url 形如 `http://demo.localhost:4173/__bootstrap#v=1`
|
||||||
|
- 游戏跑在**独立子域**:`runtime/host/config.ts:18` `derivePlayOrigin → ${protocol}//${16hex}.${baseDomain}`,由 `GameHost.vue` 以 iframe 嵌入
|
||||||
|
|
||||||
|
**根因不是漏改,是 P13 的令牌化只覆盖了单入口**:P13 改的是 `src/index.html`(主应用入口)+ `app/styles/main.css`(12 令牌)+ `app/` 与 `runtime/` 的 `.vue`。`bootstrap/` 是**第二个 HTML 入口**,不在任何一处覆盖范围内。
|
||||||
|
→ **教训(已入 ROADMAP 挂账):换肤/令牌化类改动须先 `grep -n 'input' src/vite.config.ts` 枚举所有构建入口,逐个确认覆盖。单入口假设会漏掉多入口应用。**
|
||||||
|
|
||||||
|
**跨源约束(决定修法的上限)**:
|
||||||
|
- **`localStorage` 按源隔离** → bootstrap 跑在游戏子域,**读不到**主应用存在主域 `localStorage['crearte.theme.v1']` 的显式选择。主应用 `src/index.html:15-31` 的 pre-paint 脚本判定序是 `stored → prefers-color-scheme → light`(由 `themeBootstrap.test.ts` 7 腿钉住),**在子域上拿不到 `stored` 那一级**。
|
||||||
|
- **`prefers-color-scheme` 是浏览器级、不按源隔离** → 可用。
|
||||||
|
- **hash 通道已存在**:`bootstrap/main.ts:4` 就是 `const params = new URLSearchParams(location.hash.replace(/^#/, ''))`,且 `main.ts:97` 有 `fail('启动参数完整', location.href)` 说明它是必需参数集 → **主题可搭车现有 hash 通道传入,不必新建 postMessage 协议**(P15 账本 §7 原估「完整修法须 postMessage,成本更高」,**这条被证伪**)。
|
||||||
|
|
||||||
|
**🔑 架构陷阱(本批最重要的设计约束)**:`GameHost.vue:27-31` 的 `targets` 是 **`computed`**:
|
||||||
|
```js
|
||||||
|
const targets = computed<RuntimeTarget[]>(() => resolveRuntimeTargets(props.game, {
|
||||||
|
baseDomain: config.baseDomain, protocol: location.protocol, config
|
||||||
|
}))
|
||||||
|
```
|
||||||
|
而 `useTheme.ts:51` 是模块级响应式单例 `const theme: Ref<Theme> = ref(effectiveTheme())`。
|
||||||
|
|
||||||
|
| 注入方式 | 响应式追踪 | 后果 |
|
||||||
|
|---|---|---|
|
||||||
|
| ❌ `theme: useTheme().theme.value` | **是**(读 `ref` 的 getter → computed 建立依赖) | 用户在游戏页切主题 → `targets` 重算 → **iframe `src` 变化 → 正在运行的游戏被重载**(存档/进度丢失) |
|
||||||
|
| ✅ `theme: effectiveTheme()` | **否**(`effectiveTheme()` 内部读 `localStorage` 与 `matchMedia`,都不是响应式源) | computed 不依赖主题;iframe 不重载。代价:切主题后已打开的加载屏保持旧主题,直到下次导航 |
|
||||||
|
|
||||||
|
→ **裁定用 `effectiveTheme()`**(`useTheme.ts:39` 已导出)。"游戏运行中不重载" 比 "加载屏实时跟随主题切换" 重要得多:加载屏只在**启动瞬间**可见,而重载会毁掉正在进行的游戏。**这条必须有测试钉住**(§5-T1 腿 4),否则下一个贡献者"顺手改成响应式"就会引入静默的游戏重载缺陷。
|
||||||
|
|
||||||
|
**色值映射(实测;`bootstrap/index.html` 里 8 个硬编码 hex 的令牌归属)**:
|
||||||
|
|
||||||
|
| hex | 令牌归属 | 暗色对应值 |
|
||||||
|
|---|---|---|
|
||||||
|
| `#f7f2e7` | 亮 `paper` **且** 暗 `ink`(翻转对称点) | `#17140f`(暗 paper) |
|
||||||
|
| `#141414` | 亮 `ink` | `#f7f2e7` |
|
||||||
|
| `#ffffff` | 亮 `surface` | `#221e18` |
|
||||||
|
| `#5c584d` | 亮 `ink-soft` | `#cbc4b5` |
|
||||||
|
| `#e8552f` | 亮 `accent` | `#ff7a4d` |
|
||||||
|
| `#c03a1b` | 亮 `accent-ink` | `#ffb59a` |
|
||||||
|
| `#a3a3a3` | ❌ **不对应任何令牌** | 见下 |
|
||||||
|
| `#f87171` | ❌ **不对应任何令牌** | 见下 |
|
||||||
|
|
||||||
|
**🔴 同批发现两条 pre-existing WCAG 违规(inline `style` 覆盖了达标的样式表值)**:
|
||||||
|
|
||||||
|
| 元素 | 样式表值(`<style>` 块) | inline `style=""` 值 | 谁生效 | 对比度(on `#f7f2e7`) |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `#status`(`:25`) | `#5c584d` | **`#a3a3a3`** | **inline 胜**(CSS 特异性) | 样式表 **6.3586 ✅** / inline **2.2594 ❌** |
|
||||||
|
| `#error-message`(`:27`) | `#c03a1b` | **`#f87171`** | **inline 胜** | 样式表 **4.8700 ✅** / inline **2.4776 ❌** |
|
||||||
|
|
||||||
|
→ **inline 是冗余重复且用了更低的对比度**:样式表里同元素**已有达标值**,inline 属性把它覆盖成不达标的。**删掉 inline 的 `color` 声明即修复**(保留 `font-size`),达标值自动生效。这是有实测对比度支撑的一行改,**不是设计决策**(与 GameHost 亮色徽标那条不同——那条的显而易见修法已被证伪,属撞色设计决策,仍挂账)。
|
||||||
|
|
||||||
|
**暗色候选配色(8 项全部实算通过)**:
|
||||||
|
|
||||||
|
| 用途 | 暗色值 | on | 对比度 | 需 | 判定 |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| body 正文 | `#f7f2e7`(ink) | `#17140f`(paper) | **16.4507** | 4.5 | ✅ |
|
||||||
|
| `#status` / `pre` | `#cbc4b5`(ink-soft) | `#17140f` | **10.5847** | 4.5 | ✅ |
|
||||||
|
| `#error-message` | `#ffb59a`(accent-ink) | `#17140f` | **10.7821** | 4.5 | ✅ |
|
||||||
|
| 按钮文字 | `#17140f`(paper) | `#f7f2e7`(ink) | **16.4507** | 4.5 | ✅ |
|
||||||
|
| 进度条值 | `#ff7a4d`(accent) | `#221e18`(surface) | **6.4274** | 3.0 | ✅ |
|
||||||
|
| 进度条边框/阴影 | `#f7f2e7`(ink) | `#221e18` | **14.8468** | 3.0 | ✅ |
|
||||||
|
|
||||||
|
亮色现状(**须逐字保持不变**):body 正文 16.5010 ✅ · `#status` 样式表 6.3586 ✅ · `#error-message` 样式表 4.8700 ✅ · 按钮 16.5010 ✅ · 进度条值 `#e8552f` on `#ffffff` = 3.6385 ✅(需 3.0)。
|
||||||
|
|
||||||
|
### 1.2 改动②:`AA_PAIRS` 补钉 `['ink', 'surface']`
|
||||||
|
|
||||||
|
**`AA_PAIRS` 现状**(`app/lib/contrast.test.ts:102`,类型 `Array<[fg: string, bg: string, note: string]>`,**10 对**,断言循环在 `:124`):
|
||||||
|
```
|
||||||
|
['ink','paper','正文全站 亮16.50/暗16.45'] ['paper','accent-ink','badge/按钮/error toast 亮4.87/暗10.78']
|
||||||
|
['ink-soft','paper','次要文 亮6.36/暗10.58'] ['paper','success','success toast 亮4.76/暗5.81']
|
||||||
|
['ink-faint','paper','::placeholder+弱文 亮4.83/暗6.99'] ['paper','ink','.btn-ink/markdown th 亮16.50/暗16.45']
|
||||||
|
['ink-soft','surface','卡片次要文 亮7.10/暗9.55'] ['accent-ink','paper','链接 text-accent-ink 亮4.87/暗10.78']
|
||||||
|
['ink-faint','surface','卡片弱文 亮5.40/暗6.31']
|
||||||
|
['ink','highlight','alert×8/strong/选中态/::selection 亮11.30/暗4.81']
|
||||||
|
```
|
||||||
|
|
||||||
|
**缺口**:`ink on surface` **未钉**,而它是**所有未钉配对里使用面最大的**:
|
||||||
|
- `bg-surface` 全仓 **62 处**(`app/` + `runtime/` 的 `.vue`)
|
||||||
|
- 其中只有 **4 处**显式写 `text-ink-soft`(已钉)、**2 处**的 `text-paper` 属三元表达式**另一支**(P15 账本 §1 第 19 次错误:把互斥分支当同元素共现,是假阳性)
|
||||||
|
- **58 处靠继承**:全局文字色源是 `src/index.html:34` `<body class="bg-paper text-ink font-sans antialiased">` → 继承到 **`ink`**
|
||||||
|
- 实测对比度:亮 **18.4225** / 暗 **14.8468** → **必然通过 4.5**
|
||||||
|
|
||||||
|
**为什么值得钉(不是"必然通过就不用钉")**:已钉配对里使用面最大的是 `paper on accent-ink`(14 处)。`ink on surface` 有 62 处却无守卫 → 若将来调 `--color-surface`(如暗色 `#221e18` 提亮)或 `--color-ink`,**58 处继承文字会静默回归而零拦截**。这正是 P13/P14 反复出现的形态:**守卫钉住了容易想到的,漏了使用面最大的**。
|
||||||
|
|
||||||
|
**note 文案**(体例同现有 10 条,须含用途 + 亮/暗实测值):
|
||||||
|
```
|
||||||
|
['ink', 'surface', '卡片/表格/表单底(62 处,58 靠 body 继承)亮18.42/暗14.85'],
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.3 改动③:`--color-info` 死令牌清理
|
||||||
|
|
||||||
|
**定义**:`app/styles/main.css:12`(亮 `#2b62cc`)、`:53`(暗 `#7aa7f0`)。
|
||||||
|
|
||||||
|
**零使用**(实测):全仓 `bg-info` / `text-info` / `border-info` / `ring-info` **零命中**;`color-info` 在整个 `src/` + `e2e/` 里**只有那两行定义**(命中数 = 2,即定义本身)。`main.css` 内 `info` 字样也只出现在那两行。
|
||||||
|
|
||||||
|
**矛盾点**:它在 `REQUIRED_KEYS`(`contrast.test.ts:75-88`,**12 项**,`info` 在**第 8 位**)里 → `it(\`${zh} 12 令牌齐备(spec §3.1(b))\`)`(`:119`)会**在删除时变红**。**守卫在强制一个零使用的令牌存在。**
|
||||||
|
|
||||||
|
**连带面(实测清点,5 个文件,不是"顺手删两行")**:
|
||||||
|
|
||||||
|
| 文件 | 行 | 改什么 |
|
||||||
|
|---|---|---|
|
||||||
|
| `crearte/src/app/styles/main.css` | 12, 53 | 删两行定义 |
|
||||||
|
| `crearte/src/app/lib/contrast.test.ts` | 84 | 从 `REQUIRED_KEYS` 删 `'info',` |
|
||||||
|
| 同上 | 47 | 注释「由「12 令牌齐备」断言明确报「暗色 12 令牌缺失」」→ 改 11 |
|
||||||
|
| 同上 | 74 | 注释「P13 全量 12 令牌(spec §3.1(b))」→ 改 11 |
|
||||||
|
| 同上 | 119 | `it` 标题 `${zh} 12 令牌齐备(spec §3.1(b))` → 改 11 |
|
||||||
|
| 同上 | 121 | 断言消息 `${zh} 12 令牌缺失: …` → 改 11 |
|
||||||
|
| `crearte/docs/CHANGELOG.md` | 14(0.26.0 段) | 「每主题 **12 个设计令牌**」→ 须说明 0.19.0 起为 11(**历史条目不改写**,改为在新条目里说明;见 D-C) |
|
||||||
|
| `wrapper/docs/specs/2026-10-03-p13-dark-mode-design.md` | 109, 155, 185, 427 | P13 spec 的 D-B「全量 12 令牌」+ 两处 `--color-info` 代码块 + §「暗色块缺失 → 「暗色 12 令牌齐备」红」→ **加 RE-PIN 标注**(同 P14 手法:保留历史决策 + 标注增订),不改写原文 |
|
||||||
|
| `wrapper/docs/ROADMAP.md` | 42(P13 行) | P13 行的「每主题 **12 个设计令牌**」→ 保留原文(历史记录),挂账里删掉「死令牌 `--color-info`」这一项(已处置) |
|
||||||
|
|
||||||
|
**✅ `LEGACY_KEYS` 不受影响**(实测):`contrast.test.ts:197` 的九键反面钉桩是 `['paper','surface','ink','ink-soft','ink-faint','accent','accent-ink','highlight','success']`,**不含 `info`** → 删令牌**不波及** P13 那条"防后人简化回去"的钉桩。这条必须实测确认过再动手,否则会误改一处刻意保留的历史锚点。
|
||||||
|
|
||||||
|
**为什么不选"启用它"**(处置 (b)):`success` 与 `highlight` 已覆盖 toast 与 alert 的语义需求(`paper on success` 是 success toast、`ink on highlight` 覆盖 alert×8/strong/选中态/`::selection`),`info` **无对应 UI 位置**。启用它需要新造一个 info toast 变体 = 改视觉 + 加功能,超出"代码优雅"范围,且会为一个新的 UI 元素引入新的对比度配对需要钉。**删比造便宜,且删掉的是零使用面。**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## §2 设计决策
|
||||||
|
|
||||||
|
| # | 决策点 | 裁定 | 依据 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **D-A** | bootstrap 主题来源 | **hash 参数 `theme` 优先 → `prefers-color-scheme` → `light`**(三级,比主应用少 `stored` 一级) | §1.1:`localStorage` 按源隔离读不到;hash 通道已存在(`main.ts:4`);`prefers-color-scheme` 不受源隔离。**判定序必须与主应用 `useTheme.effectiveTheme()` 的后两级逐字一致**,否则同一用户在主应用与游戏加载屏看到不同主题 |
|
||||||
|
| **D-B** | 父侧注入方式 | **`effectiveTheme()` 非响应式快照**,**不得**用 `useTheme().theme.value` | §1.1 的架构陷阱:响应式会让主题切换重算 `targets` → iframe `src` 变 → **运行中的游戏被重载**。必须有测试钉住(§5-T1 腿 4) |
|
||||||
|
| **D-C** | 死令牌清理的历史条目处理 | **不改写 P13 的 CHANGELOG 条目与 spec 原文**,改为:新条目(0.27.0)说明变更 + P13 spec 加 **RE-PIN 标注块** | CHANGELOG 是发布历史,改写会让"当时交付了什么"失真。P14 已建立正确手法:保留历史决策原文 + 紧随带 ⚠️ RE-PIN 标记的增订块(全分支终审判定这是"正确的 re-pin 手法"而非自相矛盾) |
|
||||||
|
| **D-D** | 新守卫的形态 | **新增 `app/lib/bootstrapTheme.test.ts`**(源码级、node 环境、读文件文本),**不扩展 `noHardcodedColor.test.ts`** | §1.4:`noHardcodedColor` 的 `candidates()` 只收 `.vue`(`if (file.endsWith('.vue'))`),且 `maskNonTemplate()` 把 `<style>` 块整体等长空白掉 → **它从设计上无法覆盖 `bootstrap/index.html`**(HTML 文件 + 颜色全在 `<style>` 里)。扩扫描根不会有任何效果 |
|
||||||
|
| **D-E** | inline `style` 的两处违规 | **删掉 inline 的 `color` 声明**(保留 `font-size`),让样式表的达标值生效 | §1.1 实测:inline 是冗余重复且对比度更低(2.2594 / 2.4776 vs 样式表 6.3586 / 4.8700)。删 inline 即修复,**同时消除"两处定义同一元素颜色"的分歧源** |
|
||||||
|
| **D-F** | 暗色实现方式 | 在 `bootstrap/index.html` 的 `<style>` 块里用 **`html[data-theme="dark"]` 覆盖**(同主应用 `main.css:44` 的手法),**不用 `@media (prefers-color-scheme)`** | 主应用用 `data-theme` 属性驱动(`color-scheme` 也由它驱动,D-L),使"显式选择能压过系统偏好"。bootstrap 若用 `@media` 就只能跟随系统、无法接收 hash 传入的显式主题 → **两套机制会让判定序无法一致**(违反 D-A) |
|
||||||
|
| **D-G** | pre-paint 脚本形态 | **IIFE + 仅 `var`,置于 `<head>` 内、任何 CSS 与 `<body>` 之前**,逐字对齐主应用 `src/index.html:15-31` 的写法 | 主应用该形态由 `themeBootstrap.test.ts` 腿 4("脚本在 `<head>` 内、`<body>` 之前")与腿 5(IIFE + var)钉住,理由是 CSP 落地前不能用 module/let。**同款形态让两处可被同一套守卫断言** |
|
||||||
|
| **D-H** | 派发形态 | **一个实现者子代理**做完 T1→T4(同动 crearte 单一工作树与 git index) | AGENTS.md / `sdd-parallel-dispatch` §1「一仓一写者」;P12/P13/P14 同款 |
|
||||||
|
|
||||||
|
### §2.1 被否方案
|
||||||
|
|
||||||
|
| 方案 | 否决理由 |
|
||||||
|
|---|---|
|
||||||
|
| **A:用 `@media (prefers-color-scheme: dark)` 实现 bootstrap 暗色** | 只跟随系统偏好,**无法接收 hash 传入的用户显式选择** → 显式设了暗色但系统是亮色的用户仍看到亮色加载屏。且与主应用的 `data-theme` 机制分叉,判定序无法逐字一致(违反 D-A)。这是 P15 账本 §7 原估的"廉价修法",**取证后判定不够** |
|
||||||
|
| **B:postMessage 传主题** | hash 通道已存在(`main.ts:4`),无需新协议。postMessage 是**异步**的,而加载屏是 pre-paint → 会先绘亮色再翻暗色,**正是要消除的白闪**。`host-origin.ts` 的信令通道用于安装进度上报,时序上不适合主题 |
|
||||||
|
| **C:给 bootstrap 也读 `localStorage`** | 源隔离,物理上读不到主域的值(`derivePlayOrigin` → 独立子域)。若为绕过而放宽子域隔离,会破坏 P11 的安全模型 |
|
||||||
|
| **D:扩展 `noHardcodedColor` 的扫描根到 `bootstrap/`** | §1.4:`candidates()` 只收 `.vue`、`maskNonTemplate()` 会空白掉 `<style>` 块 → 扩根**扫不到任何东西**,且会给人"已覆盖"的假信心(P13 审查者 M5 的同族形态:扫描根改坏后"扫 0 文件也报 0 违规") |
|
||||||
|
| **E:把 bootstrap 的配色改成引用 `var(--color-*)` 令牌** | 令牌定义在 `app/styles/main.css`,**bootstrap 是独立入口、不加载主应用 CSS**(它只有自己的 `<style>` 块)。要引令牌就得把整个 `@theme` 块复制进 bootstrap = 两处调色板需要同步维护,比硬编码更糟。故 bootstrap **刻意**保持自包含的 hex 值,由新守卫钉住两主题的值与主应用调色板一致 |
|
||||||
|
| **F:一并修 GameHost 亮色徽标 WCAG 3.26** | 属**设计决策**而非一行改:亮色 `accent` vs `accent-ink` 仅 **1.4943**,徽标与「加载失败」状态点同屏共存于展柜标题栏,改成 `bg-accent-ink` 会把两个语义压成一个视觉信号(已实测证伪显而易见修法,ROADMAP `9965fd7` 已登记约束)。仍需挂账 |
|
||||||
|
|
||||||
|
### §1.4 `noHardcodedColor` 为何无法覆盖 bootstrap(D-D 的依据,逐字取证)
|
||||||
|
|
||||||
|
```js
|
||||||
|
// app/lib/noHardcodedColor.test.ts:23-45
|
||||||
|
const SRC_ROOT = fileURLToPath(new URL('../..', import.meta.url))
|
||||||
|
// 终审 N4:扫描根必须含 `runtime/`。…实测 runtime/ 当前零硬编码 hex,扩根不会立刻红。
|
||||||
|
const SCAN_ROOTS = [join(SRC_ROOT, 'app'), join(SRC_ROOT, 'runtime')]
|
||||||
|
|
||||||
|
function candidates(): string[] {
|
||||||
|
const files: string[] = []
|
||||||
|
for (const root of SCAN_ROOTS) {
|
||||||
|
for (const file of walk(root)) {
|
||||||
|
if (file.endsWith('.test.ts')) continue
|
||||||
|
if (file.endsWith('.vue')) files.push(file) // ← 只收 .vue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return files
|
||||||
|
}
|
||||||
|
function maskNonTemplate(content: string): string {
|
||||||
|
const blank = (block: string): string => block.replace(/[^\n]/g, ' ')
|
||||||
|
return content
|
||||||
|
.replace(/<!--[\s\S]*?-->/g, blank)
|
||||||
|
.replace(/<(script|style)\b[^>]*>[\s\S]*?<\/\1>/gi, blank) // ← <style> 块整体空白
|
||||||
|
.replace(/<(textarea|title)\b[^>]*>[\s\S]*?<\/\1>/gi, blank)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
→ 两道过滤各自都足以排除 `bootstrap/index.html`:**扩展名不是 `.vue`**,且**颜色全在 `<style>` 块内**(会被 `blank` 掉)。该守卫的设计目标是"禁止在 `.vue` **模板**里用 `bg-[#…]` 形态的硬编码 hex"(文件头注释原文),HTML 入口的 `<style>` 块本就不在其范围内。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## §3 实现要求
|
||||||
|
|
||||||
|
### 3.1 改动① bootstrap 暗色(`src/bootstrap/index.html` + `src/runtime/host/adapters.ts` + `src/runtime/host/GameHost.vue`)
|
||||||
|
|
||||||
|
**(a) `bootstrap/index.html` 的 `<head>`**:在 `<style>` **之前**插入 pre-paint 脚本,逐字对齐主应用形态(D-G):
|
||||||
|
```html
|
||||||
|
<meta name="color-scheme" content="light dark" />
|
||||||
|
<meta name="theme-color" content="#F7F2E7" />
|
||||||
|
<!-- P15-B D-A/D-G:加载屏 pre-paint 主题脚本。必须在 <style> 与 <body> 之前,否则暗色用户每次启动游戏都闪白。
|
||||||
|
判定序比主应用少 stored 一级(游戏跑在独立子域,localStorage 按源隔离读不到主域的 crearte.theme.v1):
|
||||||
|
hash theme → prefers-color-scheme → light。后两级与 useTheme.effectiveTheme() 逐字一致,
|
||||||
|
一致性由 app/lib/bootstrapTheme.test.ts 钉住(改一处必须改另一处)。 -->
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
try {
|
||||||
|
var params = new URLSearchParams(location.hash.replace(/^#/, ''))
|
||||||
|
var hashTheme = params.get('theme')
|
||||||
|
var theme =
|
||||||
|
hashTheme === 'light' || hashTheme === 'dark'
|
||||||
|
? hashTheme
|
||||||
|
: window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||||
|
? 'dark'
|
||||||
|
: 'light'
|
||||||
|
document.documentElement.setAttribute('data-theme', theme)
|
||||||
|
var meta = document.querySelector('meta[name="theme-color"]')
|
||||||
|
if (meta) meta.setAttribute('content', theme === 'dark' ? '#17140f' : '#f7f2e7')
|
||||||
|
} catch (e) {
|
||||||
|
document.documentElement.setAttribute('data-theme', 'light')
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
⚠️ **白名单校验必须与主应用同款**(`=== 'light' || === 'dark'`),不得放宽成"任意 truthy"——`themeBootstrap.test.ts` 腿 3 正是为此存在(P13 finding #2 / N2:垃圾 `stored=neon` 必须被忽略)。
|
||||||
|
|
||||||
|
**(b) `<style>` 块**:加暗色覆盖块(D-F),值取 §1.1 的暗色调色板;`color-scheme: dark` 随块声明一次(同 `main.css:57` 的手法与理由)。**亮色值逐字不变**。
|
||||||
|
|
||||||
|
**(c) 两处 inline `style`**(D-E):`:25` 删 `color:#a3a3a3`、`:27` 删 `color:#f87171`,各保留 `font-size`。删后由 `<style>` 块的 `#status`(`#5c584d`) 与 `#error-message`(`#c03a1b`) 生效 → 对比度 6.3586 / 4.8700 达标。
|
||||||
|
|
||||||
|
**(d) `adapters.ts:10,37-38`**:`resolveRuntimeTargets` 的 `opts` 加**可选**键 `theme?: 'light' | 'dark'`;仅当 `opts.theme` 存在时 `fragment.theme = opts.theme`(**不加就不写这个键**,保持 external/hosted 模式的 url 完全不变)。
|
||||||
|
```ts
|
||||||
|
export function resolveRuntimeTargets(game: Game, opts: {
|
||||||
|
baseDomain: string; protocol: string; config?: ReturnType<typeof runtimeConfig>; theme?: 'light' | 'dark'
|
||||||
|
}): RuntimeTarget[] {
|
||||||
|
```
|
||||||
|
⚠️ **必须可选**:`adapters.test.ts` 有 4 处调用不带 `theme`,且 `useGameFrame.test.ts` 也 import 了 `RuntimeTarget` 类型 → 加必填键会破既有测试(违反"既有测试修改数 = 0")。
|
||||||
|
|
||||||
|
**(e) `GameHost.vue:27-31`**:注入非响应式快照(D-B):
|
||||||
|
```js
|
||||||
|
import { effectiveTheme } from '../../app/composables/useTheme'
|
||||||
|
const targets = computed<RuntimeTarget[]>(() => resolveRuntimeTargets(props.game, {
|
||||||
|
baseDomain: config.baseDomain, protocol: location.protocol, config, theme: effectiveTheme()
|
||||||
|
}))
|
||||||
|
```
|
||||||
|
⚠️ **不得写成 `useTheme().theme.value`**(会让 computed 依赖 theme ref → 主题切换重载 iframe)。注释须写明这个陷阱,否则下一个贡献者会"顺手改成响应式"。
|
||||||
|
|
||||||
|
### 3.2 改动② `AA_PAIRS` 补钉(`src/app/lib/contrast.test.ts:102-113`)
|
||||||
|
|
||||||
|
在 `['ink','highlight',…]` 之后(或按现有分组习惯置于 `surface` 两对之后)插入:
|
||||||
|
```ts
|
||||||
|
['ink', 'surface', '卡片/表格/表单底(bg-surface 62 处,58 靠 body 继承 text-ink)亮18.42/暗14.85'],
|
||||||
|
```
|
||||||
|
**不得改其它 10 对**(它们的 note 里带实测值,是 P13 交付的一部分)。
|
||||||
|
|
||||||
|
### 3.3 改动③ 死令牌清理(5 文件,见 §1.3 连带面表)
|
||||||
|
|
||||||
|
- `main.css:12`(亮)与 `:53`(暗)删两行 `--color-info`
|
||||||
|
- `contrast.test.ts`:`REQUIRED_KEYS` 删 `'info',`(`:84`)+ 四处 "12 令牌" 文案改 "11 令牌"(`:47,74,119,121`)
|
||||||
|
- ⚠️ **`LEGACY_KEYS`(`:197`)不动**(九键不含 `info`,已实测)
|
||||||
|
- P13 spec 加 RE-PIN 标注块(D-C,不改写原文)
|
||||||
|
- crearte CHANGELOG **不改写 0.26.0 条目**,在 0.27.0 新条目里说明
|
||||||
|
|
||||||
|
### 3.4 不得触碰
|
||||||
|
|
||||||
|
- `themeBootstrap.test.ts`(主应用 pre-paint 守卫,7 腿)——除非某腿因本批变红,那时**停下来报告**
|
||||||
|
- `noHardcodedColor.test.ts`(D-D:扩它无用)
|
||||||
|
- `useTheme.ts`(`effectiveTheme()` 已够用,改它会波及主应用 10 腿 e2e)
|
||||||
|
- `src/index.html`(主应用入口,P13 交付)
|
||||||
|
- 任何既有 `*.test.ts` / `e2e/*.spec.ts`
|
||||||
|
- `crearte-server`、`crearte-deploy`、wrapper 的 `docs/`(实现者红线;wrapper 的 spec/CHANGELOG/ROADMAP 由**控制者**记账)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## §4 边界
|
||||||
|
|
||||||
|
| 项 | 在范围内 | 不在范围内 |
|
||||||
|
|---|---|---|
|
||||||
|
| 文件 | `bootstrap/index.html`、`runtime/host/adapters.ts`、`runtime/host/GameHost.vue`、`app/lib/contrast.test.ts`、`app/styles/main.css`、**新增** `app/lib/bootstrapTheme.test.ts` | 其它 `.vue` / `useTheme.ts` / `themeBootstrap.test.ts` / `noHardcodedColor.test.ts` |
|
||||||
|
| 主题 | bootstrap 加载屏的两态 | GameHost 展柜框架本身(它吃主应用令牌,已正确跟随暗色;其亮色徽标 3.26 属设计决策,挂账) |
|
||||||
|
| 守卫 | 新增 bootstrap 主题一致性守卫 + `AA_PAIRS` 补一对 | 改既有守卫的断言语义 |
|
||||||
|
| 令牌 | 删 `--color-info` | 新增任何令牌 |
|
||||||
|
| 行为 | 加载屏配色 + hash 多一个可选键 | 任何游戏运行时行为、SW 路由、bundle 解密 |
|
||||||
|
|
||||||
|
**挂账**(本批发现但刻意不做):GameHost 亮色徽标 WCAG 3.26(设计决策,撞色约束已登记 ROADMAP `9965fd7`)· 第三态"恢复跟随系统"(P13 spike 3 已证伪:header 在 @320px 最坏格无像素容纳带标签控件)· CSP 落地时 bootstrap 内联脚本也需 `nonce`(与主应用同批处理)· SFC `<style>` 块内硬编码 hex 守卫不覆盖(P13 accepted:`app/` 无 `<style>` 块)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## §5 测试计划
|
||||||
|
|
||||||
|
### T1 —— 守卫先行(**RED 先行**):新增 `app/lib/bootstrapTheme.test.ts`
|
||||||
|
|
||||||
|
源码级守卫,**node 环境**(§0:vitest 默认 node,沿用 `fileURLToPath(new URL('../..', import.meta.url))` 读文件,与 `contrast.test.ts` / `themeBootstrap.test.ts` / `noHardcodedColor.test.ts` 同款)。
|
||||||
|
|
||||||
|
读三个文件:`bootstrap/index.html`、`app/composables/useTheme.ts`、`app/styles/main.css`。
|
||||||
|
|
||||||
|
**腿清单**(每条都要有 mutation 证明有牙):
|
||||||
|
|
||||||
|
| # | 断言 | 钉什么 | mutation(应变红) |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 1 | bootstrap 的 `<head>` 内有 pre-paint 脚本,且在 `<style>` 与 `<body>` **之前** | D-G 的 pre-paint 位置(否则暗色用户每次启动闪白) | 把 `<script>` 移到 `<style>` 之后 |
|
||||||
|
| 2 | bootstrap 判定序 = **hash theme → prefers-color-scheme → light**,且用 `indexOf` 比较三者位置 | D-A 的判定序 | 交换 hash 与 prefers 的先后;或删掉 light 兜底 |
|
||||||
|
| 3 | bootstrap 用**白名单**校验 hash theme(只接受 `light`/`dark`) | 防"任意 truthy"放宽(P13 finding #2 / N2 的同族) | 改成 `hashTheme ? hashTheme : …` |
|
||||||
|
| 4 | 脚本是 **IIFE 且只用 `var`**(无 `let`/`const`/箭头函数/`import`) | D-G 的 CSP 前形态 | 把 `var` 改成 `const` |
|
||||||
|
| 5 | **`GameHost.vue` 注入的是 `effectiveTheme()` 而非 `useTheme().theme.value`** | 🔴 **D-B 的架构陷阱**(响应式会让主题切换重载运行中的游戏) | 改成 `theme.value` → 必须红 |
|
||||||
|
| 6 | bootstrap 暗色块的 hex 值与 `main.css` 的 `html[data-theme="dark"]` 调色板**逐值一致**(`paper`/`surface`/`ink`/`ink-soft`/`accent`/`accent-ink` 六个) | D-E 方案否决理由:bootstrap 自包含 hex,故须钉"两处调色板不分叉" | 把 bootstrap 暗色 `--paper` 改成别的值 |
|
||||||
|
| 7 | bootstrap 亮色值**未被改动**(钉 §1.1 表的六个亮色 hex 仍在) | 防"顺手把亮色也改了"(本批只加暗色) | 改一个亮色值 |
|
||||||
|
| 8 | `bootstrap/index.html` 内**不存在 inline `style` 里的 `color:`**(D-E 的回归钉桩) | 防 inline 违规复发(本批刚删两处 2.2594 / 2.4776) | 加回 `style="color:#a3a3a3"` |
|
||||||
|
| 9 | **前提检查**:扫描到的文件数 ≥ 3 且 bootstrap html 长度 > 500 | 防"读 0 文件也报 0 违规"的假信心(P13 审查者 M5/M6、P14 终审者 R0 的同族教训) | 把路径改成不存在的文件 |
|
||||||
|
| 10 | `AA_PAIRS` 含 `['ink','surface']` 且 `REQUIRED_KEYS` **不含** `info`、长度为 **11** | 改动②③ 的回归钉桩 | 删掉补钉的那一对;或把 `info` 加回 |
|
||||||
|
|
||||||
|
**RED 相位要求**:T1 在 T2/T3/T4 之前提交时,`npx vitest run app/lib/bootstrapTheme.test.ts` 必须**红**,且**至少腿 1/2/5/6/8/10 红**(bootstrap 还没有脚本、没有暗色块、GameHost 还没注入、AA_PAIRS 还没补、info 还在)。腿 7/9 应当**已绿**(亮色值本来就在、文件本来可读)——**这是有意的**:它们是本批的"不得回退"钉桩,不是新功能的断言。实现者须把 RED 输出**逐字存证**到 `.superpowers/sdd-p15b/impl-evidence/t1-red.txt`,并**逐腿标注红/绿**。
|
||||||
|
|
||||||
|
⚠️ **恒真/恒假审视(P13/P14 累计教训,每条腿都要过)**:
|
||||||
|
- 腿 2 用 `indexOf` 比较位置时,**若某个 `indexOf` 返回 -1(未找到),`-1 < 任何正数` 会让顺序断言恒真** → 必须先断言三者都 `> -1`(`themeBootstrap.test.ts:42-44` 正是这么写的,照抄这个手法)
|
||||||
|
- 腿 6 的"逐值一致"**不得写成 `max(a,b) ≥ k` 形态**(P13 的恒真高发区:互补两侧有非平凡下界);应写成**字符串相等**
|
||||||
|
- 腿 10 的长度断言须是 `toBe(11)` 而非 `toBeGreaterThanOrEqual(…)`(后者在删令牌后仍绿 = 无牙)
|
||||||
|
|
||||||
|
### T2 —— 改动②③(守卫腿 10 由红转绿)
|
||||||
|
|
||||||
|
按 §3.2 / §3.3 执行。**完成后 `npx vitest run` 必须 79 文件 / 688+N 测试全绿**(N = T1 新增的腿数),且**既有测试文件零修改**。
|
||||||
|
|
||||||
|
⚠️ 改动③会让 `contrast.test.ts` 的 `it` 标题从 "12 令牌齐备" 变 "11 令牌齐备" → **测试名变了但文件是"修改"而非"新增"**。这是**唯一被允许的既有测试文件修改**,且必须在报告里显式声明(同 P14 的 `architecture_test.go` 例外处理)。除此之外 `--diff-filter=M -- '*_test.ts'` 必须为空。
|
||||||
|
|
||||||
|
### T3 —— 改动①(bootstrap 暗色 + hash 注入)
|
||||||
|
|
||||||
|
按 §3.1 执行。完成后:
|
||||||
|
- `npx vitest run` 全绿
|
||||||
|
- `npm run typecheck`(`vue-tsc --noEmit`)零错
|
||||||
|
- `adapters.test.ts` 的既有 4 处调用**零修改**仍绿(验证 `theme` 是可选键)
|
||||||
|
|
||||||
|
### T4 —— e2e 与构建产物验证
|
||||||
|
|
||||||
|
1. `npm run build`(含 `vue-tsc --noEmit` + `vite build` + `build-runtime.mjs`)exit 0
|
||||||
|
⚠️ **`npm run build` 会类型检查 `e2e/*.spec.ts`**(P13 spike 6 曾因此 `BUILD_EXIT=2`)
|
||||||
|
2. `npm run e2e` 主套件 **102+1skip** 基线不回退(P13 交付值;`dark.spec.ts` 10 腿须全绿)
|
||||||
|
3. **新增 e2e 腿**(`dark.spec.ts` 或新文件):暗色 + 虚拟游戏页 → 断言 iframe 的 `src` hash 含 `theme=dark`。**若 e2e fixture 无虚拟游戏可跑,改为 vitest 腿**(挂 `GameHost` 用 `@vue/test-utils`,断言 `targets[0].url` 含 `theme=dark`)——由实现者按 fixture 实际情况定,但**必须有一腿覆盖 hash 注入**,否则改动①的父侧无守卫。
|
||||||
|
4. **产物验证**(P13 教训:源码级守卫不等于产物正确):`dist/` 里 bootstrap 入口的 HTML **含暗色块**、**不含** `#a3a3a3` 与 `#f87171`(已删的 inline 违规值);主应用 CSS 里 `--color-info` **零命中**、`var(--color-*)` 计数与 P13 交付值(75)比对并解释差异(删一个令牌应使计数变化,须说明)。
|
||||||
|
⚠️ **产物 grep 用 `grep -F` 不手写转义**(P13 教训:`.backdrop\:bg-scrim` 手写反斜杠转义对 minified 产物假阴性;AGENTS.md 已立不变量)
|
||||||
|
|
||||||
|
### mutation 自查(实现者必做并附输出,每条测"预期红 + 其余绿 + 恢复证明")
|
||||||
|
|
||||||
|
- (a) 把 bootstrap 的 pre-paint `<script>` 移到 `<style>` 之后 → 腿 1 红
|
||||||
|
- (b) 交换 hash theme 与 `prefers-color-scheme` 的判定先后 → 腿 2 红
|
||||||
|
- (c) 把 hash theme 校验放宽成 `hashTheme ? hashTheme : …` → 腿 3 红
|
||||||
|
- (d) `GameHost.vue` 改用 `useTheme().theme.value` → 腿 5 红(**这条是 D-B 的牙**)
|
||||||
|
- (e) 改 bootstrap 暗色的一个 hex → 腿 6 红
|
||||||
|
- (f) 加回 `style="color:#a3a3a3"` → 腿 8 红
|
||||||
|
- (g) 从 `AA_PAIRS` 删掉补钉的 `['ink','surface']` → 腿 10 红
|
||||||
|
- (h) 把 `info` 加回 `REQUIRED_KEYS` → 腿 10 红
|
||||||
|
- (i) **把守卫的文件路径改成不存在的文件** → 腿 9 红(防"读 0 文件报 0 违规")
|
||||||
|
- (j) **删掉 `main.css` 的暗色块**(模拟"P13 成果回退")→ 腿 6 或 `contrast.test.ts` 的暗色腿红
|
||||||
|
|
||||||
|
每次 mutation 后**只回滚触及的文件**(`git checkout -- <file>`,**绝不用 `git checkout -- .`**——P14 控制者第 24 次自伤:全量回滚抹掉了未提交的守卫编辑,导致后续各轮全在测没有修复的树),并核 `git diff` 空 + 守卫文件腿数不变。
|
||||||
|
|
||||||
|
### 结构指标(交付时须报实测数字)
|
||||||
|
|
||||||
|
| 指标 | 基线 | 目标 |
|
||||||
|
|---|---|---|
|
||||||
|
| vitest 文件数 / 测试数 | **79 / 688** | **80 / 688+N**(N = T1 腿数,≥10) |
|
||||||
|
| 既有 `*.test.ts` 修改文件数 | — | **1**(仅 `contrast.test.ts`,且仅因 "12 令牌"→"11 令牌" 文案;须在报告显式声明) |
|
||||||
|
| `AA_PAIRS` 对数 | 10 | **11** |
|
||||||
|
| `REQUIRED_KEYS` 项数 | 12 | **11** |
|
||||||
|
| `LEGACY_KEYS` 项数 | 9 | **9**(不动) |
|
||||||
|
| `--color-info` 全仓命中 | 2(两处定义) | **0** |
|
||||||
|
| bootstrap 内 `localStorage`/`prefers-color-scheme`/`data-theme` 命中 | **0 / 0 / 0** | **0 / ≥1 / ≥1**(`localStorage` 仍须为 0:源隔离,D-A) |
|
||||||
|
| bootstrap 内 inline `style` 的 `color:` 数 | 2 | **0** |
|
||||||
|
| `typecheck` / `build` | 0 / 0 | **0 / 0** |
|
||||||
|
| 主 e2e | 102+1skip | **≥102+1skip**(新增腿另计) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## §6 记账
|
||||||
|
|
||||||
|
- `crearte/docs/CHANGELOG.md` 新增 **`## [0.27.0] - 2026-10-03`**(插 `## [0.26.0]` 前)。格式:**同条目英文行紧跟中文行、无空行**;不同条目**空一行**;小节标题双语。
|
||||||
|
- wrapper `docs/CHANGELOG.md` 新增 **`## [0.3.8] - 2026-10-03`**(插 `## [0.3.7]` 前),小节 `### Done / 完成`。**与 P15-A 合并为一条还是分两条由控制者按合并时序定**(若两批同时合并,写一条含两仓的条目更清楚)。
|
||||||
|
- `docs/ROADMAP.md`:第三波表新增 **P15-B 行** + 文档索引表新增一行。**哈希引 merge commit**。挂账里删掉「死令牌 `--color-info`」(已处置)、新增「bootstrap 内联脚本的 CSP nonce」(与主应用同批)。
|
||||||
|
- P13 spec 加 **RE-PIN 标注块**(D-C:12→11 令牌),不改写原文。
|
||||||
|
- **纪律**:显式 `git add <file>`,**禁 `git add -A`**。wrapper 的 `IDENTITY.md`/`SOUL.md`/`USER.md` **绝不 stage**。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## §7 风险与缓解
|
||||||
|
|
||||||
|
| 风险 | 缓解 |
|
||||||
|
|---|---|
|
||||||
|
| **`theme` 键写成必填 → 破既有测试** | §3.1(d) 明令可选;`adapters.test.ts` 4 处调用零修改仍绿是验收项 |
|
||||||
|
| **🔴 注入改成响应式 → 主题切换重载运行中的游戏** | D-B + §3.1(e) 的 ⚠️ 注释 + **T1 腿 5 与 mutation (d) 专门钉这条**。这是本批最容易"顺手改错"且后果最严重的一处(用户存档丢失),且**症状只在真实使用中显现**(切主题时游戏重启),单元测试不会自然暴露 |
|
||||||
|
| **判定序与主应用分叉 → 同一用户两处不同主题** | T1 腿 2 钉 bootstrap 内部顺序;**另需一条腿比对 `useTheme.effectiveTheme()` 的后两级顺序**(`themeBootstrap.test.ts` 腿 2 的同款手法:用 `indexOf` 比较,且先断言三者都 `> -1`) |
|
||||||
|
| **bootstrap 与主应用调色板分叉**(bootstrap 自包含 hex) | T1 腿 6 逐值比对六个暗色 hex + 腿 7 钉亮色值不变。**这是 D-E 方案(引令牌)被否后的必要补偿** |
|
||||||
|
| **新守卫写成恒真** | §5-T1 的 ⚠️ 三条(`indexOf` 返 -1 时顺序断言恒真、`max(a,b) ≥ k` 形态、`toBeGreaterThanOrEqual` 无牙)+ mutation (i) 腿 9 前提检查 + (a)–(h) 逐条验牙 |
|
||||||
|
| **inline 违规复发** | T1 腿 8 + mutation (f)。**本批发现的两处是 pre-existing(P13 之前就存在),修完须有钉桩**,否则下一次改 bootstrap 又会加回来 |
|
||||||
|
| **产物与源码不一致**(源码级守卫绿但产物错) | §5-T4.4 产物验证。P13 教训:`APPLY.toString()` + `new Function` 序列化注入验证构建期烘焙是**无效手法**;必须验真实 `dist/` |
|
||||||
|
| **e2e fixture 无虚拟游戏 → hash 注入无覆盖** | §5-T4.3 给了退路(改 vitest 腿挂 `GameHost`),但**必须有一腿覆盖**,不接受"fixture 不支持所以不测" |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## §8 实现纪律(P11–P14 累计教训,与 P15-A spec §8 同源,前端部分加粗)
|
||||||
|
|
||||||
|
1. **不推断,只实测。** P13 控制者在勘查期栽两次(**用运行时注入验证构建期烘焙**、**用 `APPLY.toString()`+`new Function` 序列化注入**),复核期写错断言/grep/锚点 13 次。
|
||||||
|
2. **断言或 grep 返回意外值时,先怀疑自己的模式,别先宣布缺陷。** P14 任务级审查者的第一版比对脚本误报 `CoverURL` 为 CHANGED(字符串字面量里的 `//` 被当注释起点);控制者六次因这条纪律避免假指控(含 `bg-surface=0`、`main.css 无全局色`、`七类断言=0`)。
|
||||||
|
3. **修守卫必须两方向都验**:对合法值不误红 + mutation 下不误绿。P13 控制者只验前者,交付了数学恒真的断言(`max(填充侧,边框侧) ≥ 3`,互补两侧下界 = √cr(paper,ink) = **4.0621 > 3**,50653 采样暴力验证)。
|
||||||
|
4. **全称声明需要全称范围的证据。** P14 控制者写"断言 5 是唯一能抓遮蔽的手段"被终审推翻(匿名接收者 `func (*ContentService) M()` 绕过)。**写"唯一/全部/任何"之前先枚举形态。**
|
||||||
|
5. **恒假断言与恒真断言同样无价值,且恒假更容易因"看起来严格"而通过审查。** P14 被否决的 `NumMethod()==0`(嵌入 `*T` 使方法进值+指针两个方法集,合法树上已是 22)。
|
||||||
|
6. **产物 CSS 选择器用 `grep -F`,不手写转义。** P13:`.backdrop\:bg-scrim` 手写反斜杠转义对 minified 产物假阴性;**带引号的 grep 模式对 minified 产物也假阴性**(`grep 'bg-[#]'` 匹配不到 `bg-[#123456]` 的压缩形态)。已立为 crearte AGENTS.md 不变量。
|
||||||
|
7. **源码级守卫跑 node 不跑 happy-dom。** `fileURLToPath(new URL('../..', import.meta.url))` 在 happy-dom 下抛 `ERR_INVALID_URL_SCHEME`。已立为 AGENTS.md 不变量;§0 已确认本仓 vitest 无 `environment` 键 → 默认 node。
|
||||||
|
8. **Tailwind v4 扫描全部源文件含 `.test.ts`/`.spec.ts`**:测试注释里的类名字面量会被当候选、烧死 utility 进产物。P13 实现者用拼接修对一处,控制者八分钟后在自己裁定 commit 里重新引入。已立为 AGENTS.md 不变量 §8-5b。**本批写守卫时,注释里不要出现完整的 `bg-[#…]` / `text-[#…]` 字面量**(用字符串拼接或省略号)。
|
||||||
|
9. **管道会吞掉真退出码**;**`grep -c` 输出 0 时 exit 1**,放 `&&` 链里会静默中断后续步骤(含 `git commit`)。
|
||||||
|
10. **`restore()` 只回滚 mutation 触及的文件,绝不用 `git checkout -- .`**;每轮恢复后核验守卫编辑仍在,否则 FATAL 终止。
|
||||||
|
11. **`|| echo "(zero)"` 这类兜底文本在 glob 失败时会伪装成"测量结果为零"** → 任何"零命中"结论都要先证明扫描范围非空(T1 腿 9 就是这条的机械化)。
|
||||||
|
12. **测试被迫修改 = 设计失败的信号**,除非 spec 明列例外(本批唯一例外:`contrast.test.ts` 的 "12→11 令牌" 文案)。若发现必须改其它测试,**停下来报告**。
|
||||||
Reference in New Issue
Block a user