用户两项裁定落地(均为 ask_user 明确选择):
① 授权栏口径 = navigator_only(照 WebUI 架构)
· 新建 pages/PermissionPanel.ets —— 详情页的决策面板,
对应 MailView.tsx:693 的 PermissionPanel(审批型 / 主动提问 / 已处理 三态)
· 决策入口从授权栏移到 MailDetailPage;MailDetailPage 原来只显示一个
「权限请求」小标签、根本没有决策入口(比 WebUI 少一整块,且反了:
栏里能决策、点进详情反而不能)
· PermissionTab 删掉内联「同意/拒绝」+ 备注框 + decide():
整卡可点 → onOpenMail(对齐 WebUI PermissionList.tsx:81 的 pick())
· PermissionRequest 补 source_account_id(客户端侧记来源,跳详情要定位网关)
② 服务器补 permission_options —— 修一条真实的、跨端共有的缺口
· mails.permission_options 从 INSERT 起就写进去,但**从来没有任何读路径
选过它** ⇒ 详情端点永远返回空。WebUI 的决策面板读 mail.permission_options,
所以提问型的预设选项**两端全部落空**(审批型靠 ['同意','拒绝'] 兜底蒙混)
· GetMailByID 补选该列 + JSON 反序列化(与 cc_list 同款)
③ 组件按页封装(用户要求「以便与 WebUI 一一对应」)
MainPage.ets 4592 → 3192 行
· pages/PermissionTab.ets 720 行 ↔ PermissionList.tsx
· pages/ContactsTab.ets 796 行 ↔ ContactPanel.tsx
· pages/NavDestinations.ets 181 行 ↔ Navigation 壳
· pages/NavShared.ets 65 行 ↔ 跨栏共用件
④ 判据跟着组件搬家(否则静默失效,不是红)
harmony-logic 的 pageCode / harmony-nav 的 navSrc 改为显式文件名单;
harmony-appearance 的 PANE_SOURCES 补 ContactsTab;harmony-contacts 三个
test 并入 ContactsTab;harmony-logic 的决策断言改指 PermissionPanel,
并新增「授权栏不许再有内联决策」两条(navigator_only 的正形状)。
animation-audit:共享元素转场判据从「同文件共址」改为「按 id 找驱动」。
旧形状把 in/out 端必须在同一文件当成代理,而两端**天然在两处**;
抽出写信页(NavDestinations 持有 in 端)后误报。新判据仍要求每个 id
都有 Motion.morph 驱动 —— 变异实测:把驱动换成裸 animateTo 仍判红。
判据:files=34 checks=556 red=1(仅 build-stamp,产物待重构建)
661 lines
36 KiB
JavaScript
661 lines
36 KiB
JavaScript
/**
|
||
* ArkTS **编译期**硬规则的判据(本机可跑,不需要设备)。
|
||
*
|
||
* ── 这一整个文件的来历 ──
|
||
*
|
||
* `hvigorw assembleHap` 在 `f31bc02` / `7647c24` 上都红了一条:
|
||
*
|
||
* ERROR: ArkTS:ERROR File: …/MainPage.ets
|
||
* "import" statements after other statements are not allowed (arkts-no-misplaced-imports)
|
||
*
|
||
* 原因是**我**在 `MainPage.ets` 里把 `NAV_MATERIAL_OF` 那张(带注释的)常量表
|
||
* **插在了既有 import 之前** —— 而这个仓库里**没有一条判据会跑 ArkTS 的编译规则**:
|
||
* 我那一笔的判据判的是"表达式对不对/接没接上",它们全绿,因为**文本层面没问题**,
|
||
* 问题只有编译器知道。pi 是构建时撞上的。
|
||
*
|
||
* ⇒ 教训不是"下次小心",是**把编译器能抓、而判据不抓的那一类固化下来**。
|
||
* 一组 import 位置、解构、`any`、函数表达式这些**都不需要设备**、纯文本就能判,
|
||
* 所以它们**不该**待在"等设备才能验"的欠账里。
|
||
*
|
||
* ⚠️ 这个文件**不能**替代 `hvigorw`:它覆盖的是"能静态判出来的那几类"。
|
||
* ArkTS 还有大量只有编译器知道的事(类型推断、重载解析、Sendable…)——
|
||
* 那部分仍然只有 build 能验,不许把这个文件的存在读成"编译已经验过了"。
|
||
*/
|
||
import test from 'node:test';
|
||
import assert from 'node:assert/strict';
|
||
import { readdirSync } from 'node:fs';
|
||
import { dirname, join } from 'node:path';
|
||
import { fileURLToPath } from 'node:url';
|
||
import { code, prose } from './lib/read.mjs';
|
||
|
||
// 与其它鸿蒙判据同口径(见 `harmony-admin.test.mjs` 的文件头)
|
||
/*
|
||
* ★ 仓库根必须**从本文件的位置推**,不许硬编码绝对路径。
|
||
*
|
||
* 原来这里写的是 `const ROOT = '/home/program/agentmail';` —— pi 2026-09-15 实测出后果:
|
||
* 把带违规的提交检出到**别的目录**再跑,判据**读的仍是 `/home/program/agentmail`**,
|
||
* 于是"在一个 import 顺序明显违规的检出上 3/3 全绿"。
|
||
* 两层后果,第二层最糟:
|
||
* ① 它**永远无法验证任何别的 checkout / CI / 镜像**(换目录不是"红",是 readdirSync 直接抛);
|
||
* ② 在本机做 worktree 复核时,它会**静默读另一棵树并报绿** —— 正是我们这几轮在消的形状,
|
||
* 这次长在判据自己身上。**"规则进来了,对象没进来"**。
|
||
* 修法照邻居(10 个鸿蒙判据都是 `join(HERE, '..', '..', '..')`)。
|
||
*/
|
||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||
const ROOT = join(HERE, '..', '..', '..');
|
||
const ETS_ROOT = join(ROOT, 'client/harmony/entry/src/main/ets');
|
||
|
||
function allEts(dir = ETS_ROOT) {
|
||
const out = [];
|
||
for (const e of readdirSync(dir, { withFileTypes: true })) {
|
||
const p = join(dir, e.name);
|
||
if (e.isDirectory()) { out.push(...allEts(p)); continue; }
|
||
if (e.name.endsWith('.ets')) out.push(p);
|
||
}
|
||
return out;
|
||
}
|
||
|
||
const rel = (p) => p.slice(ETS_ROOT.length + 1);
|
||
|
||
/**
|
||
* 逐行扫"最后一个 import"与"第一个非 import 语句"的位置。
|
||
*
|
||
* 要注意 import 可能是**多行**的(`import {\n a,\n b\n} from '…';`),
|
||
* 所以不能只看以 `import` 开头的行 —— 多行块里的成员名会被误判成"语句"。
|
||
* (我第一版就这么误判过:把 `NAV_BAR_BOTTOM,` 当成了一条语句。)
|
||
*/
|
||
function importOrder(src) {
|
||
const lines = src.split('\n');
|
||
let lastImport = 0;
|
||
let firstOther = null;
|
||
let inBlock = false;
|
||
for (let i = 0; i < lines.length; i++) {
|
||
const ls = lines[i].trim();
|
||
if (!ls || ls.startsWith('//') || ls.startsWith('*') || ls.startsWith('/*') || ls.startsWith('*/')) continue;
|
||
if (ls.startsWith('import ')) {
|
||
lastImport = i + 1;
|
||
inBlock = !ls.replace(/\s+$/, '').endsWith(';');
|
||
continue;
|
||
}
|
||
if (inBlock) {
|
||
// import 块内:`} from '…';` 或成员行
|
||
if (ls.startsWith('}') || ls.endsWith(';')) { lastImport = i + 1; inBlock = false; }
|
||
continue;
|
||
}
|
||
if (firstOther === null) firstOther = { line: i + 1, text: ls.slice(0, 60) };
|
||
}
|
||
return { lastImport, firstOther };
|
||
}
|
||
|
||
test('★ ArkTS:所有 import 必须在任何其它语句之前(arkts-no-misplaced-imports)', () => {
|
||
/*
|
||
* 这条是**构建时撞出来的**(见文件头)。它判的是"文件级语句顺序",
|
||
* 与"import 写全没写全""路径对不对"是不同的事 —— 那些别处判。
|
||
*/
|
||
const bad = [];
|
||
for (const f of allEts()) {
|
||
const { lastImport, firstOther } = importOrder(code(f));
|
||
if (firstOther && firstOther.line < lastImport) {
|
||
bad.push(`${rel(f)}:最后一个 import 在第 ${lastImport} 行,`
|
||
+ `但第 ${firstOther.line} 行已是语句「${firstOther.text}」`);
|
||
}
|
||
}
|
||
assert.deepEqual(bad, [],
|
||
`★ 这些文件把 import 写在了其它语句**之后** —— ArkTS 编译器会直接报错,构建不过:\n ${bad.join('\n ')}\n` +
|
||
' 修法:把 import 全部挪到文件最前(常量表、类、函数都要在它们之后)。');
|
||
});
|
||
|
||
test('★ 判据自检:import 顺序检查必须能抓到"常量插在 import 之前"', () => {
|
||
/*
|
||
* 这条自检是**必须的**:上面那条今天全绿,而它绿的原因可能是"真的没问题",
|
||
* 也可能是"我的扫描逻辑失效了"(把整段当注释跳过、多行 import 判错…)。
|
||
* 造一个**已知坏样本**,确认它会被判红 —— 这正是这次事故的形状。
|
||
*/
|
||
const badSample = [
|
||
"import { a } from './a';",
|
||
'',
|
||
'const TABLE: Record<string, number> = {',
|
||
" 'x': 1",
|
||
'};',
|
||
'',
|
||
"import { b } from './b';",
|
||
'',
|
||
'export function use(): number { return TABLE.x + b; }'
|
||
].join('\n');
|
||
const r = importOrder(badSample);
|
||
assert.ok(r.firstOther && r.firstOther.line < r.lastImport,
|
||
'自检失败:检查逻辑抓不到"常量插在两组 import 之间"——那正是本次构建报错的形状');
|
||
// 反向:合法样本不许误报
|
||
const goodSample = [
|
||
"import { a } from './a';",
|
||
"import { b } from './b';",
|
||
'',
|
||
'const TABLE: Record<string, number> = {',
|
||
" 'x': 1",
|
||
'};'
|
||
].join('\n');
|
||
const g = importOrder(goodSample);
|
||
assert.ok(g.firstOther && g.firstOther.line > g.lastImport, '自检失败:合法样本被误报');
|
||
});
|
||
|
||
test('ArkTS 词汇层硬坑:全仓 .ets 不许出现解构 / any / unknown / 函数表达式', () => {
|
||
/*
|
||
* 这几条此前**只在我新写的那个页面里**判(`harmony-admin.test.mjs` ⑨),
|
||
* 也就是说"我自己新写的文件"有判据、"别的文件"没有 —— 而我恰恰是在
|
||
* **改既有文件**(`MainPage.ets`)时犯的下一个错。
|
||
* ⇒ 铺到全部 `.ets`:编译期硬规则不该按"谁写的"分覆盖。
|
||
*/
|
||
const bad = [];
|
||
for (const f of allEts()) {
|
||
const src = code(f);
|
||
const hits = [];
|
||
if (/const\s*\{[^}]*\}\s*=/.test(src) || /let\s*\{[^}]*\}\s*=/.test(src)) hits.push('对象解构');
|
||
if (/\bany\b/.test(src)) hits.push('any');
|
||
if (/\bunknown\b/.test(src)) hits.push('unknown');
|
||
if (/\bfunction\s*\(/.test(src)) hits.push('函数表达式');
|
||
/*
|
||
* ★★ 2026-09-20 补:**正则字面量**(`arkts-no-regexp-literals`)。
|
||
*
|
||
* 这条是照 `arkts-grammar-standards` skill 的规则表逐条回扫时发现的缺口:
|
||
* · 编译器对它是**告警级**(不挡构建),所以 build 一直是绿的;
|
||
* · 全仓也没有判据管它 ⇒ `MailDetailPage.budgetDraftValid()` 里那个
|
||
* `/^\d+$/` 从 2026-09-19 一直活到今天。
|
||
* 「编译器只告警 + 判据不管」= 这条规则在本仓**事实上不存在**。
|
||
*
|
||
* 判法要**避开**三类合法的 `/`:
|
||
* ① 注释(先剥掉)
|
||
* ② 除法 `a / b`(前面是标识符/数字/`)`,且后面紧跟空格或标识符)
|
||
* ③ 字符串里的路径(`'../model/X'`)
|
||
* 所以只认这个形状:`/` 前面是**行首/`=`/`(`/`,`/`return`/`:`**,
|
||
* 且到下一个 `/` 之间不含空白与引号 —— 那是正则字面量的典型写法。
|
||
*/
|
||
const noComment = src;
|
||
const reLike = /(?:^|[=(,:]|return)\s*\/(?![/*=\s])(?:[^/\n'"\\]|\\.)+\/[gimsuy]*/gm;
|
||
if (reLike.test(noComment)) hits.push('正则字面量(用 new RegExp)');
|
||
if (hits.length) bad.push(`${rel(f)}:${hits.join('、')}`);
|
||
}
|
||
assert.deepEqual(bad, [], `★ ArkTS 硬坑(编译不过):\n ${bad.join('\n ')}`);
|
||
|
||
/*
|
||
* 自检:正则字面量那一段探测器要**认得错、不误伤对**。
|
||
* 不写自检的话,"没报"与"探测器坏了"结果一样 —— 那正是这条规则
|
||
* 躲过一轮的原因(它此前根本没人探)。
|
||
*/
|
||
const probeRe = (t) => /(?:^|[=(,:]|return)\s*\/(?![/*=\s])(?:[^/\n'"\\]|\\.)+\/[gimsuy]*/gm.test(t);
|
||
assert.equal(probeRe('return /^\\d+$/.test(t);'), true, '要抓得住 return 后的正则字面量');
|
||
assert.equal(probeRe('const ok = /ab/.test(s);'), true, '要抓得住赋值后的正则字面量');
|
||
assert.equal(probeRe('return new RegExp(\'^[0-9]+$\').test(t);'), false, '不许误伤 new RegExp');
|
||
assert.equal(probeRe('const half = total / 2;'), false, '不许误伤除法');
|
||
assert.equal(probeRe("import { X } from '../model/Y';"), false, '不许误伤字符串里的路径');
|
||
assert.equal(probeRe('const p = apiBase + path; // a / b'), false, '剥注释后的行内斜杠不误伤');
|
||
});
|
||
|
||
/* ─────────────────── 图标:不许用 Unicode 符号,且尺寸不许直接加在 AmIcon 上 ─────────────────── */
|
||
|
||
/**
|
||
* 这两条都是 2026-09-18 用户**看着界面**报出来的,而且都不是「代码不合法」——
|
||
* 编译器与所有既有判据全绿,只有屏幕上是错的。
|
||
*/
|
||
|
||
test('★ 图标不许用 Unicode 符号充当(`Text(\'✉\')` 会被系统渲染成彩色 emoji)', () => {
|
||
/*
|
||
* 登录页原本写的是 `Text('✉')`(U+2709)当品牌图标。
|
||
*
|
||
* HarmonyOS 的字体链里有**彩色 emoji 字体**,U+2709 自带 emoji 字形 ⇒
|
||
* 它被渲染成一枚**彩色 emoji**(黄白色风信封)而不是单色图标:
|
||
* ① `.fontColor(Theme.accent)` 对彩色 emoji **无效**(界面显示的是 emoji 自带颜色);
|
||
* ② 与底栏/侧栏那些 `AmIcon` 线描图标不是同一套视觉语言。
|
||
* 实测截图硬证:大屏下那枚 emoji 比旁边的文字还显眼。
|
||
*
|
||
* 本仓**早就有** `ICON_PATHS.brandMark`(就是 App 图标那个信封),
|
||
* 所以这条纪律的成本是零:界面里所有图标都走 `AmIcon`。
|
||
*
|
||
* 判据只扫 `Text('…')` 里**单独一个**符号的情况 —— 正文里的标点/箭头不算
|
||
* (例如提示语里的「·」或「→」),否则会误伤大量正常文案。
|
||
*/
|
||
const ETS = join(ROOT, 'client', 'harmony', 'entry', 'src', 'main', 'ets');
|
||
/*
|
||
* 会被 emoji 字体接管的**典型图标类**符号:
|
||
* U+2600–U+27BF(Misc Symbols / Dingbats:☀ ☂ ✈ ✉ ✏ ✔ ❤ …)
|
||
* U+2B00–U+2BFF(杂项符号与箭头)
|
||
* U+FE0F(变体选择符,把字符变成 emoji 呈现)
|
||
* ★ 刻意**不含 U+2190–U+21FF(基本箭头)**:`→`/`←` 在正文里是**标点**,
|
||
* 不是图标 —— 把它们也扫进来会误伤大量正常文案。
|
||
* (我第一版把箭头段也框了进去,结果自检自己就先红了:
|
||
* `!EMOJI_ISH.test('→')` 不成立 —— 断言写错就是断言写错,不能靠放宽它来「修」。)
|
||
*/
|
||
const EMOJI_ISH = /^[\u2600-\u27BF\u2B00-\u2BFF\uFE0F]$/;
|
||
/*
|
||
* ★★ 2026-09-20 补第二类:**排版符号当图标**(上面那一段只覆盖 emoji 那一类)。
|
||
*
|
||
* 实测漏网 4 处:`Text('‹')`(两处返回键)、`Text('›')`(会话别名前的小箭头)、
|
||
* `Text(' ▾')`(账号下拉指示)—— 全都在上面那三个区间**之外**,所以一条都没抓到。
|
||
*
|
||
* 它们的问题与 emoji 不同、但同样是"看着像图标其实不是":
|
||
* · `‹`/`›` 的字形宽窄**由字体决定**,与旁边 20 的 `AmIcon` 对不齐;
|
||
* 而且它们**不是** WebUI 用的东西 —— WebUI 那里是
|
||
* `ChevronLeftIcon` / `ChevronRightIcon`(SVG,见 `icons.tsx:135`),
|
||
* 所以这属于"两端不一致",不只是"字形不好看"。
|
||
* · `▾`(U+25BE)同理,WebUI `AccountSwitcher.tsx:84` 用的是
|
||
* 同一个 `ChevronRightIcon` **转 90°**(不是一个专门的"下箭头")。
|
||
*
|
||
* 覆盖 U+2039/203A(single/double guillemets)、U+25B2–U+25CF(几何图形:
|
||
* ▴▾◂▪● 这一类常被拿来当箭头/圆点)、U+2190–U+21FF 里的**单字符箭头**
|
||
* 仍**不**扫(`→`/`←` 在正文里是标点,上面那段注释已经说明为什么不能扫)。
|
||
* ★ 注意 `→` 在 `Text('a → b')` 这种**多条字符**里本来就不匹配
|
||
* (正则要求 1–3 个字符**整体**是个符号),所以这里加 `←→` 也不会误伤;
|
||
* 但为了不与上面那段注释自相矛盾,这一段仍然只管"几何/引号类"。
|
||
*/
|
||
const GLYPH_ISH = /^[\u2039\u203A\u00AB\u00BB\u25A0-\u25CF\u25B2\u25BC\u25C0\u25B6]$/;
|
||
const hits = [];
|
||
const walkDir = (dir) => {
|
||
for (const e of readdirSync(dir, { withFileTypes: true })) {
|
||
const full = join(dir, e.name);
|
||
if (e.isDirectory()) { walkDir(full); continue; }
|
||
if (!/\.ets$/.test(e.name)) continue;
|
||
const src = code(full);
|
||
for (const m of src.matchAll(/Text\(\s*'([^']{1,3})'\s*\)/g)) {
|
||
const ch = m[1];
|
||
const trimmed = ch.trim();
|
||
if (EMOJI_ISH.test(ch) || GLYPH_ISH.test(trimmed)) {
|
||
hits.push(`${e.name}: Text('${ch}')(U+${ch.codePointAt(0).toString(16).toUpperCase()})`);
|
||
}
|
||
}
|
||
}
|
||
};
|
||
walkDir(ETS);
|
||
assert.deepEqual(hits, [],
|
||
`用 Unicode 符号当图标会被系统渲染成 emoji,且不吃 fontColor:\n ${hits.join('\n ')}\n` +
|
||
'改用 AmIcon(图标表在 common/Icons.ets,品牌标是 brandMark)');
|
||
// 自检:探测器要真能认出这个形状(否则"没有命中"与"探测器坏了"结果一样)
|
||
assert.ok(EMOJI_ISH.test('\u2709') && EMOJI_ISH.test('\u2764'),
|
||
'探测器要认得出 U+2709 / U+2764 这类符号');
|
||
assert.ok(!EMOJI_ISH.test('·') && !EMOJI_ISH.test('→'),
|
||
'探测器不许把正文标点当成图标(会误伤大量文案)');
|
||
});
|
||
|
||
test('★ 尺寸/底色不许直接链在 `AmIcon` 上(内层容器固定 iconSize 且靠左上 ⇒ 图标贴左上角)', () => {
|
||
/*
|
||
* `AmIcon` 内部那个 `Stack` 是 `iconSize` 那么大、默认靠左上排版。
|
||
* 调用方写 `AmIcon({…}).width(48).height(48)` 想要个大点的可上色盒子时,
|
||
* **外层盒子变大、图标不动** ⇒ 图标贴在盒子左上角。
|
||
*
|
||
* 实测(登录页品牌卡,三折叠 3.5 密度,`dumpLayout` 读的实际 bounds):
|
||
* 卡片 [1523,521][1661,659] 138×138px
|
||
* 图标 [1526,524][1589,587] 63×63px ← 左边距/上边距都只有 3px
|
||
* 中心偏 10vp。用户原话:「你自己看看那个图标的位置正常吗」。
|
||
*
|
||
* 正确写法是套一层居中的容器(仓里回复球与悬浮加号本来就是这么做的):
|
||
* Stack({ alignContent: Alignment.Center }) { AmIcon({…}) }.width(48).height(48)
|
||
*
|
||
* 判据形状:同一表达式里,`AmIcon({…})` 之后**紧跟着** `.width(` / `.height(`
|
||
* 就算命中(中间只允许换行与空白)。套了 `Stack` 的写法中间隔着 `}`,
|
||
* 所以不会被误判。
|
||
*/
|
||
const ETS = join(ROOT, 'client', 'harmony', 'entry', 'src', 'main', 'ets');
|
||
const hits = [];
|
||
const walkDir = (dir) => {
|
||
for (const e of readdirSync(dir, { withFileTypes: true })) {
|
||
const full = join(dir, e.name);
|
||
if (e.isDirectory()) { walkDir(full); continue; }
|
||
if (!/\.ets$/.test(e.name)) continue;
|
||
const src = code(full);
|
||
/*
|
||
* `AmIcon({ ... })` 后面直接跟 `.width(` 或 `.height(`。
|
||
*
|
||
* ★★ 2026-09-19 修(判据自己撞出来的假红):原先写的是
|
||
* `AmIcon\(\{[\s\S]{0,200}?\}\)\s*\.\s*(width|height)\s*\(`
|
||
* —— `[\s\S]{0,200}?` 可以**跨过 `}`**。于是这种**正确**写法被判红:
|
||
*
|
||
* Row({ space: 4 }) {
|
||
* AmIcon({ ... })
|
||
* Text('新建')
|
||
* }
|
||
* .height(26) ← 这是 Row 的,不是 AmIcon 的
|
||
*
|
||
* 实测(日历工具栏的「+ 新建」按钮):报 `CalendarPage.ets:1223 → .height(`,
|
||
* 而那一行链在 `Row` 上。
|
||
*
|
||
* 修法:扫描区间里**不允许出现 `}` 或 `{`** —— 只有同一表达式内的
|
||
* 换行/空白/参数才算。`AmIcon({…}).width(48)` 里 `{…}` 已被 `\)` 收尾,
|
||
* 而跨过 `}` 到下一个组件的路径会被这条排除。
|
||
*/
|
||
for (const m of src.matchAll(/AmIcon\(\{[^{}]{0,200}?\}\)\s*\.\s*(width|height)\s*\(/g)) {
|
||
const line = src.slice(0, m.index).split('\n').length;
|
||
hits.push(`${e.name}:${line} → .${m[1]}(`);
|
||
}
|
||
}
|
||
};
|
||
walkDir(ETS);
|
||
assert.deepEqual(hits, [],
|
||
`尺寸直接加在 AmIcon 上会让图标贴左上角(内层容器固定 iconSize):\n ${hits.join('\n ')}\n` +
|
||
'改成 Stack({ alignContent: Alignment.Center }) { AmIcon({…}) }.width(N).height(N)');
|
||
// 自检:探测器必须认得这个形状,且不误伤正确的嵌套写法
|
||
const bad = "AmIcon({ iconName: 'x', iconSize: 24 }).width(48)";
|
||
const good = "Stack({ alignContent: Alignment.Center }) {\n AmIcon({ iconName: 'x', iconSize: 24 })\n}\n.width(48)";
|
||
const probe = (t) => [...t.matchAll(/AmIcon\(\{[\s\S]{0,200}?\}\)\s*\.\s*(width|height)\s*\(/g)].length;
|
||
assert.equal(probe(bad), 1, '探测器要认得出错误写法');
|
||
assert.equal(probe(good), 0, '探测器不许误伤套了 Stack 的正确写法');
|
||
});
|
||
|
||
test('★ 时间戳不许把原始 ISO 直接印到界面上(`Text(mail.created_at)`)', () => {
|
||
/*
|
||
* ★★ 2026-09-20 加。这条是**先看到屏幕上印着原始 ISO** 才回来补的判据:
|
||
*
|
||
* 发件箱那张卡的时间那一格显示的是
|
||
* 2026-09-19T02:55:33.10099Z
|
||
* (23 个字符,把「致 homeagent」那一行挤到换行)。
|
||
*
|
||
* WebUI **三种卡片全部格式化**,一个都没漏:
|
||
* · `MailList.tsx:151`(会话组头)与 `:262`(组内条目)
|
||
* · `PermissionList.tsx:143`(授权组头)与 `:251`(授权条目)
|
||
* 全部是 `toLocaleString('zh-CN', { month:'2-digit', day:'2-digit',
|
||
* hour:'2-digit', minute:'2-digit' })` ⇒ `MM/DD HH:mm`。
|
||
*
|
||
* 鸿蒙的 `compactMailTime` 就是那个格式的实现,收件箱一直在用 ——
|
||
* 所以这不是"要不要格式化"的分歧,是**三处漏调**。
|
||
*
|
||
* ★ 判据形状为什么是"不许出现 `Text(<x>.created_at)`"而不是列举调用点:
|
||
* 漏的那三处分布在三个不同的 struct(收件箱/发件箱/授权),
|
||
* 按"名字"枚举一定会再漏第四个。而"把 ISO 交给 Text"这个**形状**
|
||
* 本身就是错的(`Text` 只负责画,不做格式化)——
|
||
* 枚举形状才挡得住漂移。
|
||
*
|
||
* ★ 边界:`*For()` 那种"取值函数"不算,这里只管**直接**把字段喂给 Text 的。
|
||
* 允许 `created_at` 作为**参数**传给格式化函数(那正是正确写法)。
|
||
*/
|
||
const src = code(join(ETS_ROOT, 'pages/MainPage.ets'));
|
||
// Text( 里直接出现 .created_at / .createdAt,且中间没有函数调用
|
||
const bad = [...src.matchAll(/Text\(\s*[\w.]*\.(created_at|createdAt)\s*\)/g)]
|
||
.map(m => m[0]);
|
||
assert.deepEqual(bad, [],
|
||
`这些地方把原始 ISO 直接交给 Text 了(屏上会印出 23 个字符的时间戳):\n ${bad.join('\n ')}\n` +
|
||
'改用 compactMailTime(...)(收件箱行一直是这么写的)');
|
||
|
||
// 自检:探测器要认得错误形状,且不误伤正确写法
|
||
const probe = (t) => [...t.matchAll(/Text\(\s*[\w.]*\.(created_at|createdAt)\s*\)/g)].length;
|
||
assert.equal(probe("Text(mail.created_at)"), 1, '探测器要认得出裸字段');
|
||
assert.equal(probe("Text(compactMailTime(mail.created_at))"), 0, '不许误伤传参给格式化函数的写法');
|
||
assert.equal(probe("Text(mail.createdAt)"), 1, '驼峰写法同样要抓');
|
||
});
|
||
|
||
test('★ 服务端「只以 omitempty 形式出现」的字段:客户端读它时必须有 `??` 兜底', () => {
|
||
/*
|
||
* ★★ 2026-09-20 加。这条是**同一天踩了两次**之后加的判据。
|
||
*
|
||
* 第一次(真崩溃,白屏 + 应用重启):
|
||
* `session_workspace` 带 `omitempty` ⇒ 服务端**不输出这个 key**
|
||
* ⇒ `JSON.parse as T` 之后是 `undefined`
|
||
* ⇒ `participantAddress` 里 `workspace.trim()` 抛 TypeError。
|
||
* 第二次(差一点就是 94/96 必崩):
|
||
* 给列表行加"附件数"时写 `mail.attachments.length` ——
|
||
* 而 `Attachments` 也带 `omitempty`,实测 96 封里**只有 2 封**带这个 key。
|
||
*
|
||
* ★ 为什么"记住别这么写"不管用、要写成判据:
|
||
* 两次之间我**刚**在 `Models.ets` 头注释里写了一大段讲这个坑,
|
||
* 然后加新字段时照踩 —— 记忆不跨"写下一行代码"这个边界。判据能跨。
|
||
*
|
||
* ── 判据的关键:**不能按字段名一刀切** ──
|
||
*
|
||
* 第一版我就是按"名字在 omitempty 名单里"扫的,结果报了 6 处,
|
||
* 其中 4 处是**误报**:`session_alias` 在服务端有**两个**声明 ——
|
||
* · `models.Mail.SessionAlias` `json:"session_alias,omitempty"` ★会缺失
|
||
* · `repo.Contact.SessionAlias` `json:"session_alias"` 恒有
|
||
* 而鸿蒙那 4 处读的全是 `Contact`(联系人卡片)⇒ 按名字判根本分不出来。
|
||
*
|
||
* 正确判法:只扫**"只以 omitempty 形式出现过"的字段名** ——
|
||
* 即全仓所有 struct 里,该 json 名**从来没有**不带 omitempty 的声明。
|
||
* 那样 `session_alias`/`display_name`/`title`/`mail_count`/`key_token`
|
||
* 这几个"两种形式都有"的自动被排除(名单从服务端**算出来**,不手抄)。
|
||
*/
|
||
const GO_FILES = [];
|
||
const walkGo = (dir) => {
|
||
for (const e of readdirSync(dir, { withFileTypes: true })) {
|
||
const full = join(dir, e.name);
|
||
if (e.isDirectory()) { walkGo(full); continue; }
|
||
if (e.name.endsWith('.go')) GO_FILES.push(full);
|
||
}
|
||
};
|
||
walkGo(join(ROOT, 'server/internal'));
|
||
|
||
const omitOnly = new Map(); // json 名 → 类型集合
|
||
const alsoPlain = new Set(); // 同时存在"不带 omitempty"声明的名字
|
||
for (const f of GO_FILES) {
|
||
/*
|
||
* 读**原文**(`prose`)而不是剥注释后的 `code()`:
|
||
* struct tag 里的 `json:"...,omitempty"` 虽然不是注释,但这几行 Go 源码
|
||
* 附近有大量解释性注释,而**判据要的是字段声明的字面形状** ——
|
||
* 走 `prose` 语义最直白("我在读这一段字面文本"),也符合本仓
|
||
* 「不许裸用 readFileSync」那条(改用它就要说清是 code 还是 prose)。
|
||
*/
|
||
const goSrc = prose(f);
|
||
for (const m of goSrc.matchAll(/^\s+(\w+)\s+(\[\]\w+|\*\w+|\w+)\s+`json:"([a-z_]+)(,omitempty)?"/gm)) {
|
||
const jsonName = m[3];
|
||
if (m[4]) {
|
||
if (!omitOnly.has(jsonName)) omitOnly.set(jsonName, new Set());
|
||
omitOnly.get(jsonName).add(m[2]);
|
||
} else {
|
||
alsoPlain.add(jsonName);
|
||
}
|
||
}
|
||
}
|
||
/* 只留下"从未不带 omitempty 出现过"的名字 —— 那些读起来一定要有兜底 */
|
||
const risky = [...omitOnly.entries()]
|
||
.filter(([name, types]) => {
|
||
if (alsoPlain.has(name)) return false;
|
||
/* 只有"成员调用会抛"的类型才算:字符串与切片。
|
||
数值/bool 缺失时比较不抛(`undefined > 0` 是 false),不在此列。 */
|
||
return [...types].some(t => t === 'string' || t.startsWith('[]'));
|
||
})
|
||
.map(([name]) => name);
|
||
|
||
assert.ok(risky.length >= 5, `应当算出一批"只在 omitempty 里出现"的字段,实际 ${risky.length}`);
|
||
assert.ok(risky.includes('attachments'), '`attachments` 应当在名单里(这是第二次踩的那个)');
|
||
assert.ok(risky.includes('session_workspace'), '`session_workspace` 应当在名单里(这是崩溃那个)');
|
||
assert.ok(!risky.includes('session_alias'),
|
||
'`session_alias` 不该在名单里 —— 它在 `repo.Contact` 上是不带 omitempty 的(恒有)');
|
||
|
||
const hits = [];
|
||
const walkEts = (dir) => {
|
||
for (const e of readdirSync(dir, { withFileTypes: true })) {
|
||
const full = join(dir, e.name);
|
||
if (e.isDirectory()) { walkEts(full); continue; }
|
||
if (!/\.(ets|ts)$/.test(e.name)) continue;
|
||
const src = code(full);
|
||
for (const f of risky) {
|
||
const re = new RegExp(`\\.${f}\\s*\\.\\s*(length|trim|slice|substring|toUpperCase|toLowerCase|replace|split|startsWith|endsWith|indexOf)\\b`, 'g');
|
||
for (const mm of src.matchAll(re)) {
|
||
const lineStart = src.lastIndexOf('\n', mm.index) + 1;
|
||
const lineEnd = src.indexOf('\n', mm.index);
|
||
const line = src.slice(lineStart, lineEnd === -1 ? undefined : lineEnd);
|
||
/* 同一行有兜底就不算(`??` 或 `||`) */
|
||
if (/\?\?|\|\|/.test(line)) continue;
|
||
hits.push(`${e.name}: ${line.trim().slice(0, 96)}`);
|
||
}
|
||
}
|
||
}
|
||
};
|
||
for (const sub of ['pages', 'common', 'model']) walkEts(join(ETS_ROOT, sub));
|
||
/*
|
||
* ── 已逐个核实过的**豁免**(不是"看着像就放过")──
|
||
*
|
||
* 判据按**跨语言字段名**匹配,所以同一个名字在不同接口上含义不同时会有假阳性。
|
||
* 下面每条都写了"为什么它不是那个会缺失的字段",改这里必须先重新取证:
|
||
*
|
||
* ① `attachments` 在 `this.` 上(`MailDetailPage`):
|
||
* `this.attachments` 是 `@State attachments: AttachmentInfo[] = []` ——
|
||
* 本地状态,初值是**空数组**、且 `MailDetail.normalize()` 在赋值前
|
||
* 已经把服务端的缺失兜成 `[]`(`Models.ets:202`)。不是服务端裸值。
|
||
*
|
||
* ② `attachments` 在 `mail.` 上(`InboxPage`):
|
||
* 那个文件是**死代码** —— 不在 `resources/base/profile/main_pages.json`
|
||
* 的页面表里、全仓没有任何 `pushUrl('pages/InboxPage')`。
|
||
* (它靠"编译器仍会编译"活着,所以判据还是能扫到它。)
|
||
*
|
||
* ③ `reason`(`MailDetailPage` 的 `this.renameProposal.reason`):
|
||
* 这个不是 `models.Mail.RenameReason`,是
|
||
* `GET /sessions/{id}/rename-proposal` 的**那条** proposal。
|
||
* 服务端(`sessions.go:236`)返回的是 `map[string]string{"alias":…, "reason":…}`
|
||
* —— **Go 的 map 里的空串照样输出**(实测:
|
||
* `json.Marshal(map[string]string{"reason":""})` → `{"reason":""}`,
|
||
* 而 struct 带 omitempty 才会省略)⇒ 这个 key 恒在。
|
||
*
|
||
* ④ `last_login`(`SettingsPage`):
|
||
* 那里写的是 `this.profile.last_login !== undefined && …` ——
|
||
* **已经显式判过 undefined**(只是用了 !== undefined 而不是 ??,
|
||
* 所以上面那个正则没认出来)。等价且更明确。
|
||
*
|
||
* ⑤ `reason`(`MainPage.ets` 的 `e.reason`,2026-09-21 加):
|
||
* 这里命中纯属**同名词撞车**。判据是把服务端所有 `json:"reason,omitempty"`
|
||
* 的字段名收集起来,再回 `.ets` 里按名字扫 —— 而那个 omitempty 声明属于
|
||
* `RenameProposal`(`rename_proposal.go:39`,一个**完全不同的**接口)。
|
||
* `e` 是 **`AccountError`**,那是鸿蒙**本地类**(`MailStore.ets:73`),
|
||
* 实例只经 `AccountError.of(account, reason)` 构造 ⇒ `reason` 恒为 string。
|
||
* ⇒ 与 `Mail` 的 omitempty **毫无关系**,不存在 undefined。
|
||
*
|
||
* ⑥ `permission_result`(`MainPage.ets` 的 `m.permission_result`,2026-09-21 加):
|
||
* ★ 这一处**原先是真的会抛**,而修法不是在这里加 `??` ——
|
||
* 而是**在解析边界归一化**(本仓那条纪律:`MailDetail` 就是那么修的)。
|
||
* 已给 `MailSummary` 补了 `normalize()`(`Models.ets`),
|
||
* 并在 `MailApi.inbox()` / `sent()` / `sessionMails()` **三处**接上
|
||
* ⇒ 能走到这一行的 `m` 一定是归一过的,`permission_result` 必为 string。
|
||
* ⇒ 判据看不见"上游归一过了",所以这里必须显式豁免,并把理由写清。
|
||
*
|
||
* ★ 为什么不在这一行加 `??`:那会把**根因**(列表这条主流没有解析边界
|
||
* 兜底)继续盖住,而 `MailSummary` 上还有 `mail_type` / `session_alias`
|
||
* 同样带 omitempty —— 逐个调用点加 `??` 就是本仓反复在消的形状
|
||
* ("每加一处就得记得做一次")。归一化只做一次、且覆盖全部字段。
|
||
*/
|
||
/*
|
||
* 豁免 ⑥ 的**前提**:`MailSummary.normalize()` 真的把两个 omitempty 字段补了。
|
||
*
|
||
* 为什么要有这一步:无条件豁免 = 把那一行永久致盲(变异实测过,见 ⑥ 的注释)。
|
||
* 判据要表达的是"**因为上游归一了**,所以这里可以不写 `??`",
|
||
* 而不是"这一行不用管"。
|
||
*/
|
||
const modelsSrc = prose(join(ETS_ROOT, 'model/Models.ets'));
|
||
const summaryCls = (() => {
|
||
const at = modelsSrc.indexOf('export class MailSummary');
|
||
if (at < 0) return '';
|
||
const end = modelsSrc.indexOf('\nexport class ', at + 10);
|
||
return modelsSrc.slice(at, end < 0 ? undefined : end);
|
||
})();
|
||
const normalizeCoversOmitemptyFields =
|
||
/static normalize\(m: MailSummary\)/.test(summaryCls) &&
|
||
/m\.permission_result = str\(m\.permission_result\)/.test(summaryCls) &&
|
||
/m\.mail_type = str\(m\.mail_type\)/.test(summaryCls);
|
||
|
||
const EXEMPT = [
|
||
/^MailDetailPage\.ets: if \(this\.attachments\.length/,
|
||
/^MailDetailPage\.ets: Text\('附件 ' \+ this\.attachments\.length\)/,
|
||
/^InboxPage\.ets: if \(mail\.attachments\.length/,
|
||
/^MailDetailPage\.ets: if \(this\.renameProposal\.reason\.length/,
|
||
/^SettingsPage\.ets: \(this\.profile\.last_login !== undefined/,
|
||
/* ⑤ 同名词撞车:`AccountError.reason` 是本地类(见上面 ⑤ 的取证) */
|
||
/^MainPage\.ets: parts\.push\(e\.reason\.length/,
|
||
/*
|
||
* ⑥ 已在解析边界归一化(见上面 ⑥)。
|
||
*
|
||
* ★★ 这条豁免**不能无条件** —— 我第一版就是无条件正则,变异实测
|
||
* (把 `MailSummary.normalize` 里那行 `m.permission_result = str(...)` 删掉)
|
||
* **判据照样全绿**:豁免把这一行永久致盲了。
|
||
* 那正是本仓反复消的形状 —— **豁免口自己没人管**。
|
||
*
|
||
* ⇒ 豁免必须**以"上游真的归一了"为前提**:先断言
|
||
* `MailSummary.normalize` 里确实有这两行,命中才豁免。
|
||
* 归一被删 ⇒ 前提不成立 ⇒ 豁免失效 ⇒ 这一行重新报警。
|
||
*/
|
||
...(normalizeCoversOmitemptyFields
|
||
? [
|
||
/*
|
||
* ★★ 2026-09-23 修:这条豁免原来写的是 `^MainPage\.ets: …`,
|
||
* 而那段代码**已从 `MainPage.ets` 抽成 `pages/PermissionTab.ets`**
|
||
* (用户要求"按页面封装组件以便与 WebUI 一一对应")。
|
||
*
|
||
* 抽出之后豁免不再命中 ⇒ 三行重新报警(判据报的是三个 `permission_result.length`)。
|
||
* ★ 这个红是**对的**,而且值得记:豁免写的是**文件 + 行内容**,
|
||
* 组件搬家就会失效 —— 而失效的方向是**变红**(不是变哑),
|
||
* 这正好与那条豁免注释里担心的方向相反、也都可接受:
|
||
* 宁可多报一次让人来看,也不要静默致盲。
|
||
*
|
||
* ★ 注意这里**同时保留了旧路径**:`permission_result` 的同类读法
|
||
* 在 `MainPage.ets` 里也还有(`MailRow` / `SentRow` 的 chip 文案),
|
||
* 两条路径都要豁免,删掉哪一条都会让另一处假红。
|
||
*/
|
||
/^MainPage\.ets: if \(m\.mail_type === 'permission_request' && m\.permission_result\.length/,
|
||
/^PermissionTab\.ets: if \(m\.mail_type === 'permission_request' && m\.permission_result\.length/,
|
||
/^PermissionTab\.ets: \.fontColor\(m\.permission_result\.length/,
|
||
/^PermissionTab\.ets: \.fontWeight\(m\.permission_result\.length/
|
||
]
|
||
: [])
|
||
];
|
||
const real = hits.filter(h => !EXEMPT.some(re => re.test(h)));
|
||
assert.deepEqual(real, [],
|
||
`这些地方直接读了服务端带 omitempty 的字段(缺失时是 undefined,会抛):\n ${real.join('\n ')}\n` +
|
||
'加 `?? []` / `?? \'\'` 兜底(见 Models.ets 顶部那段教训)');
|
||
|
||
// 自检:探测器要认得错误形状,也要认得兜底后的正确形状
|
||
const probe = (t, f) => {
|
||
const re = new RegExp(`\\.${f}\\s*\\.\\s*(length|trim)\\b`, 'g');
|
||
return [...t.matchAll(re)].filter(mm => {
|
||
const line = t.slice(t.lastIndexOf('\n', mm.index) + 1, t.indexOf('\n', mm.index));
|
||
return !/\?\?|\|\|/.test(line);
|
||
}).length;
|
||
};
|
||
assert.equal(probe('const n = mail.attachments.length;', 'attachments'), 1, '要抓得住裸读');
|
||
assert.equal(probe('const n = (mail.attachments ?? []).length;', 'attachments'), 0, '不许误伤兜底写法');
|
||
});
|
||
|
||
test('★ 不许用已废弃的全局 API(`router` / `promptAction` / `animateTo` / `vp2px`)', () => {
|
||
/*
|
||
* ★★ 2026-09-20 加。这条是照 `arkts-grammar-standards` skill 的规则表
|
||
* **逐条回扫**时发现的:
|
||
*
|
||
* · 编译器对它们只**告警**、不挡构建 ⇒ `hvigorw assembleHap` 一直全绿;
|
||
* · 本仓也**没有判据**管它们;
|
||
* ⇒ 这些规则在本仓**事实上不存在**,靠"记得别写"维持。
|
||
*
|
||
* 实际抓到的:`api/Logout.ets:76` 的 `router.replaceUrl(...)` 全局调用
|
||
* (从 2026-09-19 一直活到今天)。已改成把 `UIContext` 传进 `performLogout`
|
||
* 再走 `ui.getRouter()`。
|
||
*
|
||
* 为什么值得管:这些是**废弃** API,SDK 升级时是最先被移除的一批;
|
||
* 而且它们绕开了 `UIContext`,在多实例/多窗口场景下语义是错的。
|
||
*
|
||
* 判法:找**裸用**(前面不是 `.` 也不是标识符字符),且排除注释与
|
||
* `this.getUIContext()` / `ui.getRouter()` 这类**正确**写法 ——
|
||
* 正确写法里这些词出现在 `getXxx()` 的方法名位置,前面有 `.`。
|
||
*/
|
||
const DEPRECATED = [
|
||
// [正则, 说明]
|
||
[/(^|[^.\w$])router\.(pushUrl|replaceUrl|back|getParams|clear)\s*\(/m, '全局 router.*(用 this.getUIContext().getRouter() 或 ui.getRouter())'],
|
||
[/(^|[^.\w$])promptAction\.\w+\s*\(/m, '全局 promptAction(用 this.getUIContext().getPromptAction())'],
|
||
[/(^|[^.\w$])animateTo\s*\(/m, '全局 animateTo(用 this.getUIContext().animateTo())'],
|
||
[/(^|[^.\w$])(vp2px|px2vp)\s*\(/m, '全局 vp2px/px2vp(用 this.getUIContext().vp2px(),或 display 密度换算)']
|
||
];
|
||
const bad = [];
|
||
const walkDir = (dir) => {
|
||
for (const e of readdirSync(dir, { withFileTypes: true })) {
|
||
const full = join(dir, e.name);
|
||
if (e.isDirectory()) { walkDir(full); continue; }
|
||
if (!/\.ets$/.test(e.name)) continue;
|
||
const src = code(full); // 先剥注释,免得注释里的说明被当成违规
|
||
for (const [re, msg] of DEPRECATED) {
|
||
if (re.test(src)) bad.push(`${e.name}:${msg}`);
|
||
}
|
||
}
|
||
};
|
||
walkDir(ETS_ROOT);
|
||
assert.deepEqual(bad, [],
|
||
`这些地方用了已废弃的全局 API(编译器只告警、不挡构建):\n ${bad.join('\n ')}`);
|
||
|
||
// 自检:探测器要认得裸用,也要放过正确写法
|
||
const hit = (t, i) => DEPRECATED[i][0].test(t);
|
||
assert.equal(hit('router.replaceUrl({ url: x });', 0), true, '要抓得住全局 router 裸用');
|
||
assert.equal(hit('ui.getRouter().replaceUrl({ url: x });', 0), false, '不许误伤 ui.getRouter()');
|
||
assert.equal(hit('this.getUIContext().getRouter().back();', 0), false, '不许误伤 this.getUIContext()');
|
||
assert.equal(hit('await this.getUIContext().animateTo({}, () => {});', 2), false, '不许误伤 ui.animateTo');
|
||
assert.equal(hit('animateTo({}, () => {});', 2), true, '要抓得住全局 animateTo');
|
||
assert.equal(hit('const x = this.getUIContext().vp2px(24);', 3), false, '不许误伤 ui.vp2px');
|
||
});
|