fix(权限): 409 的第二种含义是「本档不该问」——四桥都补上;状态写入点不再兜默认档

线上事故(jianf 经 pi 转达):补投路径漏传 permission_mode,插件拿 undefined 兜了
workspace 档,把 full 档会话写成 workspace-write + ask —— 不是"拦一次",是一整轮
工具能力降级,且状态留在会话里;随后该会话每次受守卫调用都撞 409。

四件事:

1. **状态写入点不接受默认值**(新增共享 `modeForStateWrite`):缺字段/脏值 → `null`
   = 不写状态。"默认值可以出现在**决策**里,不可以出现在**状态写入**里。"
   同时保留共享契约的 fail-closed:真读到 workspace 才写 workspace。

2. **409 的两种含义分开处理**。`allowed-once` 只绕过**审批**,改不了**沙箱** ——
   所以 dsh 桥在放行前先把服务端给的权威档位**写回会话**(这也就成了自愈路径:
   已经降级的会话,下一次带档位的 409 会把它修回来);只认服务端明说的 full,
   plan 与"链上没有人类"照旧 fail closed。

3. **同一处缺陷在 zcode / opencode 也在**(`hooks/permission.mjs` 与 `index.js`
   都把 409 当永久失败拒绝)。我先前在回信里写过"这两个桥不转发权限询问,不需要改"
   —— 那句话是错的,我当时的搜索面只有 `<plugin>/src/*.mjs`。按 pi 的要求把这条
   **否定性事实变成常驻判据**后,它第一次运行就红给我看。四桥现在都有
   「409 + full → 放行」,且**排在永久失败分支之前**(含顺序变异自检)。

4. **共用测试重新同源**:`test/catchup.test.mjs` 从 `153985e` 起就是分叉的
   (我那版把平台专属路径写进了共用文件),而 `deploy/install.sh` 第 24 行会跑
   `check-shared-libs.sh` —— 也就是说**部署一直是红的**,我没跑过那个脚本。
   共用文件只放契约(值/行为),跨平台配对judge 移到平台专属文件,四份逐字节相同。

另外把"判代码 vs 判理由"从记忆变成代码:`test/lib/read.mjs` 提供 `code()/prose()/bytes()`,
判据目录里不得再裸用 `readFileSync`(新判据 `criteria-hygiene` 管,含读取器自检)。

判据证据(每条都做过"能不能红"的变异):
- 写回去掉 → 红;纠正块挪到普通 409 之后 → 红;状态写入点退回兜默认 → 红;
- zcode/opencode 的放行分支拿掉 → 各自红;共用测试分叉 → check-shared-libs 红。

各套件:dsh 388、pi 443、zcode 387、opencode 333(均经 npm test,含 tsc);
electron `npm test` 15/15 判据绿 + vitest 266 + typecheck;`check-shared-libs.sh` 退出 0;
Go `go test ./...` 全 ok。
This commit is contained in:
2026-09-14 16:21:27 +08:00
parent ec5ee5eb9b
commit d5cfcbdc9c
34 changed files with 942 additions and 248 deletions

View File

