线上事故(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。
315 lines
15 KiB
JavaScript
315 lines
15 KiB
JavaScript
/**
|
||
* 「用系统方案」的可验证部分 —— 系统资源名与系统材质取值,**不需要设备**。
|
||
*
|
||
* 背景:jianf 追加要求「鸿蒙也同步,但是鸿蒙要求用系统方案」(详见
|
||
* `docs/HARMONY-ALIGN-PLAN.md` §二·五 / §7.10)。方向很清楚:能交给系统的就交给系统 ——
|
||
* `backgroundBlurStyle(BlurStyle.*)` 取代手写模糊、`$r('sys.color.*')` 语义色取代自切变量。
|
||
*
|
||
* 难点在于**这台机器上没有设备**(模拟器需要人在命令行启动,本沙箱起不来):
|
||
* `$r('sys.color.写错了')` 这种错**编译期不报**,只有真机运行到那一行才炸。
|
||
* 于是"用系统方案"会变成一块谁都验不了的区域 —— 那正是本仓库一直在防的东西。
|
||
*
|
||
* 解决办法是 SDK 自带的那张表:`sdk/default/openharmony/toolchains/id_defined.json`
|
||
* 列了**全部系统资源名及其类型**(本机 API 26:7826 条,其中 color 1059 条)。
|
||
* BlurStyle 的取值则在 `component/common.d.ts` 的 `declare enum BlurStyle` 里。
|
||
* 两边都能离线查 —— 所以"名字写对没有"可以变成构建期判据。
|
||
*/
|
||
import { prose } from './lib/read.mjs';
|
||
import { test } from 'node:test';
|
||
import assert from 'node:assert/strict';
|
||
import { existsSync, 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 ETS_DIR = join(ROOT, 'client/harmony/entry/src/main/ets');
|
||
|
||
/** DevEco CLI 的 SDK 根。可用 HARMONY_CLT 覆盖(换机器时不用改判据)。 */
|
||
const CLT = process.env.HARMONY_CLT || '/opt/huawei/command-line-tools';
|
||
const ID_TABLE = process.env.HARMONY_ID_TABLE
|
||
|| join(CLT, 'sdk/default/openharmony/toolchains/id_defined.json');
|
||
const COMMON_DTS = join(CLT, 'sdk/default/openharmony/ets/component/common.d.ts');
|
||
|
||
/**
|
||
* SDK 名表不在就没法验 —— 这时**必须判红**,不能"跳过"。
|
||
* 一条会自己静默跳过的判据,等于没有这条判据(本仓库已经栽过四次同类问题)。
|
||
*/
|
||
const missingSdk = [];
|
||
if (!existsSync(ID_TABLE)) missingSdk.push(ID_TABLE);
|
||
if (!existsSync(COMMON_DTS)) missingSdk.push(COMMON_DTS);
|
||
|
||
test('SDK 的系统资源名表与组件声明要在本机找得到(找不到就明说,不许静默跳过)', () => {
|
||
assert.deepEqual(
|
||
missingSdk,
|
||
[],
|
||
`找不到 SDK 文件,这条判据什么都验不了:\n ${missingSdk.join('\n ')}\n`
|
||
+ '若 SDK 换了位置,用 HARMONY_CLT 或 HARMONY_ID_TABLE 指向它,'
|
||
+ '不要在没核过的情况下把这条判据删掉。'
|
||
);
|
||
});
|
||
|
||
/** 收集鸿蒙源码(.ets / .ts) */
|
||
const collectSources = (dir, acc = []) => {
|
||
for (const e of readdirSync(dir, { withFileTypes: true })) {
|
||
const full = join(dir, e.name);
|
||
if (e.isDirectory()) collectSources(full, acc);
|
||
else if (/\.(ets|ts)$/.test(e.name)) acc.push(full);
|
||
}
|
||
return acc;
|
||
};
|
||
|
||
/** SDK 名表:name → type */
|
||
const loadIdTable = () => {
|
||
const recs = JSON.parse(prose(ID_TABLE)).record;
|
||
const byName = new Map();
|
||
for (const r of recs) byName.set(r.name, r.type);
|
||
return byName;
|
||
};
|
||
|
||
/** SDK 的 BlurStyle 成员 */
|
||
const loadBlurStyleMembers = () => {
|
||
const src = prose(COMMON_DTS);
|
||
const at = src.indexOf('declare enum BlurStyle');
|
||
assert.ok(at > 0, 'SDK 里应能定位到 declare enum BlurStyle');
|
||
// 枚举体到第一个顶格 `}` 为止;成员形如 ` Thin,` 或 ` COMPONENT_THIN = 6,`
|
||
// (早先的写法把文档里的 `T`、`R` 也算成了成员 —— 名字解析必须精确到分隔符)
|
||
const block = src.slice(at, src.indexOf('\n}', at));
|
||
return new Set(
|
||
[...block.matchAll(/^ {2,}([A-Za-z][A-Za-z_0-9]*)\s*[,=]/gm)].map(m => m[1])
|
||
);
|
||
};
|
||
|
||
/** 扫描 `$r('sys.<type>.<name>')` 用法 → [{file, type, name}] */
|
||
const scanSysResources = (files) => {
|
||
const found = [];
|
||
for (const f of files) {
|
||
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)) {
|
||
found.push({ file: f.slice(ROOT.length + 1), type: m[1], name: m[2] });
|
||
}
|
||
}
|
||
return found;
|
||
};
|
||
|
||
/** 扫描 `BlurStyle.<MEMBER>` 用法 → [{file, member}](注释已剥离) */
|
||
const scanBlurStyles = (files) => {
|
||
const found = [];
|
||
for (const f of files) {
|
||
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)) {
|
||
found.push({ file: f.slice(ROOT.length + 1), member: m[1] });
|
||
}
|
||
}
|
||
return found;
|
||
};
|
||
|
||
test('源码里引用的系统资源名都必须在 SDK 名表里且类型相符(否则真机才炸)', () => {
|
||
const files = collectSources(ETS_DIR);
|
||
assert.ok(files.length >= 10, `应扫到至少 10 个源文件,实际 ${files.length}`);
|
||
|
||
const table = loadIdTable();
|
||
assert.ok(table.size > 1000, `SDK 名表应有一千条以上,实际 ${table.size}`);
|
||
|
||
const used = scanSysResources(files);
|
||
const bad = used.filter(u => table.get(u.name) !== u.type);
|
||
assert.deepEqual(
|
||
bad.map(u => `${u.file}: sys.${u.type}.${u.name}(表里是 ${table.get(u.name) ?? '查无此名'})`),
|
||
[],
|
||
'系统资源名写错/类型不匹配 —— 这类错误编译期不报,只有真机运行到那一行才炸'
|
||
);
|
||
|
||
// 反向对照:判据要真能抓到一个不存在的名字(否则它只是"跑过了")
|
||
const ghost = 'backgroundColor($r(\'sys.color.ohos_id_color_这个词不存在\'))';
|
||
const ghostName = ghost.match(/sys\.color\.([A-Za-z0-9_]+)/)[1];
|
||
assert.notEqual(table.get(ghostName), 'color', '自检:判据抓不到不存在的系统色名');
|
||
});
|
||
|
||
test('BlurStyle 的取值必须在 SDK 的 enum 里(系统材质的名字同样不能凭记忆写)', () => {
|
||
const members = loadBlurStyleMembers();
|
||
assert.ok(members.size >= 8, `BlurStyle 应有 8 个以上成员,实际 ${members.size}`);
|
||
|
||
const used = scanBlurStyles(collectSources(ETS_DIR));
|
||
const bad = used.filter(u => !members.has(u.member));
|
||
assert.deepEqual(
|
||
bad.map(u => `${u.file}: BlurStyle.${u.member}`),
|
||
[],
|
||
`这些 BlurStyle 取值在 SDK 里不存在,可用:${[...members].join(' / ')}`
|
||
);
|
||
assert.ok(members.has('COMPONENT_REGULAR'), '自检:SDK 里应有 COMPONENT_REGULAR');
|
||
});
|
||
|
||
test('替换成系统方案时要用的那批系统色,先在这里核过(计划先于代码)', () => {
|
||
/*
|
||
* 这张清单是"WebUI 做法 → 鸿蒙系统能力"的对应表(文档 §二·五)落到**具体资源名**上。
|
||
* 先在这里核一遍再动代码:名字我核过了,替换时就不会一边写一边猜。
|
||
*
|
||
* ⚠️ 注意表里**没有** brand / confirm / success 这类名字 —— 系统只给"情绪色"
|
||
* (warning / alert)。所以权限三档与预算三档这些**业务语义色没有系统对应物**,
|
||
* 必须继续用自定义令牌:为了"用系统色"把 plan 档画成 warning 色,是把语义丢了换形式。
|
||
*/
|
||
const table = loadIdTable();
|
||
const intent = {
|
||
color: [
|
||
'ohos_id_color_list_card_bg', // 列表项 = 每项一张卡(底色)
|
||
'ohos_id_color_list_separator', // 列表分隔线
|
||
'ohos_id_color_background', // 页面底
|
||
'ohos_id_color_sub_background', // 次级表面
|
||
'ohos_id_color_text_primary',
|
||
'ohos_id_color_text_secondary',
|
||
'ohos_id_color_text_tertiary',
|
||
'ohos_id_color_emphasize', // 强调(品牌蓝的落点:具体取值仍须与 WebUI 对齐)
|
||
'ohos_id_color_warning',
|
||
'ohos_id_color_alert',
|
||
'ohos_id_color_mask_regular' // 遮罩
|
||
]
|
||
};
|
||
for (const [type, names] of Object.entries(intent)) {
|
||
for (const n of names) {
|
||
assert.equal(table.get(n), type, `计划要用的 sys.${type}.${n} 在 SDK 名表里不是 ${type} 类型`);
|
||
}
|
||
}
|
||
// 边界:业务语义色没有系统对应物,这一点也钉住 —— 免得以后有人"为了系统化"去硬套
|
||
for (const ghost of ['ohos_id_color_brand', 'ohos_id_color_confirm', 'ohos_id_color_success']) {
|
||
assert.equal(table.get(ghost), undefined, `${ghost} 竟然存在了?那这条边界要重新核一遍`);
|
||
}
|
||
});
|
||
|
||
/*
|
||
* ─────────── 废弃 API:**清单从 SDK 生成**,不手写 ───────────
|
||
*
|
||
* pi 的建议(2026-09-14):与其一条条手写"不得再用全局 `promptAction.showToast`",
|
||
* 不如从 SDK 的 `@deprecated` 标记**生成**一份清单,再配一份人工 allow-list ——
|
||
* 新增一条默认判红,除非有人显式放行。这与 `$r('sys.*')` 名字表的做法是同一个套路。
|
||
*
|
||
* 手写清单的死法是"只挡已经踩过的那一个":这次修了 23 处,下一个人写个新的废弃 API
|
||
* 照样全绿 —— 而 SDK 自己知道哪些废弃了,问它就行。
|
||
*/
|
||
|
||
/** 递归收集 SDK 的 .d.ts(含 api/ 与 component/) */
|
||
function sdkDtsFiles() {
|
||
const roots = [
|
||
join(CLT, 'sdk/default/openharmony/ets/component'),
|
||
join(CLT, 'sdk/default/openharmony/ets/api')
|
||
];
|
||
const out = [];
|
||
const walk = (dir) => {
|
||
for (const e of readdirSync(dir, { withFileTypes: true })) {
|
||
const full = join(dir, e.name);
|
||
if (e.isDirectory()) walk(full);
|
||
else if (e.name.endsWith('.d.ts')) out.push(full);
|
||
}
|
||
};
|
||
for (const r of roots) if (existsSync(r)) walk(r);
|
||
return out;
|
||
}
|
||
|
||
/**
|
||
* 顶层(不在 namespace/class 里)被标 `@deprecated` 的 `declare function` 名字。
|
||
*
|
||
* "顶层"这件事必须判:`declare namespace fileIo { declare function open(...) }` 里的
|
||
* `open` 是**命名空间成员**,全局调 `open(...)` 并不会走到那个废弃实现 ——
|
||
* 把它算进来会造出一堆假红(这一版就是按花括号深度筛出来的)。
|
||
*/
|
||
function deprecatedGlobalFunctions() {
|
||
const names = new Set();
|
||
for (const f of sdkDtsFiles()) {
|
||
const src = prose(f);
|
||
// 先切出 depth == 0 的片段
|
||
let depth = 0;
|
||
let segStart = 0;
|
||
const segs = [];
|
||
for (let i = 0; i < src.length; i++) {
|
||
const c = src[i];
|
||
if (c === '{') {
|
||
if (depth === 0) segs.push([segStart, i]);
|
||
depth++;
|
||
} else if (c === '}') {
|
||
depth--;
|
||
if (depth === 0) segStart = i + 1;
|
||
}
|
||
}
|
||
if (depth === 0) segs.push([segStart, src.length]);
|
||
const top = segs.map(([a, b]) => src.slice(a, b)).join('\n');
|
||
for (const m of top.matchAll(/\/\*\*((?:(?!\*\/)[\s\S])*?)\*\/\s*declare function (\w+)\s*\(/g)) {
|
||
if (m[1].includes('@deprecated')) names.add(m[2]);
|
||
}
|
||
}
|
||
return names;
|
||
}
|
||
|
||
test('废弃 API:清单**从 SDK 生成**,源码里不得调用(新增一条默认判红)', () => {
|
||
if (missingSdk.length) return; // 没有 SDK:这一条无从判起(由文件顶部统一报)
|
||
const deprecated = deprecatedGlobalFunctions();
|
||
assert.ok(deprecated.size > 20, `要从 SDK 读到一批废弃的全局函数,实际 ${deprecated.size} 个`);
|
||
// 清单真的抓到了我们关心的东西(否则"生成"这件事本身没生效)
|
||
for (const must of ['animateTo', 'getContext', 'px2vp']) {
|
||
assert.ok(deprecated.has(must), `清单里应该有 ${must}(它已被 SDK 标记废弃)`);
|
||
}
|
||
// 全局 showToast 不是 `declare function`(它是命名空间成员),由 harmony-logic 那条单钉
|
||
|
||
/**
|
||
* 允许的例外:**每条都要写"替代品"和"为什么现在不能换"**。
|
||
*
|
||
* pi 指出这套组合(生成的清单 + 空 allow-list)有个结构性风险:
|
||
* SDK 升版会往清单里加新条目 → 某天早上套件**突然红**,且红在与本次改动无关的代码上;
|
||
* 这时第一反应是把名字塞进 allow-list,而 allow-list 一旦这么用,
|
||
* 就不再是"研究过的例外",只是"红的止痛药"。
|
||
*
|
||
* 所以条目结构化成 `{ name, replacement, why }`,并断言 `replacement` **非空**:
|
||
* "暂时不想改"不是放行理由,"替代品要求的 API level 高于本机基线"才是。
|
||
* **刻意不断言"名单必须为空"** —— 那会挡住合理放行;但"有名字、没替代品"必须红,
|
||
* 这样侵蚀发生时红的是**放行这件事本身**,而不是某天的新 SDK。
|
||
*/
|
||
const ALLOW = [];
|
||
|
||
// 名单形状自检:每条都必须有替代品与理由(防止以后有人只塞个名字进来)
|
||
for (const entry of ALLOW) {
|
||
assert.ok(typeof entry.name === 'string' && entry.name.length > 0, 'allow-list 条目要有 name');
|
||
assert.ok(typeof entry.replacement === 'string' && entry.replacement.length > 0,
|
||
`allow-list 里 ${entry.name} 没写"替代品" —— 没有替代品的放行不是例外,是止痛药`);
|
||
assert.ok(typeof entry.why === 'string' && entry.why.length > 0,
|
||
`allow-list 里 ${entry.name} 没写"为什么现在不能换"`);
|
||
}
|
||
|
||
const walk = (dir, acc = []) => {
|
||
for (const e of readdirSync(dir, { withFileTypes: true })) {
|
||
const full = join(dir, e.name);
|
||
if (e.isDirectory()) walk(full, acc);
|
||
else if (/\.(ets|ts)$/.test(e.name)) acc.push(full);
|
||
}
|
||
return acc;
|
||
};
|
||
/*
|
||
* 扫描范围 = **整个 ets 目录递归**(`pages/` `common/` `model/` `api/` `entryability/` …),
|
||
* 不是只扫 `pages/` —— pi 点过这条:只扫页面的话 `common/` 里的旧写法会逃掉,
|
||
* 而"新代码照抄旧模块"这条路径最常发生在 `common/`。
|
||
*
|
||
* "扫到的文件数 ≥ N"这条自检是防它**悄悄退化**(目录改名、遍历写错、
|
||
* 只扫了一个子目录都会让这条判据变成空判据而依然全绿)——
|
||
* 与 `cross-client-theme` 里给 pages 加的那条同形。
|
||
*/
|
||
const files = walk(ETS_DIR);
|
||
const scannedDirs = new Set(files.map(f => f.slice(ETS_DIR.length + 1).split('/')[0]));
|
||
assert.ok(files.length >= 20,
|
||
`这条判据要扫到整个 ets 目录(至少 20 个文件),实际 ${files.length} 个 —— 扫描范围退化了`);
|
||
for (const dir of ['pages', 'common', 'model', 'api']) {
|
||
assert.ok(scannedDirs.has(dir), `扫描范围要包含 ${dir}/(否则那里的旧写法会逃掉)`);
|
||
}
|
||
const hits = [];
|
||
for (const f of files) {
|
||
const src = prose(f).replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '');
|
||
for (const name of deprecated) {
|
||
// 只算**全局调用**:排除成员调用 `x.name(`(`.animateTo(` 是我们要求的新写法)
|
||
const re = new RegExp(`(?<![.\\w])${name}\\s*\\(`, 'g');
|
||
if (re.test(src)) hits.push(`${f.slice(ETS_DIR.length + 1)}: ${name}`);
|
||
}
|
||
}
|
||
const allowed = hits.filter(h => ALLOW.some(a => h.includes(a.name)));
|
||
const bad = hits.filter(h => !ALLOW.some(a => h.includes(a.name)));
|
||
assert.deepEqual(bad, [],
|
||
`这些地方调了 SDK 已标记废弃的**全局**函数:${bad.join('、')} —— ` +
|
||
'换成新写法(如 UIContext 上的同名方法),或把它登记进 ALLOW 并写明理由');
|
||
assert.ok(allowed.length === ALLOW.length || allowed.length >= 0, 'allow-list 命中统计');
|
||
});
|