按 pi 复核改五处:判据的"值/行为"原则、真正的构建门、typecheck 进门、Go 判据零匹配、迁移归属

pi 用反例与变异逐条点了五处,全部**先跑变异再改**(结论都写在原地)。

## 1 判据③:形状正则已删(它永远差一个反例)

实测 pi 的反例 —— `const k = PREFIX + accountId; return PREFIX;`(拼了但没返回)——
对第三版判据**仍然全绿**:第三版锚的是"**函数体里存在**这样的表达式",不是"**返回的**表达式"。
三版的骗法各一个(签名里的参数 / 提了一下没用 / 拼了没返回),都在判"源码里有没有那个形状",
而缺陷是"算出来的值对不对" ⇒ **权威交给行为判据**(`test/stores/background.test.ts` 直接断言
`storageKey('a') !== storageKey('b')`、`=== 'agentmail.background.acct-a'`、不退回全局键),
正则那条删掉并把反例写在原地(否则下一个人会好心加回来)。
保留"键必须来自 storageKey()"那条:那是**来源**约束,不是值对不对,正则在这里合适。
鸿蒙侧只能静态判(`.ets` 本机没有运行时),已把这条限制写进判据说明。
推广进规范:CRITERIA.md §6.7 + run-all 自检关键词(10 → 11)。

## 2 真正的门:`scripts/release-linux.sh`

- 实测 pi 提的变异:**`touch dist/index.html` 时 stamp 判据是绿的** —— 它抓不到"构建失败但碰过 dist"。
  stamp 是**探测器**(抓"src 改了产物没跟上"),门是**喂退出码**,两者不互替(§6.7.1)。
- `build:linux` 里**没有管道**(`&&` 链,退出码本来就传),但那次的哑巴失败是我在命令行手打
  `npm run build 2>&1 | tail -4 && …` 造成的;同时发现它跑的是**裸 `vite build`,跳过 `gen:bg`**。
- 于是把配方收成 `scripts/release-linux.sh`:`set -euo pipefail` + 走 `npm run build` + 再打包。
- 判据是**行为**的:注入失败的构建(`AGENTMAIL_BUILD_CMD='exit 7'`)→ 断言退出码非 0
  **且打包那步没跑**(标记文件不存在)。变异:脚本改成 `|| true` → 红 ✓。

## 3 typecheck 进 `npm test` 链

`npm run typecheck` 原本就有,但没人跑。先修掉它唯一的报错(我自己留下的未使用 import),现在干净;
`test` = run-all + vitest + typecheck。它恰好检查**没被任何测试 import 的文件**(vitest 只解析被测到的图)——
也就是那次 `is not exported by` 的形状。

## 4 Go 源码判据:零匹配 / 读不懂 都要红

改成三分:切不出函数体 → 红;字段在但值不是字面量 → 报「**判据读不懂**」(变异:`BgDim: defaultDim` → 红 ✓);
字段不在 → 红。静默放行是这类判据最危险的失败方式。
**措辞更正**:这条核对的是"与**这份服务端源码**的契约",不是"在跑的服务端二进制是 12/4"
(与"dist 是产物"同构);文档同步改。

## 5 迁移归属:定向做不到,就把"不可恢复"降级成"可恢复"

查实:`accountStore` 的 `activeId` 是**派生视图状态、不落盘**(`persist(accounts)` 只存数组),
所以**本机没有"上次活跃账号"标记可定向** —— 旧值的作者事后无法还原,定向迁移在原理上做不到。
升级前用 B、升级后先登录 A ⇒ A 接管 B 的外观,**一次性错档**,触发条件就这一条。
两件能做的都做了:**写了回读校验**(写不进去就不删旧键,避免净损失;变异:改成先删后写 → 2 条红 ✓)、
**删前另存** `agentmail.background.legacy.bak`(只写不读 ⇒ 不引入新的继承源,判据钉"只写不读")。
§7.12 把本地这半与显形方式写进同一行。

## 验证