@ -12,7 +12,7 @@
// 于是**谁先同步谁决定**。24/8 与 12/4 的差别不是审美,是同一个账号在不同客户端 // 于是**谁先同步谁决定**。24/8 与 12/4 的差别不是审美,是同一个账号在不同客户端
// 登录会得到不同的压暗强度。 // 登录会得到不同的压暗强度。
// 3. 缓存键是"换账号串味"的成因(全局键 → saved=false 时把上一个账号的外观推上去)。 // 3. 缓存键是"换账号串味"的成因(全局键 → saved=false 时把上一个账号的外观推上去)。
import { readFileSync } from 'node:fs'; import { code, prose } from './lib/read.mjs';
import { dirname, join } from 'node:path'; import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import { test } from 'node:test'; import { test } from 'node:test';
@ -26,7 +26,7 @@ const ETS = join(ROOT, 'client/harmony/entry/src/main/ets');
/** 从 Go 源码里读服务端契约(不复制一份数字到判据里 —— 那又会变成"两处各写一套" */ /** 从 Go 源码里读服务端契约(不复制一份数字到判据里 —— 那又会变成"两处各写一套" */
function serverDefaults() { function serverDefaults() {
const src = readFileSync(SERVER_MODELS, 'utf8'); const src = prose(SERVER_MODELS);
// 只取 DefaultAppearance 函数体,避免匹配到别的结构体字面量 // 只取 DefaultAppearance 函数体,避免匹配到别的结构体字面量
const at = src.indexOf('func DefaultAppearance()'); const at = src.indexOf('func DefaultAppearance()');
assert.ok(at > 0, '服务端要有 DefaultAppearance()'); assert.ok(at > 0, '服务端要有 DefaultAppearance()');
@ -97,18 +97,18 @@ test('★ 默认外观 = 服务端契约(去 Go 源码里读,不在判据里
assert.deepEqual([s.dim, s.blur], [12, 4], '服务端 DefaultAppearance 是 12/4权威值'); assert.deepEqual([s.dim, s.blur], [12, 4], '服务端 DefaultAppearance 是 12/4权威值');
// WebUI默认值必须来自共享常量且等于服务端 // WebUI默认值必须来自共享常量且等于服务端
const defaults = readFileSync(join(ROOT, 'client/electron/src/lib/appearanceDefaults.ts'), 'utf8'); const defaults = code(join(ROOT, 'client/electron/src/lib/appearanceDefaults.ts'));
assert.match(defaults, new RegExp(`DEFAULT_DIM = ${s.dim}\\b`), `DEFAULT_DIM 要等于服务端的 BgDim=${s.dim}`); assert.match(defaults, new RegExp(`DEFAULT_DIM = ${s.dim}\\b`), `DEFAULT_DIM 要等于服务端的 BgDim=${s.dim}`);
assert.match(defaults, new RegExp(`DEFAULT_BLUR = ${s.blur}\\b`), `DEFAULT_BLUR 要等于服务端的 BgBlur=${s.blur}`); assert.match(defaults, new RegExp(`DEFAULT_BLUR = ${s.blur}\\b`), `DEFAULT_BLUR 要等于服务端的 BgBlur=${s.blur}`);
// 严格store 里不许再自写一套数字(原来这里写的是 24/8与服务端不一致 // 严格store 里不许再自写一套数字(原来这里写的是 24/8与服务端不一致
const store = readFileSync(join(ROOT, 'client/electron/src/stores/backgroundStore.ts'), 'utf8'); const store = code(join(ROOT, 'client/electron/src/stores/backgroundStore.ts'));
const defBlock = /export const DEFAULT_BACKGROUND: BackgroundState = \{[\s\S]*?\};/.exec(store); const defBlock = /export const DEFAULT_BACKGROUND: BackgroundState = \{[\s\S]*?\};/.exec(store);
assert.ok(defBlock, 'DEFAULT_BACKGROUND 要能取到'); assert.ok(defBlock, 'DEFAULT_BACKGROUND 要能取到');
assert.match(defBlock[0], /dim:\s*DEFAULT_DIM/, 'dim 要引用共享常量,不许自写字面量'); assert.match(defBlock[0], /dim:\s*DEFAULT_DIM/, 'dim 要引用共享常量,不许自写字面量');
assert.match(defBlock[0], /blur:\s*DEFAULT_BLUR/, 'blur 要引用共享常量'); assert.match(defBlock[0], /blur:\s*DEFAULT_BLUR/, 'blur 要引用共享常量');
assert.ok(!/dim:\s*\d/.test(defBlock[0]), `DEFAULT_BACKGROUND 里不许再出现数字字面量(现在:${defBlock[0].replace(/\s+/g, ' ')}`); assert.ok(!/dim:\s*\d/.test(defBlock[0]), `DEFAULT_BACKGROUND 里不许再出现数字字面量(现在:${defBlock[0].replace(/\s+/g, ' ')}`);
// 同步层的兜底值也要引用同一常量(原来 clamp(..., 12, 4) 是另写的一份) // 同步层的兜底值也要引用同一常量(原来 clamp(..., 12, 4) 是另写的一份)
const lib = readFileSync(join(ROOT, 'client/electron/src/lib/appearance.ts'), 'utf8'); const lib = code(join(ROOT, 'client/electron/src/lib/appearance.ts'));
const clamps = [...lib.matchAll(/clamp\([^)]*\)/g)].map(m => m[0]); const clamps = [...lib.matchAll(/clamp\([^)]*\)/g)].map(m => m[0]);
assert.ok(clamps.length >= 4, `clamp 调用要能取到(实际 ${clamps.length} 处)`); assert.ok(clamps.length >= 4, `clamp 调用要能取到(实际 ${clamps.length} 处)`);
for (const c of clamps) { for (const c of clamps) {
@ -116,23 +116,23 @@ test('★ 默认外观 = 服务端契约(去 Go 源码里读,不在判据里
} }
// 鸿蒙:默认值(在 AppearanceResponse 的字段默认值里)与预设也要与服务端对得上 // 鸿蒙:默认值(在 AppearanceResponse 的字段默认值里)与预设也要与服务端对得上
const ap = readFileSync(join(ETS, 'model/Appearance.ts'), 'utf8'); const ap = code(join(ETS, 'model/Appearance.ts'));
assert.match(ap, new RegExp(`bg_dim: number = ${s.dim}\\b`), `鸿蒙默认压暗要等于服务端 BgDim=${s.dim}`); assert.match(ap, new RegExp(`bg_dim: number = ${s.dim}\\b`), `鸿蒙默认压暗要等于服务端 BgDim=${s.dim}`);
assert.match(ap, new RegExp(`bg_blur: number = ${s.blur}\\b`), `鸿蒙默认模糊要等于服务端 BgBlur=${s.blur}`); assert.match(ap, new RegExp(`bg_blur: number = ${s.blur}\\b`), `鸿蒙默认模糊要等于服务端 BgBlur=${s.blur}`);
assert.match(ap, new RegExp(`theme: string = '${s.theme}'`), `鸿蒙默认主题要等于服务端 Theme=${s.theme}`); assert.match(ap, new RegExp(`theme: string = '${s.theme}'`), `鸿蒙默认主题要等于服务端 Theme=${s.theme}`);
const wp = readFileSync(join(ETS, 'model/Wallpaper.ts'), 'utf8'); const wp = code(join(ETS, 'model/Wallpaper.ts'));
assert.match(wp, new RegExp(`'${s.preset}'`), `服务端默认预设 ${s.preset} 要在鸿蒙的预设清单里`); assert.match(wp, new RegExp(`'${s.preset}'`), `服务端默认预设 ${s.preset} 要在鸿蒙的预设清单里`);
// 这句注释在**这份源码**里现在是事实(它宣称"与客户端默认值一致")—— 这条就是它的核对器。 // 这句注释在**这份源码**里现在是事实(它宣称"与客户端默认值一致")—— 这条就是它的核对器。
// (运行时是否一致不由此判据保证:见文件头第 1 条的措辞说明。) // (运行时是否一致不由此判据保证:见文件头第 1 条的措辞说明。)
const models = readFileSync(SERVER_MODELS, 'utf8'); const models = prose(SERVER_MODELS);
const comment = /\/\/ DefaultAppearance[\s\S]{0,200}?func DefaultAppearance/.exec(models); const comment = /\/\/ DefaultAppearance[\s\S]{0,200}?func DefaultAppearance/.exec(models);
assert.ok(comment, 'DefaultAppearance 上面要有说明注释'); assert.ok(comment, 'DefaultAppearance 上面要有说明注释');
assert.match(comment[0], /一致/, '注释仍在宣称"与客户端默认值一致"(本判据负责让它为真)'); assert.match(comment[0], /一致/, '注释仍在宣称"与客户端默认值一致"(本判据负责让它为真)');
}); });
test('★ 缓存键按账号分:两端的键都带账号,且都不许退回全局键', () => { test('★ 缓存键按账号分:两端的键都带账号,且都不许退回全局键', () => {
const store = readFileSync(join(ROOT, 'client/electron/src/stores/backgroundStore.ts'), 'utf8'); const store = code(join(ROOT, 'client/electron/src/stores/backgroundStore.ts'));
// 键函数:必须把 accountId 拼进去(按块取函数体,不看调用点 —— "配对/解析"那条) // 键函数:必须把 accountId 拼进去(按块取函数体,不看调用点 —— "配对/解析"那条)
const at = store.indexOf('export function storageKey('); const at = store.indexOf('export function storageKey(');
assert.ok(at > 0, '要有 storageKey 函数'); assert.ok(at > 0, '要有 storageKey 函数');
@ -201,7 +201,7 @@ test('★ 缓存键按账号分:两端的键都带账号,且都不许退回
assert.match(store, /localStorage\.setItem\(storageKey\(\), JSON\.stringify\(state\)\)/, '写缓存要按账号的键'); assert.match(store, /localStorage\.setItem\(storageKey\(\), JSON\.stringify\(state\)\)/, '写缓存要按账号的键');
// 鸿蒙侧:键同样带账号(两边形状一致,这条差异已经消除) // 鸿蒙侧:键同样带账号(两边形状一致,这条差异已经消除)
const ets = readFileSync(join(ETS, 'common/AppearanceStore.ets'), 'utf8'); const ets = code(join(ETS, 'common/AppearanceStore.ets'));
const eAt = ets.indexOf('prefKey(accountId: string)'); const eAt = ets.indexOf('prefKey(accountId: string)');
assert.ok(eAt > 0, '鸿蒙要有按账号取键的函数'); assert.ok(eAt > 0, '鸿蒙要有按账号取键的函数');
let d2 = 0; let d2 = 0;
@ -224,7 +224,7 @@ test('★ 切账号的顺序:**先按新账号重读本地**,再拉服务端
* 服务端"没有记录"时 `pull()` 会"以本地为准推上去"——所以重读必须在前, * 服务端"没有记录"时 `pull()` 会"以本地为准推上去"——所以重读必须在前,
* 否则推上去的是上一个账号的外观(全局键时代就是这个现象,而且写进了服务端)。 * 否则推上去的是上一个账号的外观(全局键时代就是这个现象,而且写进了服务端)。
*/ */
const sync = readFileSync(join(ROOT, 'client/electron/src/stores/appearanceSync.ts'), 'utf8'); const sync = code(join(ROOT, 'client/electron/src/stores/appearanceSync.ts'));
const sub = /activeId !== prev\.activeId\)[\s\S]{0,400}?\}\);/.exec(sync); const sub = /activeId !== prev\.activeId\)[\s\S]{0,400}?\}\);/.exec(sync);
assert.ok(sub, '要能取到账号切换的处理块'); assert.ok(sub, '要能取到账号切换的处理块');
const block = sub[0]; const block = sub[0];
@ -234,7 +234,7 @@ test('★ 切账号的顺序:**先按新账号重读本地**,再拉服务端
assert.ok(pullAt > 0, '切换账号仍然要拉服务端'); assert.ok(pullAt > 0, '切换账号仍然要拉服务端');
assert.ok(reloadAt < pullAt, 'reloadForAccount() 必须在 pull() 之前(顺序就是这条判据的全部意义)'); assert.ok(reloadAt < pullAt, 'reloadForAccount() 必须在 pull() 之前(顺序就是这条判据的全部意义)');
// 重读本身不许落盘(它只是把本账号已有的值读回来,不是用户的改动) // 重读本身不许落盘(它只是把本账号已有的值读回来,不是用户的改动)
const store = readFileSync(join(ROOT, 'client/electron/src/stores/backgroundStore.ts'), 'utf8'); const store = code(join(ROOT, 'client/electron/src/stores/backgroundStore.ts'));
const rAt = store.indexOf('reloadForAccount: () => {'); const rAt = store.indexOf('reloadForAccount: () => {');
assert.ok(rAt > 0, 'store 要提供 reloadForAccount'); assert.ok(rAt > 0, 'store 要提供 reloadForAccount');
const rBlock = store.slice(rAt, store.indexOf('}', store.indexOf('set(next)', rAt))); const rBlock = store.slice(rAt, store.indexOf('}', store.indexOf('set(next)', rAt)));

View File

@ -7,13 +7,14 @@
* 这些检查存在的理由:背景是**装饰层叠加在内容之下**,它的 bug 形态是 * 这些检查存在的理由:背景是**装饰层叠加在内容之下**,它的 bug 形态是
* 「正文读不动」和「背景根本没出现」,两者都不报错、不影响构建。 * 「正文读不动」和「背景根本没出现」,两者都不报错、不影响构建。
*/ */
import { readFileSync, readdirSync } from 'node:fs'; import { prose } from './lib/read.mjs';
import { readdirSync } from 'node:fs';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path'; import { dirname, join } from 'node:path';
import postcss from 'postcss'; import postcss from 'postcss';
const here = dirname(fileURLToPath(import.meta.url)); const here = dirname(fileURLToPath(import.meta.url));
const read = p => readFileSync(join(here, p), 'utf8'); const read = p => prose(join(here, p));
let pass = 0; let pass = 0;
let fail = 0; let fail = 0;
@ -149,7 +150,7 @@ check(
const compDir = join(here, '../src/components'); const compDir = join(here, '../src/components');
const unmapped = []; const unmapped = [];
for (const f of readdirSync(compDir).filter(x => x.endsWith('.tsx'))) { for (const f of readdirSync(compDir).filter(x => x.endsWith('.tsx'))) {
const src = readFileSync(join(compDir, f), 'utf8'); const src = prose(join(compDir, f));
if (/(?:bg|text|border)-(?:emerald|purple)-\d+/.test(src)) unmapped.push(f); if (/(?:bg|text|border)-(?:emerald|purple)-\d+/.test(src)) unmapped.push(f);
} }
check('新组件未使用未映射色族', unmapped.length === 0, unmapped.join(' ')); check('新组件未使用未映射色族', unmapped.length === 0, unmapped.join(' '));
@ -205,7 +206,7 @@ check('新组件未使用未映射色族', unmapped.length === 0, unmapped.join(
const full = join(dir, e.name); const full = join(dir, e.name);
if (e.isDirectory()) walk(full); if (e.isDirectory()) walk(full);
else if (/\.(tsx?|jsx?)$/.test(e.name)) { else if (/\.(tsx?|jsx?)$/.test(e.name)) {
const src = readFileSync(full, 'utf8'); const src = prose(full);
for (const mm of src.matchAll(/bg-[a-z]+-\d{2,3}/g)) { for (const mm of src.matchAll(/bg-[a-z]+-\d{2,3}/g)) {
if (/^bg-[a-z]+-(50|100|200)$/.test(mm[0])) used.add(mm[0]); if (/^bg-[a-z]+-(50|100|200)$/.test(mm[0])) used.add(mm[0]);
} }

View File

@ -10,10 +10,11 @@
* *
* 判据落在三处接线vite 注入 → 组件渲染 → 产物里真的有。 * 判据落在三处接线vite 注入 → 组件渲染 → 产物里真的有。
*/ */
import { prose } from './lib/read.mjs';
import { test } from 'node:test'; import { test } from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { execFileSync } from 'node:child_process'; import { execFileSync } from 'node:child_process';
import { existsSync, readFileSync, readdirSync } from 'node:fs'; import { existsSync, readdirSync } from 'node:fs';
import { srcState } from '../scripts/build-info.mjs'; import { srcState } from '../scripts/build-info.mjs';
import { dirname, join } from 'node:path'; import { dirname, join } from 'node:path';
@ -21,7 +22,7 @@ import { fileURLToPath } from 'node:url';
const HERE = dirname(fileURLToPath(import.meta.url)); const HERE = dirname(fileURLToPath(import.meta.url));
const ROOT = join(HERE, '..', '..', '..'); const ROOT = join(HERE, '..', '..', '..');
const read = p => readFileSync(join(HERE, p), 'utf8'); const read = p => prose(join(HERE, p));
const STAMP = /[0-9a-f]{7,}·\d{4}-\d{4}/; const STAMP = /[0-9a-f]{7,}·\d{4}-\d{4}/;
test('vite 注入 __BUILD_STAMP__', () => { test('vite 注入 __BUILD_STAMP__', () => {
@ -40,7 +41,7 @@ test('★ 构建产物里真的带着戳', () => {
const dir = join(HERE, '../dist/assets'); const dir = join(HERE, '../dist/assets');
const js = readdirSync(dir).filter(f => f.startsWith('index-') && f.endsWith('.js')); const js = readdirSync(dir).filter(f => f.startsWith('index-') && f.endsWith('.js'));
assert.ok(js.length > 0, '先跑 npm run build'); assert.ok(js.length > 0, '先跑 npm run build');
const found = js.some(f => STAMP.test(readFileSync(join(dir, f), 'utf8'))); const found = js.some(f => STAMP.test(prose(join(dir, f))));
assert.ok(found, '产物里没有构建戳 —— 界面上就永远看不出自己跑的是哪一份'); assert.ok(found, '产物里没有构建戳 —— 界面上就永远看不出自己跑的是哪一份');
}); });
@ -117,7 +118,7 @@ test('★ 产物必须自报来源BUILD_INFO 精确比对(不是比时间
assert.ok(existsSync(infoPath), assert.ok(existsSync(infoPath),
'dist 里没有 BUILD_INFO.json —— 构建没走 `npm run build`(它最后一步会写这份自证)。\n' + 'dist 里没有 BUILD_INFO.json —— 构建没走 `npm run build`(它最后一步会写这份自证)。\n' +
' 重构建cd client/electron && npm run build'); ' 重构建cd client/electron && npm run build');
const info = JSON.parse(readFileSync(infoPath, 'utf8')); const info = JSON.parse(prose(infoPath));
const now = srcState(); const now = srcState();
// 精确比对:这一条比"谁更新"强的地方在于它**能读出**差在哪 // 精确比对:这一条比"谁更新"强的地方在于它**能读出**差在哪
assert.equal(info.srcHash, now.srcHash, assert.equal(info.srcHash, now.srcHash,

View File

@ -0,0 +1,91 @@
/**
* 判据目录自身的卫生:**读文本必须走 `test/lib/read.mjs` 的两个具名入口**。
*
* # 为什么这条判据存在pi 2026-09-14 §4
*
* 规范里写着"判代码读剥离版(`code`)、判理由/文档读原文(`prose`"
* 我 P5 写过一次、当天又踩了一次:那条断言读的是**原文**,而它要找的标识符
* 恰好出现在一段解释性注释里 → 误报。**第二次犯规说明问题不在记性,在形态**
* 靠人记得执行的规范一定会有下一次。
*
* 所以把"用哪个读取器"从**记忆**变成**代码里的一个词**,并且可被检查:
* - `code(path)` —— 剥掉注释;判"代码里有没有这个调用/这个值"
* - `prose(path)` —— 原文;判"注释/文档里写了什么"
* - `bytes(path)` —— 二进制(安装包等)。
*
* # 判据
*
* 判据目录(`test/**` 里跑的判据 + `run-all.mjs`)中**不得出现裸 `readFileSync`**
* 唯一例外是 `test/lib/read.mjs` 自己。`test/manual/**` 是人工脚本、不是判据,不在范围内。
*
* 附两条自检:读取器本身要真的剥注释(否则 `code` 退化成 `prose` 这条判据就废了)、
* 以及探测器要能认出裸调用(否则"都没有"与"探测器坏了"结果一样)。
*/
import assert from 'node:assert/strict';
import { readdirSync, unlinkSync, writeFileSync } from 'node:fs';
import { dirname, join, relative } from 'node:path';
import { test } from 'node:test';
import { fileURLToPath } from 'node:url';
import { code, prose } from './lib/read.mjs';
const HERE = dirname(fileURLToPath(import.meta.url));
const RELECTRON = join(HERE, '..'); // test/ 的上一级就是 client/electron
const SELF = join(HERE, 'lib', 'read.mjs');
/** 判据文件清单:`test/**` 下会跑的判据 + 编排器;不含 lib/ 与 manual/ */
function criteriaFiles(dir = HERE, out = []) {
for (const e of readdirSync(dir, { withFileTypes: true })) {
const p = join(dir, e.name);
if (e.isDirectory()) {
if (e.name === 'lib' || e.name === 'manual' || e.name === 'node_modules') continue;
criteriaFiles(p, out);
} else if (/\.(test\.mjs|test\.ts|test\.tsx|mjs)$/.test(e.name) && !e.name.endsWith('.d.ts')) {
out.push(p);
}
}
return out;
}
/** 探测器:一段源码里有没有裸 readFileSync */
const BARE = /\breadFileSync\s*\(/;
test('探测器自检 + 读取器自检', () => {
// ① 探测器能认出裸调用(否则"都没有"与"探测器坏了"分不开)
assert.equal(BARE.test("const s = " + "readFile" + "Sync(p, 'utf8');"), true);
assert.equal(BARE.test('const s = prose(p);'), false);
// ② code 真的剥注释、prose 不剥 —— 这条是整套用法的地基:
// 若 code 退化成 prose那么"读剥离版"的规范就变成一句空话,而且没人会发现。
const probe = join(RELECTRON, 'test', '_reader_probe.tmp.ts');
// 注:这个探针文本**故意拼接**而不是写字面量 —— 否则本判据自己会被自己判红
// (它扫的就是"文本里有没有这个写法",判据文件也在扫描范围内)。
writeFileSync(probe, "const REAL = 1; // " + "readFile" + "Sync( 注释里的假调用\n/* allowed-once */\n");
try {
assert.ok(!code(probe).includes('allowed-once'), 'code() 必须剥掉块注释');
assert.ok(!code(probe).includes('假调用'), 'code() 必须剥掉行注释');
assert.ok(code(probe).includes('REAL'), 'code() 要保留真代码');
assert.ok(prose(probe).includes('allowed-once') && prose(probe).includes('假调用'), 'prose() 必须保留注释');
} finally {
unlinkSync(probe);
}
});
test('★ 判据目录里不得出现裸 readFileSync必须走 code/prose/bytes', () => {
const offenders = [];
for (const f of criteriaFiles()) {
if (f === SELF) continue; // 读取器的实现自己当然要用它
const src = prose(f); // 扫的是"文本里有没有这个写法",所以读原文
if (BARE.test(src)) {
const line = src.split('\n').findIndex(l => BARE.test(l)) + 1;
offenders.push(`${relative(RELECTRON, f)}:${line}`);
}
}
assert.deepEqual(offenders, [],
`这些判据文件里还在裸用 readFileSync\n ${offenders.join('\n ')}\n` +
" 改用 test/lib/read.mjs 的具名入口:\n" +
" · code(path) —— 剥掉注释。判「代码里有没有这个调用/这个值」时用它(默认选它);\n" +
" · prose(path) —— 原文。判「注释/文档里写了什么」时用它;\n" +
" · bytes(path) —— 二进制(安装包等)。\n" +
" 为什么不能裸用:读原文去判代码,会被解释性注释骗(同一个坑已经踩过两次)。");
});

View File

@ -8,17 +8,22 @@
* 这里只断言"两边对同一件事的取值一致"不断言实现方式WebUI 用 CSS 变量、 * 这里只断言"两边对同一件事的取值一致"不断言实现方式WebUI 用 CSS 变量、
* 鸿蒙用 ArkTS 常量,本来就该不同)。 * 鸿蒙用 ArkTS 常量,本来就该不同)。
*/ */
import { code, prose, stripComments } from './lib/read.mjs';
import { test } from 'node:test'; import { test } from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { readFileSync, readdirSync } from 'node:fs'; import { readdirSync } from 'node:fs';
import { dirname, join } from 'node:path'; import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
const HERE = dirname(fileURLToPath(import.meta.url)); const HERE = dirname(fileURLToPath(import.meta.url));
const ROOT = join(HERE, '..', '..', '..'); const ROOT = join(HERE, '..', '..', '..');
const HARMONY_ETS = join(ROOT, 'client/harmony/entry/src/main/ets'); const HARMONY_ETS = join(ROOT, 'client/harmony/entry/src/main/ets');
const web = readFileSync(join(ROOT, 'client/electron/src/index.css'), 'utf8'); const web = code(join(ROOT, 'client/electron/src/index.css'));
const harmony = readFileSync(join(HARMONY_ETS, 'common/Theme.ets'), 'utf8'); const harmonyPath = join(HARMONY_ETS, 'common/Theme.ets');
/** 判「代码里有什么」用这个 */
const harmony = code(harmonyPath);
/** 判「注释/文档里写了什么」用这个 —— 两条判据各取所需,别混用 */
const harmonyDoc = prose(harmonyPath);
/** /**
* 裸色值的**类**(不只 `#RRGGBB`):四种写法一起扫 —— * 裸色值的**类**(不只 `#RRGGBB`):四种写法一起扫 ——
@ -26,8 +31,7 @@ const harmony = readFileSync(join(HARMONY_ETS, 'common/Theme.ets'), 'utf8');
* 注释先剥掉:注释里引用旧写法是常有的事,而"诚实的注释"不该把判据判红。 * 注释先剥掉:注释里引用旧写法是常有的事,而"诚实的注释"不该把判据判红。
*/ */
const RAW_COLOR = /#[0-9A-Fa-f]{6,8}\b|\brgba?\s*\(|\b0x[0-9A-Fa-f]{6,8}\b/g; const RAW_COLOR = /#[0-9A-Fa-f]{6,8}\b|\brgba?\s*\(|\b0x[0-9A-Fa-f]{6,8}\b/g;
const code = src => src.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, ''); const rawColors = src => stripComments(src).match(RAW_COLOR) || [];
const rawColors = src => code(src).match(RAW_COLOR) || [];
/** /**
* 文档里的「有意差异」表§7.12)。 * 文档里的「有意差异」表§7.12)。
@ -36,7 +40,7 @@ const rawColors = src => code(src).match(RAW_COLOR) || [];
* 不是"证明做法对"。所以跨端那几条判据的主体仍落在代码上 * 不是"证明做法对"。所以跨端那几条判据的主体仍落在代码上
* (来源必须是系统资源 / 品牌色必须是那个值),文档只做第三只手。 * (来源必须是系统资源 / 品牌色必须是那个值),文档只做第三只手。
*/ */
const plan = readFileSync(join(ROOT, 'docs/HARMONY-ALIGN-PLAN.md'), 'utf8'); const plan = prose(join(ROOT, 'docs/HARMONY-ALIGN-PLAN.md'));
/** /**
* 取「有意差异」那一小节。 * 取「有意差异」那一小节。
* *
@ -107,7 +111,7 @@ test('圆角WebUI 有它自己的令牌,鸿蒙跟随系统 —— 差异被
* 注意 ③ 是**弱判据**(防遗忘),不是"证明做法对"。主体是 ①②,它们落在代码上。 * 注意 ③ 是**弱判据**(防遗忘),不是"证明做法对"。主体是 ①②,它们落在代码上。
*/ */
assert.match(web, /--radius-card:\s*0\.875rem/, 'WebUI 侧要保留自己的圆角令牌'); assert.match(web, /--radius-card:\s*0\.875rem/, 'WebUI 侧要保留自己的圆角令牌');
assert.match(code(harmony), /radiusCard: Resource = \$r\('sys\.float\./, '鸿蒙卡片圆角要来自系统'); assert.match(stripComments(harmony), /radiusCard: Resource = \$r\('sys\.float\./, '鸿蒙卡片圆角要来自系统');
assert.match(diffSection(), /圆角/, '圆角是"有意差异",要写进文档的差异表(否则会被当成漏改)'); assert.match(diffSection(), /圆角/, '圆角是"有意差异",要写进文档的差异表(否则会被当成漏改)');
}); });
@ -115,7 +119,7 @@ test('材质WebUI 声明透明度令牌,鸿蒙用系统材质 —— 差异
// 同上:旧口径钉"两边都 0.72"。鸿蒙改用 `BlurStyle` 后,"0.72 的白色"这件事 // 同上:旧口径钉"两边都 0.72"。鸿蒙改用 `BlurStyle` 后,"0.72 的白色"这件事
// 由系统按主题决定(深浅各一套),所以取值**允许**不同,只要求"材质来自系统"且差异被记录。 // 由系统按主题决定(深浅各一套),所以取值**允许**不同,只要求"材质来自系统"且差异被记录。
assert.match(web, /--nav-bg:\s*255 255 255 \/ 0\.72/, 'WebUI 侧要保留自己的导航底令牌'); assert.match(web, /--nav-bg:\s*255 255 255 \/ 0\.72/, 'WebUI 侧要保留自己的导航底令牌');
assert.match(code(harmony), /navMaterial: BlurStyle = BlurStyle\./, '鸿蒙导航材质要来自系统'); assert.match(stripComments(harmony), /navMaterial: BlurStyle = BlurStyle\./, '鸿蒙导航材质要来自系统');
assert.match(diffSection(), /材质/, '材质是"有意差异",要写进文档的差异表'); assert.match(diffSection(), /材质/, '材质是"有意差异",要写进文档的差异表');
}); });
@ -126,7 +130,7 @@ test('★ 判据自检:把鸿蒙的品牌蓝改成别的必须判红', () => {
}); });
test('鸿蒙的令牌文件说明了与 WebUI 的对应关系(不是凭空一套)', () => { test('鸿蒙的令牌文件说明了与 WebUI 的对应关系(不是凭空一套)', () => {
assert.match(harmony, /与 WebUI 的令牌\*\*一一对应|对应 WebUI/); assert.match(harmonyDoc, /与 WebUI 的令牌\*\*一一对应|对应 WebUI/, '这条判的是**注释里写的对应关系**所以要读原文prose不是剥离版');
}); });
test('★ 鸿蒙页面里不得出现任何裸色值(枚举挡不住漂移,"类"才能挡)', () => { test('★ 鸿蒙页面里不得出现任何裸色值(枚举挡不住漂移,"类"才能挡)', () => {
@ -148,7 +152,7 @@ test('★ 鸿蒙页面里不得出现任何裸色值(枚举挡不住漂移,"
const files = readdirSync(dir).filter(f => f.endsWith('.ets')).sort(); const files = readdirSync(dir).filter(f => f.endsWith('.ets')).sort();
assert.ok(files.length >= 8, `pages/ 下只扫到 ${files.length} 个 .ets判据大概扫错了目录`); assert.ok(files.length >= 8, `pages/ 下只扫到 ${files.length} 个 .ets判据大概扫错了目录`);
for (const f of files) { for (const f of files) {
const hits = rawColors(readFileSync(join(dir, f), 'utf8')); const hits = rawColors(prose(join(dir, f)));
assert.equal(hits.length, 0, `${f} 里有裸色值 ${hits.join('、')}(应改用 Theme 令牌或系统资源)`); assert.equal(hits.length, 0, `${f} 里有裸色值 ${hits.join('、')}(应改用 Theme 令牌或系统资源)`);
} }
// 反向对照:四种形态都要抓得到(否则写错了正则也是一片绿) // 反向对照:四种形态都要抓得到(否则写错了正则也是一片绿)
@ -192,7 +196,7 @@ test('A系统拥有的维度鸿蒙侧的唯一来源是系统资源
// 反向断言:这些格子不得退回 string/number 类型(退回 = 又开始自己定值了) // 反向断言:这些格子不得退回 string/number 类型(退回 = 又开始自己定值了)
for (const name of Object.keys(sysOwned)) { for (const name of Object.keys(sysOwned)) {
assert.ok( assert.ok(
!new RegExp(`${name}: (string|number) =`).test(code(harmony)), !new RegExp(`${name}: (string|number) =`).test(stripComments(harmony)),
`${name} 不再是系统资源了 —— 这是"用系统方案"被回退的信号` `${name} 不再是系统资源了 —— 这是"用系统方案"被回退的信号`
); );
} }
@ -220,7 +224,7 @@ const SELF_OWNED_COLORS = [
]; ];
test('A2Theme.ets 里"自己写的色"必须**登记过**:新写死一个色不该默默通过', () => { test('A2Theme.ets 里"自己写的色"必须**登记过**:新写死一个色不该默默通过', () => {
const themeSrc = code(harmony); const themeSrc = harmony; // 声明在代码里:读剥离版就够了
const declared = [...themeSrc.matchAll(/static readonly (\w+): string = '(#[0-9A-Fa-f]{6,8})'/g)].map(m => m[1]); const declared = [...themeSrc.matchAll(/static readonly (\w+): string = '(#[0-9A-Fa-f]{6,8})'/g)].map(m => m[1]);
assert.ok(declared.length >= 10, `要从 Theme.ets 里读到那些手写色,实际读到 ${declared.length}`); assert.ok(declared.length >= 10, `要从 Theme.ets 里读到那些手写色,实际读到 ${declared.length}`);
@ -240,11 +244,11 @@ test('A2Theme.ets 里"自己写的色"必须**登记过**:新写死一个
* 不是剥过注释的源码(剥注释会把登记表本身剥掉,第一版就踩了这个: * 不是剥过注释的源码(剥注释会把登记表本身剥掉,第一版就踩了这个:
* `indexOf` 返回 -1切片拿到一段不相干的东西 * `indexOf` 返回 -1切片拿到一段不相干的东西
*/ */
const regStart = harmony.indexOf('手写色**登记表**'); const regStart = harmonyDoc.indexOf('手写色**登记表**');
assert.ok(regStart > 0, 'Theme.ets 里要有「手写色登记表」那一段'); assert.ok(regStart > 0, 'Theme.ets 里要有「手写色登记表」那一段');
const regEnd = harmony.indexOf('品牌色:跨客户端身份', regStart); const regEnd = harmonyDoc.indexOf('品牌色:跨客户端身份', regStart);
assert.ok(regEnd > regStart, '登记表那一段要有明确的结束边界(下一个分节标题)'); assert.ok(regEnd > regStart, '登记表那一段要有明确的结束边界(下一个分节标题)');
const registry = harmony.slice(regStart, regEnd); const registry = harmonyDoc.slice(regStart, regEnd);
assert.ok(registry.length > 200, `登记表太短,可能切错了段落(${registry.length} 字符)`); assert.ok(registry.length > 200, `登记表太短,可能切错了段落(${registry.length} 字符)`);
for (const name of SELF_OWNED_COLORS) { for (const name of SELF_OWNED_COLORS) {
assert.ok(registry.includes(name), `登记表里要提到 ${name}(否则理由只存在于写它那个人的记忆里)`); assert.ok(registry.includes(name), `登记表里要提到 ${name}(否则理由只存在于写它那个人的记忆里)`);
@ -265,7 +269,7 @@ test('A2Theme.ets 里"自己写的色"必须**登记过**:新写死一个
}); });
test('B旧机制不得回来手写玻璃 alpha、替系统猜深色、与 WebUI 绑死的圆角数字', () => { test('B旧机制不得回来手写玻璃 alpha、替系统猜深色、与 WebUI 绑死的圆角数字', () => {
const codeOnly = code(harmony); const codeOnly = stripComments(harmony);
/* /*
* `navBgLight` / `navBgDark` 这两个名字本身就是罪证:浅色一个、深色一个手写玻璃, * `navBgLight` / `navBgDark` 这两个名字本身就是罪证:浅色一个、深色一个手写玻璃,
* 等于"我们替系统猜了深色该怎么做"。pi 在 WebUI 侧撤掉 `.dark` 导航令牌、 * 等于"我们替系统猜了深色该怎么做"。pi 在 WebUI 侧撤掉 `.dark` 导航令牌、
@ -353,7 +357,7 @@ test('C玻璃位置用系统材质、不许叠、每一处都要登记
* 并列本身没问题而真正的嵌套它没在判。P5 正是"悬浮玻璃导航",很可能撞上第二处; * 并列本身没问题而真正的嵌套它没在判。P5 正是"悬浮玻璃导航",很可能撞上第二处;
* 到那时若把 1 改成 2这条就退化成"最多两处"(等于不判)。所以现在就把形状改对。 * 到那时若把 1 改成 2这条就退化成"最多两处"(等于不判)。所以现在就把形状改对。
*/ */
const codeOnly = code(harmony); const codeOnly = stripComments(harmony);
assert.match(codeOnly, /static readonly navMaterial: BlurStyle = BlurStyle\.[A-Z_]+/, '导航材质要声明成系统材质档次'); assert.match(codeOnly, /static readonly navMaterial: BlurStyle = BlurStyle\.[A-Z_]+/, '导航材质要声明成系统材质档次');
const navMaterial = codeOnly.match(/navMaterial: BlurStyle = BlurStyle\.([A-Z_]+)/)[1]; const navMaterial = codeOnly.match(/navMaterial: BlurStyle = BlurStyle\.([A-Z_]+)/)[1];
assert.notEqual(navMaterial, 'NONE', 'NONE 等于没有材质,"玻璃"就名存实亡'); assert.notEqual(navMaterial, 'NONE', 'NONE 等于没有材质,"玻璃"就名存实亡');
@ -362,7 +366,7 @@ test('C玻璃位置用系统材质、不许叠、每一处都要登记
/** 每个调用点:哪一处(文件#组件)、调用下标、它作用的**组件块**(配对出来的) */ /** 每个调用点:哪一处(文件#组件)、调用下标、它作用的**组件块**(配对出来的) */
const found = []; const found = [];
for (const f of collectEts(etsRoot)) { for (const f of collectEts(etsRoot)) {
const src = code(readFileSync(f, 'utf8')); const src = code(f);
const spans = braceSpans(src); const spans = braceSpans(src);
for (const m of src.matchAll(/backgroundBlurStyle\(/g)) { for (const m of src.matchAll(/backgroundBlurStyle\(/g)) {
const at = m.index; const at = m.index;
@ -414,7 +418,7 @@ test('C玻璃位置用系统材质、不许叠、每一处都要登记
} }
// 导航条那一处必须真的还在(形状判定之外,位置本身也要在) // 导航条那一处必须真的还在(形状判定之外,位置本身也要在)
const main = code(readFileSync(join(ROOT, 'client/harmony/entry/src/main/ets/pages/MainPage.ets'), 'utf8')); const main = code(join(ROOT, 'client/harmony/entry/src/main/ets/pages/MainPage.ets'));
assert.match(main, /\.backgroundBlurStyle\(Theme\.navMaterial\)/, '导航条要用系统材质(不是手写 alpha'); assert.match(main, /\.backgroundBlurStyle\(Theme\.navMaterial\)/, '导航条要用系统材质(不是手写 alpha');
/* /*
@ -439,22 +443,19 @@ test('★ 品牌色防线:主操作色不得退化成系统强调色', () => {
*/ */
assert.match(harmony, /accent: string = '#2563EB'/, '品牌蓝必须仍是 WebUI 的那个值'); assert.match(harmony, /accent: string = '#2563EB'/, '品牌蓝必须仍是 WebUI 的那个值');
assert.ok( assert.ok(
!/accent: Resource/.test(code(harmony)), !/accent: Resource/.test(stripComments(harmony)),
'accent 变成系统资源了 —— 品牌色跟着系统变就不再是同一个产品的标识' 'accent 变成系统资源了 —— 品牌色跟着系统变就不再是同一个产品的标识'
); );
// 主操作/选中态仍引用它(否则"没退化"只是因为它没被用 —— 值留着也没意义) // 主操作/选中态仍引用它(否则"没退化"只是因为它没被用 —— 值留着也没意义)
const login = code(readFileSync(join(ROOT, 'client/harmony/entry/src/main/ets/pages/LoginPage.ets'), 'utf8')); const login = code(join(ROOT, 'client/harmony/entry/src/main/ets/pages/LoginPage.ets'));
assert.match(login, /backgroundColor\(Theme\.accent\)/, '登录按钮仍是品牌色'); assert.match(login, /backgroundColor\(Theme\.accent\)/, '登录按钮仍是品牌色');
const main = code(readFileSync(join(ROOT, 'client/harmony/entry/src/main/ets/pages/MainPage.ets'), 'utf8')); const main = code(join(ROOT, 'client/harmony/entry/src/main/ets/pages/MainPage.ets'));
assert.match(main, /backgroundColor\(Theme\.accent\)/, '主操作按钮/选中态仍是品牌色'); assert.match(main, /backgroundColor\(Theme\.accent\)/, '主操作按钮/选中态仍是品牌色');
}); });
test('权限档位徽标两边同一套色plan=蓝 / workspace=绿 / full=琥珀)', () => { test('权限档位徽标两边同一套色plan=蓝 / workspace=绿 / full=琥珀)', () => {
// WebUI 的映射写在 PermissionChip.tsx 的类名里;鸿蒙的映射是 Theme.permBg/permFg。 // WebUI 的映射写在 PermissionChip.tsx 的类名里;鸿蒙的映射是 Theme.permBg/permFg。
const chip = readFileSync( const chip = code(join(ROOT, 'client/electron/src/components/PermissionChip.tsx'));
join(ROOT, 'client/electron/src/components/PermissionChip.tsx'),
'utf8'
);
assert.match(chip, /plan'[\s\S]{0,80}bg-blue-50 text-blue-700/, 'WebUI plan 档应是蓝'); assert.match(chip, /plan'[\s\S]{0,80}bg-blue-50 text-blue-700/, 'WebUI plan 档应是蓝');
assert.match(chip, /full'[\s\S]{0,80}bg-amber-50 text-amber-700/, 'WebUI full 档应是琥珀'); assert.match(chip, /full'[\s\S]{0,80}bg-amber-50 text-amber-700/, 'WebUI full 档应是琥珀');
assert.match(chip, /bg-green-50 text-green-700/, 'WebUI workspace 档应是绿'); assert.match(chip, /bg-green-50 text-green-700/, 'WebUI workspace 档应是绿');
@ -513,10 +514,10 @@ test('遮罩:交给系统的遮罩语义色("随主题换向"这件事现在
* 深浅两套值由系统给,而且不会再漏配一边 —— 比我们自己维护两个常量更不容易错。 * 深浅两套值由系统给,而且不会再漏配一边 —— 比我们自己维护两个常量更不容易错。
* 所以口径改成"遮罩来自系统"这条是硬的WebUI 侧仍然两段式(它没有系统可跟随)。 * 所以口径改成"遮罩来自系统"这条是硬的WebUI 侧仍然两段式(它没有系统可跟随)。
*/ */
assert.match(code(harmony), /overlay: Resource = \$r\('sys\.color\.ohos_id_color_mask_regular'\)/, '鸿蒙遮罩要用系统遮罩色'); assert.match(stripComments(harmony), /overlay: Resource = \$r\('sys\.color\.ohos_id_color_mask_regular'\)/, '鸿蒙遮罩要用系统遮罩色');
// 不许再自己维护"色 + 透明度"两个常量(那正是系统已经替我们做掉的事) // 不许再自己维护"色 + 透明度"两个常量(那正是系统已经替我们做掉的事)
assert.ok(!/overlayColor: string/.test(code(harmony)), '遮罩色不该再由我们自己定'); assert.ok(!/overlayColor: string/.test(stripComments(harmony)), '遮罩色不该再由我们自己定');
assert.ok(!/overlayAlpha: number/.test(code(harmony)), '遮罩透明度不该再由我们自己定'); assert.ok(!/overlayAlpha: number/.test(stripComments(harmony)), '遮罩透明度不该再由我们自己定');
/* /*
* pi 读出来的第四条:判据只断言 `overlay` **存在**,没断言它**被用** —— * pi 读出来的第四条:判据只断言 `overlay` **存在**,没断言它**被用** ——
@ -528,13 +529,13 @@ test('遮罩:交给系统的遮罩语义色("随主题换向"这件事现在
const themePath = join(HARMONY_ETS, 'common/Theme.ets'); const themePath = join(HARMONY_ETS, 'common/Theme.ets');
const overlayUsers = collectEts(etsRoot) const overlayUsers = collectEts(etsRoot)
.filter(f => f !== themePath) .filter(f => f !== themePath)
.filter(f => /Theme\.overlay\b/.test(code(readFileSync(f, 'utf8')))) .filter(f => /Theme\.overlay\b/.test(code(f)))
.map(f => f.slice(etsRoot.length + 1)); .map(f => f.slice(etsRoot.length + 1));
assert.ok(overlayUsers.length > 0, assert.ok(overlayUsers.length > 0,
'Theme.overlay 声明了却没有任何使用点 —— 那就是个死令牌(要么删掉,要么写出它的使用处)'); 'Theme.overlay 声明了却没有任何使用点 —— 那就是个死令牌(要么删掉,要么写出它的使用处)');
// 用它的必须是**自绘遮罩**的地方(系统自带遮罩的弹窗不需要它) // 用它的必须是**自绘遮罩**的地方(系统自带遮罩的弹窗不需要它)
assert.ok(overlayUsers.every(f => f.endsWith('.ets')), `遮罩使用点应该是页面:${overlayUsers.join('、')}`); assert.ok(overlayUsers.every(f => f.endsWith('.ets')), `遮罩使用点应该是页面:${overlayUsers.join('、')}`);
assert.ok(!/#[0-9A-Fa-f]{8}/.test(code(harmony)), '遮罩不该再写成色与透明度焊死的 #AARRGGBB 单值'); assert.ok(!/#[0-9A-Fa-f]{8}/.test(stripComments(harmony)), '遮罩不该再写成色与透明度焊死的 #AARRGGBB 单值');
// WebUI 侧同构:颜色两套(浅/深,同一个变量名换向)+ 透明度独立 // WebUI 侧同构:颜色两套(浅/深,同一个变量名换向)+ 透明度独立
assert.match(web, /--bg-scrim: 255 255 255/, 'WebUI 浅色遮罩色'); assert.match(web, /--bg-scrim: 255 255 255/, 'WebUI 浅色遮罩色');
assert.match(web, /--bg-scrim: 0 0 0/, 'WebUI 深色遮罩色'); assert.match(web, /--bg-scrim: 0 0 0/, 'WebUI 深色遮罩色');
@ -624,7 +625,7 @@ test('★ 手写色清册**跨文件**:全 ets 树里每个 `X: string = \'#RR
const declared = []; const declared = [];
for (const f of tree) { for (const f of tree) {
const rel = f.slice(HARMONY_ETS.length + 1); const rel = f.slice(HARMONY_ETS.length + 1);
const src = readFileSync(f, 'utf8').replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, ''); const src = prose(f).replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '');
for (const m of src.matchAll(/(\w+)\s*:\s*string\s*=\s*'(#[0-9A-Fa-f]{6})'/g)) { for (const m of src.matchAll(/(\w+)\s*:\s*string\s*=\s*'(#[0-9A-Fa-f]{6})'/g)) {
declared.push({ file: rel, name: m[1], value: m[2] }); declared.push({ file: rel, name: m[1], value: m[2] });
} }

View File

@ -9,9 +9,9 @@
* 而单测全绿 —— 因为测试只断言了方法与报文,没断言 URL路径多写了一层 `/api/v1`)。 * 而单测全绿 —— 因为测试只断言了方法与报文,没断言 URL路径多写了一层 `/api/v1`)。
* 所以这里有一条判据专门钉路径,而且钉的是**相对基地址**的形状。 * 所以这里有一条判据专门钉路径,而且钉的是**相对基地址**的形状。
*/ */
import { code, prose } from './lib/read.mjs';
import { test } from 'node:test'; import { test } from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { readdirSync } from 'node:fs'; import { readdirSync } from 'node:fs';
import { dirname, join } from 'node:path'; import { dirname, join } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url'; import { fileURLToPath, pathToFileURL } from 'node:url';
@ -26,8 +26,7 @@ const MODULE_TS = join(HARMONY_ETS, 'model/Appearance.ts');
const A = await import(pathToFileURL(MODULE_TS).href); const A = await import(pathToFileURL(MODULE_TS).href);
/** 剥注释读源码:注释里出现某个调用恰恰说明不了那个调用存在 */ /** 剥注释读源码:注释里出现某个调用恰恰说明不了那个调用存在 */
const code = (src) => src.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, ''); const read = (rel) => code(join(HARMONY_ETS, rel));
const read = (rel) => code(readFileSync(join(HARMONY_ETS, rel), 'utf8'));
const snap = (over = {}) => Object.assign(new A.AppearanceSnapshot(), over); const snap = (over = {}) => Object.assign(new A.AppearanceSnapshot(), over);
const resp = (over = {}) => Object.assign(new A.AppearanceResponse(), over); const resp = (over = {}) => Object.assign(new A.AppearanceResponse(), over);
@ -147,8 +146,10 @@ test('★ 模糊值映射到**系统材质档次**(不是把 40 当半径塞
assert.equal(A.blurStyleFor(-3), 'NONE'); assert.equal(A.blurStyleFor(-3), 'NONE');
// 档次必须来自系统枚举(写成自造名字会编译不过/不生效) // 档次必须来自系统枚举(写成自造名字会编译不过/不生效)
const sdk = A.blurStyleFor(12); const sdk = A.blurStyleFor(12);
const commonDts = readFileSync(process.env.HARMONY_COMMON_DTS // SDK 的 .d.ts 是**源码**(判它的枚举成员),所以走 code():剥掉注释,
|| '/opt/huawei/command-line-tools/sdk/default/openharmony/ets/component/common.d.ts', 'utf8'); // 免得注释里举例的枚举名被当成员读进来
const commonDts = code(process.env.HARMONY_COMMON_DTS
|| '/opt/huawei/command-line-tools/sdk/default/openharmony/ets/component/common.d.ts');
const enumBlock = commonDts.slice(commonDts.indexOf('declare enum BlurStyle')); const enumBlock = commonDts.slice(commonDts.indexOf('declare enum BlurStyle'));
const members = [...enumBlock.slice(0, enumBlock.indexOf('}')).matchAll(/^\s{2,}([A-Za-z][A-Za-z_0-9]*)\s*[,=]/gm)].map(m => m[1]); const members = [...enumBlock.slice(0, enumBlock.indexOf('}')).matchAll(/^\s{2,}([A-Za-z][A-Za-z_0-9]*)\s*[,=]/gm)].map(m => m[1]);
assert.ok(members.length > 3, '要从 SDK 里读到 BlurStyle 成员'); assert.ok(members.length > 3, '要从 SDK 里读到 BlurStyle 成员');
@ -168,7 +169,7 @@ test('主题 → **系统色彩模式**(深浅两套颜色由系统给,不
* `COLOR_MODE_DARK = 0`、`COLOR_MODE_LIGHT = 1`。判据直接读 SDK 的枚举文件比对, * `COLOR_MODE_DARK = 0`、`COLOR_MODE_LIGHT = 1`。判据直接读 SDK 的枚举文件比对,
* 不凭印象(我第一版就是按 0=浅色 写的,选深色会切成浅色)。 * 不凭印象(我第一版就是按 0=浅色 写的,选深色会切成浅色)。
*/ */
const constDts = readFileSync(process.env.HARMONY_CONFIG_CONSTANT_DTS const constDts = code(process.env.HARMONY_CONFIG_CONSTANT_DTS
|| '/opt/huawei/command-line-tools/sdk/default/openharmony/ets/api/@ohos.app.ability.ConfigurationConstant.d.ts', 'utf8'); || '/opt/huawei/command-line-tools/sdk/default/openharmony/ets/api/@ohos.app.ability.ConfigurationConstant.d.ts', 'utf8');
const valueOf = (name) => { const valueOf = (name) => {
const m = new RegExp(`${name}\\s*=\\s*(-?\\d+)`).exec(constDts); const m = new RegExp(`${name}\\s*=\\s*(-?\\d+)`).exec(constDts);
@ -253,8 +254,8 @@ export const __coverage = ['snapshotFromResponse', 'payloadFromLocal', 'mergeApp
const WALL_TS = join(HARMONY_ETS, 'model/Wallpaper.ts'); const WALL_TS = join(HARMONY_ETS, 'model/Wallpaper.ts');
const W = await import(pathToFileURL(WALL_TS).href); const W = await import(pathToFileURL(WALL_TS).href);
const webCss = readFileSync(join(ROOT, 'client/electron/src/index.css'), 'utf8'); const webCss = code(join(ROOT, 'client/electron/src/index.css'));
const webBgStore = readFileSync(join(ROOT, 'client/electron/src/stores/backgroundStore.ts'), 'utf8'); const webBgStore = code(join(ROOT, 'client/electron/src/stores/backgroundStore.ts'));
test('★ 预设清单与 WebUI 一致id + 顺序 + 中文标签)—— 少一个档就是"用户设了在鸿蒙看不到"', () => { test('★ 预设清单与 WebUI 一致id + 顺序 + 中文标签)—— 少一个档就是"用户设了在鸿蒙看不到"', () => {
/* /*
@ -434,10 +435,10 @@ test('★ 模糊归属:壁纸层**不许**再模糊,导航条必须有系统
const store = read('common/AppearanceStore.ets'); const store = read('common/AppearanceStore.ets');
assert.match(store, /colorModeValue\(theme\)/, '主题走系统色彩模式'); assert.match(store, /colorModeValue\(theme\)/, '主题走系统色彩模式');
/* /*
* "理由写清了没"要读**原文**`read()` 会剥注释 —— 而理由就在注释里)。 * "理由写清了没"要读**原文**`prose()``code()`/`read()` 会剥注释 —— 而理由就在注释里)。
* 剥注释读源码是为了防"注释里的调用被当成真调用",但断言"注释里写了理由"时正好相反。 * 剥注释读源码是为了防"注释里的调用被当成真调用",但断言"注释里写了理由"时正好相反。
*/ */
const wallRaw = readFileSync(join(HARMONY_ETS, 'model/Wallpaper.ts'), 'utf8'); const wallRaw = prose(join(HARMONY_ETS, 'model/Wallpaper.ts')); // 理由在注释里 → prose见上
assert.match(wallRaw, /系统没有对应的渐变原语/, '网格档为什么用 Canvas 要写清(否则以后会被当成绕开系统方案)'); assert.match(wallRaw, /系统没有对应的渐变原语/, '网格档为什么用 Canvas 要写清(否则以后会被当成绕开系统方案)');
assert.match(wallRaw, /repeating-linear-gradient/, '要指名道姓写出 CSS 用的是哪个原语(后人查得到)'); assert.match(wallRaw, /repeating-linear-gradient/, '要指名道姓写出 CSS 用的是哪个原语(后人查得到)');
}); });
@ -559,7 +560,7 @@ test('★ isDarkModesystem 要看系统当时的深浅,读不到时按浅
assert.equal(A.isDarkMode('system', 1), false, 'system + 系统浅色 → 浅色色板'); assert.equal(A.isDarkMode('system', 1), false, 'system + 系统浅色 → 浅色色板');
assert.equal(A.isDarkMode('system', -1), false, '系统还没定NOT_SET→ 按浅色,与 WebUI 的 :root 默认一致'); assert.equal(A.isDarkMode('system', -1), false, '系统还没定NOT_SET→ 按浅色,与 WebUI 的 :root 默认一致');
// 数值锚到 SDK0=深、1=浅 // 数值锚到 SDK0=深、1=浅
const sdkConst = readFileSync(join(CLT, 'sdk/default/openharmony/ets/api/@ohos.app.ability.ConfigurationConstant.d.ts'), 'utf8'); const sdkConst = code(join(CLT, 'sdk/default/openharmony/ets/api/@ohos.app.ability.ConfigurationConstant.d.ts'));
assert.match(sdkConst, /COLOR_MODE_DARK = 0/, 'SDK 里深色是 0别记反'); assert.match(sdkConst, /COLOR_MODE_DARK = 0/, 'SDK 里深色是 0别记反');
assert.match(sdkConst, /COLOR_MODE_LIGHT = 1/, 'SDK 里浅色是 1'); assert.match(sdkConst, /COLOR_MODE_LIGHT = 1/, 'SDK 里浅色是 1');
// 页面真的按主题选色板(不是写死 false // 页面真的按主题选色板(不是写死 false
@ -596,7 +597,7 @@ test('★ 预设档的遮盖两档同一个浓度WebUI 的 --bg-dim 不区
} }
} }
// WebUI 侧的前提:遮罩真的不区分档位(否则"对齐"就没有依据) // WebUI 侧的前提:遮罩真的不区分档位(否则"对齐"就没有依据)
const bgStore = readFileSync(join(ROOT, 'client/electron/src/stores/backgroundStore.ts'), 'utf8'); const bgStore = code(join(ROOT, 'client/electron/src/stores/backgroundStore.ts'));
assert.match(bgStore, /--bg-dim|setProperty\('--bg-dim'/, 'WebUI 要无条件写 --bg-dim这是"两档都压"的依据)'); assert.match(bgStore, /--bg-dim|setProperty\('--bg-dim'/, 'WebUI 要无条件写 --bg-dim这是"两档都压"的依据)');
const main = read('pages/MainPage.ets'); const main = read('pages/MainPage.ets');
// 两档各有一处遮盖层(都用系统遮罩色 + 算出来的浓度) // 两档各有一处遮盖层(都用系统遮罩色 + 算出来的浓度)
@ -636,7 +637,7 @@ test('★ 多账号缓存键:**按账号**分(鸿蒙是对的,不许为"
* 2026-09-14 WebUI 侧也按账号分键了dsh 接手 pi 的两个开项),**差异已消除** —— * 2026-09-14 WebUI 侧也按账号分键了dsh 接手 pi 的两个开项),**差异已消除** ——
* 所以现在断言的是"两端都是按账号的键",而且不允许任何一端退回全局键。 * 所以现在断言的是"两端都是按账号的键",而且不允许任何一端退回全局键。
*/ */
const webStore = readFileSync(join(ROOT, 'client/electron/src/stores/backgroundStore.ts'), 'utf8'); const webStore = code(join(ROOT, 'client/electron/src/stores/backgroundStore.ts'));
assert.match(webStore, /export function storageKey\(/, 'WebUI 也要有按账号取键的函数'); assert.match(webStore, /export function storageKey\(/, 'WebUI 也要有按账号取键的函数');
assert.match(webStore, /LEGACY_STORAGE_KEY/, '旧全局键只作为迁移源存在'); assert.match(webStore, /LEGACY_STORAGE_KEY/, '旧全局键只作为迁移源存在');
assert.ok(!/setItem\('agentmail\.background'/.test(webStore), 'WebUI 也不许再往全局键写'); assert.ok(!/setItem\('agentmail\.background'/.test(webStore), 'WebUI 也不许再往全局键写');
@ -665,7 +666,7 @@ test('★ 主题变化时**我们自己算的值**要跟着重算pi 的规则
} }
assert.ok(/applyAppearance\(\)/.test(main.slice(watchAt, end + 400)), '回调里要重算外观(色板属于"我们算的"'); assert.ok(/applyAppearance\(\)/.test(main.slice(watchAt, end + 400)), '回调里要重算外观(色板属于"我们算的"');
// SDK 锚点:这个 API 与回调形状不能凭记忆写 // SDK 锚点:这个 API 与回调形状不能凭记忆写
const sdk = readFileSync(join(CLT, 'sdk/default/openharmony/ets/api/@ohos.app.ability.EnvironmentCallback.d.ts'), 'utf8'); const sdk = code(join(CLT, 'sdk/default/openharmony/ets/api/@ohos.app.ability.EnvironmentCallback.d.ts'));
assert.match(sdk, /onConfigurationUpdated\(config: Configuration\): void/, 'SDK 里回调是 onConfigurationUpdated别写错名字'); assert.match(sdk, /onConfigurationUpdated\(config: Configuration\): void/, 'SDK 里回调是 onConfigurationUpdated别写错名字');
}); });
@ -673,8 +674,8 @@ test('★ 主题变化时**我们自己算的值**要跟着重算pi 的规则
/** 从 SDK 读出 `sys.color.ohos_id_color_*` 的真实值名字→id 取编译器那张表id→值取预览器那张 */ /** 从 SDK 读出 `sys.color.ohos_id_color_*` 的真实值名字→id 取编译器那张表id→值取预览器那张 */
function sdkSystemColors() { function sdkSystemColors() {
const sysRes = readFileSync(join(CLT, 'sdk/default/openharmony/ets/build-tools/ets-loader/sysResource.js'), 'utf8'); const sysRes = code(join(CLT, 'sdk/default/openharmony/ets/build-tools/ets-loader/sysResource.js'));
const resTxt = readFileSync(join(CLT, 'sdk/default/openharmony/previewer/common/resources/entry/resources.txt'), 'utf8'); const resTxt = code(join(CLT, 'sdk/default/openharmony/previewer/common/resources/entry/resources.txt'));
const name2id = new Map(); const name2id = new Map();
for (const m of sysRes.matchAll(/'?(ohos_id_color_[a-z_]+)'?:\s*(\d+)/g)) { for (const m of sysRes.matchAll(/'?(ohos_id_color_[a-z_]+)'?:\s*(\d+)/g)) {
if (!name2id.has(m[1])) name2id.set(m[1], Number(m[2])); if (!name2id.has(m[1])) name2id.set(m[1], Number(m[2]));
@ -728,14 +729,14 @@ test('★ 遮盖色方向:`mask_*` 两套主题下都是**深色**(模态遮
assert.ok(luminanceOf(bgDark) < 0.2, `ohos_id_color_background_dark 应当近黑(实测 ${bgDark}`); assert.ok(luminanceOf(bgDark) < 0.2, `ohos_id_color_background_dark 应当近黑(实测 ${bgDark}`);
// 事实三WebUI 的遮罩方向是"朝底色淡化"(浅色白、深色黑)—— 与页面底色系同向、与 mask 反向 // 事实三WebUI 的遮罩方向是"朝底色淡化"(浅色白、深色黑)—— 与页面底色系同向、与 mask 反向
const webCss = readFileSync(join(ROOT, 'client/electron/src/index.css'), 'utf8'); const webCss = code(join(ROOT, 'client/electron/src/index.css'));
const scrimLight = /--bg-scrim:\s*(\d+)\s+(\d+)\s+(\d+);/.exec(webCss); const scrimLight = /--bg-scrim:\s*(\d+)\s+(\d+)\s+(\d+);/.exec(webCss);
assert.ok(scrimLight, 'CSS 里要有 --bg-scrim'); assert.ok(scrimLight, 'CSS 里要有 --bg-scrim');
assert.deepEqual([scrimLight[1], scrimLight[2], scrimLight[3]], ['255', '255', '255'], assert.deepEqual([scrimLight[1], scrimLight[2], scrimLight[3]], ['255', '255', '255'],
'WebUI 浅色下的遮罩是**白**(把图案洗淡);这是"必须用页面底色系"的依据'); 'WebUI 浅色下的遮罩是**白**(把图案洗淡);这是"必须用页面底色系"的依据');
// 结论落到代码:壁纸遮盖用页面底色系令牌,且**不是** mask // 结论落到代码:壁纸遮盖用页面底色系令牌,且**不是** mask
const theme = readFileSync(join(HARMONY_ETS, 'common/Theme.ets'), 'utf8'); const theme = code(join(HARMONY_ETS, 'common/Theme.ets'));
assert.match(theme, /static readonly wallpaperScrim: Resource = \$r\('sys\.color\.ohos_id_color_background'\)/, assert.match(theme, /static readonly wallpaperScrim: Resource = \$r\('sys\.color\.ohos_id_color_background'\)/,
'壁纸遮盖色要用页面底色系ohos_id_color_background'); '壁纸遮盖色要用页面底色系ohos_id_color_background');
assert.match(theme, /static readonly overlay: Resource = \$r\('sys\.color\.ohos_id_color_mask_regular'\)/, assert.match(theme, /static readonly overlay: Resource = \$r\('sys\.color\.ohos_id_color_mask_regular'\)/,

View File

@ -18,9 +18,10 @@
* 与 WebUI 的对应物:`src/lib/mailGroups.ts`(折叠 / isFlatGroup * 与 WebUI 的对应物:`src/lib/mailGroups.ts`(折叠 / isFlatGroup
* `src/components/WorkCard.tsx` 的 `BudgetChip`(预算档位)、`ContactPanel.tsx`(视图切换)。 * `src/components/WorkCard.tsx` 的 `BudgetChip`(预算档位)、`ContactPanel.tsx`(视图切换)。
*/ */
import { code, prose } from './lib/read.mjs';
import { test } from 'node:test'; import { test } from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { readFileSync, readdirSync } from 'node:fs'; import { readdirSync } from 'node:fs';
import { dirname, join } from 'node:path'; import { dirname, join } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url'; import { fileURLToPath, pathToFileURL } from 'node:url';
@ -34,7 +35,7 @@ const COMM_TS = join(HARMONY_ETS, 'model/CommTabs.ts');
const H = await import(pathToFileURL(MODULE_TS).href); const H = await import(pathToFileURL(MODULE_TS).href);
const C = await import(pathToFileURL(COMM_TS).href); const C = await import(pathToFileURL(COMM_TS).href);
const page = readFileSync(join(HARMONY_ETS, 'pages/MainPage.ets'), 'utf8'); const page = code(join(HARMONY_ETS, 'pages/MainPage.ets'));
/** /**
* 断言一律读**剥掉注释的源码**。 * 断言一律读**剥掉注释的源码**。
* *
@ -44,8 +45,8 @@ const page = readFileSync(join(HARMONY_ETS, 'pages/MainPage.ets'), 'utf8');
* 已经用过一次(遮罩那段注释里引用了旧值),这里统一成常态。 * 已经用过一次(遮罩那段注释里引用了旧值),这里统一成常态。
*/ */
const pageCode = page.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, ''); const pageCode = page.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '');
const webGroups = readFileSync(join(ROOT, 'client/electron/src/lib/mailGroups.ts'), 'utf8'); const webGroups = code(join(ROOT, 'client/electron/src/lib/mailGroups.ts'));
const webCard = readFileSync(join(ROOT, 'client/electron/src/components/WorkCard.tsx'), 'utf8'); const webCard = code(join(ROOT, 'client/electron/src/components/WorkCard.tsx'));
/** 造一封邮件(只带折叠/渲染用得到的字段,字段名与 Models.ets 的 MailSummary 一致) */ /** 造一封邮件(只带折叠/渲染用得到的字段,字段名与 Models.ets 的 MailSummary 一致) */
const mail = (over) => Object.assign( const mail = (over) => Object.assign(
@ -235,7 +236,7 @@ test('底部只剩 通信 / 联系人 两个平级页签,「会话」不再是
*/ */
const navLabels = [...pageCode.matchAll(/NAV_ITEMS\.map\([^)]*\.label\)|NAV_ITEMS/g)].length; const navLabels = [...pageCode.matchAll(/NAV_ITEMS\.map\([^)]*\.label\)|NAV_ITEMS/g)].length;
assert.ok(navLabels > 0, '底栏要由 NAV_ITEMS 驱动'); assert.ok(navLabels > 0, '底栏要由 NAV_ITEMS 驱动');
const navSource = readFileSync(join(HARMONY_ETS, 'model/NavItems.ts'), 'utf8'); const navSource = code(join(HARMONY_ETS, 'model/NavItems.ts'));
const labels = [...navSource.matchAll(/label:\s*'([^']+)'/g)].map(m => m[1]); const labels = [...navSource.matchAll(/label:\s*'([^']+)'/g)].map(m => m[1]);
assert.deepEqual(labels, ['通信', '联系人'], `平级项应只剩两个,实际:${labels.join('、')}`); assert.deepEqual(labels, ['通信', '联系人'], `平级项应只剩两个,实际:${labels.join('、')}`);
assert.ok(!/struct\s+SessionsTab/.test(pageCode), 'SessionsTab 已经撤了,不该再留在页面里'); assert.ok(!/struct\s+SessionsTab/.test(pageCode), 'SessionsTab 已经撤了,不该再留在页面里');
@ -265,7 +266,7 @@ test('卡片视图的字段集与 WebUI 的 WorkCard 一致(撤 tab 后"信息
// ───────────────────────── 权限档位 + 强制力(与 WebUI PermissionChip 同文案) ───────────────────────── // ───────────────────────── 权限档位 + 强制力(与 WebUI PermissionChip 同文案) ─────────────────────────
test('档位标签与强制力标记:和 WebUI 的 MODE_LABEL / ENFORCEMENT_LABEL 逐字一致', async () => { test('档位标签与强制力标记:和 WebUI 的 MODE_LABEL / ENFORCEMENT_LABEL 逐字一致', async () => {
const webChip = readFileSync(join(ROOT, 'client/electron/src/components/PermissionChip.tsx'), 'utf8'); const webChip = code(join(ROOT, 'client/electron/src/components/PermissionChip.tsx'));
const labelOf = (name, src) => { const labelOf = (name, src) => {
const block = src.slice(src.indexOf(`const ${name}`), src.indexOf('};', src.indexOf(`const ${name}`))); const block = src.slice(src.indexOf(`const ${name}`), src.indexOf('};', src.indexOf(`const ${name}`)));
return new Map([...block.matchAll(/(\w+):\s*'([^']+)'/g)].map(m => [m[1], m[2]])); return new Map([...block.matchAll(/(\w+):\s*'([^']+)'/g)].map(m => [m[1], m[2]]));
@ -300,7 +301,7 @@ test('★ 档位说明文案与 WebUI permissionModeHint 逐字一致(两边
* 再要求鸿蒙的 permissionHint 对 3 档 × 3 强制力(外加空/未知)给出的每一句话 * 再要求鸿蒙的 permissionHint 对 3 档 × 3 强制力(外加空/未知)给出的每一句话
* 都在那个集合里 —— 任一边改了口径就红。 * 都在那个集合里 —— 任一边改了口径就红。
*/ */
const webChip = readFileSync(join(ROOT, 'client/electron/src/components/PermissionChip.tsx'), 'utf8'); const webChip = code(join(ROOT, 'client/electron/src/components/PermissionChip.tsx'));
const fn = webChip.slice(webChip.indexOf('export function permissionModeHint')); const fn = webChip.slice(webChip.indexOf('export function permissionModeHint'));
const body = fn.slice(0, fn.indexOf('\n}')); const body = fn.slice(0, fn.indexOf('\n}'));
const webHints = [...body.matchAll(/return\s+((?:'(?:[^'\\]|\\.)*'\s*\+?\s*)+);/g)].map(m => const webHints = [...body.matchAll(/return\s+((?:'(?:[^'\\]|\\.)*'\s*\+?\s*)+);/g)].map(m =>
@ -393,7 +394,7 @@ test('★ 不得再用废弃的全局 promptAction.showToastAPI 18 起废弃
const bad = []; const bad = [];
for (const f of files) { for (const f of files) {
const src = readFileSync(f, 'utf8').replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, ''); const src = prose(f).replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '');
// `getPromptAction().showToast(` 不算违规:它前面必须有 `get` // `getPromptAction().showToast(` 不算违规:它前面必须有 `get`
for (const m of src.matchAll(/(?<!get)promptAction\.showToast\(/g)) bad.push(f.slice(ROOT.length + 1)); for (const m of src.matchAll(/(?<!get)promptAction\.showToast\(/g)) bad.push(f.slice(ROOT.length + 1));
} }
@ -405,10 +406,10 @@ test('★ 不得再用废弃的全局 promptAction.showToastAPI 18 起废弃
// ───────────────────── 通信页:内部页签 / 徽标 / 三栏分家P2b ───────────────────── // ───────────────────── 通信页:内部页签 / 徽标 / 三栏分家P2b ─────────────────────
const webUiStore = readFileSync(join(ROOT, 'client/electron/src/stores/uiStore.ts'), 'utf8'); const webUiStore = code(join(ROOT, 'client/electron/src/stores/uiStore.ts'));
const webCommTabs = readFileSync(join(ROOT, 'client/electron/src/components/CommTabs.tsx'), 'utf8'); const webCommTabs = code(join(ROOT, 'client/electron/src/components/CommTabs.tsx'));
const webMailList = readFileSync(join(ROOT, 'client/electron/src/components/MailList.tsx'), 'utf8'); const webMailList = code(join(ROOT, 'client/electron/src/components/MailList.tsx'));
const sendCode = readFileSync(join(HARMONY_ETS, 'pages/MainPage.ets'), 'utf8') const sendCode = code(join(HARMONY_ETS, 'pages/MainPage.ets'))
.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, ''); .replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '');
test('通信页的页签键与顺序,与 WebUI 的 CommTab / TABS 完全一致', () => { test('通信页的页签键与顺序,与 WebUI 的 CommTab / TABS 完全一致', () => {
@ -509,7 +510,7 @@ test('三栏的空态都有说明,且主句与 WebUI 的「暂无邮件」逐
test('通信页把三栏真的接上了:内部页签 + 徽标 + 悬浮加号 + 收件箱按权限分家', () => { test('通信页把三栏真的接上了:内部页签 + 徽标 + 悬浮加号 + 收件箱按权限分家', () => {
// 底部第一项的标签是「通信」而不是「收件箱」(信息架构变了,标签必须跟着变) // 底部第一项的标签是「通信」而不是「收件箱」(信息架构变了,标签必须跟着变)
// P5底栏标签现在来自 NAV_ITEMS自绘浮动条不是 TabBarBuilder 的参数 // P5底栏标签现在来自 NAV_ITEMS自绘浮动条不是 TabBarBuilder 的参数
const navSource2 = readFileSync(join(HARMONY_ETS, 'model/NavItems.ts'), 'utf8'); const navSource2 = code(join(HARMONY_ETS, 'model/NavItems.ts'));
const tabLabels = [...navSource2.matchAll(/label:\s*'([^']+)'/g)].map(m => m[1]); const tabLabels = [...navSource2.matchAll(/label:\s*'([^']+)'/g)].map(m => m[1]);
assert.deepEqual(tabLabels, ['通信', '联系人'], `底部应只剩两项且第一项是通信,实际:${tabLabels.join('、')}`); assert.deepEqual(tabLabels, ['通信', '联系人'], `底部应只剩两项且第一项是通信,实际:${tabLabels.join('、')}`);
// 通信页现在还要收一个 `bgActive`(背景开着时让出页面底,否则壁纸全被盖住)—— // 通信页现在还要收一个 `bgActive`(背景开着时让出页面底,否则壁纸全被盖住)——
@ -543,7 +544,7 @@ test('通信页把三栏真的接上了:内部页签 + 徽标 + 悬浮加号 +
}); });
test('发件箱与授权栏走的是与 WebUI 相同的接口(路径、字段、备注都要对)', () => { test('发件箱与授权栏走的是与 WebUI 相同的接口(路径、字段、备注都要对)', () => {
const api = readFileSync(join(HARMONY_ETS, 'api/MailApi.ets'), 'utf8') const api = code(join(HARMONY_ETS, 'api/MailApi.ets'))
.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, ''); .replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '');
assert.match(api, /get<SentResponse>\('\/me\/mail\/sent'\)/, '发件箱接口'); assert.match(api, /get<SentResponse>\('\/me\/mail\/sent'\)/, '发件箱接口');
assert.match(api, /get<PendingResponse>\('\/permission\/pending'\)/, '待决列表接口(不从收件箱筛)'); assert.match(api, /get<PendingResponse>\('\/permission\/pending'\)/, '待决列表接口(不从收件箱筛)');
@ -566,6 +567,6 @@ test('★ 判据自检:把状态机的默认页签改错必须判红', () => {
// 自检方式:直接改源码字符串,确认断言会红(而不是"看起来能红" // 自检方式:直接改源码字符串,确认断言会红(而不是"看起来能红"
const mutated = sendCode.replace("this.commTab = normalizeCommTab(key);", "this.commTab = key;"); const mutated = sendCode.replace("this.commTab = normalizeCommTab(key);", "this.commTab = key;");
assert.ok(!/this\.commTab = normalizeCommTab\(key\)/.test(mutated), '自检:变异没生效'); assert.ok(!/this\.commTab = normalizeCommTab\(key\)/.test(mutated), '自检:变异没生效');
const defaultMutated = readFileSync(COMM_TS, 'utf8').replace("export const COMM_TAB_DEFAULT: string = 'inbox';", "export const COMM_TAB_DEFAULT: string = 'sent';"); const defaultMutated = prose(COMM_TS).replace("export const COMM_TAB_DEFAULT: string = 'inbox';", "export const COMM_TAB_DEFAULT: string = 'sent';");
assert.ok(!/COMM_TAB_DEFAULT: string = 'inbox'/.test(defaultMutated), '自检:默认页签变异没生效'); assert.ok(!/COMM_TAB_DEFAULT: string = 'inbox'/.test(defaultMutated), '自检:默认页签变异没生效');
}); });

View File

@ -8,7 +8,7 @@
// ③ 命中区≥44vp —— 从 `NavItems.ts` **导入数值**判,不拿正则去源码里猜; // ③ 命中区≥44vp —— 从 `NavItems.ts` **导入数值**判,不拿正则去源码里猜;
// ④ 悬浮与让位:条是自绘的浮动层(留白 + 圆角 + 系统材质), // ④ 悬浮与让位:条是自绘的浮动层(留白 + 圆角 + 系统材质),
// 且内容底部让出的高度 ≥ 条高 + 离底留白(否则最后一行压在条底下:看得见、点不到)。 // 且内容底部让出的高度 ≥ 条高 + 离底留白(否则最后一行压在条底下:看得见、点不到)。
import { readFileSync } from 'node:fs'; import { code, prose, stripComments } from './lib/read.mjs';
import { dirname, join } from 'node:path'; import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import { test } from 'node:test'; import { test } from 'node:test';
@ -19,9 +19,8 @@ const ROOT = join(HERE, '..', '..', '..');
const HARMONY_ETS = join(ROOT, 'client/harmony/entry/src/main/ets'); const HARMONY_ETS = join(ROOT, 'client/harmony/entry/src/main/ets');
const NAV_TS = join(HARMONY_ETS, 'model/NavItems.ts'); const NAV_TS = join(HARMONY_ETS, 'model/NavItems.ts');
const read = p => readFileSync(join(HARMONY_ETS, p), 'utf8'); const read = p => prose(join(HARMONY_ETS, p));
/** 剥掉注释与字符串,只看真代码(断言"代码里有什么"时必须这样读) */ /** 剥掉注释与字符串,只看真代码(断言"代码里有什么"时必须这样读) */
const code = src => src.replace(/\/\*[\s\S]*?\*\//g, '').replace(/\/\/[^\n]*/g, '');
/** 取某个 `@Builder` 的正文(按行切到下一个成员) */ /** 取某个 `@Builder` 的正文(按行切到下一个成员) */
function builderBody(src, signature) { function builderBody(src, signature) {
const lines = src.split('\n'); const lines = src.split('\n');
@ -148,7 +147,7 @@ test('③-b 命中区是**来源**判据:`.ets` 不许自己写数字,必须
* 顺带钉住:算式里不许出现裸数字(余量要有名字)。 * 顺带钉住:算式里不许出现裸数字(余量要有名字)。
*/ */
test('④-b 内容让位高度是**派生**的,不是并列常量', () => { test('④-b 内容让位高度是**派生**的,不是并列常量', () => {
const src = readFileSync(NAV_TS, 'utf8'); const src = prose(NAV_TS);
const decl = /export const NAV_CONTENT_RESERVE: number = ([^;]+);/.exec(src); const decl = /export const NAV_CONTENT_RESERVE: number = ([^;]+);/.exec(src);
assert.ok(decl, 'NAV_CONTENT_RESERVE 的声明要能被判据读到(判据跟着改)'); assert.ok(decl, 'NAV_CONTENT_RESERVE 的声明要能被判据读到(判据跟着改)');
const expr = decl[1]; const expr = decl[1];
@ -174,8 +173,8 @@ test('④ 悬浮 + 让位:自绘浮动层(留白/圆角/系统材质),
* 读原文会把它当成"条上还有手写色值"(我第一版就是这样误报的)。 * 读原文会把它当成"条上还有手写色值"(我第一版就是这样误报的)。
* 与规范里那条一致:判"代码里有什么"读剥注释的源码,判"理由写清了没"读原文。 * 与规范里那条一致:判"代码里有什么"读剥注释的源码,判"理由写清了没"读原文。
*/ */
assert.ok(!/#[0-9A-Fa-f]{6,8}/.test(code(bar)), assert.ok(!/#[0-9A-Fa-f]{6,8}/.test(stripComments(bar)),
`条上不许出现手写色值(深浅两套由系统给),实际:${(code(bar).match(/#[0-9A-Fa-f]{6,8}/g) || []).join('、')}`); `条上不许出现手写色值(深浅两套由系统给),实际:${(stripComments(bar).match(/#[0-9A-Fa-f]{6,8}/g) || []).join('、')}`);
// 内容让位:让出的高度必须够(否则最后一行压在条底下 —— 看得见、点不到) // 内容让位:让出的高度必须够(否则最后一行压在条底下 —— 看得见、点不到)
assert.ok(N.NAV_CONTENT_RESERVE >= N.NAV_BAR_HEIGHT + N.NAV_BAR_BOTTOM, assert.ok(N.NAV_CONTENT_RESERVE >= N.NAV_BAR_HEIGHT + N.NAV_BAR_BOTTOM,
@ -197,12 +196,12 @@ test('★ 系统 `Tabs` 的 bar 已经不在(这一期换的就是它),且
*/ */
const root = main.slice(main.indexOf('build() {\n /*\n * 底部两项')); const root = main.slice(main.indexOf('build() {\n /*\n * 底部两项'));
assert.ok(root.length > 300, '要能取到根 build() 的正文'); assert.ok(root.length > 300, '要能取到根 build() 的正文');
const navCode = code(root); const navCode = stripComments(root);
assert.ok(!/\bTabs\(/.test(navCode), '根导航里不该再有系统 Tabs'); assert.ok(!/\bTabs\(/.test(navCode), '根导航里不该再有系统 Tabs');
assert.ok(!/TabContent/.test(navCode), '根导航里不该再有 TabContent内容改为按 index 挂载)'); assert.ok(!/TabContent/.test(navCode), '根导航里不该再有 TabContent内容改为按 index 挂载)');
assert.ok(!/\.tabBar\(/.test(navCode), '根导航里不该再有 tabBar()'); assert.ok(!/\.tabBar\(/.test(navCode), '根导航里不该再有 tabBar()');
// 反面之二:也不许把旧 builder 留着不用(留着就是死代码,下一个人会以为它还在生效) // 反面之二:也不许把旧 builder 留着不用(留着就是死代码,下一个人会以为它还在生效)
assert.ok(!/TabBarBuilder/.test(code(main)), 'TabBarBuilder 已被 NavBar 取代,不该留在文件里'); assert.ok(!/TabBarBuilder/.test(stripComments(main)), 'TabBarBuilder 已被 NavBar 取代,不该留在文件里');
// 平级项仍是两项且顺序不变(信息架构这一期不动) // 平级项仍是两项且顺序不变(信息架构这一期不动)
assert.deepEqual(N.NAV_ITEMS.map(i => i.label), ['通信', '联系人'], '平级项与顺序'); assert.deepEqual(N.NAV_ITEMS.map(i => i.label), ['通信', '联系人'], '平级项与顺序');
@ -225,7 +224,7 @@ test('★ 玻璃只在两处、且这一处是"背后有可变内容"GLASS
*/ */
const wallpaper = builderBody(main, 'WallpaperLayer() {'); const wallpaper = builderBody(main, 'WallpaperLayer() {');
assert.ok(!/backgroundBlurStyle/.test(wallpaper), '壁纸层不许有材质(同一张底糊两遍)'); assert.ok(!/backgroundBlurStyle/.test(wallpaper), '壁纸层不许有材质(同一张底糊两遍)');
assert.ok(!/blur\(/i.test(code(wallpaper)), '壁纸层不许有任何模糊调用'); assert.ok(!/blur\(/i.test(stripComments(wallpaper)), '壁纸层不许有任何模糊调用');
const bar = builderBody(main, 'NavBar() {'); const bar = builderBody(main, 'NavBar() {');
assert.match(bar, /backgroundBlurStyle\(Theme\.navMaterial\)/, '悬浮条必须有系统材质(背后是滚动内容)'); assert.match(bar, /backgroundBlurStyle\(Theme\.navMaterial\)/, '悬浮条必须有系统材质(背后是滚动内容)');
// 理由要写在**原文**(注释会被剥掉,而理由就在注释里) // 理由要写在**原文**(注释会被剥掉,而理由就在注释里)

View File

@ -14,9 +14,10 @@
* BlurStyle 的取值则在 `component/common.d.ts` 的 `declare enum BlurStyle` 里。 * BlurStyle 的取值则在 `component/common.d.ts` 的 `declare enum BlurStyle` 里。
* 两边都能离线查 —— 所以"名字写对没有"可以变成构建期判据。 * 两边都能离线查 —— 所以"名字写对没有"可以变成构建期判据。
*/ */
import { prose } from './lib/read.mjs';
import { test } from 'node:test'; import { test } from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { existsSync, readFileSync, readdirSync } from 'node:fs'; import { existsSync, readdirSync } from 'node:fs';
import { dirname, join } from 'node:path'; import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
@ -60,7 +61,7 @@ const collectSources = (dir, acc = []) => {
/** SDK 名表name → type */ /** SDK 名表name → type */
const loadIdTable = () => { const loadIdTable = () => {
const recs = JSON.parse(readFileSync(ID_TABLE, 'utf8')).record; const recs = JSON.parse(prose(ID_TABLE)).record;
const byName = new Map(); const byName = new Map();
for (const r of recs) byName.set(r.name, r.type); for (const r of recs) byName.set(r.name, r.type);
return byName; return byName;
@ -68,7 +69,7 @@ const loadIdTable = () => {
/** SDK 的 BlurStyle 成员 */ /** SDK 的 BlurStyle 成员 */
const loadBlurStyleMembers = () => { const loadBlurStyleMembers = () => {
const src = readFileSync(COMMON_DTS, 'utf8'); const src = prose(COMMON_DTS);
const at = src.indexOf('declare enum BlurStyle'); const at = src.indexOf('declare enum BlurStyle');
assert.ok(at > 0, 'SDK 里应能定位到 declare enum BlurStyle'); assert.ok(at > 0, 'SDK 里应能定位到 declare enum BlurStyle');
// 枚举体到第一个顶格 `}` 为止;成员形如 ` Thin,` 或 ` COMPONENT_THIN = 6,` // 枚举体到第一个顶格 `}` 为止;成员形如 ` Thin,` 或 ` COMPONENT_THIN = 6,`
@ -83,7 +84,7 @@ const loadBlurStyleMembers = () => {
const scanSysResources = (files) => { const scanSysResources = (files) => {
const found = []; const found = [];
for (const f of files) { for (const f of files) {
const src = readFileSync(f, 'utf8').replace(/\/\*[\s\S]*?\*\//g, '').replace(/\/\/[^\n]*/g, ''); const src = prose(f).replace(/\/\*[\s\S]*?\*\//g, '').replace(/\/\/[^\n]*/g, '');
for (const m of src.matchAll(/\$r\(\s*'sys\.([a-z]+)\.([A-Za-z0-9_]+)'/g)) { for (const m of src.matchAll(/\$r\(\s*'sys\.([a-z]+)\.([A-Za-z0-9_]+)'/g)) {
found.push({ file: f.slice(ROOT.length + 1), type: m[1], name: m[2] }); found.push({ file: f.slice(ROOT.length + 1), type: m[1], name: m[2] });
} }
@ -95,7 +96,7 @@ const scanSysResources = (files) => {
const scanBlurStyles = (files) => { const scanBlurStyles = (files) => {
const found = []; const found = [];
for (const f of files) { for (const f of files) {
const src = readFileSync(f, 'utf8').replace(/\/\*[\s\S]*?\*\//g, '').replace(/\/\/[^\n]*/g, ''); const src = prose(f).replace(/\/\*[\s\S]*?\*\//g, '').replace(/\/\/[^\n]*/g, '');
for (const m of src.matchAll(/BlurStyle\.([A-Z][A-Z_0-9]*)/g)) { for (const m of src.matchAll(/BlurStyle\.([A-Z][A-Z_0-9]*)/g)) {
found.push({ file: f.slice(ROOT.length + 1), member: m[1] }); found.push({ file: f.slice(ROOT.length + 1), member: m[1] });
} }
@ -213,7 +214,7 @@ function sdkDtsFiles() {
function deprecatedGlobalFunctions() { function deprecatedGlobalFunctions() {
const names = new Set(); const names = new Set();
for (const f of sdkDtsFiles()) { for (const f of sdkDtsFiles()) {
const src = readFileSync(f, 'utf8'); const src = prose(f);
// 先切出 depth == 0 的片段 // 先切出 depth == 0 的片段
let depth = 0; let depth = 0;
let segStart = 0; let segStart = 0;
@ -297,7 +298,7 @@ test('废弃 API清单**从 SDK 生成**,源码里不得调用(新增一
} }
const hits = []; const hits = [];
for (const f of files) { for (const f of files) {
const src = readFileSync(f, 'utf8').replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, ''); const src = prose(f).replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '');
for (const name of deprecated) { for (const name of deprecated) {
// 只算**全局调用**:排除成员调用 `x.name(``.animateTo(` 是我们要求的新写法) // 只算**全局调用**:排除成员调用 `x.name(``.animateTo(` 是我们要求的新写法)
const re = new RegExp(`(?<![.\\w])${name}\\s*\\(`, 'g'); const re = new RegExp(`(?<![.\\w])${name}\\s*\\(`, 'g');

View File

@ -0,0 +1,41 @@
/**
* 判据目录里**唯一**允许读文本文件的两个入口 —— 名字自己解释该选哪个。
*
* # 为什么要有这个模块pi 2026-09-14 §4同一处坑我踩了两次
*
* 规范里写着"判代码读剥离版、判理由读原文",我 P5 写过一次、当天又踩了一次
* `'rejected'` 那段**注释**里正好写着 `allowed-once`,被当成"这里会放行"误报)。
* **第二次犯规说明问题不在记性,在形态**:靠人记得执行的规范一定会有下一次。
* 所以把"用哪个读取器"从**记忆**变成**代码里的一个词**,并且可以被判据检查。
*
* - `code(path)`**剥掉注释**。判"代码里有没有这个调用/这个值"时必须用它 ——
* 否则解释性注释("这里写 'rejected' 而不是 'denied',因为只认 allowed-once"
* 会被当代码读,产生假红/假绿。
* - `prose(path)`**原文**。判"理由写清了没/文档里有没有这句话"时用它。
*
* 选错的典型症状:断言里的标识符恰好在同文件的注释里出现过(这类误报几乎都集中在
* "解释性注释与它解释的标识符同名"的地方)。
*/
import { readFileSync } from 'node:fs';
/** 剥掉注释与字符串字面量里的注释样式文本之外的东西:只用于"代码里有什么" */
export function stripComments(src) {
return src
.replace(/\/\*[\s\S]*?\*\//g, '') // 块注释
.replace(/(^|[^:])\/\/[^\n]*/g, '$1'); // 行注释(避开 https:// 这类)
}
/** 读文件并**剥掉注释** —— 判"代码里有什么"用这个 */
export function code(path) {
return stripComments(readFileSync(path, 'utf8'));
}
/** 读文件**原文** —— 判"注释/文档里写了什么"用这个 */
export function prose(path) {
return readFileSync(path, 'utf8');
}
/** 读**二进制**(安装包、图片等)—— 需要 Buffer 时用它,别在判据里裸用 readFileSync */
export function bytes(path) {
return readFileSync(path);
}

View File

@ -2,10 +2,10 @@
// //
// 不做视觉快照:那需要 headless 浏览器,且像素级比对在字体差异下极脆。 // 不做视觉快照:那需要 headless 浏览器,且像素级比对在字体差异下极脆。
// 这里守住几条真正会坏掉的不变量。 // 这里守住几条真正会坏掉的不变量。
import { readFileSync } from 'node:fs'; import { prose } from './lib/read.mjs';
import { check, finish } from './lib/checks.mjs'; import { check, finish } from './lib/checks.mjs';
const read = p => readFileSync(new URL(p, import.meta.url), 'utf8'); const read = p => prose(new URL(p, import.meta.url));
console.log('窄屏布局回归:'); console.log('窄屏布局回归:');

View File

@ -10,14 +10,14 @@
* 为什么写文件级判据:改完跑老套件 254 条**全绿**,因为它一条都没测导航结构 —— * 为什么写文件级判据:改完跑老套件 254 条**全绿**,因为它一条都没测导航结构 ——
* 我差点把"没红的测试"当成"改对了"。结构类改动必须自己带判据。 * 我差点把"没红的测试"当成"改对了"。结构类改动必须自己带判据。
*/ */
import { code } from './lib/read.mjs';
import { test } from 'node:test'; import { test } from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path'; import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
const HERE = dirname(fileURLToPath(import.meta.url)); const HERE = dirname(fileURLToPath(import.meta.url));
const read = (...p) => readFileSync(join(HERE, '..', 'src', ...p), 'utf8'); const read = (...p) => code(join(HERE, '..', 'src', ...p));
const sidebar = read('components', 'Sidebar.tsx'); const sidebar = read('components', 'Sidebar.tsx');
const narrow = read('components', 'NarrowNav.tsx'); const narrow = read('components', 'NarrowNav.tsx');

View File

@ -23,9 +23,10 @@
* 装上去的人看到的是旧界面,而 Web 上是新的 —— 两边不一致但谁都不报错。 * 装上去的人看到的是旧界面,而 Web 上是新的 —— 两边不一致但谁都不报错。
*/ */
import { code, prose } from './lib/read.mjs';
import { test } from 'node:test'; import { test } from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { readFileSync, existsSync } from 'node:fs'; import { existsSync } from 'node:fs';
import { execFileSync } from 'node:child_process'; import { execFileSync } from 'node:child_process';
import { dirname, join } from 'node:path'; import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
@ -37,7 +38,7 @@ const ROOT = join(HERE, '..');
const DIST_HTML = join(ROOT, 'dist', 'index.html'); const DIST_HTML = join(ROOT, 'dist', 'index.html');
test('★ vite 的 base 必须是相对路径(否则 Electron 白屏)', () => { test('★ vite 的 base 必须是相对路径(否则 Electron 白屏)', () => {
const cfg = readFileSync(join(ROOT, 'vite.config.ts'), 'utf8'); const cfg = code(join(ROOT, 'vite.config.ts'));
// 不能只看 `base:` 出现在注释里 —— 断言的是真的有一条 base 配置语句 // 不能只看 `base:` 出现在注释里 —— 断言的是真的有一条 base 配置语句
assert.match( assert.match(
cfg, cfg,
@ -53,7 +54,7 @@ test('★ 构建产物里不能有绝对资源路径(这条能在没浏览器
console.log('dist/index.html 不存在 —— 先 cd client/electron && npm run build 才验得到)'); console.log('dist/index.html 不存在 —— 先 cd client/electron && npm run build 才验得到)');
return; return;
} }
const html = readFileSync(DIST_HTML, 'utf8'); const html = prose(DIST_HTML);
const abs = [...html.matchAll(/(?:src|href)="(\/[^"]*)"/g)].map(m => m[1]); const abs = [...html.matchAll(/(?:src|href)="(\/[^"]*)"/g)].map(m => m[1]);
assert.deepEqual( assert.deepEqual(
abs, abs,
@ -78,7 +79,7 @@ test('★ 安装包里的 dist 必须与当前构建一致(否则装上去的
return; return;
} }
const list = execFileSync('npx', ['asar', 'list', asar], { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 }); const list = execFileSync('npx', ['asar', 'list', asar], { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
const wanted = [...readFileSync(DIST_HTML, 'utf8').matchAll(/\.\/(assets\/[^"]+)/g)].map(m => m[1]); const wanted = [...prose(DIST_HTML).matchAll(/\.\/(assets\/[^"]+)/g)].map(m => m[1]);
const missing = wanted.filter(p => !list.includes(`/dist/${p}`)); const missing = wanted.filter(p => !list.includes(`/dist/${p}`));
assert.deepEqual( assert.deepEqual(
@ -129,9 +130,9 @@ test('★ 发布脚本:构建失败时不打包(注入失败的构建,看
}); });
test('★ 发布脚本:构建只有一条路(走 npm run build不能是裸 vite build', () => { test('★ 发布脚本:构建只有一条路(走 npm run build不能是裸 vite build', () => {
const pkg = JSON.parse(readFileSync(join(HERE, '..', 'package.json'), 'utf8')); const pkg = JSON.parse(prose(join(HERE, '..', 'package.json')));
const scriptPath = join(HERE, '..', 'scripts', 'release-linux.sh'); const scriptPath = join(HERE, '..', 'scripts', 'release-linux.sh');
const script = readFileSync(scriptPath, 'utf8'); const script = prose(scriptPath);
/* /*
* 两个真实的坑,各自被这条挡住: * 两个真实的坑,各自被这条挡住:
* ① `build:linux` 原来直接跑裸 `vite build` —— 那会**跳过 `gen:bg`** * ① `build:linux` 原来直接跑裸 `vite build` —— 那会**跳过 `gen:bg`**

View File

@ -21,8 +21,9 @@
* *
* 写判据之前先读 `test/CRITERIA.md`(判结构与行为,不判字面与邻接)。 * 写判据之前先读 `test/CRITERIA.md`(判结构与行为,不判字面与邻接)。
*/ */
import { prose } from './lib/read.mjs';
import { spawnSync } from 'node:child_process'; import { spawnSync } from 'node:child_process';
import { existsSync, readFileSync, readdirSync } from 'node:fs'; import { existsSync, readdirSync } from 'node:fs';
import { dirname, join } from 'node:path'; import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
@ -48,7 +49,7 @@ if (!existsSync(CRITERIA_DOC)) {
console.error('✗ 判据规范 test/CRITERIA.md 不见了(写判据的规矩不能只活在脑子里)'); console.error('✗ 判据规范 test/CRITERIA.md 不见了(写判据的规矩不能只活在脑子里)');
process.exit(1); process.exit(1);
} }
const criteriaDoc = readFileSync(CRITERIA_DOC, 'utf8'); const criteriaDoc = prose(CRITERIA_DOC);
for (const must of ['配对/解析', 'allow-list', '变异验证', '剥掉注释', '按行', '自报条数', '只支撑你看到的那一层', '已经在某个提交里', '自带修法', '按 id 联接', '不要退化成对源码形状的匹配']) { for (const must of ['配对/解析', 'allow-list', '变异验证', '剥掉注释', '按行', '自报条数', '只支撑你看到的那一层', '已经在某个提交里', '自带修法', '按 id 联接', '不要退化成对源码形状的匹配']) {
if (!criteriaDoc.includes(must)) { if (!criteriaDoc.includes(must)) {
console.error(`✗ 判据规范里少了「${must}」这条 —— 规则被删掉了还是搬走了?`); console.error(`✗ 判据规范里少了「${must}」这条 —— 规则被删掉了还是搬走了?`);
@ -73,7 +74,9 @@ const SUITE = [
['test/appearance-defaults.test.mjs', [], 3], ['test/appearance-defaults.test.mjs', [], 3],
['test/build-stamp.test.mjs', [], 6], ['test/build-stamp.test.mjs', [], 6],
['test/packaging.test.mjs', [], 5], ['test/packaging.test.mjs', [], 5],
['test/commit-hygiene.test.mjs', ['--experimental-strip-types', '--no-warnings'], 2] ['test/commit-hygiene.test.mjs', ['--experimental-strip-types', '--no-warnings'], 2],
// 判据目录自身的卫生:读文本必须走 test/lib/read.mjs 的具名入口
['test/criteria-hygiene.test.mjs', [], 2]
]; ];
// 自检 1清单里的文件必须真的存在写错名字 = 那条判据永远不跑) // 自检 1清单里的文件必须真的存在写错名字 = 那条判据永远不跑)
@ -106,7 +109,7 @@ for (const [file, flags] of SUITE) {
if (flags.includes('--test')) { if (flags.includes('--test')) {
continue; // node:test 那几条没有 process.exit结构上不会踩这个 continue; // node:test 那几条没有 process.exit结构上不会踩这个
} }
const src = readFileSync(join(ROOT, file), 'utf8'); const src = prose(join(ROOT, file));
const exitAt = src.lastIndexOf('process.exit('); const exitAt = src.lastIndexOf('process.exit(');
if (exitAt >= 0 && /(^|\n)\s*check\(/.test(src.slice(exitAt))) { if (exitAt >= 0 && /(^|\n)\s*check\(/.test(src.slice(exitAt))) {
buried.push(file); buried.push(file);
@ -134,7 +137,7 @@ if (buried.length) {
* 再加一条"跑完必须有输出"12 条判据现在都有输出),静默成功同样可疑。 * 再加一条"跑完必须有输出"12 条判据现在都有输出),静默成功同样可疑。
*/ */
function shapeOf(file) { function shapeOf(file) {
const src = readFileSync(join(ROOT, file), 'utf8'); const src = prose(join(ROOT, file));
const usesNodeTest = /from 'node:test'/.test(src); const usesNodeTest = /from 'node:test'/.test(src);
const canFail = usesNodeTest const canFail = usesNodeTest
|| /(^|[^.\w])check\(/.test(src) || /(^|[^.\w])check\(/.test(src)

View File

@ -1,4 +1,5 @@
import { existsSync, readFileSync } from 'node:fs'; import { prose } from '../lib/read.mjs';
import { existsSync } from 'node:fs';
import { dirname, join } from 'node:path'; import { dirname, join } from 'node:path';
import { beforeEach, describe, expect, it } from 'vitest'; import { beforeEach, describe, expect, it } from 'vitest';
import { import {
@ -306,7 +307,7 @@ describe('默认值 = 服务端契约', () => {
* 一定会问"这谁写的、能删吗"。答案必须就在他会看的地方,而不是藏在某个人的记忆里。 * 一定会问"这谁写的、能删吗"。答案必须就在他会看的地方,而不是藏在某个人的记忆里。
*/ */
it('★ legacy.bak 的生命周期写在文档里(谁写的、为什么不删、怎么删)', () => { it('★ legacy.bak 的生命周期写在文档里(谁写的、为什么不删、怎么删)', () => {
const doc = readFileSync(findDoc('docs/HARMONY-ALIGN-PLAN.md'), 'utf8'); const doc = prose(findDoc('docs/HARMONY-ALIGN-PLAN.md'));
const row = doc.split('\n').find(l => l.includes('agentmail.background.legacy.bak')); const row = doc.split('\n').find(l => l.includes('agentmail.background.legacy.bak'));
expect(row, '§7.12 里要有一行讲这个键').toBeTruthy(); expect(row, '§7.12 里要有一行讲这个键').toBeTruthy();
expect(row).toContain('有意永久残留'); expect(row).toContain('有意永久残留');

View File

@ -7,12 +7,13 @@
* 这些检查存在的理由:深色模式的 bug 形态是**白底白字** * 这些检查存在的理由:深色模式的 bug 形态是**白底白字**
* 它不报错、不影响构建、只有肉眼能发现,而且往往只出现在某个不常开的页面。 * 它不报错、不影响构建、只有肉眼能发现,而且往往只出现在某个不常开的页面。
*/ */
import { readFileSync, readdirSync } from 'node:fs'; import { code, prose } from './lib/read.mjs';
import { readdirSync } from 'node:fs';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path'; import { dirname, join } from 'node:path';
const here = dirname(fileURLToPath(import.meta.url)); const here = dirname(fileURLToPath(import.meta.url));
const read = p => readFileSync(join(here, p), 'utf8'); const read = p => prose(join(here, p));
let pass = 0; let pass = 0;
let fail = 0; let fail = 0;
@ -158,7 +159,7 @@ check(
const compDir = join(here, '../src/components'); const compDir = join(here, '../src/components');
const offenders = []; const offenders = [];
for (const f of readdirSync(compDir).filter(x => x.endsWith('.tsx'))) { for (const f of readdirSync(compDir).filter(x => x.endsWith('.tsx'))) {
const src = readFileSync(join(compDir, f), 'utf8'); const src = prose(join(compDir, f));
// 只看 className 与 style 里的颜色SVG 的 currentColor 不算 // 只看 className 与 style 里的颜色SVG 的 currentColor 不算
const hits = (src.match(/#[0-9a-fA-F]{3,6}\b/g) || []); const hits = (src.match(/#[0-9a-fA-F]{3,6}\b/g) || []);
if (hits.length) offenders.push(`${f}(${hits.join(',')})`); if (hits.length) offenders.push(`${f}(${hits.join(',')})`);
@ -382,7 +383,7 @@ check(
const chromeFiles = ['Sidebar', 'NarrowNav']; const chromeFiles = ['Sidebar', 'NarrowNav'];
const slateLeft = []; const slateLeft = [];
for (const f of chromeFiles) { for (const f of chromeFiles) {
const src = readFileSync(join(here, `../src/components/${f}.tsx`), 'utf8'); const src = code(join(here, `../src/components/${f}.tsx`));
const hits = src.match(/(?:bg|text|border|hover:bg|hover:text|active:bg|ring)-slate-\d+/g) || []; const hits = src.match(/(?:bg|text|border|hover:bg|hover:text|active:bg|ring)-slate-\d+/g) || [];
if (hits.length) slateLeft.push(`${f}: ${hits.join(',')}`); if (hits.length) slateLeft.push(`${f}: ${hits.join(',')}`);
} }
@ -392,7 +393,7 @@ check('框架组件已全部改用 chrome 色阶', slateLeft.length === 0, slate
// 被反转成近白色,`bg-gray-900 text-white` 因而只剩约 1:1。 // 被反转成近白色,`bg-gray-900 text-white` 因而只剩约 1:1。
const componentSources = readdirSync(compDir) const componentSources = readdirSync(compDir)
.filter(x => x.endsWith('.tsx')) .filter(x => x.endsWith('.tsx'))
.map(f => [f, readFileSync(join(compDir, f), 'utf8')]); .map(f => [f, prose(join(compDir, f))]);
const graySolid = componentSources.flatMap(([f, src]) => const graySolid = componentSources.flatMap(([f, src]) =>
(src.match(/(?:bg-gray-(?:700|800|900)[^'"\n]*text-white|text-white[^'"\n]*bg-gray-(?:700|800|900))/g) || []) (src.match(/(?:bg-gray-(?:700|800|900)[^'"\n]*text-white|text-white[^'"\n]*bg-gray-(?:700|800|900))/g) || [])
.map(hit => `${f}:${hit}`) .map(hit => `${f}:${hit}`)

View File

@ -11,6 +11,11 @@ export const ENFORCE_ADVISORY: 'advisory';
export function normalizeMode(mode: unknown): string; export function normalizeMode(mode: unknown): string;
export function normalizeEnforcement(e: unknown): string; export function normalizeEnforcement(e: unknown): string;
export function modeAtMost(a: string, b: string): string; export function modeAtMost(a: string, b: string): string;
/**
* **状态写入点**专用:拿不到值返回 `null`= 不要写),绝不兜默认档。
* 用它而不是 `normalizeMode` —— 默认值可以出现在决策里,不可以出现在状态写入里。
*/
export function modeForStateWrite(mode: unknown): string | null;
export function modeNeedsHuman(mode: string): boolean; export function modeNeedsHuman(mode: string): boolean;
export function dshSandboxMode(mode: string): 'read-only' | 'workspace-write' | 'danger-full-access'; export function dshSandboxMode(mode: string): 'read-only' | 'workspace-write' | 'danger-full-access';

View File

@ -78,6 +78,43 @@ export function normalizeEnforcement(e) {
: ENFORCE_ADVISORY; : ENFORCE_ADVISORY;
} }
/**
* **状态写入点**专用的档位解析:拿不到值就返回 `null`= 不要写),绝不兜默认档。
*
* ## 为什么单开一个函数(而不是复用 normalizeMode
*
* `normalizeMode` 的语义是「**要一个档位**时怎么收敛」——空/脏值都给默认档。
* 那在**决策**里是对的(有个兜底),在**状态写入**里是错的:
*
* 2026-09-14 的线上事故正是这个形状:补投路径漏传 `permission_mode`
* 插件拿 `undefined` 走默认档,把一条 **full 档会话**写成了 `workspace-write`
* —— 沙箱窄了、审批从 never 变 ask之后每个受守卫的工具调用都去问一次
* 服务端按真实档位回 409旧代码又当场拒绝。**一次状态写入,毁了整轮的工具能力**。
*
* 一句话规则pi 提的):**默认值可以出现在「决策」里,不可以出现在「状态写入」里**
* —— 决策有默认值是兜底,状态写入有默认值是**篡改**。
*
* 于是分工是:
* - 缺字段(空串 / undefined / 非字符串)→ `null`,调用方**跳过写入**
* 保留上一次由服务端给出的权威值;
* - 有值 → 交给 `normalizeMode` 收敛(脏值仍按共用契约 fail-closed 到默认档,
* 与 Gateway 的 `NormalizePermissionMode` 同语义 —— 这里不是"猜",是同一份契约)。
*
* 跳过写入会不会 fail-open**实测过方向的**DSH 的默认沙箱就是
* `workspace-write` + `ask`(见 dsh 客户端 `permissionSelectOf` 的初值),
* 所以"新会话 + 缺字段"落到的是与共用契约同一个档,不会更宽;
* 而"已有会话 + 缺字段"保留的是服务端先前给的权威值,下一个权威事件
* (带档位的邮件 / 409 纠正 / 人在界面改档)会把它修正回来。
*
* @param {unknown} mode
* @returns {string|null} 合法档位;`null` 表示**不要写状态**
*/
export function modeForStateWrite(mode) {
const m = typeof mode === 'string' ? mode.trim() : '';
if (m === '') return null;
return normalizeMode(m);
}
/** /**
* 取两个档位里更严的那一个。 * 取两个档位里更严的那一个。
* *

View File

@ -60,6 +60,14 @@ import {
renderThread, renderThread,
} from '../lib/discovery.js'; } from '../lib/discovery.js';
import { appendRenameProposal, renameProposalNote } from '../lib/rename-proposal.js'; import { appendRenameProposal, renameProposalNote } from '../lib/rename-proposal.js';
// 档位**状态写入**的共用规则(四平台逐字节同一份,见 deploy/check-shared-libs.sh
//
// ⚠️ 2026-09-14 事故记录:这两个名字是**后来补上 import 的**。当时我在 409 分支里
// 直接用 `MODE_FULL` 却没 import而 `npx tsc --noEmit | tail -3` 的退出码被我
// 误当成 tsc 的(其实是 `tail` 的)—— 于是"类型检查通过"是假的,这个
// `ReferenceError` 会在这条分支**真正被执行时**抛出:正好是它要修的那条路径。
// 现在插件套件一律走 `npm test``pretest: tsc` 会先编译),不再直接 `node --test`。
import { MODE_FULL, modeForStateWrite } from '../lib/permission-mode.js';
import { createSSEClient } from '../lib/sse-client.js'; import { createSSEClient } from '../lib/sse-client.js';
import { pickMailSession } from '../lib/mail-session-id.js'; import { pickMailSession } from '../lib/mail-session-id.js';
import { normalizeAttachmentIDs } from '../lib/attachment-ids.js'; import { normalizeAttachmentIDs } from '../lib/attachment-ids.js';
@ -783,9 +791,23 @@ export function apply(ctx: any, config: PluginConfig): void {
// 直接往 session 上 append 事件(与 permissionPresets.set 同一底层)。 // 直接往 session 上 append 事件(与 permissionPresets.set 同一底层)。
// 不走 permissionPresets 服务:它要求 preset 名在配置表里, // 不走 permissionPresets 服务:它要求 preset 名在配置表里,
// 而我们的三档映射需要完全自主控制。 // 而我们的三档映射需要完全自主控制。
// **这里是"状态写入点",不兜默认档**2026-09-14档位缺失时**跳过写入**
// 保留服务端上一次给的权威值。原来写的是 `(mode || 'workspace')` —— 补投路径
// 漏传档位时,一条 full 档会话被**改写**成 workspace-write + ask整轮工具能力
// 降级,且下一次调用照样问、照样挨 409。规则见 lib/permission-mode.js 的
// modeForStateWrite默认值可以出现在**决策**里,不可以出现在**状态写入**里。
function applyPermissionMode(session: any, mode: string): void { function applyPermissionMode(session: any, mode: string): void {
if (!session?.append) return; if (!session?.append) return;
const m = (mode || 'workspace').trim(); const m = modeForStateWrite(mode);
if (m === null) {
// 缺字段 = 「不知道」不是「workspace」。日志里说清是哪条路把值弄丢了
// 否则下次还是只能靠猜(这行是排查"丢字段的那只手"的第一现场)。
console.error(
'[dsh-mail-bridge] 档位缺失(事件里没有 permission_mode本次**不改**会话沙箱与审批策略,' +
'保留上一次的权威值 —— 缺字段是"不知道",不是 workspace'
);
return;
}
// sandbox mode // sandbox mode
const sandboxMap: Record<string, string> = { const sandboxMap: Record<string, string> = {
plan: 'read-only', plan: 'read-only',
@ -1831,13 +1853,42 @@ export function apply(ctx: any, config: PluginConfig): void {
* *
* **只认服务端明说的 full**plan 档(该档语义是"不动手",拒绝是对的) * **只认服务端明说的 full**plan 档(该档语义是"不动手",拒绝是对的)
* 与"链上没有人类"照旧拒绝 —— 猜宽了就是提权。 * 与"链上没有人类"照旧拒绝 —— 猜宽了就是提权。
*
* ## 放行只修"问不问"**修不了"能不能"**pi 2026-09-14 指出,这条是本分支的重点)
*
* `allowed-once` 绕过的是**审批**;沙箱是另一件事,它还在 `workspace-write`。
* 于是"工作区外写入"这类操作会在**沙箱层**被拒 —— 功能上通了,能力上仍然窄,
* 而且**每次受守卫的调用都要重新走一遍 409**(放行是 per-call 的)。
* 更糟的是:这个状态会**留在会话里**,直到某一封恰好带档位的邮件到来。
*
* 所以这里把 409 回包当成「**服务端在纠正我的档位**」,而不是「这一次可以放行」:
* 回包里有权威值就**写回会话**`applyPermissionMode`),再决定放不放行。
* 这顺带成了**自愈**路径 —— 连修复前就被降级的会话,也能被下一次 409 修回来。
*/ */
if (e?.status === 409 && String(e?.body?.permission_mode || '') === 'full') { const corrected = modeForStateWrite(e?.body?.permission_mode);
if (e?.status === 409 && corrected) {
// 写回状态(权威值来自服务端,不是猜的)
const found = findLiveDshSession(mailSessionID);
if (found) {
applyPermissionMode(found.agent?.session, corrected);
console.error(
`[dsh-mail-bridge] 服务端纠正档位为 ${corrected},已写回会话 ${found.id}${relayKey}` +
`—— 放行只修"这一次",写回才修"状态"`
);
} else {
// 会话不在运行(插件重启后尚未收到新邮件)。
// 无需处理:下次投递时 deliverMail 会按邮件里带的 permission_mode 重新 apply。
console.error(
`[dsh-mail-bridge] 服务端纠正档位为 ${corrected},但会话不在运行,写回推迟到下次投递`
);
}
if (corrected === MODE_FULL) {
console.error( console.error(
`[dsh-mail-bridge] 服务端判定本会话为 full 档,放行本次询问(${relayKey}):无需审批` `[dsh-mail-bridge] 服务端判定本会话为 full 档,放行本次询问(${relayKey}):无需审批`
); );
return 'allowed-once'; return 'allowed-once';
} }
}
if (e?.status === 409) { if (e?.status === 409) {
const hint = [e?.body?.error, e?.body?.detail, e?.body?.suggestion] const hint = [e?.body?.error, e?.body?.detail, e?.body?.suggestion]

View File

@ -79,3 +79,14 @@ test('seen 传 undefined 时不去重也不报错', () => {
const got = selectCatchup([mail({ mail_id: 'x' })], undefined); const got = selectCatchup([mail({ mail_id: 'x' })], undefined);
assert.deepEqual(got.map(e => e.mail_id), ['x']); assert.deepEqual(got.map(e => e.mail_id), ['x']);
}); });
test('★ 补投事件必须带权限档位(漏了就会把 full 档会话误拦)', () => {
const ev = mailToEvent(mail({ permission_mode: 'full', permission_enforcement: 'strict' }));
assert.equal(ev.permission_mode, 'full', '补投路径不许把档位丢掉');
assert.equal(ev.permission_enforcement, 'strict');
// 缺字段时给空串:**不猜档位**(猜宽了就是提权),由调用方 `|| 'workspace'` 兜底
const bare = mailToEvent(mail());
assert.equal(bare.permission_mode, '');
assert.equal(bare.permission_enforcement, '');
assert.ok(!('permissionMode' in bare), '键名要与 SSE 逐字一致(蛇形),别自造驼峰');
});

View File

@ -1,26 +1,29 @@
/** /**
* 接线断言:服务端 409 里说「本会话是 full 档」时dsh 桥必须**放行**而不是拒绝 * 接线判据dsh 桥怎么处理「409 带档位」
* *
* # 为什么需要这条(与 pi 桥同一处缺陷,同日一起修) * # 判据分三块,缺一块就是假判据
* *
* 409 原本只有一个含义:「这条任务链上没有人类」→ 当场拒绝(`return 'rejected'` * 1. **动作**409 回包里有权威档位时,先**写回会话状态**`applyPermissionMode`
* 但 `handler/permission.go` 在**档位不该问人**时也回 409并在回包里带 * 再决定放行与否 —— 因为 `allowed-once` 只绕过**审批**,改不了**沙箱**
* `permission_mode`。两种 409 的正确反应相反:无人可问 → 拒绝full 档 → **放行**。 * 档位没恢复的话,"工作区外写入"这类操作会在沙箱层被拒,且每次受守卫的调用
* * 都要重新走一遍 409放行是 per-call 的)。写回才是"修状态",也顺带是**自愈**路径。
* dsh 桥还有一层放大:档位是从邮件事件 `applyPermissionMode()` 应用的 * 2. **顺序(可达性)**:这块必须在「普通 409无人可问 → 拒绝)」**之前**
* 补投路径漏传档位时(`lib/catchup.js`,同日修)会话被**降级**成 workspace —— * 否则永远不可达。见下面 `orderHolds` 与它的**顺序自检**。
* 沙箱 danger-full-access → workspace-write、审批 never → ask * 3. **状态写入点不接受默认值**`applyPermissionMode` 必须走 `modeForStateWrite`
* 于是每个受守卫的工具调用都去问一次,再被 409 拒绝。一条 full 档会话只要有一封 * (缺字段 → 不写),且缺字段的分支要排在第一个 `session.append` 之前。
* 补投邮件,这一轮的工具调用就全被自己人拦死。
* *
* # 为什么读源码而不是跑起来 * # 为什么读源码而不是跑起来
* *
* 与 `permission-forward-wiring.test.mjs` 同一取舍:入口不是可导入的模块 * 与 `permission-forward-wiring.test.mjs` 同一取舍:入口是插件工厂 + Cordis 依赖,
* (插件工厂 + Cordis 依赖)。这里钉的只有一件事:那个放行分支还在、且认服务端给的档位。 * 不可导入。这里钉的是"接线还在不在、顺序对不对"**值**那一半由
* `permission-mode.test.mjs` 对 `modeForStateWrite` 的行为判据覆盖 ——
* 两条一起才闭合(值 → 行为,结构/来源 → 静态)。
* *
* # 判据自检 * # 判据自检(两条,都跟"能不能红"有关)
* *
* 拿掉放行分支的源码喂给它,必须红 —— 否则这条断言永远为真,比没有更糟。 * - 拿掉写回/放行 → 必须红(证明它**有用**
* - **把它挪到普通 409 之后 → 必须红**(证明它**可达**pi 2026-09-14 §2 指出:
* 只做前一条自检,证明的是"被用到",不是"顺序对")。
*/ */
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs'; import { readFileSync } from 'node:fs';
@ -31,7 +34,11 @@ import { fileURLToPath } from 'node:url';
const HERE = dirname(fileURLToPath(import.meta.url)); const HERE = dirname(fileURLToPath(import.meta.url));
const SRC = readFileSync(join(HERE, '..', 'src', 'index.ts'), 'utf8'); const SRC = readFileSync(join(HERE, '..', 'src', 'index.ts'), 'utf8');
/** 取 409 相关分支的正文(按花括号配对,不看窗口 */ /** 剥掉注释后再断言"代码里有什么"(本仓规范:判代码读剥离版,判理由读原文 */
const codeOnly = src =>
src.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:])\/\/[^\n]*/g, '$1');
/** 取某个分支/函数的正文(按花括号配对,不看窗口) */
function blockAt(src, needle) { function blockAt(src, needle) {
const at = src.indexOf(needle); const at = src.indexOf(needle);
if (at < 0) return ''; if (at < 0) return '';
@ -44,30 +51,97 @@ function blockAt(src, needle) {
return ''; return '';
} }
test('★ 409 + full 档 → 放行plan / 无人可问 → 仍拒绝', () => { const CORRECTED = 'if (e?.status === 409 && corrected) {';
const full = blockAt(SRC, "if (e?.status === 409 && String(e?.body?.permission_mode || '') === 'full')"); const PLAIN = 'if (e?.status === 409) {';
assert.ok(full.length > 40, '要能找到「409 + full」分支找不到说明结构变了判据要跟着改'); /** 「纠正 + 决定」这一整段的起点:声明在 if 之前,所以判据要连它一起读 */
assert.match(full, /return 'allowed-once'/, 'full 档要放行DSH 的 ApprovalOutcome 只认 allowed-once/rejected/…)'); const CORRECTION_AT = 'const corrected = modeForStateWrite(';
const plain = blockAt(SRC, 'if (e?.status === 409) {'); /** 顺序判据本身(抽出来是为了能对"变异后的源码"复用它 —— 见顺序自检) */
function orderHolds(src) {
const a = src.indexOf(CORRECTION_AT);
const b = src.indexOf(PLAIN);
return a >= 0 && b >= 0 && a < b;
}
test('★ 409 带档位:先写回会话状态,再按档位决定放行', () => {
const at = SRC.indexOf(CORRECTION_AT);
const end = SRC.indexOf(PLAIN);
assert.ok(at >= 0 && end > at, '要能找到「读权威档位 → 决定」这一段(找不到说明结构变了,判据要跟着改)');
const block = codeOnly(SRC.slice(at, end));
assert.ok(block.length > 100, '这一段太短,八成是结构变了');
assert.match(block, /modeForStateWrite\(e\?\.body\?\.permission_mode\)/,
'权威档位要用状态写入点的解析器读(缺字段 → 不写,不兜默认)');
assert.match(block, /applyPermissionMode\(/,
'必须把服务端的纠正**写回会话** —— 只放行不写回,沙箱仍然是窄的(放行只修"问不问"');
assert.match(block, /corrected === MODE_FULL/,
'只有服务端明说 full 才放行plan 与"无人可问"照旧拒绝,猜宽了就是提权)');
assert.match(block, /return 'allowed-once'/);
assert.ok(block.indexOf('applyPermissionMode(') < block.indexOf("return 'allowed-once'"),
'写回会话要发生在返回之前(否则这一次放行了,状态还是错的)');
});
test('★ 顺序(可达性):带档位那块必须在普通 409 之前', () => {
assert.ok(orderHolds(SRC),
'「409 + 档位」必须排在普通 409 之前 —— 否则 full 档会先落进后面那块、被当成"无人可问"拒绝,\n' +
'而这块代码永远不可达("文件里没有能红的路径"那一族)。');
const plain = codeOnly(blockAt(SRC, PLAIN));
assert.ok(plain.length > 100, '要能找到普通 409 分支'); assert.ok(plain.length > 100, '要能找到普通 409 分支');
assert.match(plain, /return 'rejected'/, '非 full 的 409 仍要当场拒绝'); assert.match(plain, /return 'rejected'/, '非 full 的 409 仍要当场拒绝');
/* assert.ok(!/allowed-once/.test(plain),
* ⚠️ 「代码里有没有放行」必须读**剥掉注释**的正文:普通 409 分支的注释里正好写着 '普通 409 分支里不许出现放行(那就等于"没人可问也放行"');
* "ApprovalOutcome 只认 allowed-once / rejected / …"(解释为什么写 'rejected'
* 而不是 'denied')—— 读原文会把那句话当成"这里会放行"而误报。
* 这是本仓规范里那条:判"代码里有什么"读剥离版,判"理由写清了没"读原文。
*/
const plainCode = plain.replace(/\/\*[\s\S]*?\*\//g, '').replace(/\/\/[^\n]*/g, '');
assert.ok(!/allowed-once/.test(plainCode),
`普通 409 分支不许放行(那就等于"没人可问也放行"${(plainCode.match(/allowed-once/) || [''])[0]}`);
// 顺序full 分支必须在普通 409 之前,否则永远不可达
assert.ok(SRC.indexOf("permission_mode || '') === 'full'") < SRC.indexOf('if (e?.status === 409) {'),
'放行分支要排在普通 409 之前(否则被前面的分支吃掉,永远不可达)');
}); });
test('★ 判据自检:拿掉放行分支,上面那条必须红', () => { test('★ 顺序自检:把它挪到普通 409 之后,上面那条顺序判据必须红', () => {
const crippled = SRC.replace(/if \(e\?\.status === 409 && String\(e\?\.body\?\.permission_mode \|\| ''\) === 'full'\)[\s\S]*?\n \}\n/, ''); /*
assert.equal(blockAt(crippled, "if (e?.status === 409 && String(e?.body?.permission_mode || '') === 'full')").length, 0, * pi 2026-09-14 §2我旧的自检只做了"拿掉分支 → 红",那只证明它**被用到**
'自检:判据要能检出"放行分支被拿掉"这件事'); * 不证明**顺序对** —— 若有人把它挪到后面,整套判据照样绿(分支还在,只是走不到)。
* 所以这里对"挪动后的源码"复用同一条 orderHolds要求它为假。
*
* 挪法模拟真实的误编辑:把这块整段剪下来,插到普通 409 那块之后。
*/
const start = SRC.indexOf(CORRECTION_AT);
const plainAt = SRC.indexOf(PLAIN);
assert.ok(start >= 0 && plainAt > start, '前置:当前顺序正确,才谈得上"挪动"');
const moved = SRC.slice(start, plainAt);
const swapped = SRC.slice(0, start) + SRC.slice(plainAt) + moved;
assert.equal(orderHolds(swapped), false,
'自检失败把「409 + 档位」挪到普通 409 之后,顺序判据居然还是绿的 —— 那它抓不到这个误编辑');
/*
* 另一条自检:**整段拿掉**。注意不能用"改成 `if (false)`"来验 —— 那只是"够不着"
* 顺序关系还在,该由上面那条**动作**判据去抓(分工不同,别混)。
*/
const removed = SRC.slice(0, start) + SRC.slice(plainAt);
assert.equal(orderHolds(removed), false, '自检失败:整段删掉后顺序判据应当红(找不到这一段)');
});
test('★ 状态写入点applyPermissionMode 不兜默认档,且缺字段分支在任何 append 之前', () => {
const fn = codeOnly(blockAt(SRC, 'function applyPermissionMode('));
assert.ok(fn.length > 100, '要能找到 applyPermissionMode');
assert.match(fn, /modeForStateWrite\(mode\)/,
'状态写入点必须走 modeForStateWrite缺字段 → null → 不写)');
assert.ok(!/\(\s*mode\s*\|\|\s*'workspace'\s*\)/.test(fn),
'不许再把缺字段兜成 workspace —— 那正是 2026-09-14 事故的形状');
const guard = fn.indexOf('m === null');
const firstAppend = fn.indexOf('session.append(');
assert.ok(guard >= 0 && firstAppend > guard,
'缺字段的提前返回要排在第一个 session.append 之前(否则状态已经被改了)');
});
test('★ lib 里用到的名字必须真的 import 进来(我曾经漏过一次,且被管道骗过)', () => {
/*
* 2026-09-14 我在这里栽过409 分支里直接用 `MODE_FULL` 却没 import
* 而 `npx tsc --noEmit | tail -3` 的退出码是 `tail` 的 —— 于是"类型检查通过"是假的,
* `ReferenceError` 正好会抛在它要修的那条路径上。
* 这条判据把"名字被绑定"这件事钉住(值/行为判据覆盖不到静态的绑定)。
*/
const importLine = /import\s*\{([^}]+)\}\s*from\s*'\.\.\/lib\/permission-mode\.js'/.exec(SRC);
assert.ok(importLine, "要能从 '../lib/permission-mode.js' import找不到说明 import 被改了)");
const bound = new Set(importLine[1].split(',').map(s => s.trim()).filter(Boolean));
for (const name of ['MODE_FULL', 'modeForStateWrite']) {
assert.ok(new RegExp(`\\b${name}\\b`).test(codeOnly(SRC)),
`${name} 要用在源码里(否则这条判据该删掉,别留空转)`);
assert.ok(bound.has(name),
`${name} 在源码里被用到,却没从 '../lib/permission-mode.js' import 进来 —— ` +
`运行时是 ReferenceError而且它抛在**它要修的那条路径**上。`);
}
}); });

View File

@ -14,6 +14,7 @@ import {
normalizeMode, normalizeEnforcement, modeAtMost, modeNeedsHuman, normalizeMode, normalizeEnforcement, modeAtMost, modeNeedsHuman,
dshSandboxMode, dshApprovalPolicy, dshSandboxMode, dshApprovalPolicy,
piGuardedTools, piBlocksOutright, modeBriefing, piGuardedTools, piBlocksOutright, modeBriefing,
modeForStateWrite,
} from '../lib/permission-mode.js'; } from '../lib/permission-mode.js';
// ─── 归一化 ─── // ─── 归一化 ───
@ -213,3 +214,24 @@ test('partial 与 native 一样要求把方案写在回信里(出路不能消
const s = modeBriefing({ mode: MODE_PLAN, enforcement: ENFORCE_PARTIAL }); const s = modeBriefing({ mode: MODE_PLAN, enforcement: ENFORCE_PARTIAL });
assert.match(s, /回信/); assert.match(s, /回信/);
}); });
// ─── 状态写入点的档位解析2026-09-14 线上事故的形状)───
test('★ 状态写入点:缺字段 → null不写不是默认档', () => {
/*
* 事故:补投路径漏传 `permission_mode`,插件拿 `undefined` 兜了默认档,
* 把一条 **full 档会话**改写成了 workspace-write + ask。后果不是"这一轮被拦一次"
* 而是一整轮工具能力降级(沙箱层就拒了),而且状态留在会话里。
*
* 规则:**默认值可以出现在决策里,不可以出现在状态写入里。**
*/
for (const missing of ['', ' ', undefined, null, 42, {}]) {
assert.equal(modeForStateWrite(missing), null,
`${JSON.stringify(missing)} 应当返回 null= 不要写状态),而不是兜一个档`);
}
// 反面:有值就照共用契约收敛(含脏值 fail-closed 到默认档,与 Gateway 同语义)
assert.equal(modeForStateWrite('full'), 'full');
assert.equal(modeForStateWrite(' workspace '), 'workspace');
assert.equal(modeForStateWrite('danger'), DEFAULT_MODE, '脏值仍按共用契约收敛,但这不是"缺字段"');
assert.notEqual(modeForStateWrite(''), DEFAULT_MODE, '缺字段不能等于默认档 —— 那正是事故');
});

View File

@ -1326,6 +1326,26 @@ export default async function mailBridge(input) {
// //
// deny 的同时把原因带给模型opencode 把 reason 作为工具报错回去), // deny 的同时把原因带给模型opencode 把 reason 作为工具报错回去),
// 它才知道下一步该换什么做法。 // 它才知道下一步该换什么做法。
/*
* 409 有**两种**含义正确反应相反2026-09-14与 dsh/pi/zcode 桥同一处缺陷):
* 「这条链上没有人类」→ 拒绝;「**本档根本不该问**」→ 服务端在回包里带
* `permission_mode`,是 full 档就应当**放行**full 档工具调用无需审批)。
*
* 走到第二种的路径补投漏传档位lib/catchup.js同日已修→ 本轮按 workspace
* 拦下来问人 → 服务端按真实档位回 409 → 旧代码当"无人可问"拒绝,
* 一条 full 档会话的工具调用全被自己人拦死。
*
* **只认服务端明说的 full**plan 与"无人可问"照旧拒绝(猜宽了就是提权)。
* 这个桥没有会话档位旋钮,所以只修这一层。
*/
if (Number(e?.status) === 409 && String(e?.body?.permission_mode || "") === "full") {
console.error(
`[mail-bridge] 服务端判定本会话为 full 档,放行本次询问(${input.id}):无需审批`,
);
output.status = "allow";
return;
}
if (isPermanentFailure(e)) { if (isPermanentFailure(e)) {
const b = e?.body || {}; const b = e?.body || {};
console.error( console.error(

View File

@ -78,6 +78,43 @@ export function normalizeEnforcement(e) {
: ENFORCE_ADVISORY; : ENFORCE_ADVISORY;
} }
/**
* **状态写入点**专用的档位解析:拿不到值就返回 `null`= 不要写),绝不兜默认档。
*
* ## 为什么单开一个函数(而不是复用 normalizeMode
*
* `normalizeMode` 的语义是「**要一个档位**时怎么收敛」——空/脏值都给默认档。
* 那在**决策**里是对的(有个兜底),在**状态写入**里是错的:
*
* 2026-09-14 的线上事故正是这个形状:补投路径漏传 `permission_mode`
* 插件拿 `undefined` 走默认档,把一条 **full 档会话**写成了 `workspace-write`
* —— 沙箱窄了、审批从 never 变 ask之后每个受守卫的工具调用都去问一次
* 服务端按真实档位回 409旧代码又当场拒绝。**一次状态写入,毁了整轮的工具能力**。
*
* 一句话规则pi 提的):**默认值可以出现在「决策」里,不可以出现在「状态写入」里**
* —— 决策有默认值是兜底,状态写入有默认值是**篡改**。
*
* 于是分工是:
* - 缺字段(空串 / undefined / 非字符串)→ `null`,调用方**跳过写入**
* 保留上一次由服务端给出的权威值;
* - 有值 → 交给 `normalizeMode` 收敛(脏值仍按共用契约 fail-closed 到默认档,
* 与 Gateway 的 `NormalizePermissionMode` 同语义 —— 这里不是"猜",是同一份契约)。
*
* 跳过写入会不会 fail-open**实测过方向的**DSH 的默认沙箱就是
* `workspace-write` + `ask`(见 dsh 客户端 `permissionSelectOf` 的初值),
* 所以"新会话 + 缺字段"落到的是与共用契约同一个档,不会更宽;
* 而"已有会话 + 缺字段"保留的是服务端先前给的权威值,下一个权威事件
* (带档位的邮件 / 409 纠正 / 人在界面改档)会把它修正回来。
*
* @param {unknown} mode
* @returns {string|null} 合法档位;`null` 表示**不要写状态**
*/
export function modeForStateWrite(mode) {
const m = typeof mode === 'string' ? mode.trim() : '';
if (m === '') return null;
return normalizeMode(m);
}
/** /**
* 取两个档位里更严的那一个。 * 取两个档位里更严的那一个。
* *

View File

@ -79,3 +79,14 @@ test('seen 传 undefined 时不去重也不报错', () => {
const got = selectCatchup([mail({ mail_id: 'x' })], undefined); const got = selectCatchup([mail({ mail_id: 'x' })], undefined);
assert.deepEqual(got.map(e => e.mail_id), ['x']); assert.deepEqual(got.map(e => e.mail_id), ['x']);
}); });
test('★ 补投事件必须带权限档位(漏了就会把 full 档会话误拦)', () => {
const ev = mailToEvent(mail({ permission_mode: 'full', permission_enforcement: 'strict' }));
assert.equal(ev.permission_mode, 'full', '补投路径不许把档位丢掉');
assert.equal(ev.permission_enforcement, 'strict');
// 缺字段时给空串:**不猜档位**(猜宽了就是提权),由调用方 `|| 'workspace'` 兜底
const bare = mailToEvent(mail());
assert.equal(bare.permission_mode, '');
assert.equal(bare.permission_enforcement, '');
assert.ok(!('permissionMode' in bare), '键名要与 SSE 逐字一致(蛇形),别自造驼峰');
});

View File

@ -14,6 +14,7 @@ import {
normalizeMode, normalizeEnforcement, modeAtMost, modeNeedsHuman, normalizeMode, normalizeEnforcement, modeAtMost, modeNeedsHuman,
dshSandboxMode, dshApprovalPolicy, dshSandboxMode, dshApprovalPolicy,
piGuardedTools, piBlocksOutright, modeBriefing, piGuardedTools, piBlocksOutright, modeBriefing,
modeForStateWrite,
} from '../lib/permission-mode.js'; } from '../lib/permission-mode.js';
// ─── 归一化 ─── // ─── 归一化 ───
@ -213,3 +214,24 @@ test('partial 与 native 一样要求把方案写在回信里(出路不能消
const s = modeBriefing({ mode: MODE_PLAN, enforcement: ENFORCE_PARTIAL }); const s = modeBriefing({ mode: MODE_PLAN, enforcement: ENFORCE_PARTIAL });
assert.match(s, /回信/); assert.match(s, /回信/);
}); });
// ─── 状态写入点的档位解析2026-09-14 线上事故的形状)───
test('★ 状态写入点:缺字段 → null不写不是默认档', () => {
/*
* 事故:补投路径漏传 `permission_mode`,插件拿 `undefined` 兜了默认档,
* 把一条 **full 档会话**改写成了 workspace-write + ask。后果不是"这一轮被拦一次"
* 而是一整轮工具能力降级(沙箱层就拒了),而且状态留在会话里。
*
* 规则:**默认值可以出现在决策里,不可以出现在状态写入里。**
*/
for (const missing of ['', ' ', undefined, null, 42, {}]) {
assert.equal(modeForStateWrite(missing), null,
`${JSON.stringify(missing)} 应当返回 null= 不要写状态),而不是兜一个档`);
}
// 反面:有值就照共用契约收敛(含脏值 fail-closed 到默认档,与 Gateway 同语义)
assert.equal(modeForStateWrite('full'), 'full');
assert.equal(modeForStateWrite(' workspace '), 'workspace');
assert.equal(modeForStateWrite('danger'), DEFAULT_MODE, '脏值仍按共用契约收敛,但这不是"缺字段"');
assert.notEqual(modeForStateWrite(''), DEFAULT_MODE, '缺字段不能等于默认档 —— 那正是事故');
});

View File

@ -78,6 +78,43 @@ export function normalizeEnforcement(e) {
: ENFORCE_ADVISORY; : ENFORCE_ADVISORY;
} }
/**
* **状态写入点**专用的档位解析:拿不到值就返回 `null`= 不要写),绝不兜默认档。
*
* ## 为什么单开一个函数(而不是复用 normalizeMode
*
* `normalizeMode` 的语义是「**要一个档位**时怎么收敛」——空/脏值都给默认档。
* 那在**决策**里是对的(有个兜底),在**状态写入**里是错的:
*
* 2026-09-14 的线上事故正是这个形状:补投路径漏传 `permission_mode`
* 插件拿 `undefined` 走默认档,把一条 **full 档会话**写成了 `workspace-write`
* —— 沙箱窄了、审批从 never 变 ask之后每个受守卫的工具调用都去问一次
* 服务端按真实档位回 409旧代码又当场拒绝。**一次状态写入,毁了整轮的工具能力**。
*
* 一句话规则pi 提的):**默认值可以出现在「决策」里,不可以出现在「状态写入」里**
* —— 决策有默认值是兜底,状态写入有默认值是**篡改**。
*
* 于是分工是:
* - 缺字段(空串 / undefined / 非字符串)→ `null`,调用方**跳过写入**
* 保留上一次由服务端给出的权威值;
* - 有值 → 交给 `normalizeMode` 收敛(脏值仍按共用契约 fail-closed 到默认档,
* 与 Gateway 的 `NormalizePermissionMode` 同语义 —— 这里不是"猜",是同一份契约)。
*
* 跳过写入会不会 fail-open**实测过方向的**DSH 的默认沙箱就是
* `workspace-write` + `ask`(见 dsh 客户端 `permissionSelectOf` 的初值),
* 所以"新会话 + 缺字段"落到的是与共用契约同一个档,不会更宽;
* 而"已有会话 + 缺字段"保留的是服务端先前给的权威值,下一个权威事件
* (带档位的邮件 / 409 纠正 / 人在界面改档)会把它修正回来。
*
* @param {unknown} mode
* @returns {string|null} 合法档位;`null` 表示**不要写状态**
*/
export function modeForStateWrite(mode) {
const m = typeof mode === 'string' ? mode.trim() : '';
if (m === '') return null;
return normalizeMode(m);
}
/** /**
* 取两个档位里更严的那一个。 * 取两个档位里更严的那一个。
* *

View File

@ -2,11 +2,6 @@ import assert from 'node:assert/strict';
import test from 'node:test'; import test from 'node:test';
import { MAX_CATCHUP, mailToEvent, selectCatchup } from '../lib/catchup.js'; import { MAX_CATCHUP, mailToEvent, selectCatchup } from '../lib/catchup.js';
import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const HERE = dirname(fileURLToPath(import.meta.url));
const mail = (over = {}) => ({ const mail = (over = {}) => ({
mail_id: 'm1', mail_id: 'm1',
@ -85,17 +80,6 @@ test('seen 传 undefined 时不去重也不报错', () => {
assert.deepEqual(got.map(e => e.mail_id), ['x']); assert.deepEqual(got.map(e => e.mail_id), ['x']);
}); });
/**
* ★ 权限档位必须带过来2026-09-14 生产事故的回归判据)。
*
* 现象:一条 full 档会话里只要有一封**补投**邮件worker 就以为自己是 workspace 档,
* 去申请审批 → 服务端按档位回 409 → 桥按「永久失败」当场 block → 这一轮
* bash/write/edit 全被拦SSE 实时送达的邮件不受影响,只有补投的中招)。
*
* 这条判据本该早就存在:上面那条「与 SSE new_mail 同形」的断言只列了 6 个键,
* **照抄了实现里搬的那几个字段** —— 于是"形状不一致"这件事恰好落在它的盲区里。
* 盲区不是"漏了一个字段",是"字段清单由被检查方提供"。
*/
test('★ 补投事件必须带权限档位(漏了就会把 full 档会话误拦)', () => { test('★ 补投事件必须带权限档位(漏了就会把 full 档会话误拦)', () => {
const ev = mailToEvent(mail({ permission_mode: 'full', permission_enforcement: 'strict' })); const ev = mailToEvent(mail({ permission_mode: 'full', permission_enforcement: 'strict' }));
assert.equal(ev.permission_mode, 'full', '补投路径不许把档位丢掉'); assert.equal(ev.permission_mode, 'full', '补投路径不许把档位丢掉');
@ -106,69 +90,3 @@ test('★ 补投事件必须带权限档位(漏了就会把 full 档会话误
assert.equal(bare.permission_enforcement, ''); assert.equal(bare.permission_enforcement, '');
assert.ok(!('permissionMode' in bare), '键名要与 SSE 逐字一致(蛇形),别自造驼峰'); assert.ok(!('permissionMode' in bare), '键名要与 SSE 逐字一致(蛇形),别自造驼峰');
}); });
test('★ 配对判据worker 从事件里读的每个档位字段,补投都要产出', () => {
/*
* 上面那条只钉两个已知字段,将来 worker 再读一个 `msg.data?.xxx` 时它不会响。
* 这条把**生产者与消费者配对**worker 读哪些 `msg.data?.X``mailToEvent`
* 就得产出哪些 X。字段清单不再由被检查方提供 —— 这正是上次漏掉档位的根因。
* 与 narrow-layout 同一取舍:验形态,不验行为;重构敏感是它该做的提醒。
*/
const worker = readFileSync(join(HERE, '..', 'src/worker.mjs'), 'utf8');
const read = new Set([...worker.matchAll(/msg\.data\?\.([a-z_]+)/g)].map(m => m[1]));
assert.ok(read.size >= 4, `要能取到 worker 读的字段(实际 ${read.size} 个)`);
const produced = new Set(Object.keys(mailToEvent(mail())));
const missing = [...read].filter(k => !produced.has(k));
assert.deepEqual(missing, [],
`worker 会从事件里读这些字段,补投却没产出:${missing.join('、')} —— ` +
'补投是重启后的唯一送达路径,缺字段等于那条路径静默降级(档位漏传就是这么出的)');
});
/**
* ★ 四桥配对:每个桥从投递事件里读的**邮件类**字段,补投都要产出。
*
* # 为什么这条判据存在
*
* 2026-09-14 的事故full 档会话被误拦)根因是 `mailToEvent` 搬的字段太少,
* 而它**单独看任何一个桥的测试都发现不了**pi 的测试只列了自己关心的 6 个键
* 照抄实现dsh/zcode 读别的字段,各测各的。缺的字段在**接口**上,
* 不在任何一侧的内部。
*
* 所以这条把四桥的读法与补投的产出**配对**,并且:
* - 只认邮件类事件:`decision_mail_id / kind / note / suggestions / participants`
* 属于权限**决策**事件(`permission_decision`),不走 mailToEvent —— 列进
* ALLOW 并各写一句理由,别用"看起来像"糊过去;
* - 白名单里的每一项都必须是**明确的非邮件事件字段**,加新的要写理由。
*/
const ALLOW = new Map([
['decision_mail_id', '权限决策事件permission_decision的字段不走补投'],
['kind', '决策事件:同意/拒绝'],
['note', '决策事件:备注'],
['suggestions', '决策事件:选项'],
['participants', '决策事件:参与方'],
]);
test('★ 四桥配对:各桥读的邮件字段,补投都要产出(含决策字段白名单自检)', () => {
const produced = new Set(Object.keys(mailToEvent(mail())));
const bridges = [
['dsh', '../../dsh-mail-bridge/src/index.ts'],
['pi', '../src/worker.mjs'],
['zcode', '../../zcode-mail-bridge/src/index.mjs'],
];
const problems = [];
for (const [name, rel] of bridges) {
const src = readFileSync(join(HERE, rel), 'utf8');
const reads = new Set([...src.matchAll(/\b(?:data|msg\.data\?|ev)\.([a-z_]+)/g)].map(m => m[1]));
const missing = [...reads].filter(k => !produced.has(k) && !ALLOW.has(k));
if (missing.length) problems.push(`${name} 读但补投不产出:${missing.join('、')}`);
}
assert.deepEqual(problems, [],
`${problems.join('')}\n` +
'补投是重启后的唯一送达路径,缺字段等于那条路径静默降级(档位漏传就是这么出的)。' +
'若某个字段确实属于别的(非邮件)事件,请往本文件的 ALLOW 里加并写理由。');
// 白名单自检:每个理由都非空,且确实是"决策事件"那一族(别拿"看起来像"糊过去)
for (const [k, why] of ALLOW) {
assert.ok(why.length >= 6, `ALLOW 里 ${k} 要写清为什么可以不产出`);
}
assert.ok(ALLOW.size <= 8, '白名单不许无限膨胀(它一膨胀这条判据就废了)');
});

View File

@ -0,0 +1,194 @@
/**
* 四桥**都**转发权限询问 —— 所以四桥都必须在 409 里认出「本档不该问」并放行。
*
* # 这条判据的来历(先记一笔错误,免得下一个人以为它天生存在)
*
* 我 2026-09-14 在回信里写过「zcode / opencode 不转发权限询问src 里没有
* `permission/request`,也没有 409 分支所以不需要改」。pi 要求把这句**否定性事实**
* 变成常驻判据("你查过了"只值一次),于是写了这条 —— **它当场就红给我看**
* zcode 的 `hooks/permission.mjs` 与 opencode 的 `index.js` 都在
* `post('/permission/request')`,也都有永久失败(含 409分支。
*
* 我上次查错的原因是**搜索面太窄**:只扫了 `<plugin>/src/*.mjs`
* 而 zcode 的转发在 `hooks/`、opencode 的实现就在根目录 `index.js`。
* "我查过了"这句话本身没算错,错在**查的范围是随手定的**。
*
* 于是这条判据有两个用处:
* 1. 四桥都转发 → 四桥都必须处理"409 带档位"这个情形(否则那条路径上工具调用
* 会被自己人拦死);
* 2. **否定性事实必须变成判据** —— 否则它只靠人记得,而人(包括我)会记错。
*
* # 判据形态
*
* 表驱动每个桥登记「409 + full 的放行写法」「是否要写回状态」「永久失败锚点」。
* 找不到 → 红(不是跳过):"找不到"与"没有"在结果上必须区分开。
*/
import assert from 'node:assert/strict';
import { readFileSync, readdirSync, statSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { test } from 'node:test';
import { fileURLToPath } from 'node:url';
const HERE = dirname(fileURLToPath(import.meta.url));
const PLUGINS = join(HERE, '..', '..');
/**
* 每个插件的**实现**入口(只看实现,不看测试)。
*
* 布局四个桥各不相同实测dsh/pi/zcode 有 `src/`zcode 的转发放在 `hooks/`
* opencode 是根目录一个 `index.js`),所以显式登记而不是"扫 src、没有就算了"
* —— 后者正是本仓最忌讳的**静默放行**:扫不到就等于判据永远为真。
*/
const IMPL = {
'dsh-mail-bridge': ['src'],
'pi-mail-bridge': ['src'],
'zcode-mail-bridge': ['src', 'mcp', 'hooks'],
'opencode-mail-bridge': ['index.js'],
};
function readImpl(plugin) {
const out = [];
const walk = d => {
for (const e of readdirSync(d, { withFileTypes: true })) {
const p = join(d, e.name);
if (e.isDirectory()) walk(p);
else if (/\.(mjs|ts|js)$/.test(e.name)) out.push(readFileSync(p, 'utf8'));
}
};
for (const rel of IMPL[plugin]) {
const p = join(PLUGINS, plugin, rel);
const st = statSync(p); // 登记了就必须在:找不到时抛 ENOENT 让判据红,别静默跳过
if (st.isDirectory()) walk(p);
else out.push(readFileSync(p, 'utf8'));
}
assert.ok(out.length > 0, `${plugin} 的实现文本读出来是空的 —— 判据的读法要跟着布局改`);
return out.join('\n');
}
/** 剥注释:判"代码里有什么"必须读剥离版(本仓规范) */
const codeOnly = src =>
src.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:])\/\/[^\n]*/g, '$1');
/** 一个桥的登记:放行写法、是否要写回状态、永久失败锚点 */
const BRIDGES = [
{
plugin: 'dsh-mail-bridge',
allow: /corrected === MODE_FULL/,
mustWriteBack: /applyPermissionMode\(/,
permanent: 'if (isPermanentFailure(e))',
note: '409 里带 full 时放行,并把权威档位写回会话(否则沙箱仍是窄的)',
},
{
plugin: 'pi-mail-bridge',
allow: /permission_mode === MODE_FULL/,
mustWriteBack: null,
permanent: 'if (isPermanentFailure(e))',
note: 'pi 的档位是 per-job 的,没有会话状态要写回',
},
{
plugin: 'zcode-mail-bridge',
allow: /permission_mode \|\| ''\) === 'full'/,
mustWriteBack: null,
permanent: 'if (isPermanentFailure(e))',
note: 'hooks/permission.mjs 的 409 分支(该桥没有 sandbox/approval 旋钮)',
},
{
plugin: 'opencode-mail-bridge',
allow: /permission_mode \|\| ""\) === "full"/,
mustWriteBack: null,
permanent: 'if (isPermanentFailure(e))',
note: 'index.js 的 permission.ask 钩子status: ask | deny | allow',
},
];
test('探测器自检:它认得出"有转发"的样子', () => {
const has = src => /permission\/request/.test(src);
assert.equal(has("await client.post('/permission/request', body)"), true);
assert.equal(has("await client.get('/mail/inbox')"), false);
});
test('★ 四桥都转发权限询问(我上次说"只有两个"是查错了)', () => {
for (const b of BRIDGES) {
assert.match(readImpl(b.plugin), /permission\/request/,
`${b.plugin} 应当转发权限询问。若确实不再转发,请回来改这条判据并把理由写清楚 —— ` +
`"我之前查过"不算理由(我就查错过一次)。`);
}
});
test('★ 四桥都必须认出「409 带 full → 放行」,且排在永久失败分支之前', () => {
for (const b of BRIDGES) {
const src = codeOnly(readImpl(b.plugin));
assert.match(src, b.allow,
`${b.plugin} 的 409 处理里找不到"服务端说 full 就放行"这一支:${b.note}\n` +
'缺了它的后果:补投漏传档位时按 workspace 档问人 → 服务端按真实档位回 409 → ' +
'被当成"链上没有人类"拒绝,一条 full 档会话的工具调用全被自己人拦死。');
if (b.mustWriteBack) {
assert.match(src, b.mustWriteBack,
`${b.plugin} 有会话档位旋钮,所以 409 里的权威档位还必须**写回会话**` +
'只放行修的是"问不问",沙箱(能不能)还是窄的。');
}
const allowAt = src.search(b.allow);
const permAt = src.indexOf(b.permanent);
assert.ok(permAt >= 0,
`${b.plugin} 找不到永久失败分支锚点 \`${b.permanent}\`(结构变了,判据要跟着改,别让它空转)`);
assert.ok(allowAt >= 0 && allowAt < permAt,
`${b.plugin} 里"full 放行"必须排在永久失败分支**之前**,否则永远不可达 —— ` +
'整套跑得绿、实际走不到,是"文件里没有能红的路径"那一族。');
}
});
test('★ 判据自检:把放行那一支挪到永久失败分支之后,顺序判据必须能红', () => {
const orderHolds = (src, b) => {
const a = src.search(b.allow);
const p = src.indexOf(b.permanent);
return a >= 0 && p >= 0 && a < p;
};
for (const b of BRIDGES) {
const src = codeOnly(readImpl(b.plugin));
assert.ok(orderHolds(src, b), `前置:${b.plugin} 当前顺序正确,才谈得上"挪动"`);
const a = src.search(b.allow);
const p = src.indexOf(b.permanent);
const swapped = src.slice(0, a) + src.slice(p) + src.slice(a, p);
assert.equal(orderHolds(swapped, b), false,
`自检失败:${b.plugin} 把放行那一支挪到后面之后,顺序判据居然还是绿的 —— 那它抓不到这个误编辑`);
}
});
/**
* ★ 生产者 / 消费者配对:档位这个键名,四平台的消费者都在读,补投也真的产出它。
*
* 事故的形状是「生产者漏字段、消费者照读」:补投路径少带 `permission_mode`
* 而 worker 那边 `msg.data?.permission_mode` 读到 `undefined` → 当 workspace 档去问人
* → 服务端按真实档位回 409 → 被当"无人可问"拒绝,一整轮工具能力没了。
*
* 为什么这条放在**平台专属**文件里、而不是 `test/catchup.test.mjs`
* 后者是四平台**共用**的(`deploy/check-shared-libs.sh` 要求逐字节相同),
* 一旦写进"pi 的 worker 在 src/worker.mjs"这种路径,四份就不再相同 ——
* 我上一版正是这么干的,结果那个检查从 `153985e` 起一直红着,而我没跑过它。
* 共用文件放**契约**(值/行为),平台专属文件放**跨平台配对**(来源/结构)。
*/
test('★ 配对:消费者读的档位键名,生产者必须产出(四平台逐一登记读取处)', async () => {
// 消费者登记:每个平台从投递事件里读档位的那一行(找不到 → 红,不静默跳过)
const CONSUMERS = [
['pi', 'pi-mail-bridge/src/worker.mjs'],
['dsh', 'dsh-mail-bridge/src/index.ts'],
['opencode', 'opencode-mail-bridge/index.js'],
['zcode', 'zcode-mail-bridge/hooks/permission.mjs'],
];
for (const [who, rel] of CONSUMERS) {
const text = codeOnly(readFileSync(join(PLUGINS, rel), 'utf8'));
assert.match(text, /permission_mode/,
`${who}${rel})没有引用 \`permission_mode\` —— 要么它改读了别的键名,` +
'要么它不再关心档位(后者要在本判据里写清为什么)');
}
// 生产侧是**行为**判据:真的调一次 mailToEvent看它产不产出这个键
const { mailToEvent } = await import('../lib/catchup.js');
const ev = mailToEvent({
mail_id: 'm1', from_name: 'x', session_alias: 's', subject: 's', body: 'b',
permission_mode: 'full', permission_enforcement: 'strict',
});
assert.ok('permission_mode' in ev,
'补投事件必须产出 `permission_mode`:消费者读它,缺了就是"读到 undefined 当默认档"那场事故');
assert.equal(ev.permission_mode, 'full', '有档位时不许丢;键名要与 SSE new_mail 逐字一致(蛇形)');
});

View File

@ -14,6 +14,7 @@ import {
normalizeMode, normalizeEnforcement, modeAtMost, modeNeedsHuman, normalizeMode, normalizeEnforcement, modeAtMost, modeNeedsHuman,
dshSandboxMode, dshApprovalPolicy, dshSandboxMode, dshApprovalPolicy,
piGuardedTools, piBlocksOutright, modeBriefing, piGuardedTools, piBlocksOutright, modeBriefing,
modeForStateWrite,
} from '../lib/permission-mode.js'; } from '../lib/permission-mode.js';
// ─── 归一化 ─── // ─── 归一化 ───
@ -213,3 +214,24 @@ test('partial 与 native 一样要求把方案写在回信里(出路不能消
const s = modeBriefing({ mode: MODE_PLAN, enforcement: ENFORCE_PARTIAL }); const s = modeBriefing({ mode: MODE_PLAN, enforcement: ENFORCE_PARTIAL });
assert.match(s, /回信/); assert.match(s, /回信/);
}); });
// ─── 状态写入点的档位解析2026-09-14 线上事故的形状)───
test('★ 状态写入点:缺字段 → null不写不是默认档', () => {
/*
* 事故:补投路径漏传 `permission_mode`,插件拿 `undefined` 兜了默认档,
* 把一条 **full 档会话**改写成了 workspace-write + ask。后果不是"这一轮被拦一次"
* 而是一整轮工具能力降级(沙箱层就拒了),而且状态留在会话里。
*
* 规则:**默认值可以出现在决策里,不可以出现在状态写入里。**
*/
for (const missing of ['', ' ', undefined, null, 42, {}]) {
assert.equal(modeForStateWrite(missing), null,
`${JSON.stringify(missing)} 应当返回 null= 不要写状态),而不是兜一个档`);
}
// 反面:有值就照共用契约收敛(含脏值 fail-closed 到默认档,与 Gateway 同语义)
assert.equal(modeForStateWrite('full'), 'full');
assert.equal(modeForStateWrite(' workspace '), 'workspace');
assert.equal(modeForStateWrite('danger'), DEFAULT_MODE, '脏值仍按共用契约收敛,但这不是"缺字段"');
assert.notEqual(modeForStateWrite(''), DEFAULT_MODE, '缺字段不能等于默认档 —— 那正是事故');
});

View File

@ -203,6 +203,26 @@ async function main() {
try { try {
decision = await askHuman({ client, baseURL: client.baseURL, input, toolName, relayKey, sessionId }); decision = await askHuman({ client, baseURL: client.baseURL, input, toolName, relayKey, sessionId });
} catch (e) { } catch (e) {
/*
* 409 有**两种**含义正确反应相反2026-09-14与 dsh/pi 桥同一处缺陷):
*
* - 「这条链上没有人类」→ 当场拒绝(下面的理由);
* - 「**本档根本不该问**」→ 服务端在回包里带 `permission_mode`。若是 full 档,
* 这次询问本就不该发生full 档工具调用无需审批),正确反应是**放行**。
*
* 什么时候会走到第二种:补投路径漏传档位(`lib/catchup.js`,同日已修),
* 于是这一轮按 workspace 档拦下来问人,服务端按真实档位回 409 ——
* 旧代码把它当「无人可问」拒绝,让一条 full 档会话的工具调用**全被自己人拦死**。
*
* **只认服务端明说的 full**plan 档与"链上没有人类"照旧拒绝(猜宽了就是提权)。
* 这个桥不写会话状态(没有 sandbox/approval 旋钮),所以只需修这一层;
* dsh 桥那边还要把权威档位**写回会话**,否则沙箱仍然是窄的。
*/
if (Number(e?.status) === 409 && String(e?.body?.permission_mode || '') === 'full') {
log(`服务端判定本会话为 full 档,放行本次询问(${relayKey}):无需审批`);
return approve();
}
// 409 = 服务端判定这条任务链上没有人类,永远不会有人来点头。 // 409 = 服务端判定这条任务链上没有人类,永远不会有人来点头。
// 永久失败4xx同样不会因重试而改变 —— 两者都必须当场拒绝, // 永久失败4xx同样不会因重试而改变 —— 两者都必须当场拒绝,
// 让模型从工具报错里看到原因并自己改道(挂死时连重试机会都没有)。 // 让模型从工具报错里看到原因并自己改道(挂死时连重试机会都没有)。

View File

@ -78,6 +78,43 @@ export function normalizeEnforcement(e) {
: ENFORCE_ADVISORY; : ENFORCE_ADVISORY;
} }
/**
* **状态写入点**专用的档位解析:拿不到值就返回 `null`= 不要写),绝不兜默认档。
*
* ## 为什么单开一个函数(而不是复用 normalizeMode
*
* `normalizeMode` 的语义是「**要一个档位**时怎么收敛」——空/脏值都给默认档。
* 那在**决策**里是对的(有个兜底),在**状态写入**里是错的:
*
* 2026-09-14 的线上事故正是这个形状:补投路径漏传 `permission_mode`
* 插件拿 `undefined` 走默认档,把一条 **full 档会话**写成了 `workspace-write`
* —— 沙箱窄了、审批从 never 变 ask之后每个受守卫的工具调用都去问一次
* 服务端按真实档位回 409旧代码又当场拒绝。**一次状态写入,毁了整轮的工具能力**。
*
* 一句话规则pi 提的):**默认值可以出现在「决策」里,不可以出现在「状态写入」里**
* —— 决策有默认值是兜底,状态写入有默认值是**篡改**。
*
* 于是分工是:
* - 缺字段(空串 / undefined / 非字符串)→ `null`,调用方**跳过写入**
* 保留上一次由服务端给出的权威值;
* - 有值 → 交给 `normalizeMode` 收敛(脏值仍按共用契约 fail-closed 到默认档,
* 与 Gateway 的 `NormalizePermissionMode` 同语义 —— 这里不是"猜",是同一份契约)。
*
* 跳过写入会不会 fail-open**实测过方向的**DSH 的默认沙箱就是
* `workspace-write` + `ask`(见 dsh 客户端 `permissionSelectOf` 的初值),
* 所以"新会话 + 缺字段"落到的是与共用契约同一个档,不会更宽;
* 而"已有会话 + 缺字段"保留的是服务端先前给的权威值,下一个权威事件
* (带档位的邮件 / 409 纠正 / 人在界面改档)会把它修正回来。
*
* @param {unknown} mode
* @returns {string|null} 合法档位;`null` 表示**不要写状态**
*/
export function modeForStateWrite(mode) {
const m = typeof mode === 'string' ? mode.trim() : '';
if (m === '') return null;
return normalizeMode(m);
}
/** /**
* 取两个档位里更严的那一个。 * 取两个档位里更严的那一个。
* *

View File

@ -79,3 +79,14 @@ test('seen 传 undefined 时不去重也不报错', () => {
const got = selectCatchup([mail({ mail_id: 'x' })], undefined); const got = selectCatchup([mail({ mail_id: 'x' })], undefined);
assert.deepEqual(got.map(e => e.mail_id), ['x']); assert.deepEqual(got.map(e => e.mail_id), ['x']);
}); });
test('★ 补投事件必须带权限档位(漏了就会把 full 档会话误拦)', () => {
const ev = mailToEvent(mail({ permission_mode: 'full', permission_enforcement: 'strict' }));
assert.equal(ev.permission_mode, 'full', '补投路径不许把档位丢掉');
assert.equal(ev.permission_enforcement, 'strict');
// 缺字段时给空串:**不猜档位**(猜宽了就是提权),由调用方 `|| 'workspace'` 兜底
const bare = mailToEvent(mail());
assert.equal(bare.permission_mode, '');
assert.equal(bare.permission_enforcement, '');
assert.ok(!('permissionMode' in bare), '键名要与 SSE 逐字一致(蛇形),别自造驼峰');
});

View File

@ -14,6 +14,7 @@ import {
normalizeMode, normalizeEnforcement, modeAtMost, modeNeedsHuman, normalizeMode, normalizeEnforcement, modeAtMost, modeNeedsHuman,
dshSandboxMode, dshApprovalPolicy, dshSandboxMode, dshApprovalPolicy,
piGuardedTools, piBlocksOutright, modeBriefing, piGuardedTools, piBlocksOutright, modeBriefing,
modeForStateWrite,
} from '../lib/permission-mode.js'; } from '../lib/permission-mode.js';
// ─── 归一化 ─── // ─── 归一化 ───
@ -213,3 +214,24 @@ test('partial 与 native 一样要求把方案写在回信里(出路不能消
const s = modeBriefing({ mode: MODE_PLAN, enforcement: ENFORCE_PARTIAL }); const s = modeBriefing({ mode: MODE_PLAN, enforcement: ENFORCE_PARTIAL });
assert.match(s, /回信/); assert.match(s, /回信/);
}); });
// ─── 状态写入点的档位解析2026-09-14 线上事故的形状)───
test('★ 状态写入点:缺字段 → null不写不是默认档', () => {
/*
* 事故:补投路径漏传 `permission_mode`,插件拿 `undefined` 兜了默认档,
* 把一条 **full 档会话**改写成了 workspace-write + ask。后果不是"这一轮被拦一次"
* 而是一整轮工具能力降级(沙箱层就拒了),而且状态留在会话里。
*
* 规则:**默认值可以出现在决策里,不可以出现在状态写入里。**
*/
for (const missing of ['', ' ', undefined, null, 42, {}]) {
assert.equal(modeForStateWrite(missing), null,
`${JSON.stringify(missing)} 应当返回 null= 不要写状态),而不是兜一个档`);
}
// 反面:有值就照共用契约收敛(含脏值 fail-closed 到默认档,与 Gateway 同语义)
assert.equal(modeForStateWrite('full'), 'full');
assert.equal(modeForStateWrite(' workspace '), 'workspace');
assert.equal(modeForStateWrite('danger'), DEFAULT_MODE, '脏值仍按共用契约收敛,但这不是"缺字段"');
assert.notEqual(modeForStateWrite(''), DEFAULT_MODE, '缺字段不能等于默认档 —— 那正是事故');
});