跨端: pi 五条评审落地 —— 产物自证替代时间戳代理、静态判据可到期、判据能读一层标识符、值/来源配对规则、归因不许动别人的树

pi 2026-09-14 的评审(`9839f8a9`)五条,逐条落地;其中 §6 的两条是**核对后已成立**,不重复劳动。

## 1 产物自证:`dist/BUILD_INFO.json`(pi §1)

原来那条判据是"`dist` 比 `src` 新"——**代理变量**,pi 指出两层都靠不住:看不见"构建是否
成功"(实测过),而"`dist` 比 `src` 新"也不等于"dist 是从这份 src 构建的"(`checkout`/`cp`/时钟
都骗得过 mtime;我确实用 checkout 造过一次假红)。现在改成**内容自证**:
`scripts/build-info.mjs` 在构建最后一步写 `{gitRev, gitDirty, srcHash, srcFiles, buildCmd, builtAt}`,
判据重算当前指纹再**精确比对**(`test/build-stamp.test.mjs`)——"代理"两个字没有了,
报错能直接读出两边指纹。`release-linux.sh` 打完包把同一份信息打进日志(一个包自带
"它对应哪个源码状态")。`gitDirty` **只展示不判定**:共享工作区常年是脏的,拿它当红/绿依据
会天天误报。

实测三变异:改 src 内容不重建 → 红(两个指纹都打出来);BUILD_INFO 记成别的提交 → 红;
**`touch`(只动 mtime)→ 绿** —— 旧判据在这里是**假红**,新判据不误伤,这是它严格更好的地方。

## 2 静态判据的**欠账**与到期(pi §5)

"暂时"不是状态、是待办,规范里写下的"暂时"没有任何机制回来读它。改成可机检的形状:
`run-all.mjs` 登记 `STATIC_ONLY`(5 条:`.ets` 只能验形态)+ **必填到期前提**(探针,真跑
`hdc list targets`);汇总打 `RESULT static=N`(**欠账余额**);**前提一旦为真,这些判据当场
变红**并要求"改成行为判据或换更准的前提"。实测:探针恒真 → 5 条同时报"到期";把前提写成
`'vibes'`(未知探针/陈述)→ 套件红。这是"自报条数 < 登记条数"的**时间版本**。

## 3 判据能解析一层标识符(pi §3)

Go 默认值判据原来"值不是字面量 → 判据读不懂 → 红",长期结局是有人做一次无害重构
(`BgDim: defaultDim`)就把判据逼宽、再下一步少核一个字段。现在:字面量直接用;
标识符在**同一文件**查 `NAME = <字面量>`;查不到(跨包/计算/iota)才报"读不懂"。
实测:`BgDim: defaultDim` + `const defaultDim = 12` → **绿**(无害重构不再误伤);
`const defaultDim = baseDim + 0` → 红且报文说"读不懂"。**只解析一层**:再深就是"执行 Go"了。

## 4 §6.7 的可机检分流规则(pi §2)

新增 §6.7.0:**值 → 行为判据,来源 → 静态判据**,理由写成覆盖问题(行为判据只覆盖它跑到的
路径 ⇒ 原理上判不了"有没有别的路绕过去";来源约束要的是全程序可达性 ⇒ 只有读代码能答)。
两条推论:静态判据判值永远差一个反例、行为判据判来源永远差一条路径;**不是强弱,是分工**,
缺任一条那一对就是假判据。附本仓已有的完整样例(P5 命中区:值判据"≥44"+ 来源判据
"应用点必须引用常量"),新增 §6.8 记欠账机制、§8 记共享树归因纪律。

## 5 值/来源配对补齐 + 让位派生(pi §6 的两条)

- 命中区原来只有值那一半:补**来源**判据(`.ets` 里出现 `minHeight: 44` 这类裸数字 → 红,
  报文点出"值判据管数字够不够大、来源判据管用的是不是同一个数字")。变异:写死 44 → 红。
- "`76` 应从条高派生":**核对后已成立**(`NAV_CONTENT_RESERVE = NAV_BAR_HEIGHT +
  NAV_BAR_BOTTOM + 8`,判据也按常量算)。顺手把裸的 `8` 起名 `NAV_CONTENT_GAP`
  (这一族里唯一还需要人判断的数),并加判据钉住**派生关系**:写回字面量 `76` → 红。

## 6 `legacy.bak` 的生命周期(pi §4):**有意永久残留**,并说清代价

pi 质疑成立:判据钉死"没有任何代码读它"⇒ 也没有任何代码能删它。**否决了"点重置外观时删"**:
最可能点重置的人正是外观被接管的那个人,而这份备份是他唯一的旧值,那时删等于把恢复数据
毁在最需要的时刻。所以定为"有意永久残留"(没有代码路径能判定何时安全删除——那取决于人),
并把代价量出来写进 §7.12:值受 `MAX_DATA_URL_BYTES`(2.4MB)约束,**最坏是一张用户原图的
整份副本**,共享机器上即一份别人的外观;要清就手工 `removeItem`。判据钉住这行**同时**给出
"为什么不删"与"怎么删"(变异:删掉删除方法 → 红)。

## 7 归因方法(pi §6 第三条):认错并写成纪律

我当天归因 `narrow-layout` 的 3 条红时用了 `git stash push -- MailView.tsx`,动的是**并发写
作者的未提交改动**。结论对、方法不行:它写共享工作区(别人崩溃/`git add -A` 就丢他的活),
而且只影响 tracked 文件(untracked 的 WIP 还在 ⇒ "干净了"是假干净,结论也可能假)。
纪律写进 CRITERIA.md §8:只读手段(`git show HEAD:path > /tmp/...`、`git worktree add`)或直接问作者。

## 验证