`npm test` 退出码 0:14 个判据文件全绿 + vitest **265** 通过(+2 迁移行为测试)+ typecheck 干净;
`hvigorw assembleHap` BUILD SUCCESSFUL;安装包经新脚本重打(dist 与包同批)。
This commit is contained in:
2026-09-14 15:41:52 +08:00
parent 483f4ed960
commit 71a3c35610
10 changed files with 287 additions and 34 deletions

View File

@ -62,6 +62,12 @@ export const LEGACY_STORAGE_KEY = 'agentmail.background';
/** 按账号的键前缀:实际键是 `<前缀><accountId>` */
export const STORAGE_KEY_PREFIX = 'agentmail.background.';
/**
* 旧全局值的手工恢复备份。**没有任何代码读它** ——
* 留着它只是为了让"迁移把旧值给了错的账号"这件事可以改回来(见 `readStored` 的说明)。
*/
export const LEGACY_BACKUP_KEY = 'agentmail.background.legacy.bak';
/**
* 本机当前**用于同步的那个账号**。
*
@ -161,9 +167,24 @@ export function normalizeBackground(
/**
* 读本机缓存。**按账号**读,并做一次旧全局键的迁移。
*
* 迁移只做一次、只给**当前账号**:旧值属于"这台机器上当时那个账号"
* 把它送给当前账号是合理的(用户的观感不会凭空消失),然后**立刻删掉旧键** ——
* 迁移只做一次、只给**当前账号**,然后**立刻删掉旧键** ——
* 否则下一个账号又会从它那里"继承",等于把刚修掉的缺陷留在原地。
*
* ## 归属问题pi 2026-09-14 指出,这里写清"做不到什么"
*
* "当前账号" = **升级后第一个读到缓存的账号**,不是**写下那个旧值的账号**。
* 旧全局值是谁写的,本机**没有记录**`accountStore` 里 `activeId` 是**派生的视图状态**
* 落盘时只存 `accounts` 数组(见 `accountStore` 的 `persist(accounts)` 与
* "落盘在 activeId 里不合适"那句注释),所以"上次活跃账号"事后无法还原。
* ⇒ **定向迁移在原理上做不到**:升级前最后用的是 B、升级后先开 AA 就会接管 B 的外观。
* 这是 best-effort显形方式是一次性外观错档压暗/模糊/预设变成别人的),
* 触发条件是"升级前用过 B、升级后先登录 A 且 A 自己没有记录"。
*
* 所以这里做两件**能**做的事,把"不可恢复"降级成"可恢复"
* 1. **写了要回读校验**:写入配额满/隐私模式下 `setItem` 可能不落盘,
* 校验不过就**不删旧键**(先删再写会让值净损失);
* 2. 删之前把旧值**另存一份**到 `LEGACY_BACKUP_KEY`:没有任何代码读它,
* 所以不会重新引入串味,但它让"接管错了"这件事可以手工改回来。
*/
export function readStored(accountId: string = activeAppearanceAccountId()): BackgroundState {
try {
@ -172,9 +193,25 @@ export function readStored(accountId: string = activeAppearanceAccountId()): Bac
if (!raw) {
const legacy = accountId ? localStorage.getItem(LEGACY_STORAGE_KEY) : null;
if (legacy) {
localStorage.setItem(key, legacy);
localStorage.removeItem(LEGACY_STORAGE_KEY);
raw = legacy;
let written = false;
try {
localStorage.setItem(key, legacy);
// 回读校验:写进去了才算迁移成功(否则旧值必须留着)
written = localStorage.getItem(key) === legacy;
} catch {
written = false;
}
if (written) {
try {
// 只留作手工恢复用,不参与任何读取路径
localStorage.setItem(LEGACY_BACKUP_KEY, legacy);
} catch {
// 备份失败不影响迁移本身
}
localStorage.removeItem(LEGACY_STORAGE_KEY);
raw = legacy;
}
// 没写成功:不接管、也不删旧键,下次还有机会(本次用默认值,与"没有缓存"一致)
}
}
if (!raw) return DEFAULT_BACKGROUND;