起因:做邮件详情时顺手把 `Text('‹')` 换成 `AmIcon('chevronLeft')`,
想着"本仓不是有这条判据吗,怎么没抓到" —— 去看了判据,发现它**只扫 emoji 那一段**。
判据(`harmony-arkts.test.mjs:168`)扫的是 U+2600–27BF / U+2B00–2BFF / U+FE0F,
而漏网的 5 处全在这三段**之外**:
MainPage.ets Text('‹') 返回键(会话视图)
MainPage.ets Text('›') 会话别名前的小箭头
SettingsPage.ets Text('›') 「管理」的进入下一级指示
ComposePage.ets Text(' ▾') 账号下拉指示
MailDetailPage.ets Text('‹') 返回键
★ 它们与 emoji 那类**问题不同、但同样是"看着像图标其实不是"**:
· 字形宽窄由**字体**决定,与旁边 20vp 的 `AmIcon` 对不齐;
· 而且**WebUI 用的根本不是字符** —— `icons.tsx:135` 是
`ChevronRightIcon`(SVG)、`AccountSwitcher.tsx:84` 是它**转 90°**。
所以这同时是"两端不一致",不只是"字形不好看"。
修法:5 处一律换 `AmIcon`,并按 WebUI 的**同一形状**给:
· 两个返回键 → `chevronLeft` 20vp、可点区 `HEADER_BACK_HIT`(与 `AppHeader` 同值)
· 会话别名 / 「管理」→ `chevronRight`
· 账号下拉 → `chevronRight` + `rotate(open ? 90 : 0)`(照 `AccountSwitcher` 那句)
★ 判据扩了一类(`GLYPH_ISH`:U+2039/203A/00AB/00BB/25A0–25CF/25B2/25BC/25C0/25B6),
并保持原有那条边界不被破坏:**U+2190–21FF 基本箭头仍不扫** ——
`→`/`←` 在正文里是标点,扫进来会误伤大量正常文案(原注释里已经踩过这个坑)。
这次的符号是"单个字符整体",所以 `Text('a → b')` 这类多字符本来也不匹配。
★ 扩完之后判据**当场又抓到一处我自己没注意的**(`SettingsPage.ets` 的 `›`)——
这正是"把判据从'枚举实例'扩到'枚举类'立刻多抓一个"的现场证据,
也说明原来那三条区间是照"已经出现过的实例"框的,不是照"这类东西的共同形状"框的。
308 lines
16 KiB
JavaScript
308 lines
16 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 } 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('函数表达式');
|
||
if (hits.length) bad.push(`${rel(f)}:${hits.join('、')}`);
|
||
}
|
||
assert.deepEqual(bad, [], `★ ArkTS 硬坑(编译不过):\n ${bad.join('\n ')}`);
|
||
});
|
||
|
||
/* ─────────────────── 图标:不许用 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 的正确写法');
|
||
});
|