按 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

@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it } from 'vitest';
import {
DEFAULT_BACKGROUND,
LEGACY_STORAGE_KEY,
LEGACY_BACKUP_KEY,
MAX_DATA_URL_BYTES,
readStored,
storageKey,
@ -226,6 +227,44 @@ describe('按账号分键(★ 这条修的是"新账号继承上一个账号
expect(readStored('acct-b')).toEqual(DEFAULT_BACKGROUND);
});
it('写入失败时**不删旧键**(先删再写会让值净损失)', () => {
/*
* pi 2026-09-14:迁移的顺序必须是"写新键 → 回读校验 → 再删旧键"。
* 隐私模式 / 配额满时 `setItem` 可能抛异常或不落盘;那时若已经删了旧键,
* 用户的外观就是**净损失**(不可恢复)。这里模拟"写不进去",断言旧键还在。
*/
localStorage.clear();
localStorage.setItem(LEGACY_STORAGE_KEY, JSON.stringify({ ...DEFAULT_BACKGROUND, dim: 42 }));
const real = Storage.prototype.setItem;
Storage.prototype.setItem = function (k: string, v: string) {
if (k === storageKey('acct-a')) throw new Error('QuotaExceededError');
return real.call(this, k, v);
};
try {
expect(readStored('acct-a')).toEqual(DEFAULT_BACKGROUND); // 本次拿不到(等于没有缓存)
expect(localStorage.getItem(LEGACY_STORAGE_KEY)).not.toBeNull(); // 但旧值**没丢**
expect(localStorage.getItem(storageKey('acct-a'))).toBeNull();
} finally {
Storage.prototype.setItem = real;
}
// 存储恢复后再读一次:仍然迁移得到("下次还有机会")
expect(readStored('acct-a').dim).toBe(42);
});
it('迁移前把旧值另存一份(接管错了还能手工改回来)', () => {
/*
* 归属在原理上做不到定向(activeId 是派生视图状态、不落盘,见 readStored 的说明),
* 所以至少要让"接管错了"可恢复:旧值删之前留一份备份,且**没有任何代码读它**。
*/
localStorage.clear();
const legacy = JSON.stringify({ ...DEFAULT_BACKGROUND, dim: 42, presetId: 'dusk' });
localStorage.setItem(LEGACY_STORAGE_KEY, legacy);
readStored('acct-a');
expect(localStorage.getItem(LEGACY_BACKUP_KEY)).toBe(legacy);
// 备份键不参与读取:另一个账号读到的仍是默认外观(不是从备份继承来的)
expect(readStored('acct-b')).toEqual(DEFAULT_BACKGROUND);
});
it('未登录时不迁移(旧值不能送给一个还不知道是谁的账号)', () => {
localStorage.clear();
localStorage.setItem(LEGACY_STORAGE_KEY, JSON.stringify({ ...DEFAULT_BACKGROUND, dim: 42 }));