`npm test` 全绿(14 个判据文件 + vitest 266 + typecheck),`RESULT static=5`;
`hvigorw assembleHap` BUILD SUCCESSFUL;`scripts/release-linux.sh` 重打 deb(日志带产物指纹)。
提交前两道**真红**按预期挡了我:改了 src 未重建 → build-stamp 红;重建了 dist 未重打包 →
packaging 红。**未验**(照旧不写成已完成):鸿蒙侧视觉/交互观感、运行期换肤重算(静态判据,
到期前提见 `RESULT static=5`)。

注:本次 `dist`/deb 是在**共享工作区**上构建的,树里含并发写作者未提交的
`CalendarView.tsx`/`index.css` —— 产物里的 `gitDirty: true` + `gitRev` 正是为此留的,
复核时先看那一行。
This commit is contained in:
2026-09-14 16:00:34 +08:00
parent f9b849dcfc
commit 456ae5a66d
12 changed files with 423 additions and 39 deletions

View File

@ -14,7 +14,7 @@
"scripts": {
"dev": "npm run gen:bg && vite",
"dev:electron": "ELECTRON_START_URL=http://localhost:5173 electron electron/main.cjs",
"build": "npm run gen:bg && vite build",
"build": "npm run gen:bg && vite build && node scripts/build-info.mjs",
"build:win": "vite build && electron-builder --win",
"build:linux": "bash scripts/release-linux.sh",
"preview": "vite preview",

View File

@ -0,0 +1,110 @@
/**
* 构建产物的**自证**`dist/BUILD_INFO.json`。
*
* # 为什么不是时间戳
*
* 原来那条判据是"`dist` 比 `src` 新" —— pi 2026-09-14 指出它有两层代理:
* ① 看不见"构建是否成功"(实测:构建失败但碰过 dist 时它照样绿,见 CRITERIA.md §6.7.1
* ② **`dist` 比 `src` 新也不等于 `dist` 是从这份 `src` 构建的**`checkout`/`cp`/时钟都骗得过 mtime
* 换成内容指纹之后,②也消掉了:判据说的是"产物记的源状态 == 当前源状态"
* 而不是"谁的时间更晚"。任何"包不是从这份源码来的"都变成一条可读出的不等。
*
* # 这个文件同时是"套件自报来源"的构建侧同款
*
* pi 的建议:**一条红/一个包都应自带"它对应哪个源码状态"**`git status --porcelain` 那一族)。
* 消费方:
* - `test/build-stamp.test.mjs` 精确比对(不是比新旧);
* - `scripts/release-linux.sh` 打完包把同一份信息打进日志(出问题时先看它)。
*
* 指纹范围 = **会影响产物的一切**`src/` + 入口 html + 构建配置 + 生成脚本),
* 排除生成物 `background-takeover.generated.css`(它由 `gen:bg` 现生成,
* 内容已由生成脚本决定 —— 计入会让"先 build 后 gen"这种无害顺序变化产生假红)。
*/
import { createHash } from 'node:crypto';
import { execFileSync } from 'node:child_process';
import { readFileSync, readdirSync, statSync, writeFileSync, mkdirSync } from 'node:fs';
import { dirname, join, relative, sep } from 'node:path';
import { fileURLToPath } from 'node:url';
export const ELECTRON_DIR = join(dirname(fileURLToPath(import.meta.url)), '..');
/** 参与指纹的文件/目录(相对 client/electron */
const TRACKED = [
'src',
'index.html',
'vite.config.ts',
'tsconfig.json',
'package.json',
'scripts/gen-background-takeover.mjs'
];
/** 生成物:由上面某个文件决定内容,计入会产生假红 */
const EXCLUDE = new Set(['src/background-takeover.generated.css']);
function walk(abs, rel, out) {
const st = statSync(abs);
if (st.isDirectory()) {
for (const name of readdirSync(abs).sort()) {
walk(join(abs, name), `${rel}/${name}`, out);
}
return;
}
const key = rel.split(sep).join('/');
if (EXCLUDE.has(key)) return;
out.push(key);
}
/**
* 当前源码状态。
*
* 返回 `{ gitRev, gitDirty, srcHash }`
* - `gitRev`HEAD 短哈希(读不到 git 时给 `''`,此时只看指纹);
* - `gitDirty`:工作树是否有未提交改动(**只作信息**:共享工作区常年是脏的,
* 判据不拿它当红/绿依据,拿它当"复核时先看这一行"
* - `srcHash`:影响产物的那批文件的**内容**指纹。
*/
export function srcState(root = ELECTRON_DIR) {
const files = [];
for (const t of TRACKED) {
const abs = join(root, t);
try { walk(abs, t, files); } catch { /* 缺文件就不计(例如还没有 dist 时) */ }
}
files.sort();
const h = createHash('sha256');
for (const f of files) {
h.update(f).update('\0').update(readFileSync(join(root, f))).update('\0');
}
let gitRev = '';
let gitDirty = false;
const repoRoot = join(root, '..', '..');
try {
gitRev = execFileSync('git', ['rev-parse', '--short', 'HEAD'], { cwd: repoRoot, encoding: 'utf8' }).trim();
gitDirty = execFileSync('git', ['status', '--porcelain'], { cwd: repoRoot, encoding: 'utf8' }).trim().length > 0;
} catch { /* 不在 git 里(例如解包后的目录):指纹仍然有效 */ }
return { gitRev, gitDirty, srcHash: h.digest('hex'), files: files.length };
}
/** 写 `dist/BUILD_INFO.json`(构建路径的最后一步) */
export function writeBuildInfo(root = ELECTRON_DIR, buildCmd = process.env.npm_lifecycle_event || '') {
const st = srcState(root);
const info = {
gitRev: st.gitRev,
gitDirty: st.gitDirty,
srcHash: st.srcHash,
srcFiles: st.files,
buildCmd,
builtAt: new Date().toISOString(),
// 说明这份文件是什么、谁在比它(读到此文件的人不用去翻代码)
note: '构建自证test/build-stamp.test.mjs 用 srcHash/gitRev 精确比对,不是比时间戳'
};
const out = join(root, 'dist', 'BUILD_INFO.json');
mkdirSync(dirname(out), { recursive: true });
writeFileSync(out, `${JSON.stringify(info, null, 2)}\n`);
return info;
}
// 直接执行时 = 构建的最后一步
if (process.argv[1] && import.meta.url === `file://${process.argv[1]}`) {
const info = writeBuildInfo();
console.log(`[build-info] dist/BUILD_INFO.json 已写rev=${info.gitRev || '(无 git)'} srcHash=${info.srcHash.slice(0, 12)} dirty=${info.gitDirty}`);
}

View File

@ -36,4 +36,12 @@ bash -c "$BUILD_CMD"
echo "[release] 打包:$PACK_CMD"
bash -c "$PACK_CMD"
# 产物自报来源:把 BUILD_INFO 打进日志pi 提的"一个包要自带它对应哪个源码状态")。
# 出问题时先看这几行:包是在哪个提交、哪份源码指纹上构建的,以及当时树干不干净。
if [[ -f dist/BUILD_INFO.json ]]; then
echo "[release] 产物来源:$(cat dist/BUILD_INFO.json | tr -d '\n' | sed 's/ */ /g')"
else
echo "[release] 警告dist/BUILD_INFO.json 不存在 —— 构建没走 npm run build判据会红" >&2
fi
echo "[release] 完成dist 与安装包同批(这一句只在两步都成功后才出现)"

View File

@ -65,6 +65,20 @@ export const STORAGE_KEY_PREFIX = 'agentmail.background.';
/**
* 旧全局值的手工恢复备份。**没有任何代码读它** ——
* 留着它只是为了让"迁移把旧值给了错的账号"这件事可以改回来(见 `readStored` 的说明)。
*
* # 生命周期:**有意永久残留**2026-09-14 由 pi 提出后定的,别再问"这键谁写的"
*
* pi 的质疑成立:判据钉死"没有任何代码读它"**也就没有任何代码能删它** ——
* 它是一份永久副本。我考虑过"用户点重置外观时删掉",但**恰恰否决了**
* 最可能点重置的人,正是那个"外观被别人接管了"的人,而这份备份是他唯一的旧值 ——
* 在那时删掉,等于把恢复数据毁在最需要它的时刻。
*
* 所以本键的规则是:**没有任何代码路径能判定何时安全删除**(那取决于人,
* 而不取决于代码看到的状态),因此它有意永久保留。代价当场说清,不藏着:
* - 体积有界:值受 `MAX_DATA_URL_BYTES`2.4MB)约束,最坏是一张用户上传的原图整份副本;
* - 共享机器上它是一份**别人的外观**(可能是图片)—— 想清掉就手工删
* `localStorage.removeItem('agentmail.background.legacy.bak')`。
* 这条决定与删除方法**同时**写在 `docs/HARMONY-ALIGN-PLAN.md` §7.12(审计的人先看那里)。
*/
export const LEGACY_BACKUP_KEY = 'agentmail.background.legacy.bak';

View File

@ -238,10 +238,51 @@ strictEqual(storageKey('a').includes('a'), true);
- 判"**代码里有没有这个调用/这个来源**"(例如"写缓存只许用 `storageKey()`,不许写死键名"
—— 这是**来源**约束,不是值对不对,正则在这里是合适工具;
- 对象**跑不起来**时(`.ets` 在本机没有运行时:编译要 hvigorw、运行要设备
而设备在这条链上不可用,见计划文档 §7.21),静态匹配是唯一可用的手段 ——
而设备在这条链上不可用,见计划文档 §7.21、以及本文件 §6.8 的到期机制),
静态匹配是唯一可用的手段 ——
但要把"这只证明形状、不证明值"写进判据的说明里,别让它冒充行为验证;
- 与"自报条数 < 登记条数"同族**静默放行**是这类判据最危险的失败方式
#### 6.7.0 可机检的分流规则:**值 → 行为,来源 → 静态**pi 2026-09-14 要求写硬)
上面那段说清了"结论"但没给下一个人一条**照着走就行**的规则 —— 于是仍然靠感觉
按对象分流只有两类
| 你要约束的东西 | 只能用哪种判据 | **为什么只能是它**不是风格问题是覆盖问题 |
| --- | --- | --- |
| **值算得对不对**键拼错没有数字对不对、"点两下真的切换了吗" | **行为判据** | 行为判据只覆盖它**跑到的路径**所以它**原理上判不了**"有没有别的路绕过去" |
| **有没有别的路绕过去 / 值必须来自某处**不许自己写死键名命中区必须引用共享常量 | **静态判据** | 它要的是**全程序可达性**只有读代码能回答"还有没有第二个写入口" |
两条推论写成一句话记住
- **静态判据判""永远差一个反例** —— §6.7 那张表里我三版各被穿透一次就是这个
- **行为判据判"来源"永远差一条路径** —— 你跑的那条路对了不等于没有第二条路
- 所以两者**不是强弱关系是分工**。缺任一条那一对判据就是**假判据**看着有其实漏一半)。
**照这条规则回看本仓已有的一对样例**P5 底栏命中区正好是" + 来源"的完整配对
| 判据 | 类型 | 内容 |
| --- | --- | --- |
| `③ 命中区 ≥44vp` | ****判据 | `NavItems.ts` **导入**数值判不是正则猜源码 |
| `③ 命中区的应用点` | **来源**判据 | `.ets` 里**不许自己写 `32`**必须引用 `NAV_ITEM_MIN_HIT` |
这两条合起来才闭合只判值 组件自己写 `32` 照样"值是对的"只判来源 常量被改成 `20` 也没人管
写新判据时先问一句"我这一对齐全吗"比事后补更省事。)
### 6.8 静态判据是**欠账**,必须能到期(`UNBLOCK` + `RESULT static=N`
pi 2026-09-14 指出规范里的"**暂时**"不是一种状态是一个**待办** —— 没有任何机制会回来读它
于是它永远留在原地改写成能自己到期的形状已实现 `test/run-all.mjs`
1. 只能验形态的判据登记在 `run-all.mjs` `STATIC_ONLY` 每条**必填到期前提**
`UNBLOCK`且前提必须是**可机检的**"设备可用"这种探针不是一句陈述
写成 `'vibes'` 这类未知前提 套件当场红
2. 汇总打 `RESULT static=N` —— 这是**欠账余额**涨了要看得见
3. **前提一旦为真,这些判据自动变红**实测把探针改成恒真 五条登记判据同时报"到期")。
这就是"暂时"的到期机制设备可用的那天欠账当场显形不等人想起来
它是"自报 0 < 登记条数"**时间版本**那条管"判据还在不在"这条管"它该升级了没有"。
### 6.7.1 附:**探测器**与**门**不是一回事
同一次讨论pi 2026-09-14 §2里还有一条`dist` `src` **只说明"src 改了而产物没跟上"**
@ -257,3 +298,29 @@ strictEqual(storageKey('a').includes('a'), true);
移交信里交代的头号纪律判据通过了但用户点不到等于没做
所以断言尽量落在"用户会触发的那个入口/那条路径"
例如"点同意/拒绝后列表要变"要钉到那条链路上而不是钉"某函数存在"。
## 8. 在**共享工作区**上做归因:别动别人的未提交改动
pi 2026-09-14 §4 指出我当天正是这么干的。)
事情经过`narrow-layout` 出现 3 条红我用 `git stash push -- src/components/MailView.tsx`
**并发写作者的未提交改动**暂时收起来看红是否跟着消失 `stash pop` 放回去
结论是对的**方法不行**
- `git stash` ****共享工作区 —— 那不是我自己的文件即便随后 `pop` 回来
这中间任何一次崩溃冲突或对方恰好跑一次 `git add -A`丢的是**别人的工作**
- 它还只影响 **tracked** 文件对方的 untracked 新文件照样留在原地
于是"stash 之后干净了"本身是**假的干净**归因结论也可能是假的
- 同一工作区里同时有别的写入者时任何"我来把树弄干净一下"的动作都在赌别人的东西不丢
**该用的手段**都只读不碰工作区
```bash
git show HEAD:client/electron/src/components/MailView.tsx > /tmp/mv.head.tsx # 拿基线对比
git worktree add /tmp/attr HEAD # 或者在独立工作树里复现
```
或者直接问写那半代码的人 —— 共享工作区里"谁改的"根本不是能从树上读出来的信息
这也是为什么 `BUILD_INFO.json` 要记 `gitRev`/`gitDirty`产物自带来源比事后猜强)。
**一句话**归因手段的选择标准是"**会不会让别人的东西处于危险里**"不是"哪条命令最快"。

