Files
MailUI4Agents/client/electron/test/harmony-arkts.test.mjs
JianFeeeee f4d5a75976 跨端: 补 MailSummary 的解析边界兜底(列表这条主流的 omitempty 一直是敞的)
判决书来自 `harmony-arkts` 那条判据,它在我加了授权栏历史之后报出两处调用点:

    MainPage.ets: parts.push(e.reason.length > 0 ? …)                 ← 假阳性
    MainPage.ets: if (m.mail_type === 'permission_request' && m.permission_result.length > 0)   ← 真 bug

## 真 bug:`MailSummary` 没有解析边界兜底

本仓早就为这个形状付过代价,**而且修法只修了一半**:

· `MailDetail` 有 `normalize()`,并在 `MailApi.mailDetail()` 接了(当时那次是**整页白屏**);
· 而列表用的 `MailSummary` **没有** —— 于是 `mail_type` / `permission_result` /
  `session_alias` 这些带 `omitempty` 的字段在缺失时是 `undefined`,
  而三处调用点直接读 `.length`。

服务端 `omitempty` 的语义是**整个 key 不出现**(不是给空串),
ArkTS 裸 cast(`JSON.parse(raw) as T`)缺键给 `undefined`、**不会**应用类里那个 `= ''`。

⇒ 这是同一形状的**第四次**(前三次:`MailDetail` 白屏、`participantAddress` 的 trim、
`AddressSuggestion.title`)。前三次都是"读的人临时守一下",
**而列表这条主流一直敞着**。

修法(与 `MailDetail` 同一处、同一纪律):给 `MailSummary` 加 `normalize()`,
在 `MailApi.inbox()` / `sent()` / `sessionMails()` **三处**解析边界接上。

★ 为什么不在调用点加 `??`:`MailSummary` 上还有 `mail_type`/`session_alias`
  同样带 omitempty —— 逐个调用点加就是"每加一处就得记得做一次",
  本仓反复在消的形状。归一化做一次、覆盖全部字段。

## 假阳性:同名词撞车

`e.reason` 的 `e` 是 **`AccountError`** —— 鸿蒙**本地类**(`MailStore.ets:73`),
只经 `AccountError.of(account, reason)` 构造 ⇒ `reason` 恒为 string。
而判据收集的那个 `json:"reason,omitempty"` 属于 `RenameProposal`
(`rename_proposal.go:39`,**完全不同的**接口)。已按既有
「已逐个核实过的豁免」格式加 ⑤,附取证。

## 期间修了判据自己的一个洞(变异实测)

我给 ⑥ 加豁免时第一版写的是**无条件**正则 —— 变异实测
(把 `MailSummary.normalize` 里那行 `m.permission_result = str(...)` 删掉)
**判据照样全绿**:豁免把那一行永久致盲了。

这正是本仓反复消的形状:**豁免口自己没人管**。

⇒ 改成"**有前提**的豁免":先断言 `MailSummary.normalize` 里确实有那两行,
命中才豁免。变异复验:

    删掉 normalize 里 permission_result → 判据红 ✓
    删掉 normalize 里 mail_type        → 判据红 ✓
    两者都在                          → 全绿 ✓

⇒ 豁免表达的是"**因为上游归一了**,所以这里可以不写 ?? ",
  而不是"这一行不用管"。
2026-09-21 17:06:19 +08:00

641 lines
35 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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
? [/^MainPage\.ets: if \(m\.mail_type === 'permission_request' && 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');
});