Files
MailUI4Agents/client/electron/test/criteria-hygiene.test.mjs
JianFeeeee 4af083e8b8 test(判据): 4 个判据读的是**另一棵树** —— 修掉硬编码的仓库根,并让 stripComments 保持行号
pi 2026-09-15 实测出来的,**这次长在判据自己身上** —— 正是我们前几轮一直在消的那个形状。

## 一、`const ROOT = '/home/program/agentmail'`:规则进来了,对象没进来

`harmony-arkts.test.mjs` 把仓库根写成了绝对路径。后果我按 pi 的步骤亲手复现了:

```
$ git worktree add --detach /tmp/wt-verify 7f4fa26      # 那个检出里 import 顺序**确实**违规
  (核对:最后 import 在第 80 行,而第 63 行已是 `const NAV_MATERIAL_OF…`)
$ cd /tmp/wt-verify/client/electron && node --test test/harmony-arkts.test.mjs
ok 1 / ok 2 / ok 3   # pass 3  # fail 0      ← **在一个明显违规的检出上 3/3 全绿**
```

因为它读的不是 `/tmp/wt-verify`,是 `/home/program/agentmail`(那份早已修好)。
两层后果,第二层最糟:
① 它**永远无法验证任何别的 checkout / CI / 镜像** —— 换目录不是"红",是 `readdirSync` 直接抛;
② 在本机做 worktree 复核时,它**静默读另一棵树并报绿**。
**判据的逻辑是对的、对象是错的** —— 这比"判据写错了"更难发现,因为它在原地永远是绿的。

同一个毛病在 4 个文件里,**恰好全是最近这几笔新写的**(另 10 个鸿蒙判据写法是对的):

```
harmony-admin / harmony-imageprep / harmony-presets / harmony-arkts  →  const ROOT = '/home/program/agentmail';
其余 10 个                                                          →  const ROOT = join(HERE, '..', '..', '..');
```

已全部照邻居改掉。**修好之后在同一个违规检出上:`# fail 1`** —— 它终于会红了。

## 二、修这条时又牵出一个:`stripComments` **改变了行号**

修好路径后,判据报出"最后一个 import 在第 64 行、第 47 行已是语句",
而**真实文件里是第 80 / 63 行**。成因:`stripComments` 把块注释整块抹成 `''`,
而块注释**自带换行** ⇒ 它之后所有行号整体前移。

这不是小节:全仓判据都用 `文件:行号` 定位(`grep -n`、编辑器跳转、`git show` 核对),
**报出来的行号必须能直接用**,否则读者第一步得先猜"这是剥过的还是没剥的"。
改成"块注释里的每个换行换成等量空行"。修完报的就是 **80 / 63**,与文件逐字对上。

## 三、新增两条判据,让这两个形状不能再回来

1. **`★ 判据不许把仓库根硬编码成绝对路径`** —— 扫判据目录里**真代码**
   (`code()` 剥注释,否则本文件自己的说明文字就会误报),找
   `const X = '/绝对路径'` 且**看着像仓库内**的声明。
   **例外按名字放行**(含 `TOOLCHAIN`/`SDK`/`HDC` 的常量)—— 工具链本来就不在仓库里、推不出来;
   按**值**做白名单会逼着下一个人为了过判据去改那个路径的写法。
2. **`★ stripComments 必须保持行号`** —— 造含多行块注释的样本,断言剥完
   **行数不变**、且第 N 行仍是原来的第 N 行;**同时**断言注释内容确实被去掉了
   (别为了保行号把注释留下)。

两条都做了**变异验证**:
- 把 `harmony-admin` 的 ROOT 改回硬编码 ⇒ 新判据**红**,并点名那个文件;还原后绿。
- 在 `MainPage.ets` **import 之前**插一条语句 ⇒ `harmony-arkts` **红**
  (第 79 行 vs 第 1 行);还原后绿。**这条同时证明了"读的是自己那棵树"** ——
  同样这个变异,在修路径**之前**是绿的。

## 四、未做 / 未验