View File

@ -47,17 +47,42 @@ function serverDefaults() {
*/
assert.ok(body.replace(/[\s{}]/g, '').length > 0,
'DefaultAppearance 的函数体切出来是空的(函数被改名/挪走了?判据要跟着改,别静默放行)');
const field = (name, re, what) => {
/*
* pi 2026-09-14 补充的一条:**标识符应该能解析一层**,否则「读不懂就红」的长期结局是
* 有人做一次无害重构(`BgDim: defaultDim`)→ 判据红 → 唯一出路是把判据改宽 →
* 下一步通常是"少核一个字段" → 契约又漂了。
* 所以:字面量直接用;标识符在同一文件里查 `NAME = <字面量>`;查不到(跨包 / 计算 / iota
* 才报"读不懂"。**只解析一层**:再深就不是"读一个常量"而是"执行 Go"了,那时该报读不懂。
*/
const resolve = (name, reLiteral, identRe, what) => {
assert.ok(new RegExp(`\\b${name}\\s*:`).test(body), `DefaultAppearance 里没有 ${name} 字段了(判据要跟着服务端改)`);
const m = re.exec(body);
assert.ok(m, `${name} 在,但值不是${what} —— **判据读不懂**,请人工核对接线,别让它悄悄跳过。函数体:${body.replace(/\s+/g, ' ')}`);
return m;
const lit = reLiteral.exec(body);
if (lit) return lit;
const ident = identRe.exec(body);
assert.ok(ident, `${name} 在,但值既不是${what}也不是标识符 —— **判据读不懂**,请人工核对接线。函数体:${body.replace(/\s+/g, ' ')}`);
const identName = ident[1];
/*
* 同一文件里找 `NAME = <字面量>`(也覆盖 const 组里的 `NAME = 12`)。
* **只解析一层**:再深就不是"读一个常量"而是"执行 Go"了 —— 那时该报读不懂,
* 让人来看,而不是判据自己猜。
*/
const declRe = new RegExp(`\\b${identName}\\s*=\\s*([^\\n]+)`, 'g');
const decls = [...src.matchAll(declRe)].map(m => m[1].replace(/\/\/.*$/, '').trim());
const literalOf = what === '数字字面量' ? /^(\d+)$/ : /^"([^"]+)"$/;
for (const v of decls) {
const hit = literalOf.exec(v);
if (hit) return hit;
}
assert.fail(
`${name} 指向标识符 \`${identName}\`,但同一文件里找不到它的字面量定义 —— ` +
`**判据读不懂**(跨包 / 计算 / iota请人工核对。找到的声明${decls.join('、') || '(无)'}`
);
};
const dim = field('BgDim', /BgDim:\s*(\d+)/, '数字字面量');
const blur = field('BgBlur', /BgBlur:\s*(\d+)/, '数字字面量');
const theme = field('Theme', /Theme:\s*"([^"]+)"/, '字符串字面量');
const kind = field('BgKind', /BgKind:\s*"([^"]+)"/, '字符串字面量');
const preset = field('BgPresetID', /BgPresetID:\s*"([^"]+)"/, '字符串字面量');
const dim = resolve('BgDim', /BgDim:\s*(\d+)/, /BgDim:\s*([A-Za-z_]\w*)/, '数字字面量');
const blur = resolve('BgBlur', /BgBlur:\s*(\d+)/, /BgBlur:\s*([A-Za-z_]\w*)/, '数字字面量');
const theme = resolve('Theme', /Theme:\s*"([^"]+)"/, /Theme:\s*([A-Za-z_]\w*)/, '字符串字面量');
const kind = resolve('BgKind', /BgKind:\s*"([^"]+)"/, /BgKind:\s*([A-Za-z_]\w*)/, '字符串字面量');
const preset = resolve('BgPresetID', /BgPresetID:\s*"([^"]+)"/, /BgPresetID:\s*([A-Za-z_]\w*)/, '字符串字面量');
return {
dim: Number(dim[1]),
blur: Number(blur[1]),

View File

@ -13,7 +13,9 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { execFileSync } from 'node:child_process';
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
import { existsSync, readFileSync, readdirSync } from 'node:fs';
import { srcState } from '../scripts/build-info.mjs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
@ -87,7 +89,7 @@ test('★ 版本库里不得跟踪缓存/构建产物(.tmp、node_modules、di
assert.ok(tracked.includes(EMBED_PLACEHOLDER), 'go:embed 的占位文件丢了 —— 没有它 static/ 目录不存在Go 侧编不过');
});
test('★ dist 必须比 src 新(前端改了没重新构建 —— 套件照样全绿,只有这道门会红', () => {
test('★ 产物必须自报来源BUILD_INFO 精确比对(不是比时间戳', () => {
/*
* 这条是踩出来的2026-09-14 接手 pi 的 WebUI 开项时):
* 我把 `npm run build` 串在管道里(`npm run build 2>&1 | tail -4 && electron-builder …`
@ -95,35 +97,43 @@ test('★ dist 必须比 src 新(前端改了没重新构建 —— 套件照
* 拿旧的 dist 打了一个新包 —— 而所有判据都是绿的:
* - vitest 绿:测试运行时对"缺的具名导出"很宽容(拿到 undefined只有打包器会报
* - packaging 绿:它比的是"dist vs 安装包",两边都是旧的,自然一致。
* 结果就是"代码改了、产物没改",而没有任何一条判据看得见。
*
* 这里直接比时间戳dist 的入口必须不早于 src 最新文件
* 第一版判据比的是**时间戳**dist 不早于 src 最新文件),它能抓住那次事故,
* 但 pi 2026-09-14 指出它是**代理变量**,两层都靠不住:
* ① 看不见"构建是否成功"(实测:构建失败但碰过 dist 时它照样绿 —— 那个坑由
* scripts/release-linux.sh 喂退出码来堵,见 CRITERIA.md §6.7.1
* ② **dist 比 src 新也不等于 dist 是从这份 src 构建的**`git checkout`、`cp`、
* 时钟都能骗过 mtime —— 我确实用 checkout 造过一次假红)。
* 现在改成**内容自证**`dist/BUILD_INFO.json` 记下构建时的 srcHash 与 gitRev
* 判据重算一遍当前的指纹再**精确比对**。于是判据说的是"产物记的源状态 == 当前源状态"
* 而不是"谁的时间更晚" —— "代理"两个字没有了。
*/
const infoPath = join(ROOT, 'client/electron/dist/BUILD_INFO.json');
const distIndex = join(ROOT, 'client/electron/dist/index.html');
if (!existsSync(distIndex)) {
console.log('(没有 dist —— 先 npm run build 才验得到这条)');
return;
}
const distAt = statSync(distIndex).mtimeMs;
// 构建过程自己会写 src/background-takeover.generated.css在 dist 之前),不算"源码改动"
const newest = { at: 0, file: '' };
const walk = dir => {
for (const e of readdirSync(dir, { withFileTypes: true })) {
const p = join(dir, e.name);
if (e.isDirectory()) walk(p);
else if (e.name !== 'background-takeover.generated.css') {
const at = statSync(p).mtimeMs;
if (at > newest.at) { newest.at = at; newest.file = p; }
}
}
};
walk(join(ROOT, 'client/electron/src'));
assert.ok(newest.file, 'src 下应当有文件');
assert.ok(
distAt >= newest.at,
`前端源码比 dist 新(改了没重新构建):\n` +
` src 最新:${newest.file.replace(ROOT + '/', '')}\n` +
` dist${distIndex.replace(ROOT + '/', '')}\n` +
` 重构建cd client/electron && npm run build注意别把它的退出码丢在管道里`
);
assert.ok(existsSync(infoPath),
'dist 里没有 BUILD_INFO.json —— 构建没走 `npm run build`(它最后一步会写这份自证)。\n' +
' 重构建cd client/electron && npm run build');
const info = JSON.parse(readFileSync(infoPath, 'utf8'));
const now = srcState();
// 精确比对:这一条比"谁更新"强的地方在于它**能读出**差在哪
assert.equal(info.srcHash, now.srcHash,
`产物与当前源码不是同一份(改了没重新构建):\n` +
` 产物记录的 srcHash${String(info.srcHash).slice(0, 12)}${info.srcFiles} 个文件)\n` +
` 当前源码的 srcHash${now.srcHash.slice(0, 12)}${now.files} 个文件)\n` +
` 重构建cd client/electron && npm run build注意别把它的退出码丢在管道里`);
if (now.gitRev && info.gitRev) {
assert.equal(info.gitRev, now.gitRev,
`产物是在另一个提交上构建的(产物记 ${info.gitRev},当前 HEAD ${now.gitRev})—— 重构建再复核`);
}
/*
* `gitDirty` 只**展示**不判定:共享工作区常年是脏的(并发写作者的未提交改动),
* 拿它当红/绿依据会让这条判据天天误报。它写在 BUILD_INFO 里是为了复核时先看这一行
* —— "这个包对应的源码状态干净吗"pi 提的"一条红自报来源"的构建侧同款)。
*/
assert.ok(typeof info.gitDirty === 'boolean', 'BUILD_INFO 要记录构建时工作树是否干净(供复核者判断)');
assert.ok(info.builtAt && info.buildCmd !== undefined, 'BUILD_INFO 要带构建时间与构建命令');
});

View File

@ -125,6 +125,40 @@ test('③ 命中区 ≥44vp判的是数值本身从 NavItems.ts 导入,
assert.match(bar, /\.height\(NAV_BAR_HEIGHT\)/, '条高取同一常量');
});
/*
* ③ 的**来源**那一半pi 2026-09-14配对规则见 CRITERIA.md §6.7.0
* 上面的"≥44"判的是**值**,但值对不等于"组件用的是那个值" —— 组件自己写死一个
* 够大的数字照样绿,而共享常量被改小时它不会跟着变。所以这一条判**来源**
* 应用点必须引用常量,不许出现裸数字。
*/
test('③-b 命中区是**来源**判据:`.ets` 不许自己写数字,必须引用 NAV_ITEM_MIN_HIT', () => {
const item = builderBody(main, 'NavItem(item: NavItem, index: number) {');
const bare = /(minHeight|minWidth|height|width):\s*\d+/.exec(item);
assert.equal(bare, null,
`导航项里出现了裸数字 \`${bare?.[0]}\` —— 命中区/条高必须引用 NavItems.ts 里的常量:` +
`值判据≥44管"数字够不够大",来源判据(这条)管"用的是不是同一个数字"。` +
`两条合起来才闭合CRITERIA.md §6.7.0)。`);
});
/*
* 让位高度必须**派生**pi 2026-09-14 §6`76` 不该是并列常量)。
*
* 判的是 NavItems.ts 里的**声明**:算式里要出现条高与离底留白两个常量 ——
* 写死 `= 76` 就会在"哪天把条高改成 64"时静默失配(内容被压住,看得见点不到)。
* 顺带钉住:算式里不许出现裸数字(余量要有名字)。
*/
test('④-b 内容让位高度是**派生**的,不是并列常量', () => {
const src = readFileSync(NAV_TS, 'utf8');
const decl = /export const NAV_CONTENT_RESERVE: number = ([^;]+);/.exec(src);
assert.ok(decl, 'NAV_CONTENT_RESERVE 的声明要能被判据读到(判据跟着改)');
const expr = decl[1];
assert.match(expr, /NAV_BAR_HEIGHT/, `让位高度必须含条高,现在是 \`${expr}\``);
assert.match(expr, /NAV_BAR_BOTTOM/, `让位高度必须含离底留白,现在是 \`${expr}\``);
const bare = /[^A-Z_]\d+/.exec(expr.replace(/NAV_[A-Z_]+/g, ''));
assert.equal(bare, null,
`算式里还有裸数字 \`${bare?.[0]}\` —— 余量也要起名字NAV_CONTENT_GAP否则"该让多少"永远是谜`);
});
test('④ 悬浮 + 让位:自绘浮动层(留白/圆角/系统材质),内容底部让出的高度 ≥ 条高 + 离底留白', () => {
const bar = builderBody(main, 'NavBar() {');
/*

View File

@ -206,6 +206,76 @@ for (const [file, flags, expected] of SUITE) {
}
}
/*
* ─── 静态判据的**欠账**与到期机制pi 2026-09-14 提议) ───
*
* 有些判据只能验**形态**(读 `.ets` 源码),因为它们要验的东西在本机跑不起来:
* 鸿蒙侧编译要 hvigorw、运行要设备/模拟器。这类判据登记在下面,并各自写清
* **什么前提一旦成立它就过期**。
*
* 为什么不能只写一句"暂时"**"暂时"不是一种状态,是一个待办** —— 规范里写下的
* "暂时"没有任何机制会回来读它,于是永远留在原地。这里把它变成可机检的形状:
* 1. `UNBLOCK` 必须是**可检测的前提**(这里就是"有没有可用设备"),不是陈述;
* 2. 汇总里打 `RESULT static=N` —— 这是**欠账余额**,涨了要看得见;
* 3. **前提一旦为真,欠账当场变红**:不等人想起来,设备可用的那天这些判据必须
* 改成行为判据(或明确降级并写理由)。
*
* 这条是"自报 0 条 < 登记条数"的**时间版本**:那条管"判据还在不在",这条管
* "它该升级了没有"。
*/
const PROBES = {
device: {
desc: '有可用的设备/模拟器hdc 看得到目标)',
run() {
const sdkHdc = '/opt/huawei/command-line-tools/sdk/default/openharmony/toolchains/hdc';
const candidates = [sdkHdc, 'hdc'];
for (const bin of candidates) {
try {
const out = execFileSync(bin, ['list', 'targets'], { encoding: 'utf8', timeout: 15000 });
const t = out.trim();
if (t && !/\[Empty\]/.test(t)) return true;
} catch { /* 没有 hdc 或超时:前提不成立 */ }
}
return false;
}
}
};
/** 只能验形态的判据:文件 + 为什么只能静态 + 到期前提 */
const STATIC_ONLY = [
['test/harmony-nav.test.mjs', '底栏结构/命中区常量/挂载关系:`.ets` 要 hvigorw 才能编译、要设备才能点', 'device'],
['test/harmony-appearance.test.mjs', '壁纸/令牌/遮罩渲染:观感与运行期换肤要设备', 'device'],
['test/harmony-logic.test.mjs', '页面状态机与文案:`.ets` 状态要跑起来才算数', 'device'],
['test/cross-client-theme.test.mjs', '跨端令牌与玻璃分工:一端是 `.ets`,只能静态对齐', 'device'],
['test/appearance-defaults.test.mjs', '默认值契约里 `.ets` 那半:运行时行为要设备', 'device']
];
for (const [file, , probe] of STATIC_ONLY) {
if (!SUITE.some(([f]) => f === file)) {
console.error(`✗ 静态判据登记里的 ${file} 不在套件清单里(登记要跟着套件走)`);
process.exit(1);
}
if (!PROBES[probe]) {
console.error(`${file} 的到期前提 \`${probe}\` 不是已知探针UNBLOCK 必须是可机检的前提,不是一句陈述)`);
process.exit(1);
}
}
const dueStatic = STATIC_ONLY.filter(([, , probe]) => PROBES[probe].run());
if (dueStatic.length > 0) {
console.error(`\n✗ 静态判据**到期**了:${PROBES[dueStatic[0][2]].desc} 现在是成立的 ——`);
for (const [file, why, probe] of dueStatic) {
console.error(` - ${file}(到期前提:${PROBES[probe].desc};当初只能静态的原因:${why}`);
}
console.error(
' 这些判据当时只能验形态。前提成立后必须做其中一件(别默默留着):\n' +
' a) 改成**行为判据**(真跑一遍/真点一次),静态那条降级或删掉;\n' +
' b) 明确写"为什么仍然只能静态"(如设备能编译但点不了),并改换一个更准的到期前提。\n' +
' 这是"暂时"的到期机制:它的作用就是不等谁想起来。'
);
process.exit(1);
}
console.log(`RESULT static=${STATIC_ONLY.length}(只能验形态的判据:到期前提成立就自动变红)`);
console.log(`\n========== 判据汇总 ==========`);
if (reds.length === 0) {
console.log(`全部通过(${SUITE.length} 个判据文件:${SUITE.map(([f]) => f.replace('test/', '').replace('.test.mjs', '')).join('、')}`);

View File

@ -1,3 +1,5 @@
import { existsSync, readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { beforeEach, describe, expect, it } from 'vitest';
import {
DEFAULT_BACKGROUND,
@ -33,6 +35,20 @@ beforeEach(() => {
document.documentElement.className = '';
});
/** 从当前工作目录往上找仓内文件vitest 里 `import.meta.url` 不是 file: 方案,别指望它) */
function findDoc(rel: string): string {
let d = process.cwd();
for (let i = 0; i < 6; i++) {
const p = join(d, rel);
if (existsSync(p)) return p;
const up = dirname(d);
if (up === d) break;
d = up;
}
throw new Error(`找不到 ${rel}(从 ${process.cwd()} 往上找了 6 层)—— 判据要能自己说清找的是哪个文件`);
}
describe('数值夹取', () => {
it('压暗与模糊都被夹在声明区间内', () => {
expect(clampDim(-10)).toBe(0);
@ -281,3 +297,19 @@ describe('默认值 = 服务端契约', () => {
expect(DEFAULT_BACKGROUND.blur).toBe(4);
});
});
/*
* `legacy.bak` 的生命周期必须**落在审计的人会看的那一行**pi 2026-09-14 追问)。
*
* 这条判的是"决定有没有被记下来",不是"决定对不对" —— 弱判据,但它是必要的:
* 这个键被另一条判据钉成"只写不读"(⇒ 永远删不掉),于是下一个审计 localStorage 的人
* 一定会问"这谁写的、能删吗"。答案必须就在他会看的地方,而不是藏在某个人的记忆里。
*/
it('★ legacy.bak 的生命周期写在文档里(谁写的、为什么不删、怎么删)', () => {
const doc = readFileSync(findDoc('docs/HARMONY-ALIGN-PLAN.md'), 'utf8');
const row = doc.split('\n').find(l => l.includes('agentmail.background.legacy.bak'));
expect(row, '§7.12 里要有一行讲这个键').toBeTruthy();
expect(row).toContain('有意永久残留');
expect(row).toContain('没有任何代码路径能判定何时安全删除');
expect(row, '要给出删除方法,否则审计的人只能猜').toMatch(/removeItem\('agentmail\.background\.legacy\.bak'\)/);
});

View File

@ -45,13 +45,27 @@ export const NAV_BAR_BOTTOM: number = 12;
/** 圆角vp。用"胶囊"半径(= 高度一半),与 WebUI 的 `rounded-2xl` 观感一致 */
export const NAV_BAR_RADIUS: number = 28;
/**
* 内容与浮动条之间的余量vp
*
* 单独起名而不是把 `8` 埋在算式里:它是这一族里**唯一**还需要人判断的数
* (条离底 12 + 条高 56 + 余量 8命名之后"内容到底让多少"只剩一个数可争,
* 其余全部派生。pi 2026-09-14 提的"让位高度应从条高派生,不要写成并列常量"
* 落实在这两行上。
*/
export const NAV_CONTENT_GAP: number = 8;
/**
* 内容底部要让出的高度vp条高 + 离底留白 + 一点余量。
*
* **这是"点得到"的问题,不是美观问题**:条是浮在内容之上的,不让出这段高度,
* 列表最后一行就永远压在玻璃条底下 —— 看得见、点不到。
*
* **派生,不并列**:上面三个常量任何一个变大,这里自动跟着变;写死一个数(例如 76
* 就会在改条高的那天悄悄失配。判据 `harmony-nav.test.mjs` 同时钉"派生关系"与
* "在算式中出现",防它退回字面量。
*/
export const NAV_CONTENT_RESERVE: number = NAV_BAR_HEIGHT + NAV_BAR_BOTTOM + 8;
export const NAV_CONTENT_RESERVE: number = NAV_BAR_HEIGHT + NAV_BAR_BOTTOM + NAV_CONTENT_GAP;
/** 把任意下标归一化到合法范围(点击/外部传值都过这里,别直接赋值) */
export function normalizeNavIndex(index: number): number {

View File

@ -516,7 +516,7 @@ deb 也不必从 targets 里摘。已写进 `client/electron/BUILD.md`(含排
| 动效 | 自定义 transition/时长 | `animateTo` + 系统 `curves` | 动效曲线应跟随系统设置"减弱动效" |
| 遮罩 | 自声明 `--bg-scrim` + `--bg-dim` 两段式 | 系统 `sys.color.ohos_id_color_mask_regular` | 遮罩要随主题换向浅色洗白/深色压黑这件事系统已经做了 |
| **品牌色** | `--c-blue-600: 37 99 235` | `Theme.accent = '#2563EB'` | **不允许差异** —— 两个客户端是同一个产品 |
| **本地外观缓存的键** | `agentmail.background.<accountId>`**已修**原来全局 | `appearance.<accountId>` | **差异已消除**2026-09-14 dsh 接手 pi 的开项两端都按账号分键且都留有不许退回全局键的判据全局键的后果是切到服务端没有记录的账号时 `saved=false` 会把**上一个账号的外观** push 上去新账号"继承"了外观还写进了服务端)。WebUI 侧保留旧全局键**仅作一次性迁移源**接管后立刻删除且未登录时不迁移。**本地这半的归属问题pi 指出同一行的另一半**:「当前账号」= **升级后第一个读到缓存的账号**不是**写下旧值的账号**旧值是谁写的本机**没有记录**`accountStore` `activeId` **派生的视图状态**、落盘时只存 `accounts` 数组见其 `persist(accounts)` 落盘在 activeId 里不合适那句注释)⇒ **定向迁移在原理上做不到**显形方式**一次性外观错档**升级前用 B升级后先登录 A A 自己没有记录 A 接管 B 的外观触发条件就这一条已做的两件补救**写了回读校验**写不进去就不删旧键避免净损失)、**删前另存一份** `agentmail.background.legacy.bak`没有任何代码读它 不引入新的继承源但让接管错了可以手工改回)。行为判据在 `test/stores/background.test.ts` |
| **本地外观缓存的键** | `agentmail.background.<accountId>`**已修**原来全局 | `appearance.<accountId>` | **差异已消除**2026-09-14 dsh 接手 pi 的开项两端都按账号分键且都留有不许退回全局键的判据全局键的后果是切到服务端没有记录的账号时 `saved=false` 会把**上一个账号的外观** push 上去新账号"继承"了外观还写进了服务端)。WebUI 侧保留旧全局键**仅作一次性迁移源**接管后立刻删除且未登录时不迁移。**本地这半的归属问题pi 指出同一行的另一半**:「当前账号」= **升级后第一个读到缓存的账号**不是**写下旧值的账号**旧值是谁写的本机**没有记录**`accountStore` `activeId` **派生的视图状态**、落盘时只存 `accounts` 数组见其 `persist(accounts)` 落盘在 activeId 里不合适那句注释)⇒ **定向迁移在原理上做不到**显形方式**一次性外观错档**升级前用 B升级后先登录 A A 自己没有记录 A 接管 B 的外观触发条件就这一条已做的两件补救**写了回读校验**写不进去就不删旧键避免净损失)、**删前另存一份** `agentmail.background.legacy.bak`没有任何代码读它 不引入新的继承源但让接管错了可以手工改回)。行为判据在 `test/stores/background.test.ts`。**`legacy.bak` 的生命周期**pi 2026-09-14 追问钉了只写不读它就永远删不掉」,指定这一行要给出结论定为**有意永久残留** —— 判据钉死没有任何代码读它」,因此也**没有任何代码路径能判定何时安全删除**那取决于人不取决于代码看到的状态)。**明确否决了用户点重置外观时删掉」**最可能点重置的人正是外观被接管的那个人而这份备份是他唯一的旧值 —— 那时删等于把恢复数据毁在最需要它的时刻代价当场说清不藏着值受 `MAX_DATA_URL_BYTES`2.4MB约束最坏情况是**一张用户上传原图的整份副本**在共享机器上即一份**别人的外观**要清掉就手工 `localStorage.removeItem('agentmail.background.legacy.bak')`这条决定本身有判据钉着`test/stores/background.test.ts`这行必须同时给出为什么不删怎么删」) |
| **遮盖色的令牌** | `--bg-scrim`浅色白 / 深色黑"朝底色淡化" | `Theme.wallpaperScrim` = `sys.color.ohos_id_color_background`同向`Theme.overlay` = mask **只用于模态弹层** | **不允许混用** —— mask 两套主题下都是深色浅色 `#99182431`拿它当壁纸遮盖会在浅色主题下压暗 WebUI 反向)。两个语义两个令牌理由与实测值见 §7.17b-2 |
| **遮罩浓度的默认值** | `12 / 4`**已修**原来 store `24/8` `clamp` `12/4` 两套并存 | 12 / 4只有一套 | **不是审美,是服务端契约**pi 更正了自己上一封`DefaultAppearance()` 明写 `BgDim: 12, BgBlur: 4` 且注释宣称"与客户端默认值一致" —— WebUI 24/8 使那句注释为****。现已统一到共享常量 `src/lib/appearanceDefaults.ts`判据**直接去 Go 源码读**这两个数比对。**措辞要准**pi 同封指出这条核对的是「**与这份服务端源码的契约一致**」,**不是**「在跑的那个服务端二进制是 12/4」—— `dist` 是产物源码修好 用户手上的包修好同构若服务端由别的流水线构建部署判据对运行时**没有**发言权本来后果很重服务端"没有记录"时客户端以本地为准推上去**新账号的初始外观由第一个同步它的客户端决定** |