/** * 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 = {', " '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 = {', " '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(.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'); });