- 到期闸门那 7 条**没动**(要真装真点,是另一件活)。
- **"把 build 做成一条判据"我探了,两个硬障碍**(详见给 pi 的回信):
  ① `client/harmony/oh_modules` 被 `.gitignore` 排除且未入库 ⇒ **全新检出没有它**,
     构建会先死在装依赖上;② 本沙箱**拒写 `/root/.hvigor`**(`mkdir` Permission denied),
     `hvigorw` 在 worktree 里直接 `EACCES: mkdir '/root/.hvigor/project_caches/…'`。
  所以它在本仓能编过、在干净检出编不过 —— 作为判据它现在会**假红**。
2026-09-15 12:06:15 +08:00

211 lines
11 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.

/**
* 判据目录自身的卫生:**读文本必须走 `test/lib/read.mjs` 的两个具名入口**。
*
* # 为什么这条判据存在(pi 2026-09-14 §4)
*
* 规范里写着"判代码读剥离版(`code`)、判理由/文档读原文(`prose`)",
* 我 P5 写过一次、当天又踩了一次:那条断言读的是**原文**,而它要找的标识符
* 恰好出现在一段解释性注释里 → 误报。**第二次犯规说明问题不在记性,在形态**:
* 靠人记得执行的规范一定会有下一次。
*
* 所以把"用哪个读取器"从**记忆**变成**代码里的一个词**,并且可被检查:
* - `code(path)` —— 剥掉注释;判"代码里有没有这个调用/这个值";
* - `prose(path)` —— 原文;判"注释/文档里写了什么";
* - `bytes(path)` —— 二进制(安装包等)。
*
* # 判据
*
* 判据目录(`test/**` 里跑的判据 + `run-all.mjs`)中**不得出现裸 `readFileSync`**,
* 唯一例外是 `test/lib/read.mjs` 自己。`test/manual/**` 是人工脚本、不是判据,不在范围内。
*
* 附两条自检:读取器本身要真的剥注释(否则 `code` 退化成 `prose` 这条判据就废了)、
* 以及探测器要能认出裸调用(否则"都没有"与"探测器坏了"结果一样)。
*/
import assert from 'node:assert/strict';
import { readdirSync, unlinkSync, writeFileSync } from 'node:fs';
import { dirname, join, relative } from 'node:path';
import { test } from 'node:test';
import { fileURLToPath } from 'node:url';
import { code, prose, stripComments } from './lib/read.mjs';
const HERE = dirname(fileURLToPath(import.meta.url));
const RELECTRON = join(HERE, '..'); // test/ 的上一级就是 client/electron
const SELF = join(HERE, 'lib', 'read.mjs');
/** 判据文件清单:`test/**` 下会跑的判据 + 编排器;不含 lib/ 与 manual/ */
function criteriaFiles(dir = HERE, out = []) {
for (const e of readdirSync(dir, { withFileTypes: true })) {
const p = join(dir, e.name);
if (e.isDirectory()) {
if (e.name === 'lib' || e.name === 'manual' || e.name === 'node_modules') continue;
criteriaFiles(p, out);
} else if (/\.(test\.mjs|test\.ts|test\.tsx|mjs)$/.test(e.name) && !e.name.endsWith('.d.ts')) {
out.push(p);
}
}
return out;
}
/** 探测器:一段源码里有没有裸 readFileSync */
const BARE = /\breadFileSync\s*\(/;
test('探测器自检 + 读取器自检', () => {
// ① 探测器能认出裸调用(否则"都没有"与"探测器坏了"分不开)
assert.equal(BARE.test("const s = " + "readFile" + "Sync(p, 'utf8');"), true);
assert.equal(BARE.test('const s = prose(p);'), false);
// ② code 真的剥注释、prose 不剥 —— 这条是整套用法的地基:
// 若 code 退化成 prose,那么"读剥离版"的规范就变成一句空话,而且没人会发现。
const probe = join(RELECTRON, 'test', '_reader_probe.tmp.ts');
// 注:这个探针文本**故意拼接**而不是写字面量 —— 否则本判据自己会被自己判红
// (它扫的就是"文本里有没有这个写法",判据文件也在扫描范围内)。
writeFileSync(probe, "const REAL = 1; // " + "readFile" + "Sync( 注释里的假调用\n/* allowed-once */\n");
try {
assert.ok(!code(probe).includes('allowed-once'), 'code() 必须剥掉块注释');
assert.ok(!code(probe).includes('假调用'), 'code() 必须剥掉行注释');
assert.ok(code(probe).includes('REAL'), 'code() 要保留真代码');
assert.ok(prose(probe).includes('allowed-once') && prose(probe).includes('假调用'), 'prose() 必须保留注释');
} finally {
unlinkSync(probe);
}
});
test('★ 判据目录里不得出现裸 readFileSync(必须走 code/prose/bytes)', () => {
const offenders = [];
for (const f of criteriaFiles()) {
if (f === SELF) continue; // 读取器的实现自己当然要用它
const src = prose(f); // 扫的是"文本里有没有这个写法",所以读原文
if (BARE.test(src)) {
const line = src.split('\n').findIndex(l => BARE.test(l)) + 1;
offenders.push(`${relative(RELECTRON, f)}:${line}`);
}
}
assert.deepEqual(offenders, [],
`这些判据文件里还在裸用 readFileSync:\n ${offenders.join('\n ')}\n` +
" 改用 test/lib/read.mjs 的具名入口:\n" +
" · code(path) —— 剥掉注释。判「代码里有没有这个调用/这个值」时用它(默认选它);\n" +
" · prose(path) —— 原文。判「注释/文档里写了什么」时用它;\n" +
" · bytes(path) —— 二进制(安装包等)。\n" +
" 为什么不能裸用:读原文去判代码,会被解释性注释骗(同一个坑已经踩过两次)。");
});
/**
* ★ 用到 `lib/read.mjs` 的导出名就必须真的 import(我这轮在三个文件里各犯过一次)。
*
* 形状一模一样:`code(...)` / `prose(...)` 写下去,import 里却只有另一个 ——
* 于是在**跑起来的那一刻**才炸 `ReferenceError`,而它抛在判据自己身上,
* 看起来像"这条判据红了",不像"判据写错了"。dsh 桥那边也栽过同一形状
* (`MODE_FULL` 没 import,而且被 `tsc | tail` 的退出码骗过)。
*
* 判据做法:把每个判据文件里出现的 `code(`/`prose(`/`bytes(` 收集起来,
* 与它从 `lib/read.mjs` 实际 import 的名字比对;缺一个就红,并点名文件与名字。
* **例外**:文件里自己定义了同名函数(本地实现)时不算缺 —— 但那种情况要显式声明。
*/
test('★ 用到 code/prose/bytes 就必须 import(不许靠运行时才发现)', () => {
const EXPORTS = ['code', 'prose', 'bytes'];
const problems = [];
const SELF_PATH = fileURLToPath(import.meta.url);
for (const f of criteriaFiles()) {
// 它自己的源码里就写着 code/prose/bytes 这几个名字(EXPORTS 列表),跳过自己
if (f === SELF_PATH) continue;
const src = prose(f);
if (src.includes("from './lib/read.mjs'") || src.includes("from '../lib/read.mjs'")) {
const m = /import \{([^}]*)\} from '\.\.?\/lib\/read\.mjs'/.exec(src);
const bound = new Set((m ? m[1] : '').split(',').map(x => x.trim().split(/\s+as\s+/).pop()).filter(Boolean));
for (const name of EXPORTS) {
if (new RegExp(`\\b${name}\\(`).test(src) && !bound.has(name)) {
problems.push(`${relative(RELECTRON, f)} 用了 ${name}(…) 但没 import(已 import:${[...bound].join('、') || '无'})`);
}
}
} else {
for (const name of EXPORTS) {
if (new RegExp(`\\b${name}\\(`).test(src)) {
problems.push(`${relative(RELECTRON, f)} 用了 ${name}(…) 但根本没从 lib/read.mjs import`);
}
}
}
}
assert.deepEqual(problems, [],
`这些判据会以 ReferenceError 的形式红,看起来像"判据失败了",其实是"判据写错了":\n ${problems.join('\n ')}`);
});
/**
* ★ 判据**必须读自己那棵树**,不许把仓库根硬编码成绝对路径。
*
* pi 2026-09-15 实测出的形状(这次长在**判据自己**身上,正是我们前几轮一直在消的那个):
* `harmony-arkts.test.mjs` 里写着 `const ROOT = '/home/program/agentmail'`。
* 把带违规的提交检出到别的目录再跑,它**读的仍是 `/home/program/agentmail`** ⇒
* **在一个 import 顺序明显违规的检出上 3/3 全绿**。
*
* 两层后果,第二层最糟:
* ① 它**永远无法验证任何别的 checkout / CI / 镜像** —— 换个目录不是"红",
* 是 `readdirSync` 直接抛(broken),而 broken 证明不了任何判据成立或不成立;
* ② 在本机做 worktree 复核时,它会**静默读另一棵树并报绿**。
* **"规则进来了,对象没进来"** —— 判据的逻辑对,对象错。
*
* 判据做法:扫判据目录里**真代码**(`code()` 剥注释,否则本文件自己的说明就会误报),
* 找形如 `const X = '/绝对路径'` 的仓库根声明。修法照邻居:`join(HERE, '..', '..', '..')`。
*/
test('★ 判据不许把仓库根硬编码成绝对路径(必须从本文件位置推)', () => {
/*
* 例外:**工具链/SDK 的绝对路径是合法的** —— 那些东西本来就不在仓库里,
* 推不出来(`TOOLCHAIN_ROOT = '/opt/huawei/command-line-tools'`)。
* 所以按**名字**放行含 `TOOLCHAIN`/`SDK`/`HAP` 的常量,而不是按值的白名单 ——
* 值白名单会逼着下一个人为了过判据去改那个路径的写法。
* 另一半保证:仓库**内部**的路径一律不许硬编码,那才是"读错树"的来源。
*/
const TOOLCHAIN_OK = /TOOLCHAIN|_SDK|SDK_|HAP_|EMULATOR|HDC/i;
const problems = [];
for (const f of criteriaFiles()) {
const src = code(f);
// 只看**真的在赋值绝对路径**的那些行;注释已被剥掉,不会拿说明文字误报
for (const m of src.matchAll(/(?:const|let|var)\s+(\w+)\s*=\s*'(\/[^']*)'/g)) {
const [, name, val] = m;
const looksLikeRepo = new RegExp(`(^|/)${relative(RELECTRON, f).split('/')[0]}|agentmail`, 'i').test(val)
|| /PROJECT|REPO|WORKSPACE/i.test(name);
if (looksLikeRepo && !TOOLCHAIN_OK.test(name)) {
problems.push(`${relative(RELECTRON, f)}:\`${name} = '${val}'\` —— 这是**仓库内**的路径,`
+ `必须从 \`import.meta.url\` 推(\`join(dirname(fileURLToPath(import.meta.url)), '..', …)\`),`
+ `否则这个判据读的不是它自己那棵树`);
}
}
}
assert.deepEqual(problems, [],
`这些判据被钉死在一条**仓库内**的绝对路径上 —— 在别的检出/CI/镜像里,`
+ `它们要么读错树报假绿,要么直接抛(broken):\n ${problems.join('\n ')}`);
});
/**
* ★ `stripComments` 必须**保持行号不变**。
*
* 块注释自带换行,若整块抹成 `''`,它之后**所有行号整体前移** ——
* 而全仓判据都在用 `文件:行号` 定位(grep、编辑器跳转、`git show` 核对)。
* 实测(我自己的 `harmony-arkts` 报违规时):报出 64/47,**真实文件是 80/63**,
* 读者第一步就得先猜"这是剥过的还是没剥的"。
*
* 判据做法:造一个含多行块注释的样本,断言剥完**行数不变**、且行号仍然对得上;
* 再断言注释内容确实被去掉了(别为了保行号把注释留下)。
*/
test('★ stripComments 必须保持行号(否则判据报的行号全是错的)', () => {
const sample = [
'/*',
' * 多行块注释',
' * 第二行',
' */',
'const a = 1; // 行尾注释',
'/* 单行块注释 */',
'const b = 2;',
].join('\n');
const out = stripComments(sample);
assert.equal(out.split('\n').length, sample.split('\n').length,
'stripComments 改变了行数 —— 它之后所有行号都会错位');
assert.ok(!out.includes('多行块注释') && !out.includes('行尾注释'),
'stripComments 没把注释去掉');
// 行号对得上:第 5 行仍应是 `const a = 1;`
assert.match(out.split('\n')[4], /const a = 1;/,
'剥完之后第 5 行不再是原来的第 5 行');
assert.match(out.split('\n')[6], /const b = 2;/,
'单行块注释所在的那一行之后,行号错位了');
});