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

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