Files
MailUI4Agents/client/electron/test/run-all.mjs
JianFeeeee e99a657143 修复: rc=2 那半个缝(降权构造 blind/unreadable 的上游锚点);顺带修掉它暴露出的**真 bug**:sha256sum 不在 PATH 时 UnboundLocalError 崩在打出 RESULT 之前
pi 2026-09-18 报的 M18。我复现了它,补锚点时又**意外撞出一个提交态就有的真 bug**(不是 pi 报的、也不是我预想的)。

## 一、M18 复现:rc=2 只有下游锚点、没有上游锚点

`--exitcode-selftest` 原来四例期望只覆盖 **0/1**(实测读出 `, 0,` `, 1,` `, 1,` `, 0,`)。
把 `summary.py` 的 `if blind or unreadable: return 2` 改成 `return 0`:

| 自检 | rc | 该不该响 |
|---|---|---|
| `--exitcode-selftest`(上游) | **0(绿)** | **该红** |
| `--mutants-line-selftest`(下游) | **0(绿)** | 不该(它喂合成 status) |

真跑 blind 确认后果:`nobody + chmod 000 jobs/` ⇒ 变异版 **rc=0**(应 2)
⇒ "环境:没读数"整条链又断回"看着正常",**两个自检都不响**。与 pi 逐字一致。

★ 根因是**结构性障碍**,不是疏忽:`summary.py` 用 `os.access(JOBS_DIR, R_OK|X_OK)` 判 blind,
而 **`os.access` 对 uid=0 恒为 True**(实测:`chmod 000` 后 root `access()==True`、nobody 才 False)
—— 与我早先在 `check-file-modes.sh` 踩的 `[ -x ]` 是**同一个 root 陷阱**,只是换成了 `os.access`。
所以自检以 root 跑时 blind 分支**物理上不可达**。

## 二、补法:降权构造前提(三格都有锚点:0 / 1 / **2**)

`runIn()` 加降权模式:临时目录整棵放开到 nobody 可达 → 按场景收紧
(`blind`:`chmod 000 jobs/`;`unreadable`:目录 755 + 单文件 000)→ 用
`runuser -u nobody`(无则退 `setpriv --reuid=65534`)跑**真脚本**。两条路我都实测得 rc=2。

新增两条案例:`盲读⇒2`、`单文件读不到⇒2`。

★ **前提构造不出来时**(拿不到降权工具)报 **RED 并明说"本机覆盖不到、这不是通过是没有锚点"** ——
照 pi 那句"哪条前提构造不出来就写明,别让它默认看起来是被守着的"。不静默跳过。

**变异验证**(都已还原):
· **M18**(`blind ⇒ return 0`)⇒ 两条降权案例**都红**、exit 1(改前两个自检全绿)。
· **M19**(只把 `unreadable` 那一半改成 0)⇒ **只有**"单文件读不到"那条红 ——
  证明两条锚点是**独立**的,不是互相冗余。

## 三★★ 补锚点时撞出的**真 bug**(提交态就有,不是本笔引入)

构造"前提 B(单文件不可读)"时跑出 `UnboundLocalError: cannot access local variable 'r'`:

```python
try:
    r = subprocess.run(['sha256sum', '-c', bl], ...)   # ← 在 try 内绑定
    ...
except Exception:
    baseline_ok = (False, -1, -1)                       # ← 设了非 None
...
else:                                                   # baseline_ok 非 None ⇒ 走这里
    detail = [... for ln in (r.stdout or '').splitlines() ...]   # ← r 从未绑定 ⇒ 崩
```

崩点在**打出 `RESULT` 行之前** ⇒ 上游只看到"没打出 RESULT",而真因(一个外部命令缺失)
埋在 traceback 里。**触发条件很实在**:`sha256sum` 不在 PATH(精简镜像 / PATH 被裁的 CI)。
实测:正常 rc=0;`PATH` 里去掉 `sha256sum` ⇒ `UnboundLocalError`、rc=1。

⇒ 两件事一起修:
① `r` 不再依赖 try 内绑定;
② 把"**跑不了这项检查**"与"**检查了、没过**"**分成两种状态**(合并正是这个 bug 的成因)——
现在那一格打 `baseline=(**跑不了 sha256sum 校验**:FileNotFoundError: ...)—— 这一格**不是**"底本对"也**不是**"有残留",是**没读数**`。

并补第 7 条锚点:**故意让 PATH 里没有 `sha256sum`**,要求 ① 不崩 ② 仍打出 `RESULT` ③ 那格说"跑不了"。
**M20**(把 `r` 绑定退回 try 内)⇒ 该案例红、exit 1,报的正是那个 `UnboundLocalError`。

## 四、自检自身的两个问题(照实记)

1. **`--exitcode-selftest` 清理漏了**:清理原来是顺序执行的最后两步,`runIn` 中途抛错就跳过 ——
   实测 `/tmp` 里留下 `exitcode-*`。改成 `try/finally`(失败正是最常被重跑的路径),
   并加了"抛错时也清理"的验证(模拟中途 throw ⇒ 残留 0)。
2. **PATH 一裁连解释器都找不到**:`no-sha256sum` 那条如果不把 `python3` 解析成绝对路径,
   会变成"脚本没跑起来"(退 1),而 **1 又是别的案例的期望值** ⇒ 又一次"没跑起来长得像通过"。
   已先解析绝对路径。

## 五、验证与状态

· 7 条锚点全绿:一致⇒0 / unlisted⇒1 / ghosts⇒1 / 说明条目⇒0 / **盲读⇒2** / **单文件⇒2** / **无 sha256sum⇒不崩**。
· 四个自检全 exit 0:`--mutants-line-selftest` 8 例、`--exitcode-selftest` **7 例**、`--skip-selftest` 5 例、`--probe-selftest` 3 例。
· 全套件 `checks=459 pass=455 fail=4 skip=0 red=9 broken=0 unreported=0`(与改动前**同样 9 条**)、`mutants=48 ran=47 skipped=1 on_new_criteria=35`。
· 变异:M18 / M19 / M20 全部按预期红并已还原;`/tmp` 无 `exitcode-*`/`barepath-*` 残留。
2026-09-18 05:40:38 +08:00

1656 lines
106 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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.

/**
* 判据总入口 —— **全部跑完再算退出码**。
*
* 为什么不再用 `&&` 串起来:
*
* 原先 `npm test` 是 `a && b && c …`。这种行为有个不起眼但很贵的后果 ——
* **前面红一条,后面全部不跑**。于是"只红了一条"看起来像"只有一个问题",
* 实际上后面那些判据连跑都没跑(这次就真发生了:`background` 红着,
* `packaging` 从来没跑到过,而它正是能发现"界面改了没重打包"的那条)。
* 换句话说:`&&` 链下的"全绿"是可信的,**"红"是不可信的**。
*
* 现在:每条判据都跑,红的收集起来,最后一起报、一起退出。
*
* 另外两条防"判据自己不会跑"的自检(与 process.exit 之后写判据是同一族问题):
* 1. 清单里的文件必须存在(名字写错 = 静默跳过一条判据);
* 2. `test/` 下的每个 `*.test.mjs` 都必须在清单里
* —— 这次 `cross-client-theme.test.mjs` 就是"写好了但没接进套件",
* 在它进套件之前一直是隐身状态。加了这条,**新增判据忘了接线会直接红**。
* 3. 判据规范 `test/CRITERIA.md` 要在、且要点到那几条规则
* —— 写判据的规矩本身也会被"忘了带"(形状记在某个人的脑子里等于没有)。
*
* 写判据之前先读 `test/CRITERIA.md`(判结构与行为,不判字面与邻接)。
*/
import { prose, stripComments } from './lib/read.mjs';
import { spawnSync } from 'node:child_process';
import { existsSync, readdirSync, mkdtempSync, mkdirSync, cpSync, writeFileSync, rmSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { tmpdir } from 'node:os';
const HERE = dirname(fileURLToPath(import.meta.url));
const ROOT = join(HERE, '..');
/**
* 判据清单:[文件, 额外 node 参数]。
*
* `--test` 给用 node:test 写的判据;鸿蒙那条要 `--experimental-strip-types`
* 才能直接执行 `client/harmony/.../MailGrouping.ts`(判据跑的是客户端真正引用的那份逻辑)。
*/
/*
* 自检 3:判据规范在不在、有没有写到那几条关键规则。
*
* 为什么把"文档"也判:`CRITERIA.md` 里的每条都是踩出来的(窗口式判据、邻接式判据、
* 生成的清单被侵蚀、剥注释读不到理由……)。规则只在某个人的脑子里时,下一个人会重踩一遍;
* 文件被删/被搬走却没人发现,等于规则也没了。这里只断"还在 + 关键条目还在",
* 不断它的措辞 —— 那是笔记,不是接口。
*/
const CRITERIA_DOC = join(HERE, 'CRITERIA.md');
if (!existsSync(CRITERIA_DOC)) {
console.error('✗ 判据规范 test/CRITERIA.md 不见了(写判据的规矩不能只活在脑子里)');
process.exit(1);
}
const criteriaDoc = prose(CRITERIA_DOC);
for (const must of ['配对/解析', 'allow-list', '变异验证', '剥掉注释', '按行', '自报条数', '只支撑你看到的那一层', '已经在某个提交里', '自带修法', '按 id 联接', '不要退化成对源码形状的匹配']) {
if (!criteriaDoc.includes(must)) {
console.error(`✗ 判据规范里少了「${must}」这条 —— 规则被删掉了还是搬走了?`);
process.exit(1);
}
}
const SUITE = [
['test/markdown-xss.test.mjs', [], 9],
['test/narrow-layout.test.mjs', [], 64],
['test/nav-merge.test.mjs', [], 8],
['test/animation-audit.test.mjs', [], 4], // 动画全量盘点:死动画/过宽作用域/弹层接线/reduced-motion
// 深色模式:色板反转 + 玻璃 alpha 档 + 底必须是暗的(2026-09-17 那次
// 「只有通信页深色正常」的回归锁 —— 38 条里后 8 条是这次新增)。
['test/theme.test.mjs', [], 38],
['test/background.test.mjs', [], 43],
['test/cross-client-theme.test.mjs', [], 15],
// 预设的**行为**判据:每一档都真的画得出来(能真跑,不需要设备 ⇒ 不进 static 欠账)。
// 与 appearance-defaults 那条「清单 id/顺序相等」配对:值判据管清单,行为判据管渲染器。
['test/harmony-presets.test.mjs', ['--experimental-strip-types', '--no-warnings'], 5],
['test/harmony-logic.test.mjs', ['--experimental-strip-types', '--no-warnings'], 28],
['test/harmony-system-api.test.mjs', [], 5],
// P4 外观同步:跑 model/Appearance.ts(纯逻辑),所以也要 strip-types
['test/harmony-appearance.test.mjs', ['--experimental-strip-types', '--no-warnings'], 25],
// P5 悬浮玻璃导航:点击配对 / index 决定挂载 / 命中区 ≥44vp / 悬浮与让位
['test/harmony-nav.test.mjs', ['--experimental-strip-types', '--no-warnings'], 18],
// 外观契约:默认值去 Go 源码里读(服务端 DefaultAppearance 是权威)+ 缓存键按账号
['test/appearance-defaults.test.mjs', [], 4],
['test/build-stamp.test.mjs', [], 7],
['test/packaging.test.mjs', [], 5],
['test/align-refs.test.mjs', [], 3],
['test/harmony-deviceprobe.test.mjs', ['--experimental-strip-types', '--no-warnings'], 8],
// 契约/形状 15 条 + ★接线 4 条 + session_id 值来源 1 条(2026-09-17)。前 15 条钉的是"点"
// (逻辑对不对),后 5 条钉的是"边"(谁调用、谁消费、**传的是哪个值**)——
// "写好了但没人调用/没人读/传错了字段"编译一样通过(ArkTS 只编译可达模块,反向对照已复现过)。
// 接线④是真机实测逼出来的:只写 pendingRoute 的版本冷启能跳、热启一次都不跳。
// session_id 那条钉的是"值的来源":传服务器地址不会报错(服务端不校验、且只写不读),
// 只会在库里存假数据 —— 这类"不报错的错"只能靠判据拦。
['test/harmony-push.test.mjs', ['--experimental-strip-types', '--no-warnings'], 20],
// 服务器地址(apiBase):补 /api/v1 / 去尾斜杠不吃协议 // / 校验自带修法 /
// 明文只对公网告警 / 404 说清“少了 /api/v1” / 网络错误码分类 / 归一化只有一份实现。
// 值判据跑真逻辑(model/ApiBase.ts);`.ets` 那几条是**静态**接线判据(无设备)。
['test/harmony-apibase.test.mjs', ['--experimental-strip-types', '--no-warnings'], 13],
['test/harmony-calendar.test.mjs', ['--experimental-strip-types', '--no-warnings'], 23],
['test/debt-visibility.test.mjs', [], 1],
['test/commit-hygiene.test.mjs', ['--experimental-strip-types', '--no-warnings'], 4],
// 判据目录自身的卫生:读文本必须走 test/lib/read.mjs 的具名入口
['test/criteria-hygiene.test.mjs', [], 6],
// 用户管理页(P4c 同批):动作↔服务端调用同名 / 门禁只认严格 admin /
// 启停只发 status / 「受限」徽标口径 / 页面零写死色值 / 接线(纯逻辑真被调用)
['test/harmony-admin.test.mjs', ['--experimental-strip-types', '--no-warnings'], 22],
// P4c 图片上传:阈值与两档策略 / 失败必带原因 / 退档判定只有一处 /
// release 都 await / 解码按目标尺寸 / multipart 字段名 / 上传后重新同步
['test/harmony-imageprep.test.mjs', ['--experimental-strip-types', '--no-warnings'], 29],
// ArkTS **编译期**硬规则(纯文本可判、不需要设备)。这一条是构建撞出来的:
// 我把常量表插在了既有 import 之前 ⇒ arkts-no-misplaced-imports,而当时没有任何判据会跑它。
['test/harmony-arkts.test.mjs', [], 3],
// ★★ 下面三条是**补接线**,不是新写的判据(2026-09-17)。
//
// 它们**早就存在**,却从没进过 SUITE ⇒ 自检 2("每个 *.test.mjs 都要在清单里")
// 直接 `process.exit(1)` ⇒ **整套判据一条都不跑**,只打一行 stderr。
// 实测(worktree 逐提交对照):`616c994`(09-16 07:46,加了其中两条)之前
// `RESULT files=26 ran=23`,之后**输出只剩那一行"没接进套件"**;09-17 `f4b8bc1`
// 又加了第三条。**两个相位都死**(build 与 install 都先撞这条守卫)。
// ⇒ 整仓**超过一天没有任何判据读数**,而失败信号只有一行、看起来像环境问题。
//
// ★ 这条守卫的形状值得单独记(**不删它** —— 它的价值是真的:漏接线不会被静默忽略):
// 它是**结构守卫**:清单与磁盘不一致 ⇒ 整体停。好处是"写了没接线"绝不静默,
// 代价是**一个**漏接的文件停掉**全部**判据 —— "新增判据的人"能一处失误
// 让整仓失去全部读数。与"第一道红吃掉后面的诊断"同族,第一道红是清单自检。
// ⇒ 守卫保留,且**接线是作者的义务**;另外给这条守卫补一句"因此没跑任何判据"
// (原来的输出只说了"没接进套件",**没说你因此一条读数都没有**)。
// 通知开关(用户强调多遍的可配置项,默认开)→ 纯文本 + node:test
['test/harmony-push-optin.test.mjs', ['--experimental-strip-types', '--no-warnings'], 4],
// 三维地址拼装:直接执行 `model/ReplyTarget.ts` 真逻辑(行为判据)
['test/harmony-reply-target.test.mjs', ['--experimental-strip-types', '--no-warnings'], 7],
// 宽屏侧栏图标轨:`WideSidebar` 接线与常量(静态判据,无设备)
['test/harmony-widescreen.test.mjs', [], 6]
];
// 自检 1:清单里的文件必须真的存在(写错名字 = 那条判据永远不跑)
const ghosts = SUITE.map(([f]) => f).filter((f) => !existsSync(join(ROOT, f)));
// 自检 2:test/ 下每个 *.test.mjs 都要在清单里(防"写好了没接线")
const onDisk = readdirSync(join(ROOT, 'test'))
.filter((f) => f.endsWith('.test.mjs'))
.map((f) => `test/${f}`);
const unwired = onDisk.filter((f) => !SUITE.some(([s]) => s === f));
if (ghosts.length || unwired.length) {
if (ghosts.length) console.error(`清单里的判据文件不存在:${ghosts.join('、')}`);
if (unwired.length) {
console.error(`这些判据文件没接进套件(写了却不会跑):${unwired.join('、')}`);
}
/*
* ★★ **必须说清"因此一条判据都没跑"**(2026-09-17 补)。
*
* 原来的输出只说了"这些文件没接进套件",**没说你因此失去了全部读数** ——
* 于是一条 `process.exit(1)` 加一行 stderr,和"环境问题导致套件没起来"
* 在观感上无法区分。实测代价:`616c994`(09-16) 起整仓**超过一天没有任何判据读数**,
* 而失败信号就是这一行。
* ★ 这正是本仓反复消的那个形状:**输出没说的事,读的人不会知道**。
* 守卫本身是对的(漏接线不该静默),缺的是**它要为自己造成的后果作证**。
*/
console.error(
`\n ⇒ 因此**本次一条判据都没跑**(不是"跑了且都绿",也不是"环境问题")。\n`
+ ` 这是**清单自检**在跑判据**之前**退出的:上面 ${unwired.length + ghosts.length} 个文件\n`
+ ` 与清单不一致 ⇒ 整体停。修法是**把文件接进 SUITE**(不是改这里)。\n`
+ ` ★ 为什么这么狠:漏接线的判据"写了却永远不跑",静默忽略比停整套更糟。\n`
+ ` 代价是一个文件漏接会停掉**全部**判据 —— 所以接线是**新增判据者的义务**。`);
process.exit(1);
}
/*
* 自检 3:判据不得写在 `process.exit()` **之后**(pi 提议,2026-09-14)。
*
* 自检 1/2 管的是"文件没接线",管不到"检查写在了退出之后" —— 而那正是实际发生过的
* 第 4 例:4 条玻璃判据被并发写入落到了文件末尾、`process.exit()` 后面,
* 于是**一条都不执行、也不计入通过/失败**,输出看起来完全正常。
* 这种事的成因是结构性的(并发写入总是往文件末尾追加),所以它一定会再发生,
* 而它下一次仍然不报错 —— 静态扫一遍最省事。
*/
const buried = [];
for (const [file, flags] of SUITE) {
if (flags.includes('--test')) {
continue; // node:test 那几条没有 process.exit,结构上不会踩这个
}
const src = prose(join(ROOT, file));
const exitAt = src.lastIndexOf('process.exit(');
if (exitAt >= 0 && /(^|\n)\s*check\(/.test(src.slice(exitAt))) {
buried.push(file);
}
}
if (buried.length) {
console.error(`判据写在 process.exit() 之后,永远不会跑(挪到汇总之前):${buried.join('、')}`);
process.exit(1);
}
/*
* 自检 4(pi 2026-09-14 提的家族,第 6 例):**"判据自己不会跑"**。
* 第 6 例的宿主是 runner 自己:清单里的 flag 与判据写法如果配错,症状是"看起来全绿"。
*
* ⚠️ 落地前先实测了两条真实样本,结论与 pi 的猜测**不同**,记在这里免得后人重猜:
* - `node --test <自定义 check() 的判据>`:**退出码照样传出来**(文件 exit 1 → 命令行 exit 1),
* 并没有被 runner 吞掉;
* - 但 `node --test <什么都不做的文件>` 会报 `# tests 1 / # pass 1` ——
* **计数不是"检查跑过"的证据**。所以"解析 pass 计数、0 就判红"这条路既
* 抓不到空判据(它报 1),又会在 `narrow-layout`(汇总行"全部通过"里没有数字)上误报。
*
* 换成**结构证据**:每条判据文件里必须存在"能红"的路径 ——
* node:test 的 `test(`、自定义 `check(`、或显式 `process.exit(1)`。
* 一个都没有 = 它永远不会红,与"全通过"长得一模一样。
* 再加一条"跑完必须有输出"(12 条判据现在都有输出),静默成功同样可疑。
*/
function shapeOf(file) {
const src = prose(join(ROOT, file));
const usesNodeTest = /from 'node:test'/.test(src);
const canFail = usesNodeTest
|| /(^|[^.\w])check\(/.test(src)
|| /process\.exit\(\s*1\s*\)/.test(src);
return { usesNodeTest, canFail };
}
const shapeless = [];
for (const [file, flags] of SUITE) {
if (flags.includes('--test')) {
console.error(`清单里不要手写 --test(它由文件内容推导):${file}`);
process.exit(1);
}
if (!existsSync(join(ROOT, file))) continue; // 自检 1 已经报过了
if (!shapeOf(file).canFail) shapeless.push(file);
}
if (shapeless.length) {
console.error('这些判据文件里找不到任何"能红"的路径(test( / check( / process.exit(1)):'
+ `${shapeless.join('、')} —— 它们永远不会红,与"全通过"看起来一样`);
process.exit(1);
}
const reds = [];
/*
* 判据**条数**的累计(pi 2026-09-15)。
*
* 为什么要有它:套件此前**不打总数** —— 于是每次要"一共多少条、过了多少条",
* 都得在套件外面用 `node --test test/*.test.mjs` 那样的 **glob 调用法**绕出去。
* 而 glob 那套正是让"写了判据却没接线"逃掉的那个调用法(那次 `SUITE` 里少两个文件,
* 绕出去的 glob 反而把它们跑绿了)。⇒ 权威来源必须由**套件自己**给出:
* 它只累加**它自己实际跑过并解析到自报条数**的那些文件,不猜、不扫目录。
*/
const records = [];
/**
* 各计数器**不在这里累加** —— 它们由下面的 `records` **投影**出来(见 `records` 的说明)。
*
* 旧形状是这四个 `let` 各自 `+=` 一处,靠"源码里每个计数器只许一处写"的**文本扫描**保证。
* 那个形状长出了一整族扫描器毛病(注释/字符串/等价写法/锚/正则引号),
* 而**改成投影后这一族连扫描对象都不存在** ⇒ 四个计数器与那条扫描自检一起删掉了。
* 读它们的地方一个字都没改(`RESULT` 行、自检 5、汇总),只是它们现在是**派生值**。
*/
// 下面这四行是**投影**(`totalTests`/`totalFail`/`ranFilesCount`/`unreportedFiles` 的旧名保留,
// 让读的地方不用改;它们的值全部来自 `records`,没有任何一步是"就地累加")。
/* eslint-disable no-unused-vars */
let totalTests;
let totalFail;
let totalSkip;
let unreportedFiles;
let ranFilesCount;
/* eslint-enable no-unused-vars */
/*
* ─── 相位(pi 2026-09-14 裁定)───
*
* 同一份套件会被**两个不同相位**调用:
* · 构建/发布相位(`npm test`、`--release`):问"我要产出的东西是不是从**当前源码**新鲜构建的?"
* → `packaging`(包内 dist == 当前 dist)、`build-stamp`(产物 == HEAD)在这一相位才有意义;
* · 安装相位(`deploy/install.sh`,见 `AGENTMAIL_CRITERIA_PHASE=install`):
* 问"**别人已经产出的这个东西**能不能装到这台机器上?" —— 这里"dist 与源码树的关系"
* 根本不是该问的问题:部署路径不重新打包(也**不应该**重新打包,否则"review 过的产物"
* 和"装上去的产物"不是同一个东西,而且会重新构建的干跑不是干跑)。
*
* 所以规则(可机检):**部署门禁只允许读"待安装的产物 + 目标机状态",不许依赖
* "源码树是最新构建的"这个前提。** 一条门如果在某个相位恒红或恒绿,那不是门的问题,
* 是**相位挂错了** —— 今天撞到三条:`check-shared-libs`(从 153985e 起恒红)、
* `packaging`(前端一改就卡死)、`HOME: unbound variable`(在门禁跑完之后才炸)。
*
* 下面登记每条判据读的是哪一侧;`install` 相位里出现 `SOURCE` 侧判据 → 直接红。
*/
const SIDE = {
ARTIFACT: 'artifact', // 只读产物 / 目标机状态(装得上装不上)
SOURCE: 'source' // 依赖"源码树是最新构建的"(构建相位才成立)
};
/** 判据文件 → 它读的那一侧。没登记的按 ARTIFACT(读源码的必须显式登记,否则会被这条例外掉) */
const SIDES = {
'test/build-stamp.test.mjs': SIDE.SOURCE, // 产物 gitRev/srcHash == 当前源码树
'test/packaging.test.mjs': SIDE.SOURCE // 包内 dist == 当前 dist
};
const PHASE = process.env.AGENTMAIL_CRITERIA_PHASE === 'install' ? 'install' : 'build';
function sideOf(file) { return SIDES[file] || SIDE.ARTIFACT; }
const brokens = [];
/*
* ★★ 本相位**实际会跑**的文件 —— 期望值必须由**同一个判据**算出来。
*
* pi 2026-09-15 抓到的假红:我上一版把"记录数"跟 `SUITE.length` 比,
* 而 install 相位有 2 条构建相位判据**进不了循环**(不 push 记录)⇒ `records=23 ≠ 25` ⇒ 报红,
* 报文还给出**恰好错误**的成因("漏了或多跑了")—— 什么都没漏、也没多跑。
* 而这条命令正是 `install.sh --check` 的前端门禁(`AGENTMAIL_CRITERIA_PHASE=install npm test`)
* ⇒ 一个**假红**会打出一行 `[FAIL]`,教读者"那行 `[FAIL]` 可以忽略"。
*
* 所以:期望值 = 在**本相位**下不被跳过的文件数,而不是套件总数。
* 并且它和下面的 skip 判定用**同一个谓词**(同一个 `sideOf`),免得两处各写一份条件而漂移 ——
* "同一个事实有两份实现"正是这一路反复出现的形状。
*/
const phaseWillRun = SUITE.map(([f]) => f)
.filter(f => !(PHASE === 'install' && sideOf(f) === SIDE.SOURCE));
const skippedByPhase = [];
for (const [file, flags, expected] of SUITE) {
if (PHASE === 'install' && sideOf(file) === SIDE.SOURCE) {
skippedByPhase.push(file);
continue;
}
console.log(`\n========== ${file} ==========`);
const shape = shapeOf(file);
const all = shape.usesNodeTest ? [...flags, '--test'] : flags;
/*
* 收集输出再自己打回去:观感不变(stdio:'inherit' 的等价物),但能顺手做"跑了吗"的检查
*
* ★★ `cwd: ROOT` 这一格是**读数的一部分**,不是顺手加的(pi 2026-09-17 实测)。
*
* 原来这里不传 `cwd` ⇒ 子进程**继承调用者的 cwd**,于是**同一份代码、两种调用法、两个结论**:
*
* cd client/electron && node test/run-all.mjs ⇒ ran=29 checks=440 red=8 unreported=0
* node client/electron/test/run-all.mjs(仓库根)⇒ ran=27 checks=348 red=10 unreported=2
*
* 差 92 条(= 88 + 4),红从 8 变 10。真因:有两条判据按**包内相对路径**读文件
* (用 read.mjs 那个剥注释的入口、传包内相对路径那种写法),它们本来就假定
* `cwd = client/electron`,
* 只是没人把那个假定钉住 ⇒ 从仓库根跑时 `ENOENT` 崩在 import 期。
*
* ⇒ **要让运行器去满足判据的既有假定,不是改判据去迁就调用者**:
* `ROOT` 已经是判据文件的绝对路径,这里再钉 `cwd` 就是让"包内相对路径"这个
* 既有约定**真的成立**。一行覆盖现有的与将来的所有判据。
* (判据自己按 `HERE` 解析路径是**第二件事**、第二道保险,不替代这一行。)
*
* ★ 这条比"套件整个死掉"隐蔽得多:死掉时输出只剩一行、谁都看得出来;
* 而这里是**看起来很正常的汇总行**,只有把两种调用法的数字并排放才看得出。
* **"同一份代码两种调用法两个结论"本身就是读数不可信** —— 所以这不是"调用者姿势不对"。
*/
const r = spawnSync(process.execPath, [...all, join(ROOT, file)], { encoding: 'utf8', cwd: ROOT });
const out = (r.stdout || '') + (r.stderr || '');
process.stdout.write(r.stdout || '');
process.stderr.write(r.stderr || '');
/*
* `broken` 与 `red` 必须分开(pi 2026-09-14 §3)。
*
* 我这轮两次把"跑不起来"当成"判据红了":一次变异注入少了 import(报 build failed)、
* 一次判据里写了没绑定的标识符(ReferenceError 抛在判据自己身上)。
* 两者的后果都是同一种骗人方式:**看起来像判据失败,其实是判据没跑**。
* 所以判据是:退出码非零 **且输出里没有一句"断言失败"** → 那是 broken(崩了),不是 red。
* 变体验证里出现 broken = **这次变异无效,重做**,不许记成"红过了"。
*/
/*
* **别解析人类可读的文本**(pi 2026-09-14 §5):文本格式是运行器的实现细节。
* 第一版我用"输出里有没有 `not ok`"判崩,当场误判 —— node:test 把**导入期**的
* `ReferenceError` 也报成 `not ok 1 - …`,于是"崩了"和"断言失败"在文本上同形。
*
* 现在按 TAP 的**名字**判,而不是按错误正文猜:文件级失败的测试名就是**文件路径**,
* 而真正的断言失败名字是判据名。所以:
* 所有 `not ok` 的名字都像路径(含 .mjs / 以 / 开头)→ **broken(判据没跑起来)**;
* 只要有一个名字像判据名 → **red(判据说不成立)**。
* 再叠一层语言级崩痕只是兜底(没有 `not ok` 却退出非零的情形)。
*
* 我不再往里加"更多错误关键字"——那是往文本解析里加补丁,方向是错的。
*/
const notOkNames = [...out.matchAll(/^not ok \d+ - (.*)$/gm)].map(m => m[1].trim());
const looksLikePath = n => /(^\/|\.mjs\b|\.ts\b|\.js\b)/.test(n);
const assertionFailed = notOkNames.some(n => !looksLikePath(n));
const onlyFileLevel = notOkNames.length > 0 && !assertionFailed;
const CRASH_SIGNS = /(SyntaxError|ReferenceError|TypeError|Cannot find module|ERR_MODULE_NOT_FOUND|is not defined|is not a function|CompileError|build failed|Unexpected identifier|missing ',' in argument list)/;
const crashed = r.status !== 0 && (onlyFileLevel || (notOkNames.length === 0 && CRASH_SIGNS.test(out)));
const empty = out.trim().length === 0; /*
* ── 条数累计:**必须在红/绿分支之外** ──
*
* 原来它写在下面 `else { … }`(只在**绿**时进入)里,于是**红文件一条都不计入**,
* `checks` **静默少算**。实测:`checks=379` 而逐文件手算 387 —— 差的那些正是红文件里的。
* **"红"只说明判据说"不成立",不说明它没跑**;跑过的条数一样要算进来。
*
* ★ **单位必须是"总条数",不是"通过条数"**(pi 2026-09-15 指出,我 511982ac §五
* 自己就写过这条、但**没落进代码**):
*
* node:test 会打三行 —— `# tests N` / `# pass N` / `# fail N`,而 `# tests = # pass + # fail`。
* 原来取的是 `/^# pass (\d+)/` ⇒ `checks` = Σ`# pass` = **399**,
* 比真实总条数(400)**少了失败的那几条**;接着 `pass = totalTests - totalFail`
* = 399 − 1 = **398**,**在一个从未包含失败的和上又减了一次失败**(真实通过数是 399)。
* 实测(唯一有失败的文件 `build-stamp`:`# tests 7 / # pass 6 / # fail 1`):
* Σ 自报 pass = 399、Σ 自报 fail = 1 ⇒ 真实总条数 **400**
* 汇总行却是 `checks=399 pass=398` —— 两格都少算。
*
* ⇒ 所以:**总条数与失败条数各取各的**,`pass` 只作为**派生量**打印。
* 这样 `pass + fail == checks` 才从**定义式**(`pass := checks - fail`,按构造恒成立、
* 永远抓不到任何东西)变成**真不变量**(两边各自独立测得)。
* 上一轮那条"虚高时恒等式照样成立"是同一个病 —— **恒等式两边同源就没有检验力**。
*/
const marker = /RESULT pass=(\d+) fail=(\d+)/.exec(out); // 自定义 check():pass/fail 自报
const nodeTests = /^# tests (\d+)/m.exec(out); // node:test:总条数
const nodeFail = /^# fail (\d+)/m.exec(out);
/*
* ★ `# skipped K`(node:test):**没跑**的条数,必须单独取一格(dsh 2026-09-18)。
*
* 起因:给到期判据加行为层时,第一条行为判据在"设备不在"时走 `t.skip` ——
* 而 `# tests N` **把跳过的也算进去**(实测 `# tests 16 / # pass 15 / # skipped 1`,
* 16 = 15 + 0 + 1)。不取这一格,下面的 `pass = checks - fail` 就把那 1 条读成"通过":
* 设备在与设备不在两次运行的 `RESULT files=…` **一字不差**(实测两行完全相同)。
* 这就是"**看不到 ⇒ 绿**"—— 本仓反复消的那条,这次长在总数行上。
*
* ⚠️ 为什么不能靠"跳过的判据会自己打印 SKIP 字样"来兜:那是**文件内的输出**,
* 而这里要的是**可数的余额**。余额的定义就是"汇总行里读得出来的那个数" ——
* 读不出来的跳过等于没有跳过(同 CRITERIA.md 里"跳过若不可数就等于不显形")。
* `check()` 风格的文件没有跳过机制 ⇒ 恒 0。
*/
const nodeSkip = /^# skipped (\d+)/m.exec(out);
/*
* ★★ 跳过的**具名**与**原因**(pi 2026-09-18 的闸 (i) 前半)。
*
* 上面那格 `skip=N` 让"跳过"在余额里**数得出来**了,但**看不出来是谁**:
* 实测(变异验证)——
* 设备在 + 注入一条真实回归 ⇒ `fail=5 … red=10`,红清单里有 `harmony-nav`;
* 设备不在 + **同一条回归** ⇒ `fail=4 skip=1 … red=9`,红清单与**基线逐条一致**。
* ⇒ **一条真实回归在设备不在时零痕迹**。`skip=1` 那个数不具名,
* 与"设备恰好不在、什么都没坏"**不可区分**。
* (pi 的话:这与"红的判据"应当同级 —— 红的能点名,跳过的也必须能点名。)
*
* node:test 其实**已经**把原因打出来了(`ok 16 - … # SKIP 设备不在 —— …`),
* 是这段解析只取了计数、把名字和原因丢了。⇒ 取回来。
*/
const nodeSkips = parseSkips(out);
/*
* `# tests` 缺失时**回退**到 `# pass + # fail + # skipped`(不是 `# pass`)——
* 老版本 node 或输出被截断时,和才等于总条数;单取 `# pass` 正是上面那个 bug。
* 两者都没有 ⇒ null("没自报条数",走 unreported 那条红)。
*/
const nodePass = /^# pass (\d+)/m.exec(out);
const nodeTotal = nodeTests ? Number(nodeTests[1])
: (nodePass ? Number(nodePass[1]) + (nodeFail ? Number(nodeFail[1]) : 0)
+ (nodeSkip ? Number(nodeSkip[1]) : 0) : null);
const fileChecks = marker ? Number(marker[1]) + Number(marker[2]) : nodeTotal;
const fileFail = marker ? Number(marker[2]) : (nodeFail ? Number(nodeFail[1]) : 0);
/* 跳过的条数:`check()` 风格没有这个机制 ⇒ 0(marker 存在即非 node:test 输出) */
const fileSkip = marker ? 0 : (nodeSkip ? Number(nodeSkip[1]) : 0);
/*
* ★★ 唯一的信息来源:**这个文件只 push 一条记录**(pi 2026-09-15 的建议,我采纳)。
*
* 旧写法是四个模块级 `let` 各自 `+=`,于是"每个计数器只许一处写"只能**靠扫描源码文本**去盯
* —— 而那条扫描器自己就长出了一整族毛病:注释、字符串、`= X + n`、`X++`、锚钉在行首
* vs 语句起点、正则字面量里的引号(**最后这条还是假绿方向:会吞掉真写**)。
* 每一轮修好其中一个实例,族还在。
*
* ⇒ 换成投影后:**"把一个文件计两次"没有对应的语句可以写** ——
* 不是"判据盯着别写",而是**写不出来**(要计两次得先把同一条记录 push 两次,
* 而那件事有**行为**判据盯着,见汇总处的 `records.length === SUITE.length`)。
* 那一整族(注释/字符串/拼法/锚/正则引号)**连扫描对象都不存在了**。
*
* 注意这里刻意**不做任何累加** —— `ran === null` 的含义("跑了但没自报条数")
* 被原样记进记录,怎么解读交给下面那个投影,不在这里就地分类。
*/
records.push({
file,
checks: fileChecks, // null = 跑了但没能自报条数
fail: fileChecks === null ? null : fileFail,
/*
* ★ 跳过条数(dsh 2026-09-18):与 `fail` 一样是 `checks` 的**子集**,
* 一起随记录走。它不是"失败",但**也不是通过** —— 所以既不能并进 `fail`,
* 也不能让它落在 `pass = checks - fail` 里冒充通过。
*/
skip: fileChecks === null ? null : fileSkip,
/* 跳过的**明细**(名字 + 原因):让"谁没跑"能像"谁红了"一样被点名。 */
skips: fileChecks === null ? [] : nodeSkips,
exitCode: r.status,
});
/*
* ★★ 结构判据补一格:**退出码非零 且 一条条数都没自报** ⇒ broken(不是 red)。
*
* 这是 pi 2026-09-17 报的第二个洞,而它比 cwd 那条更毒 —— 它是**分类错了**:
* `narrow-layout` 在 import 期 `ENOENT` 崩掉(**一条 `not ok` 都没有**),
* 于是走 `CRASH_SIGNS` 那条兜底;而 ENOENT **不在**那个关键字表里
* ⇒ `crashed=false` ⇒ 落进 `reds`:`- test/narrow-layout.test.mjs(退出码 1)`。
* 读者看到的是"**这条判据不成立**",真相是"**这个文件没读完就死了,它有多少条根本没测**"。
* 同一次运行里真红的样子是带 `↳ 自报 N 条 > 登记 M 条 …` 自述的 ——
* **同一种红,两种含义**,而 `red=10` 这个总数把两者混在一起报。
*
* ★ 为什么不照 pi 说的往 `CRASH_SIGNS` 里加 `ENOENT|EACCES|no such file`:
* 上面第 329 行那条注释早就把方向定死了 —— **"我不再往里加更多错误关键字:
* 那是往文本解析里加补丁,方向是错的"**,而且那个表**必然漏**(今天漏 ENOENT,
* 明天漏 ENOTDIR/ELOOP/ERR_DLOPEN_FAILED…)。照加上去就是**第六次**修同一个形状。
*
* ⇒ 改用**结构**事实:这条判据是不是**跑完过**?不是就看它有没有留下**任何**条数记录。
* 一个跑完的文件必然自报条数(`RESULT pass=` 或 `# tests`);**跑了但没自报**是
* 另一条已知的、单独记账的情形(`unreported`,见下面 `else` 分支)。
* 两者**不会同时成立** ⇒ 这条规则只吃掉"崩了且毫无自报"那一格,不碰任何真红:
* 断言失败的文件 `exited != 0` 但**有**自报条数 ⇒ 仍然走 red。
* (★ 这个区分才是要害:`exitCode !== 0` 本身**不足以**判 broken ——
* 不然真红会被一起吞掉,那是**假绿方向的错**,比误报成红更坏。)
*/
const diedWithoutReporting = r.status !== 0 && fileChecks === null;
// 不 break:后面每条都要跑出来,否则"红了几条"这个信息本身是假的
if (crashed || empty || diedWithoutReporting) {
const firstErr = (out.match(/^.*(Error|error:).*$/m) || [''])[0].trim().slice(0, 160);
brokens.push(`${file}(${empty ? '跑完没有任何输出'
: diedWithoutReporting && !crashed ? `退出码 ${r.status},且一条条数都没自报` : `退出码 ${r.status},但没有一句断言失败`})` +
(firstErr ? `\n ↳ ${firstErr}` : '') +
'\n ↳ 这是 **broken(跑不起来)**,不是 red:它证明不了任何判据成立或不成立。' +
'\n 常见成因:语法/标识符错(`X is not defined`)、import 写错、编译不过、' +
'\n **按相对路径读文件但 cwd 不对**(`ENOENT: … open \'src/index.css\'`)。' +
'\n 用于变体验证时:broken **不算这次变异有效**,要重做。');
} else if (r.status !== 0) reds.push(`${file}(退出码 ${r.status})`);
else {
/*
* ★ 这里**只保留"没自报条数"的那条红**;**任何累加都不在这里做** ——
* 投影之后累加根本不作为语句存在,上面那条 push 出来的记录是唯一的信息来源。
*
* 这段曾经是累加块(`totalTests += ran` / `ranFilesCount += 1` / `totalFail += …`)**唯一的**
* 位置,而它在 `else` 里 ⇒ **红文件一条都不计入**,`checks` 静默少算
* (实测:`checks=379` 而逐文件手算 387,差的正是红文件里的)。
* 后来把累加搬到红绿**分支之外**,
* **但没删掉这一份** —— 于是变成:
*
* `totalTests`/`ranFilesCount`/`totalFail` 对**每个 exit 0 的文件计两遍**,
* 只有退出码非 0 的文件(当时是 `build-stamp`)计一遍。
* 实测吻合:25 个 section、24 个 exit 0、1 个非 0
* ⇒ `ran = 24×2 + 1 = 49`(汇总行正是 `ran=49`)
* ⇒ `checks ≈ 逐文件单次合计 × 2`(`checks=792`)
*
* ★ 它是**自洽**的,所以读数检查不出来:`pass + fail == checks`
* (791+1=792)**两边一起虚高,恒等式照样成立** ——
* "自洽的读数不等于正确的读数"。而 `RESULT files=…` 这行本是为
* "总数要有权威来源"才加的,结果**它自己就是错的**:
* 新机制进来时把旧路径留在原地。这和我们这一路在消的形状同族,
* 只是这次长在总数行上(pi 2026-09-15 找到的)。
*
* ⇒ 教训写在这儿:**搬动一段代码时,删旧的那一步要和加新的那一步一起提交**;
* 只加不删的话,两处都跑得通、都报得出数,而**没有任何东西会红**。
*
* ★ 而**我自己就是这条教训的下一个实例**(pi 2026-09-15 复查抓到):
* 我在上一次修这里时,删掉了那份 `totalTests`/`ranFilesCount`/`totalFail`,
* **却把紧跟的 `unreportedFiles += 1` 留在了原地** —— 于是它**仍然计两次**
* (`:317` 的 `else` 一次、这里又一次)。我在信里写"顺带修掉了",**代码里没有**。
* "说修了"与"修了"是两件事,而**只有后者可复核** —— 这正是本仓反复立的规矩。
* ⇒ 这一行现在删掉了,并且由自检 5 的 `unreportedFiles ≤ files` 钉着
* (它今天不可见:`unreported=0`,没有文件触发 —— **看不见正是它需要结构性上界的理由**)。
*/
if (fileChecks === null) {
/*
* 报错**自带修法**(pi 2026-09-14):这条契约的受众不只是读过规范的人 ——
* 并发写 WebUI 的 agent 新加判据时不会打开 CRITERIA.md,看到红的第一反应
* 很可能是"套件坏了"(删自检、往清单里塞豁免)。**red 是 ta 一定会看到的东西,
* 文档不一定会被打开** —— 所以把修法直接写进这条错误里,并给出可抄的样板。
*/
reds.push(`${file}
↳ 没找到自报条数。修法(二选一):
1) 用共享 helper(新判据推荐):
import { check, finish } from './lib/checks.mjs';
check('判据名', 条件, '失败时给人看的细节');
finish('标签'); // 它负责打 RESULT pass=N fail=M
样板:test/markdown-xss.test.mjs、test/narrow-layout.test.mjs
2) 自己打一行(老写法,计数必须写在 check() 内部,否则"实现被换空"看不见):
console.log(\`RESULT pass=\${pass} fail=\${fail}\`);
样板:test/theme.test.mjs、test/background.test.mjs
(用 node:test 写的判据不用管:runner 认 \`# pass N\`。)`);
} else if (expected > 0 && fileChecks !== expected) {
// **相等**,不是"不少于"(pi 邮件 `9fed4386`):只判下界时,**多加的条数不受保护** ——
// 下一个人删掉那条"多出来的"判据不会有任何东西变红,于是"被删会红"这个性质只覆盖前缀。
// 两个方向的成因与处置都不同,所以分开写。
const why = fileChecks < expected
? `自报 ${fileChecks} 条 < 清单里登记的 ${expected} 条。常见成因:判据被删/被跳过(写在 process.exit() 之后、
条件里提前 return)、check() 的实现被改坏(合并冲突)、marker 打在了汇总之前但计数没接上。
确认确实该减少条数时,把清单里那个数字一起改掉(那是一次显式、可复核的编辑)。`
: `自报 ${fileChecks} 条 > 清单里登记的 ${expected} 条 —— 新加的那几条**不在"被删会红"的保护内**:
只判下界时,删掉多出来的部分不会有任何东西变红。请把清单里这个数字改成 ${fileChecks}(一次显式编辑),
新判据才真正被登记保护。`;
reds.push(`${file}\n ↳ ${why}`);
}
}
}
/*
* ─── 投影:把 `records` 算成那四个计数器(**这里是它们唯一的"来处"**)───
*
* ★ 为什么是这个形状(pi 2026-09-15 建议,我采纳并实测):
* "每个文件只该计一次"原文是靠**扫源码文本**保证的,而那条扫描器自己长出了一整族毛病
* (注释 → 字符串 → `= X + n`/`X++` → 锚钉行首 vs 语句起点 → 正则字面量里的引号;
* 最后这条还是**假绿方向**:会吞掉真写、把"其实有两处写"数成 1)。
* 换成投影后,"计两次"不再有对应的语句 —— 那一族**连扫描对象都不存在**。
*
* ★ 但我要**修正 pi 的一句措辞**(实测过才敢说):他写"计两次**写不出来**"。
* `records.push` 本身**仍然可以被写两次**(复制粘贴就会),所以"写不出来"过强。
* 准确的说法:**重复不再表现为"某个计数器被加两次",而表现为"同一条记录出现两次"** ⇒
* 它可以被**行为**判据抓住(见下面的 `records.length === SUITE.length`),
* 而不是只能靠"文本里 `+=` 有几处"去猜。
* ★ **这是"文本判据 → 行为判据",不是"可能错 → 不可能错"** ——
* 前者可测,后者只是话好听。我不想把改进说成保证。
*/
const reportedRecords = records.filter(r => r.checks !== null);
totalTests = reportedRecords.reduce((n, r) => n + r.checks, 0);
totalFail = reportedRecords.reduce((n, r) => n + r.fail, 0);
/*
* ★ 跳过余额(dsh 2026-09-18)——与 `totalFail` 同源、同层,第 5 个投影。
*
* 它与 `totalFail` 是**兄弟**:都从 `records` 里取、都是 `checks` 的子集、
* 都要在汇总行上可读。区别是语义 —— `fail` 是"判据说这不成立",`skip` 是
* "这条本次没跑"(设备不在 / 相位跳过)。**混进任一边都是假读数**:
* 并进 `fail` 会把"没跑"报成"坏了";留在 `pass` 里会把"没跑"报成"过了"。
*/
totalSkip = reportedRecords.reduce((n, r) => n + r.skip, 0);
ranFilesCount = reportedRecords.length;
unreportedFiles = records.length - reportedRecords.length;
/*
* ─── 静态判据的**欠账**与到期机制(pi 2026-09-14 提议) ───
*
* 有些判据只能验**形态**(读 `.ets` 源码),因为它们要验的东西在本机跑不起来:
* 鸿蒙侧编译要 hvigorw、运行要设备/模拟器。这类判据登记在下面,并各自写清
* **什么前提一旦成立它就过期**。
*
* 为什么不能只写一句"暂时":**"暂时"不是一种状态,是一个待办** —— 规范里写下的
* "暂时"没有任何机制会回来读它,于是永远留在原地。这里把它变成可机检的形状:
* 1. `UNBLOCK` 必须是**可检测的前提**(这里就是"有没有可用设备"),不是陈述;
* 2. 汇总里打 `RESULT static=N` —— 这是**欠账余额**,涨了要看得见;
* 3. **前提一旦为真,欠账当场变红**:不等人想起来,设备可用的那天这些判据必须
* 改成行为判据(或明确降级并写理由)。
*
* 这条是"自报 0 条 < 登记条数"的**时间版本**:那条管"判据还在不在",这条管
* "它该升级了没有"。
*/
/**
* 探针:判定静态判据的"到期前提"是否成立。
*
* # 三值,不是两值(pi 2026-09-14 §2)
*
* 原先只有"成立/不成立"两种结果,于是**"探针跑不了"和"设备不可用"被归成同一格**:
* 设备那天真可用了,闸门也永远不会开 —— 机制在,闸门锈死,而且看起来完全健康
* (这是"自报 0 条 < 登记 30 条"的第三种形状:探针自己坏了,没人知道)。
*
* 所以:
* - `true` 可用(前提成立 → 依赖它的静态判据**到期**,必须处理);
* - `false` 不可用(探针**确实跑成了**,结论是没有目标);
* - `'unknown'` 拿不准(**命令在但跑不成**:超时、非零退出、抛异常)→ **按到期处理**。
*
* # 这条设计当场抓到了什么(写下来,因为它是"闸门锈死"的实证)
*
* 换三值之后第一次运行就报了 `probe=unknown` —— 一查:探针里调的是 `execFileSync`,
* 而这个文件 import 的是 `spawnSync`(**名字根本没定义**)。也就是说
* **探针从写下的那天起一次都没跑成过**,而旧的二值设计把 `ReferenceError`
* 连同"没有设备"一起吞掉、统一报成"设备不可用"——**机制在、闸门从来没开过,
* 而它看起来完全健康**。三值把它变成了一声明确的红:探针自己坏了,必须有人看一眼。
*
* 另外,`'unknown'` 只留给"命令在、但跑不成";**所有候选都不存在**(本机没装 hdc)
* 是可判的事实(没有工具就不可能有设备),报 `false`,否则没装 SDK 的机器会天天假红。
* 拿不准就红,让人看一眼 —— 这条判断比"猜一个"便宜得多。
*
* 测试用 `AGENTMAIL_PROBE_DEVICE=ok|none|unknown` 覆盖(判据自检要用:
* 没有这个开关就没法验证"unknown 会不会红")。
*/
const PROBES = {
device: {
// 前提写成"**本工作区**能装能点设备",不是"机器上有设备"(pi 2026-09-14):
// 同一台机器上设备对 gui-lab 那条通道可用、对我这条不可用 —— 写"机器上有设备"的话,
// 探针变真会让 5 条判据同时红,而**我修不了**(要写 /run、/var/log,在工作区外)。
// 写成"本工作区能装能点",红就只在我能动手时出现。
desc: '本工作区能装、能点设备(hdc 服务健康 + 看得到目标)',
need: [
'设备/模拟器要在本工作区里起得来:模拟器启动要写 /run/harmony-emulator.pid 与 /var/log/harmony-emulator.log',
'hdc 的**服务端**要健康(`hdc -m` 在跑);服务端起不来时 `list targets` 也会打印 [Empty],那是"拿不准"不是"没设备"',
'要有 hdc 目标;能装上 hap 并能点(hvigorw assembleHap + hdc install)'
],
run() {
const override = process.env.AGENTMAIL_PROBE_DEVICE;
if (override === 'ok') return true;
if (override === 'none') return false;
if (override === 'unknown') return 'unknown';
/*
* # 候选清单的权威性(pi 2026-09-14 §2:硬编码清单今天咬了两次)
*
* 上一版在**固定两条路径**里找 hdc,全不在就判"本机没装 → 没有设备"(false)。
* 这与 build.sh 在固定几个根里找 SDK 源码、第一个命中就用,是**同一个形状**:
* 硬编码清单 + "命中/未命中"当真值。那次命中了 /root/ha-test 的老 checkout,
* 于是把错误结论当成了事实。所以这里:
* ① 候选来源写清楚(下面 TOOLCHAIN_ROOT 是华为命令行工具的**文档安装根**);
* ② **工具链根在、里面却没有 hdc** → `unknown`(不是"没装",是"装了但长得不一样");
* ③ `hdc` 也走 PATH(spawnSync 自己会查 PATH),不再只认那一条写死的路径。
*/
const TOOLCHAIN_ROOT = '/opt/huawei/command-line-tools';
const sdkHdc = `${TOOLCHAIN_ROOT}/sdk/default/openharmony/toolchains/hdc`;
const candidates = [sdkHdc, 'hdc'];
let ranOnce = false; // 至少有一条命令**跑成过**(哪怕是"没有目标")
let sawEmpty = false;
let anomaly = ''; // 命令在,但跑不成(超时/非零退出/…)—— "拿不准"
let allMissing = true; // 所有候选都不存在
for (const bin of candidates) {
const r = spawnSync(bin, ['list', 'targets'], { encoding: 'utf8', timeout: 15000 });
if (r.error && r.error.code === 'ENOENT') continue;
allMissing = false;
if (r.error) { anomaly = r.error.code || String(r.error.message || r.error); continue; }
if (r.status !== 0) { anomaly = `exit ${r.status}`; continue; }
ranOnce = true;
const t = (r.stdout || '').trim();
if (t && !/\[Empty\]/.test(t)) return true; // 明确可用
sawEmpty = true; // "没有目标"(下面还要问服务端健不健康)
}
/*
* `[Empty]` **不等于**"没有设备"(pi 2026-09-14 §1)。
*
* 它至少混三种原因:① 真没有设备;② **hdc 自己的服务端起不来**
* (socket/权限 —— 而"要写工作区外的目录"正是我这轮撞上的限制);
* ③ 被打印成空集的权限拒。所以先证服务端健康,才敢把空集当成"可判的没有":
* 服务端进程在(`hdc -m`,含它在别的路径下)→ 空集是可判事实 → false;
* 服务端找不到 → 空集可能正是"探针自己被环境弄坏了" → unknown(红)。
*/
if (ranOnce && sawEmpty) {
return hdcServerLooksHealthy() ? false : 'unknown';
}
if (anomaly) return 'unknown'; // 命令在、跑不成 → 拿不准
if (allMissing) {
// 工具链根在、里面却没有 hdc → "装了但长得不一样",不是"没装"
return existsSync(TOOLCHAIN_ROOT) ? 'unknown' : false;
}
return 'unknown';
}
}
};
/**
* hdc 的服务端是不是在跑。只读 /proc/<pid>/cmdline(不依赖 `ps` 也不起子进程
* —— 探针自己不该再引入"命令跑不起来"这种不确定性。
* 判据:命令行里第二个参数是 `-m`(hdc 的 server 模式)。
*/
function hdcServerLooksHealthy() {
let pids = [];
try { pids = readdirSync('/proc').filter(d => /^\d+$/.test(d)); } catch { return false; }
for (const pid of pids) {
try {
// 走 lib/read.mjs 的 prose(判据目录不许裸 readFileSync —— 那条判据也管这里)
const cmd = prose(`/proc/${pid}/cmdline`).split('\0').filter(Boolean);
if (cmd.length && /(^|\/)hdc$/.test(cmd[0]) && cmd.includes('-m')) return true;
} catch { /* 进程刚退出 / 没权限读 —— 换下一个 */ }
}
return false;
}
/** 探针结论 → 是否等于"前提成立(到期)" */
const probeIsDue = v => v === true || v === 'unknown';
/*
* 从 node:test 的 TAP 输出里取"哪几条被跳过了、为什么"。
*
* ★ 必须是**唯一实现**(`--skip-selftest` 要验的就是这个函数)。
* 我第一版把正则**抄了一份**进自检(当时的想法是"自检不该依赖被测对象")——
* 而那样一来自检验的是**副本**:生产那份改坏了,自检照样绿。
* 这正是本仓反复消的"同一个事实多份实现"(`blurStyleFor`、`stripStrings` 兄弟副本
* 都栽在这上面),所以只留这一份,自检**直接调它**。
*/
function parseSkips(out) {
return [...(out || '').matchAll(/^(?:ok|not ok) \d+ - (.+?) # SKIP(.*)$/gm)]
.map(m => ({ name: m[1].trim(), why: m[2].trim().replace(/^[-–—::]\s*/, '') }));
}
/** 只能验形态的判据:文件 + 为什么只能静态 + 到期前提 */
/*
* 探针自检(`--probe-selftest`):它判的是**判据自己的分辨力** ——
* "unknown 到底会不会红"。没有这条,`probeIsDue` 哪天被改成 `v === true`
* 也没人会发现,而那正是"闸门锈死"的写法。
*/
if (process.argv.includes('--probe-selftest')) {
const cases = [
['ok 可用 → 到期', true, true],
['none 不可用 → 不到期', false, false],
['unknown 拿不准 → 到期(必须红)', 'unknown', true],
];
let bad = 0;
for (const [what, value, want] of cases) {
const got = probeIsDue(value);
console.log(`${got === want ? 'ok ' : 'RED '} ${what}(probeIsDue(${JSON.stringify(value)}) = ${got})`);
if (got !== want) bad++;
}
process.exit(bad ? 1 : 0);
}
/*
* 变异体播报自检(`--mutants-line-selftest`):钉住"**先看退出码,再看正则**"。
*
* 为什么需要:这条决策**在正常路径上走不到** —— 套件以 root 跑,而盲读只在
* **非 root + `jobs/` 不可读**时才发生。只在我手跑 `runuser -u nobody` 时才经过的分支
* 等于**没有判据守着**;哪天顺序被调回"先看正则",没有任何东西会响,
* 而它是"读数器说自己没读数"的**唯一通道**。
* (我实测过:盲读/部分可读时 `summary.py` 照样打得出一行**看着正常**的
* `RESULT mutants=<数字>`,所以这个顺序不是风格问题。)
*/
if (process.argv.includes('--mutants-line-selftest')) {
const blind = 'RESULT mutants=NO-READING ran=NO-READING skipped=NO-READING on_new_criteria=NO-READING(**没读数**)\n'
+ ' ✗✗ **读不到 jobs/ 目录**(/x/jobs)—— 上面的数字**全部无效**:\n';
const partial = 'RESULT mutants=47 ran=0 skipped=47 on_new_criteria=0(…原始条目 73…)\n'
+ ' ★ 清单与磁盘不一致 —— 上面的数字**不代表"磁盘上现在有多少个变异体"**:\n'
+ ' 清单里有、**在但读不到**:jobs-one.json(权限问题,不是缺失)\n';
const normal = 'RESULT mutants=48 ran=47 skipped=1 on_new_criteria=35(口径A=只挂新判据 35 …) baseline=7/7✓\n';
/*
* 第三例(pi 2026-09-18):`unlisted`/`ghosts` 走 **status=1** ——
* 数字仍然打出来、正则也匹配,但**它不是全集**(磁盘上还有没登记的文件)。
*/
const unlisted = 'RESULT mutants=48 ran=47 skipped=1 on_new_criteria=35(…原始条目 73…)\n'
+ ' ★ 清单与磁盘不一致 —— 上面的数字**不代表"磁盘上现在有多少个变异体"**:\n'
+ ' 未列入清单:jobs-UNLISTED-probe.json(新加的 job 文件必须显式加进 jobs.manifest.json)\n'
+ ' 清单里有、磁盘上没有:jobs-ghost.json\n';
const cases = [
// ① 正常:原样播报、不转印任何东西
['正常(status 0)', normal, 0, { line: ' RESULT mutants=48 ran=47 skipped=1 on_new_criteria=35', why: 0 }],
// ② 盲读:**必须**说"没读数",且把 ✗✗ 转印出来
['盲读(status 2,正则不匹配)', blind, 2, { line: '没读数', why: 1 }],
/*
* ③ 部分可读:status=2 但 stdout 里是一行**看着完全正常**、**且匹配正则**的
* `mutants=47 …`。这一条是整条自检的**要害** —— 它证明"先判 status"不是多余:
* 只按正则走就会把 47 播报成权威数字,而真相是"有一个 job 文件没读到"。
*/
['部分可读(status 2,正则**匹配**)', partial, 2, { line: '没读数', why: 2 }],
/*
* ③(pi 2026-09-18 的第三例)清单与磁盘不一致:status=**1**、正则**匹配**。
* `mutants=48 …` 按清单算是**对的**,但磁盘上还有没登记的文件 ⇒ 读的人必须知道
* "这不是全集"。原来它们退 **0** ⇒ `whyLines` 被 `status !== 0 ? … : []` 丢掉、
* 套件输出里 grep "未列入清单" = 0 次(我端到端复现过)。
* ⇒ 这里要求:**数字照播**(不改它,它没错)+ 挂上"不是全集" + 把两行原因都转印。
*/
['清单与磁盘不一致(status 1,正则匹配)', unlisted, 1,
{ line: '不代表磁盘上现在有多少个变异体', why: 3 }],
// ④ 异常退出码但正则匹配(既不是 0/1/2)⇒ 也要留痕,不许静默
['未知退出码', normal, 3, { line: 'status=3', why: 0 }],
// ⑤ 完全没打出 RESULT(如脚本不存在)⇒ 报 status 与 stderr 末行
['没打出 RESULT', '', 1, { line: '没打出 RESULT', why: 0 }],
/*
* ⑥★ **封缝**:status=0 且 stdout 里**确实带着**警告行 —— 这是第三例的**形状**,
* 只是把"上游忘了退非零"这一半也模拟出来。
* 期望:**转印 0 行**(因为按契约 status=0 = 一切正常,不该有警告)。
* 这条不是"想要这个行为",而是把**缝的位置**钉在测试里:
* 如果哪天又出现"检查存在、结论到不了套件"的组合,它会与③形成对照 ——
* ③ 要求转印、⑥ 要求不转印,**两者同时绿才算"退出码这条通道是活的"**。
*/
['status 0 但带着警告行(缝的形状)', unlisted, 0, { line: 'mutants=48', why: 0 }],
];
let bad = 0;
for (const [what, stdout, status, want] of cases) {
const got = summarizeMutants(stdout, status, 'boom');
const okLine = got.line.includes(want.line);
/*
* ★ 期望值用**案例里声明的** `want.why`,不在这里另算一遍 ——
* 我第一版既声明了 `want.why`、又用一条正则从 stdout 现算 `wantWhy`,
* 于是同一个事实**两份实现**(正是本仓反复消的形状):`want.why` 写了却**从没被读**,
* 而现算那条一旦与 `whyLines` 的过滤器不同步,自检就会**自证自恰**地绿。
*/
const okWhy = got.whyLines.length === want.why;
const ok = okLine && okWhy;
console.log(`${ok ? 'ok ' : 'RED '} ${what}:line="${got.line.trim().slice(0, 60)}" ` +
`转印 ${got.whyLines.length} 行${ok ? '' : `(期望含 "${want.line}"、转印 ${want.why} 行)`}`);
if (!ok) bad++;
}
// ⑥ 反面对照:把顺序调回"先看正则"会怎样 —— 直接验证那个错误实现确实会被骗
{
const m = /(RESULT mutants=\d+ ran=\d+ skipped=\d+ on_new_criteria=\d+)/.exec(partial);
const wrong = m ? ` ${m[1]}` : '(else)';
const ok = wrong.includes('mutants=47');
console.log(`${ok ? 'ok ' : 'RED '} 反面对照:先看正则的实现会把部分可读播报成 mutants=47` +
`(=它确实会被骗,这正是本自检要守的)`);
if (!ok) bad++;
}
process.exit(bad ? 1 : 0);
}
/*
* summary.py 退出码契约自检(`--exitcode-selftest`):**跑真的那个脚本**。
*
* ★ 为什么必须补这一条(我变异时发现的,见下):`--mutants-line-selftest` 只钉住**下游**
* 纯函数 `summarizeMutants` —— 我把 `summary.py` 里 `if unlisted or ghosts: return 1`
* 整段删掉(即**退回第三例**)之后,那个自检**照样全绿**:下游收到的是我**喂给它的**
* `status`,上游到底退几,它管不着。⇒ 那是同一个缝换了位置 ——
* **上游的退出码没有判据守着**,而它正是"结论能不能到达套件"的唯一通道。
* (两次都是"缝在两层之间",所以这次把**两层都钉住**:下游用合成 stdout,
* 上游用真脚本 + 临时目录。)
*
* `summary.py` 按 `__file__` 定位 `jobs/` 与 `jobs.manifest.json` ⇒ 把**真脚本**
* 逐字节复制进一个临时目录、配上构造的 `jobs/` 与清单,就能在不碰仓库的前提下逐态验证。
* 这是"证据走默认路径":验的是**仓库里那份** `summary.py`,不是重写的替身。
*/
if (process.argv.includes('--exitcode-selftest')) {
const src = join(HERE, 'mutants', 'summary.py');
const realJob = join(HERE, 'mutants', 'jobs', 'jobs-one.json');
/*
* `summary.py` 依赖同目录下**三个**文件(不是只有一个 job 文件):
* · `test-keys.json`(第 34 行,模块级 `_KEYS`)
* · `baseline.sha`(第 136 行)
* · `jobs.manifest.json` + `jobs/`
* 我第一版只拷了 summary.py 与 jobs/ ⇒ 每次都 `FileNotFoundError` 退 1,
* 而**四个案例期望里有两个正好也是 1**,于是"失败"长得像"通过" ——
* 只有期望 0 的那两条把它揭出来。⇒ 依赖要拷全。
* (`REPO` 是 `HERE/../../../..`:在临时目录里它指向别处,所以 baseline 比对会落进
* "既不在底本也与 HEAD 不同"那一支 —— 但那支**不影响退出码**,本自检只判退出码。)
*/
const deps = ['test-keys.json', 'baseline.sha'];
/*
* ★★ rc=**2** 的上游锚点(pi 2026-09-18 的 M18 指出的那半个缝)。
*
* 原来四个案例的期望只覆盖 **0/1** ⇒ `blind or unreadable ⇒ return 2` **没有任何上游锚点**:
* 把 `return 2` 改成 `return 0`,**两个自检都不响**(我复现过,见提交信息)。
* 而 `rc=2` 的前提**在 root 下物理上不可达** —— `summary.py` 用
* `os.access(JOBS_DIR, R_OK|X_OK)` 判 `blind`,而 **`os.access` 对 uid=0 恒为 True**
* (实测:`chmod 000` 一个目录后 root 仍 `access()==True`,nobody 才是 False)。
* 这与我早先在 `check-file-modes.sh` 里踩的 `[ -x ]` 是**同一个 root 陷阱**,
* 只是换成了 `os.access`。
*
* ⇒ 补法:**降权跑**(`runuser -u nobody`,无则退 `setpriv`)。两条路我都实测得 rc=2。
*
* ⚠️ 三条注意(不注意就会得到**假绿**,pi 也点了):
* 1. 临时目录整棵要对 nobody 可达(`mkdtemp` 默认 700)⇒ 逐层放开到 `jobs/`。
* 否则 `FileNotFoundError` 退 **1**,而 **1 恰好是别的案例的期望值** ⇒
* "没跑起来"会长得像"通过"。所以下面那条 `Traceback` 判据**尤其**重要。
* 2. 降权跑写 `__pycache__` 会失败(无害),但会在 stderr 留痕 ⇒ `Traceback` 判据不受影响。
* 3. 跑完要先恢复权限再 `rmSync`,别依赖 root 一定能删。
*
* ★ 前提构造不出来时怎么办(pi 那条更一般的话):**明说"本机覆盖不到",不许默认看起来被守着**。
* 所以下面拿不到降权工具时,这几条报 **RED**(而不是 skip)——
* 因为"没有锚点"本身就等于这条分支没被判据守着,不能静静混过去。
* (这台机器上 `runuser`/`setpriv` 都在,所以正常路径会真的跑。)
*/
const dropTo = (() => {
for (const [cmd, args] of [['runuser', ['-u', 'nobody', '--']],
['setpriv', ['--reuid=65534', '--regid=65534', '--clear-groups']]]) {
const sp = spawnSync(cmd, [...args, 'true'], { encoding: 'utf8' });
if (sp.status === 0) return { cmd, args };
}
return null;
})();
const runIn = (manifest, files, degrade) => {
const d = mkdtempSync(join(tmpdir(), 'exitcode-'));
let bareDir = null; // `finally` 里要清理,所以声明在 try 之外
/*
* ★ 清理必须走 `try/finally`(我自己踩过):原来清理是**顺序执行**的最后两步,
* `runIn` 中途抛错(我改这段时真抛过 `ReferenceError`)就**跳过清理** ——
* 实测发现 `/tmp` 里留下了 `exitcode-*` 目录。自检**不该在失败时留垃圾**,
* 因为失败恰恰是最常被重复跑的时候。
*/
try {
cpSync(src, join(d, 'summary.py')); // 每次都复制**真脚本**
for (const f of deps) cpSync(join(HERE, 'mutants', f), join(d, f));
mkdirSync(join(d, 'jobs'), { recursive: true });
for (const f of files) cpSync(realJob, join(d, 'jobs', f));
writeFileSync(join(d, 'jobs.manifest.json'), JSON.stringify(manifest, null, 2));
if (degrade) {
// 整棵给 nobody 可达(mkdtemp 默认 700),最后一层按场景收紧
spawnSync('chmod', ['755', d]);
for (const f of [...deps, 'summary.py']) spawnSync('chmod', ['644', join(d, f)]);
spawnSync('chmod', ['644', join(d, 'jobs.manifest.json')]);
spawnSync('chmod', ['755', join(d, 'jobs')]);
for (const f of files) spawnSync('chmod', ['644', join(d, 'jobs', f)]);
// 前提 A:目录不可进入 ⇒ blind;前提 B:目录可进、单文件不可读 ⇒ unreadable
spawnSync('chmod', [degrade === 'blind' ? '000' : '755', join(d, 'jobs')]);
if (degrade === 'unreadable') spawnSync('chmod', ['000', join(d, 'jobs', files[0])]);
}
/*
* `no-sha256sum`:让子进程的 PATH 里**找不到** `sha256sum` ——
* 用一条只含最小命令的 PATH 实现(不依赖容器/镜像差异)。
* ⚠️ `python3` 本身也要走**绝对路径**:`spawnSync('python3')` 是按 PATH 解析的,
* PATH 一裁,连解释器都找不到 ⇒ 那会变成"脚本没跑起来"(退 1),
* 而 1 又是别的案例的期望值 ⇒ 又是"没跑起来长得像通过"。所以先解析绝对路径。
*/
let env = process.env;
if (degrade === 'no-sha256sum') {
bareDir = mkdtempSync(join(tmpdir(), 'barepath-'));
const gitPath = spawnSync('sh', ['-c', 'command -v git'], { encoding: 'utf8' }).stdout.trim();
if (gitPath) try { cpSync(gitPath, join(bareDir, 'git')); } catch { /* 拷不动不影响本例 */ }
env = { ...process.env, PATH: bareDir };
}
const py = spawnSync('sh', ['-c', 'command -v python3'],
{ encoding: 'utf8' }).stdout.trim() || 'python3';
const argv = [join(d, 'summary.py')];
const sp = dropTo && degrade && degrade !== 'no-sha256sum'
? spawnSync(dropTo.cmd, [...dropTo.args, py, ...argv], { encoding: 'utf8' })
: spawnSync(py, argv, { encoding: 'utf8', env });
return { status: sp.status, out: sp.stdout || '', err: sp.stderr || '' };
} finally {
// ★ 无论成功、失败还是抛错都清理(失败正是最常被重跑的路径)
if (bareDir) rmSync(bareDir, { recursive: true, force: true });
spawnSync('chmod', ['-R', '755', d]); // 先恢复权限再删(别依赖 root 一定能删)
try { rmSync(d, { recursive: true, force: true }); } catch { /* 删不掉也别盖住真因 */ }
}
};
const cases = [
// 清单与磁盘一致 ⇒ 0
['一致 ⇒ 0', ['jobs-one.json'], ['jobs-one.json'], 0, null, false],
// 磁盘上多一个、清单里没有(**unlisted**)⇒ 1(清单该改,**不是**环境)
['未列入清单 ⇒ 1', ['jobs-one.json'], ['jobs-one.json', 'jobs-extra.json'], 1, '未列入清单', false],
// 清单里有、磁盘上没有(**ghosts**)⇒ 1
['清单有磁盘无 ⇒ 1', ['jobs-one.json', 'jobs-gone.json'], ['jobs-one.json'], 1, '磁盘上没有', false],
// `_` 开头的说明条目不算 ghosts ⇒ 0(否则这份清单永远红)
['说明条目不算 ghosts ⇒ 0', ['_note', 'jobs-one.json'], ['jobs-one.json'], 0, null, false],
// ★★ rc=2 的两个上游锚点(降权构造前提)—— 这两条就是 M18 缺的那一半
['盲读(目录不可进,降权)⇒ 2', ['jobs-one.json'], ['jobs-one.json'], 2, '读不到', 'blind'],
['单文件读不到(降权)⇒ 2', ['jobs-one.json'], ['jobs-one.json'], 2, '在但读不到', 'unreadable'],
/*
* ★★ 锚住一个**我加这组自检时意外撞出来的真 bug**(不是 pi 报的,也不是我预想的):
*
* `summary.py` 里 `r = subprocess.run(['sha256sum', ...])` 在 `try` 内,而 `except`
* 设了 `baseline_ok` 非 None ⇒ 走 `else` 分支读 `r.stdout` ⇒ **`r` 从未绑定** ⇒
* `UnboundLocalError`,崩在**打出 `RESULT` 之前**。
* 触发条件很实在:**`sha256sum` 不在 PATH**(精简镜像 / PATH 被裁的 CI)。
*
* 所以这条案例**故意让 PATH 里没有 `sha256sum`**,要求:① 不许崩(无 Traceback);
* ② 仍要打出 `RESULT` 行;③ 那一格必须说"**跑不了**",不许说成"底本对"或"有残留"。
* —— 正是"**没读数** ≠ **读数正常**",同一条纪律第四次。
*/
['PATH 里没有 sha256sum ⇒ 不崩且说"跑不了"', ['jobs-one.json'], ['jobs-one.json'],
0, '跑不了 sha256sum', 'no-sha256sum'],
];
let bad = 0;
// 前提构造不出来 ⇒ 明说 + 计红(不许静默跳过)
if (!dropTo) {
console.log('RED 降权工具(runuser/setpriv)不可用 ⇒ **rc=2 的两条分支本机覆盖不到** —— ' +
'这不是"通过",是"没有锚点"。请在有降权工具的机器上跑,或手工验证。');
bad++;
}
for (const [what, manifest, files, want, wantOut, degrade] of cases) {
if (degrade && !dropTo) continue; // 上面已经计过一次红并说明了
const got = runIn(manifest, files, degrade);
/*
* ★ 先判"是不是脚本根本没跑起来":`Traceback` 在 stderr 里 ⇒ **立即可疑**,
* 不许让它落进下面那条"rc 不等于期望值"的普通失败里 ——
* 因为环境下错时"四个案例里有两个期望本来就是非 0",会**长得像**部分通过。
* (我第一版就是这个情况:只拷了一半依赖,B 案与 C 案"恰好"符合期望。
* 自检必须能说"这不是它答错了,是它没跑起来"。)
*/
if (/Traceback/.test(got.err)) {
console.log(`RED ${what}:**脚本没跑起来**(stderr 有 Traceback)—— ` +
`先修自检的临时目录,别把它当成"退出码不对":${got.err.trim().split('\n').slice(-1)[0]}`);
bad++;
continue;
}
const okStatus = got.status === want;
const okOut = wantOut === null || got.out.includes(wantOut);
const ok = okStatus && okOut;
console.log(`${ok ? 'ok ' : 'RED '} ${what}:rc=${got.status}` +
`${ok ? '' : `(期望 rc=${want}${wantOut ? ` 且输出含 "${wantOut}"` : ''})`}`);
if (!ok) bad++;
}
process.exit(bad ? 1 : 0);
}
/*
* 跳过解析自检(`--skip-selftest`):与上面那些同形状 —— 判的是**解析器的分辨力**。
*
* 为什么需要:闸 (i) 的"具名跳过"完全依赖从 node:test 的 TAP 输出里取回
* 名字与原因。那个正则哪天写坏了(`# SKIP` 大小写、` - ` 分隔、原因前缀的破折号),
* **表现是"跳过的判据"那一行不打印** —— 而"没打印"和"没有跳过"长得一模一样。
* ⇒ 拿合成 TAP 行把三种情形钉住:带原因的、不带原因的、以及**不该被当成跳过的**普通通过行。
*/
if (process.argv.includes('--skip-selftest')) {
// ★ 调**生产那一个**函数,不抄副本 —— 抄副本的话自检验的是副本(见 parseSkips 的注释)。
const parse = parseSkips;
const cases = [
/*
* ⚠️ 第一条我第一版写**错**了:拿的是"不带 ` # SKIP` 标记的行"却期望解析出 1 条 ——
* 自检当场红("解析出 0 条")。真输出里的跳过行**一定**带 ` # SKIP <原因>`,
* 所以第一条要用**真形状**;而不带标记的行正是后面那条要钉的"不许被当成跳过"。
* (顺带:这正是自检该有的样子 —— 它连**我写测试时的错**都抓到了。)
*/
['真形状:` # SKIP` + 原因', 'ok 16 - ★ 行为(设备):底栏 # SKIP 设备不在 —— 本次不跑\n# skipped 1\n',
[{ name: '★ 行为(设备):底栏', why: '设备不在 —— 本次不跑' }]],
['原因带破折号前缀要剥掉', 'ok 3 - 某条 # SKIP - 设备忙\n',
[{ name: '某条', why: '设备忙' }]],
['`not ok` 的跳过行(node 也会这么打)', 'not ok 4 - 某条 # SKIP 不支持的平台\n',
[{ name: '某条', why: '不支持的平台' }]],
['理由为空也要认出来(别因没原因就当没跳过)', 'ok 5 - 某条 # SKIP\n',
[{ name: '某条', why: '' }]],
['普通通过行**不该**被当成跳过', 'ok 1 - 一条普通的判据\n# tests 1\n# pass 1\n', []],
];
let bad = 0;
for (const [what, tap, want] of cases) {
const got = parse(tap);
const ok = JSON.stringify(got) === JSON.stringify(want);
console.log(`${ok ? 'ok ' : 'RED '} ${what}:解析出 ${got.length} 条` +
(ok ? '' : `(期望 ${JSON.stringify(want)},实际 ${JSON.stringify(got)})`));
if (!ok) bad++;
}
process.exit(bad ? 1 : 0);
}
const STATIC_ONLY = [
['test/harmony-appearance.test.mjs', '壁纸/令牌/遮罩渲染:观感与运行期换肤要设备', 'device'],
['test/harmony-logic.test.mjs', '页面状态机与文案:`.ets` 状态要跑起来才算数', 'device'],
['test/cross-client-theme.test.mjs', '跨端令牌与玻璃分工:一端是 `.ets`,只能静态对齐', 'device'],
['test/appearance-defaults.test.mjs', '默认值契约里 `.ets` 那半:运行时行为要设备', 'device'],
// P4c 同批的两条:判的是 `.ets` 里的页面/组件,本机没有设备也没有模拟器
['test/harmony-admin.test.mjs', '用户管理页:`.ets` 页面要 hvigorw 才能编译、要设备才能点(本机两者都没有)', 'device'],
['test/harmony-imageprep.test.mjs', '图片上传链:要 `@ohos.multimedia.image` + 相册 + 服务端,三样本机都没有', 'device']
];
for (const [file, , probe] of STATIC_ONLY) {
if (!SUITE.some(([f]) => f === file)) {
console.error(`✗ 静态判据登记里的 ${file} 不在套件清单里(登记要跟着套件走)`);
process.exit(1);
}
if (!PROBES[probe]) {
console.error(`✗ ${file} 的到期前提 \`${probe}\` 不是已知探针(UNBLOCK 必须是可机检的前提,不是一句陈述)`);
process.exit(1);
}
}
const probeResults = {};
for (const [, , probe] of STATIC_ONLY) {
if (probeResults[probe] === undefined) probeResults[probe] = PROBES[probe].run();
}
let selfCheckFailed = false;
/*
* 自检 4(pi 2026-09-15):**到期闸不许把汇总和其它红一起吃掉**。
*
* 起因:到期块原先在报文末尾 `process.exit(1)`,而它在汇总之前 ⇒ 只要它红,
* `RESULT phase=` 行、欠账余额、条数不符、**所有其它红**都不打(pi 实测 `harmony-presets` 那条真红一次没显示)。
* 修法是"记账 + 走完汇总",但**修法本身没有判据钉着** —— 谁改回去都不会被拦。这就是那条判据。
*
* ⚠️ 它验的是**套件自己的源码形状**,不是"跑起来真的没被吃":
* 标签只覆盖"源码里那两种写法不在了",不覆盖"运行时一定同现"。想验后者要跑两次完整套件,成本高。
*/
try {
const selfSrc = prose(fileURLToPath(import.meta.url));
/*
* ⚠️ 锚点**不许可自匹配**(pi 2026-09-15 实测踩到):原来这两个锚点写的是
* `'let dueFailed = false;'` / `'const probeSummary'`,而**这两串字本身就出现在下面这段自检代码里**。
* 把自检块**上移**到到期闸之前以后,`indexOf` 第一次命中的就是**自检自己那两行** ⇒ 扫的区间变成
* 自检自己的两行 ⇒ 真闸从没被扫过 ⇒ 它**空转报 ok**(比原来更糟:连失效都不响)。
* 所以:① 锚点取 `lastIndexOf` —— 真闸在这段自检**之后**,而上面两串"标记"必然也出现在自检代码里(作为 indexOf 的实参),
* `indexOf` 会先命中自检自己;`lastIndexOf` 才指向真闸;
* ② 再加一道自匹配断言(见下 `selfSrc.indexOf` 那条),宁可红也不许空转。
*/
/*
* ★ 锚点在**运行时拼**(dsh 2026-09-15 的建议):搜索键不许以字面量出现在搜索者自己的源码里。
* 上一版用 `lastIndexOf` 治的是**位置**("可执行那处在更后面"),依赖"字面量只出现两次且顺序稳定" ——
* 而位置是会被搬动的(这次就是被搬了才出的第 7 例)。拼出来之后,源码里根本不存在那串字,
* 自检搬到哪儿都不会把自己当锚点;下面那条"区间里不许有 selfSrc.indexOf"的断言继续做兜底。
*/
const GATE_START = 'const dueStatic = STATIC_ONLY' + '.filter';
const GATE_END = 'const probeSummar' + 'y = '; // 同样拼着写:这串字本身也是搜索键
const from = selfSrc.indexOf(GATE_START);
const to = selfSrc.indexOf(GATE_END);
const GATE_MARKERS_OK = from >= 0 && to > from;
/*
* ⚠️ 必须**先去注释与字符串**再找 `process.exit(`:这条自检自己的报错文案里就有那串字,
* 不去掉的话它会**指着自己报红**(我第一次跑就是这样 —— 与 `@ohos`/`toISOString` 那两次同族:
* 扫描器咬到自己的文档)。规矩不变:**不靠挪扫描口径解决,靠去掉不该扫的部分**。
*/
const gate = GATE_MARKERS_OK ? stripComments(selfSrc.slice(from, to)) : '';
const probs = [];
if (!gate) {
probs.push('找不到到期闸区块(标记被改动)⇒ 这条自检**自己失效**,必须红 —— 不许静默通过');
} else if (gate.includes('selfSrc.indexOf')) {
probs.push('扫描区间里含**自检自己的代码**(锚点自匹配)⇒ 它只会空转报 ok,必须红');
} else {
if (/process\.exit\s*\(/.test(gate)) {
probs.push('到期闸区块里又出现了 `process.exit()` ⇒ 它会在汇总之前退出,把汇总与其它红一起吃#掉');
}
if (!/&&\s*!dueFailed/.test(selfSrc)) {
probs.push('汇总分支缺 `&& !dueFailed` 守卫 ⇒ 到期闸红时可能反过来误报"全部通过"(修一处引坏一处)');
}
}
if (probs.length) {
console.error('✗ 套件自检 4(到期闸不许吃掉汇总):');
for (const x of probs) console.error(` - ${x}`);
selfCheckFailed = true;
} else {
console.log('ok 4 - 到期闸是**记账**而不是 exit(汇总与其它红不会被它吃掉)');
}
} catch (e) {
console.error(`✗ 套件自检 4 跑不了:${e.message || e}`);
selfCheckFailed = true;
}
/*
* 自检 5(pi 2026-09-15 找到的总数行 bug):**`ran` 不许超过 `files`** —— 每个文件只该计一次。
*
* 起因(真发生过):累加块在红绿分支**之外**和绿分支 `else` 里**各有一份**,
* 于是每个 exit 0 的文件被计**两遍** ⇒ `ran=49`(而 files=25)、`checks` 约 2 倍。
* 它**自洽**,所以读数检查不出来:`pass + fail == checks`(791+1=792)两边一起虚高,恒等式照样成立。
* 而 `RESULT files=…` 这行本是为"总数要有权威来源"才加的 —— **它自己就是错的**。
*
* 判据形状:`ran <= files` 是**结构性的上限**(一个文件最多贡献一个 `ranFilesCount`),
* 不需要知道任何自报条数就能判。`checks` 没有这种上限(条数是文件自己报的),
* 所以别在这里给 `checks` 编一个 —— 那会变成"用猜数字的判据"。
*
* ★ **这个上界对"每一个按文件累加的计数器"都成立**(pi 2026-09-15 指出我只判了两个):
* 它们一共三个 —— `ranFilesCount` / `totalFail` / `unreportedFiles`。
* 我漏了 `unreportedFiles`,而它当时**真的仍然计两次**(我上一次"修好了"的那个提交里,
* 删了 `totalTests` 那三行却把紧跟的 `unreportedFiles += 1` 留在原地)。
* ⇒ 教训:**"每个文件只该计一次"是一条对整族计数器都成立的性质,不是对某一个**;
* 只给其中两个判上界,第三个就永远没人管。
* (`totalFail <= checks` 只用 "fail 是 checks 的子集",比 `<= files` 更紧且同样结构性。)
*
* ⚠️ 这条自检管的是**总数的自洽性**,不是"条数对不对":`checks` 的正确值只有
* 逐文件核得出(这正是这个 bug 难发现的原因)。它拦的是"又一次搬动只加不删"。
*/
try {
const probs = [];
if (ranFilesCount > SUITE.length) {
probs.push(`ran=${ranFilesCount} > files=${SUITE.length} —— 每个文件只该计一次,` +
`超出来说明**累加块有不止一份**(搬动时只加没删)。` +
`修法:数 \`ranFilesCount\` 的**写**有几处(**不一定是 \`+=\`** —— 见下面的源码扫描,` +
`只按 \`+=\` 搜会漏掉 \`= ranFilesCount + 1\` 这种等价写法)。`);
}
if (totalTests < totalFail) {
probs.push(`checks=${totalTests} < fail=${totalFail} —— fail 是 checks 的子集,这不可能成立`);
}
/*
* ★ `skip` 是 `checks` 的**子集**(dsh 2026-09-18),与 `fail` 同一形状:
* "跳过的条数"当然属于"自报的总条数"。这条上界对**每个按文件累加的计数器**都成立
* (pi 2026-09-15 的教训:只给其中两个判上界,第三个就永远没人管)——
* 所以 `skip` 一进来就把上界补上,别等它出事。
*/
if (totalTests < totalSkip) {
probs.push(`checks=${totalTests} < skip=${totalSkip} —— skip 是 checks 的子集,这不可能成立`);
}
/*
* ★★ 这里曾经有一条**源码文本扫描**("四个计数器各只许一处写"),连同它的自检一起删掉了。
*
* 为什么删(pi 2026-09-15 建议,我采纳):那条扫描器自己长出了**五轮**毛病 ——
* 注释 → 字符串 → `= X + n`/`X++` → 锚钉行首 vs 语句起点 →
* 正则字面量里的引号(**最后一轮是假绿方向**:会吞掉真写,把"其实有两处写"数成 1,
* 而"第二处写"正是它唯一存在的理由)。每轮我都修好了那个实例,**族还在**。
*
* ⇒ 改成**投影**后(见上面 `reportedRecords`),那四个计数器**没有"写"可言**,
* 扫描器**没有对象可扫** —— 整段删掉,`stripStrings`/`stripComments` 的引入也一并去掉。
* (`stripStrings` 本身留在 `read.mjs`:它是通用工具,只是**这条判据**不再用它 ——
* 所以这里连 import 也去掉了。**留着不用的 import 就是下一次"看着像还在用"的源头。**)
*
* ⚠️ 但**别把这次改动说成"不可能再计两次"**(pi 的原话是"写不出来",我实测后认为过强):
* `records.push` 仍然可以被复制粘贴两次。准确的说法是:
* 重复不再表现为"某个计数器被加两次",而表现为"**同一条记录出现两次**" ⇒
* 于是它**能被行为判据抓住**,而不是只能靠猜文本。
* **这是"文本判据 → 行为判据",不是"可能错 → 不可能错"。**
*/
if (records.length !== phaseWillRun.length) {
const seen = new Map();
for (const r of records) seen.set(r.file, (seen.get(r.file) || 0) + 1);
const dup = [...seen].filter(([, n]) => n > 1);
probs.push(`跑出的记录数 ${records.length} ≠ **本相位实际会跑**的文件数 ${phaseWillRun.length}`
+ `(套件共 ${SUITE.length} 条,本相位跳过 ${skippedByPhase.length} 条构建相位判据)—— `
+ `每个文件**恰好一条**记录(这是"每个文件只该计一次"的**行为**版本;`
+ `原来那条是扫源码文本的,已删,理由见上)。\n`
+ (dup.length > 0
? ` 重复的文件:${dup.map(([f, n]) => `${f}×${n}`).join('、')}`
+ `⇒ **push 了两次**(不是漏跑)。\n`
: ` 没有重复文件 ⇒ 是**漏了**或**多跑了**,不是 push 两次:检查 SUITE 与循环。\n`)
+ ` ⚠️ 期望值用 \`phaseWillRun\`(本相位不被跳过的那些)而**不是** \`SUITE.length\`:`
+ `install 相位会跳过构建相位判据,拿套件总数比就是**假红**`
+ `(这条命令正是 \`install.sh --check\` 跑的那条)。`);
}
if (ranFilesCount > SUITE.length) {
probs.push(`ran=${ranFilesCount} > files=${SUITE.length} —— 自报条数的文件比文件总数还多,`
+ `不可能成立。\n`
+ ` ★ 这条现在**冗余**于上面那条(投影下 ranFilesCount 就是"checks 非空的记录数",`
+ ` 有 ≤ 记录数 ≤ 文件数 的构造性保证)。**留着当"投影被改坏"的探测器**:`
+ ` 哪天有人把它换回就地累加,这条会立刻响。`);
}
if (totalTests < totalFail) {
probs.push(`checks=${totalTests} < fail=${totalFail} —— fail 是 checks 的子集,这不可能成立`);
}
if (probs.length) {
console.error('✗ 套件自检 5(总数行的自洽性):');
for (const x of probs) console.error(` - ${x}`);
selfCheckFailed = true;
} else {
/*
* ★ 报文只许写**真的判过**的东西(pi 2026-09-15 抓到我把没判的写上去过):
* 原来这句是 `ran ≤ files,unreported ≤ files,fail 是 checks 的子集` ——
* 而**全文件没有任何地方断言 `unreported ≤ files`**(`grep "unreportedFiles >"` 零命中)。
* 我测出那条上界是空的、换成了源码处数扫描,**却把半句留在成功报文里** ⇒
* 报文在**替一条已经被我判定无效的判断作证**。
* 形状:**"读数器替一件事作证"落在自检自己身上** —— 而这条自检存在的理由,
* 正是"`RESULT` 那行自作证"。**读数器也要被读数。**
*
* ★ 本条的判据形状**在 pi 2026-09-15 的建议后换过**:原来是"扫自己的源码,
* 数四个计数器各有几处写"(文本判据,长出了五轮毛病);
* 现在是"**记录数 == 本相位实际会跑的文件数**"(行为判据,见上面 `reportedRecords`)。
* 报文如实说它现在由谁判 —— 改了判据却留着旧文案,就是又一次"说法与实现不一致"。
* (**这句话我这轮又踩了一次**:改成 `phaseWillRun` 之后报文还写着"== 文件数",
* 而 install 相位 `files=25` 里只有 23 条会被跑 ⇒ 报文本身就成了假话。)
*/
console.log(`ok 5 - 总数行自洽(ran=${ranFilesCount} ≤ files=${SUITE.length},` +
`fail 是 checks 的子集;四个计数器由 ${records.length} 条记录**投影**而来 —— ` +
`判的是"记录数 == **本相位实际会跑的** ${phaseWillRun.length} 条"`
+ `${skippedByPhase.length ? `(套件 ${SUITE.length} 条里本相位跳过 ${skippedByPhase.length} 条)` : ''}`
+ `这个**行为**,不是"源码里 \`+=\` 有几处")`);
}
} catch (e) {
console.error(`✗ 套件自检 5 跑不了:${e.message || e}`);
selfCheckFailed = true;
}
/*
* pi 2026-09-15:这块原先在报文末尾 `process.exit(1)`,而**它在汇总之前** ⇒
* 只要到期闸红了,`RESULT` 行、欠账余额、条数不符、以及**所有其它红**都不会打出来。
* 实测:`harmony-presets` 实报 6 / 登记 5 这条真红一次都没显示过。
* 形状:**一道闸把不属于它的证据一起吃掉**。修法不是"少红",是**记账 + 继续走完汇总**。
*/
let dueFailed = false;
const dueStatic = STATIC_ONLY.filter(([, , probe]) => probeIsDue(probeResults[probe]));
const unknownProbes = Object.entries(probeResults).filter(([, v]) => v === 'unknown').map(([k]) => k);
/*
* ★★ 闸 (ii):「**移出 STATIC_ONLY**」必须要求「行为层**真跑过一次**」(pi 2026-09-18)。
*
* 为什么需要它:把一条判据从 `STATIC_ONLY` 移除,现在是个纯**记账动作** ——
* 它从"到期闸点名"挪到"行为条管辖",而**没有任何东西要求行为条真的执行过**。
* ⇒ 一条判据可以在"已升级"的名义下**永远不跑**,而 `static=` 那个余额读起来像
* "又清了一条"。这与本文件反复消的形状同源:**余额里读不出来的东西等于不存在**。
*
* pi 用变异把后果钉出来了(我复现,两处都一致):
* 设备在 + 注入一条真实回归 ⇒ `fail=5 … red=10`,红清单里有 harmony-nav;
* 设备不在 + **同一条回归** ⇒ `fail=4 skip=1 … red=9`,红清单与基线**逐条一致**
* ⇒ **一条真实回归,在设备不在时零痕迹。**
*
* ⇒ 这里记一份"已结算"清单:每条都必须能出示**本机**的行为层绿跑记录
* (由 `lib/harmony-device.mjs` 的 `noteBehavioralRan` 在断言**之后**写)。
* 记录不存在 ⇒ 到期闸继续**点名**它(回到被看见的状态),而不是让它静静滑过去。
* ★ 方向要紧:这条闸宁可**多报**(说"你还没验过"),也不许**漏报**(把没验过的
* 当成验过了)—— 所以"记录读不到"一律按"没验过"处理。
*/
const SETTLED_FROM_STATIC = [
// 移出 STATIC_ONLY 的判据要登记在这里,并出示行为层绿跑记录。
// 形状:['判据文件', '行为记录里的 key(noteBehavioralRan 的那个字符串)', '行为条在哪', '到期前提探针']
['test/harmony-nav.test.mjs', 'harmony-nav/底栏可点项',
'test/harmony-nav.test.mjs 的「★ 行为(设备):底栏真渲染了可点的导航项」', 'device'],
];
let unsettled = [];
try {
// 账本路径必须与 `lib/harmony-device.mjs` 里 `ranPath()` 的默认值**一致**:
// 那个文件在 `test/lib/` 下,用 `../../../../.tmp/…`(= 仓库根);
// 这里 `ROOT` = `client/electron`,所以是 `../..` 再进 `.tmp`。
// 两处各拼一次是"同一个事实多份实现"——但这边**只读**、写的那边是唯一权威;
// 若哪天路径改了,这条闸会因"读不到记录"而**多报**(保守方向),不会漏报。
const ranLedger = JSON.parse(prose(join(ROOT, '..', '..', '.tmp', 'harmony-behavioral-ran.json')));
for (const [file, key, where, probe] of SETTLED_FROM_STATIC) {
// 只在**本相位会跑**它时才判:构建相位不跑判据文件,不该因此报"没验过"
if (skippedByPhase.includes(file)) continue;
/*
* ★ 只在**到期前提成立**(探针判到期)时才追这条账 —— 与到期闸本身同一条纪律。
*
* 为什么必须这样收口:没设备/没 SDK 的机器上,行为层**不可能**留下记录
* (它只会 skip),那条红就是**天天假红**且**无法行动** —— 没人能在那台机器上
* 把它做绿。这正是探针三值设计要避免的东西。
* ⇒ 前提不成立时,这条判据的可见性交给**闸 (i) 具名跳过**:它会点名到文件与
* 判据、写出原因、并给出连续跳过轮数(且连续超 K 轮会自行变红)。
* 也就是说"没跑"这件事**仍然看得见**,只是不以这条清不掉的账的形式。
* 前提成立而记录仍缺 ⇒ 那才真异常:"设备明明在,却从没跑绿过"。
*/
if (!probeIsDue(probeResults[probe])) continue;
if (!ranLedger[key]) unsettled.push([file, key, where]);
}
} catch {
// 记录文件不存在/坏了 ⇒ **前提成立**的那些算"没验过"(保守方向:宁可多报)
unsettled = SETTLED_FROM_STATIC
.filter(([file, , , probe]) => !skippedByPhase.includes(file) && probeIsDue(probeResults[probe]));
}
if (unsettled.length > 0) {
console.error('\n✗ 有判据**被移出 STATIC_ONLY,但行为层从未真跑过**:');
for (const [file, key, where] of unsettled) {
console.error(` - ${file}(行为记录 key:${key})`);
console.error(` 它已不在到期闸的点名范围里,等于"已升级"—— 但没有任何证据表明`);
console.error(` 行为条真的执行过。${where}`);
}
console.error(
' ⇒ 这只是被**挪走**,不是被**升级**。让行为条在本机真跑绿一次即可(记录会自动落下),\n' +
' 或者把它放回 STATIC_ONLY(继续被点名,好过静静滑过去)。\n' +
' 为什么要有这条闸(pi 2026-09-18 实测):设备不在时,注入一条真实回归,\n' +
' 红清单与基线**逐条一致** —— 判据"已升级"而实际零覆盖,且看不出来。'
);
dueFailed = true;
}
if (dueStatic.length > 0) {
const firstUnknown = unknownProbes.includes(dueStatic[0][2]);
console.error(firstUnknown
? `\n✗ 探针**跑不了**(${PROBES[dueStatic[0][2]].desc})—— 拿不准就按到期处理,别让闸门锈死:`
: `\n✗ 静态判据**到期**了:${PROBES[dueStatic[0][2]].desc} 现在是成立的 ——`);
for (const [file, why, probe] of dueStatic) {
console.error(` - ${file}(到期前提:${PROBES[probe].desc};当初只能静态的原因:${why})`);
}
console.error(
' 这些判据当时只能验形态。前提成立后必须做其中一件(别默默留着):\n' +
' a) 改成**行为判据**(真跑一遍/真点一次),静态那条降级或删掉;\n' +
' b) 明确写"为什么仍然只能静态"(如设备能编译但点不了),并改换一个更准的到期前提。\n' +
' 这是"暂时"的到期机制:它的作用就是不等谁想起来。'
);
/*
* 到期报文自带**要人做什么**(pi 2026-09-14 §2):
* 这条红第一次出现时,最可能的结局是"被当成噪音消掉"——因为看的人不知道要放行什么。
* 前提写的是"**本工作区**能装能点",所以这里把工作区外的门槛逐条列出来。
*/
const need = PROBES[dueStatic[0][2]].need || [];
if (need.length) {
console.error(' 要让它变成行为判据,需要先在本工作区打通:');
for (const n of need) console.error(` · ${n}`);
console.error(' 这些都在工作区外(需要人放行或提权)—— 所以这条红**不要求你现在修**,' +
'要求的是:别把它当噪音,并在打通后回来把静态那条升级掉。');
}
dueFailed = true;
}
/*
* 汇总里"欠账余额"和"探针是否健康"是两个不同的数字(pi §2):
* static=5 —— 还欠着 5 条只能验形态的判据;
* probe=ok —— 探针自己是好的(unknown 说明闸门可能锈死了,得人看一眼)。
*/
const probeSummary = unknownProbes.length ? 'unknown' : 'ok';
if (skippedByPhase.length) {
console.log(`相位=${PHASE}:本相位不判 ${skippedByPhase.length} 条**构建相位**判据(${skippedByPhase.join('、')})` +
`—— 它们问的是"产物是不是从当前源码来的",安装相位问的是"已产出的产物能不能装";` +
`安装相位判它只能读**产物自证**(见 deploy/install.sh 的前置检查)。`);
}
/*
* 欠账余额打进**常态可见**的这一行(pi 2026-09-14 裁定 §1/§3):
* "跳过"若不可数就等于不显形 —— 而 `go test` 跑通时不打印包的输出,
* 所以 Go 那边的 Skip 余额必须在**这边**可见。两边读**同一个**登记(docs/DEBTS.json),
* 于是"三笔欠账一处看全"且有余额、有到期。
*/
/*
* 欠账余额:**登记值与实测值一起打**(pi 2026-09-14 裁定 §1)。
*
* 为什么不能只打登记值:`docs/DEBTS.json` 是**手可编辑的副本**,而"可见的那个数字就是副本"
* 恰好违反我自己写进 §12 的那句 —— 比较的对象必须是权威来源,不是手边那份会过期的副本。
* 所以这一行同时给出:
* · `registered`:登记值(副本,谁都能改);
* · `measured`:**本相位能实测的部分**(这里有权威来源的只有 static:`STATIC_ONLY.length`);
* · 本相位测不了的(Go 侧那几笔)**明说由谁比对**,不冒充已实测。
* 不等即红:下面这条判据就是这一行(static 的登记值必须等于实测值)。
*/
const DEBTS_PATH = join(HERE, '..', '..', '..', 'docs', 'DEBTS.json');
let debtsLine = '';
let debtRegStatic = null;
try {
const led = JSON.parse(prose(DEBTS_PATH));
const total = led.debts.reduce((n, d) => n + d.count, 0);
const items = led.debts.map(d => `${d.id}:${d.count}`).join(',');
const regStatic = led.debts.find(d => d.id === 'static-criteria');
debtRegStatic = regStatic ? regStatic.count : null;
const measuredStatic = STATIC_ONLY.length;
/*
* 子集关系必须**打进字符串本身**(pi 2026-09-14 裁定 §2)。
* 上一版打的是 `debts=9(registered) measured_static=5==登记 ✓`:读的人先看到 9≠5,
* 而"本相位只有 static 有权威实测"只写在**信里**、不在那一行里 —— 于是那一行
* **没有一个字在判它可读**。理由与我写进 §12 的那条同源:
* **余额里任何需要靠注释才能读对的地方,就是下一个假绿。**
*/
const other = total - (debtRegStatic || 0);
const staticNote = debtRegStatic === null
? 'static=登记里没有这一项 ✗'
: debtRegStatic === measuredStatic
? `static-only=${measuredStatic}==登记 ✓`
: `static-only=${measuredStatic}≠登记${debtRegStatic} ✗`;
const env = led.debts.filter(d => d.kind === 'env').reduce((n, d) => n + d.count, 0);
debtsLine = ` debts=${total} (${staticNote} / other=${other} by go-test / env=${env} 等环境或人)(${items})`;
} catch (e) {
debtsLine = ` debts=登记读不到(${e.code || e.message})`;
}
/*
* 变异体计数**也进这一行**(pi 2026-09-15):此前它只活在信里,于是同一个量
* 换过 40/48/58/41 四种说法,而"job 集合是什么"没有定义 —— 复核方拿到的是一个
* "我复算不出你那个数、而且你的报数方法也不在场"的目录。
* ⇒ 口径写死在 `test/mutants/summary.py`(唯一权威),这里只**播报**它,
* 并顺着同一条"要提醒人的东西必须走默认路径"的规则。
* 跑不起来(summary.py 不在、python3 不在)**不静默** —— 明说"没跑",
* 免得下一个人把"这行没提"当成"没问题"。
*/
/**
* 把 `summary.py` 的 (stdout, status) 变成**汇总行里那一格** + 要转印的原因行。
*
* ★ 抽成**纯函数**的理由(与 `parseSkips` 同一条):这个决策**在正常路径上走不到** ——
* 套件以 root 跑,而盲读只在**非 root + jobs/ 不可读**时才发生。
* "只在我手跑 `runuser -u nobody` 时才经过"的分支,等于**没有判据守着**:
* 哪天 `status === 2` 那格被写错(把 2 写成 '2' 的字符串比较、或把顺序调回
* "先看正则"),没有任何东西会响,而它恰好是"读数器说自己没读数"的唯一通道。
* ⇒ 纯函数 + `--mutants-line-selftest` 把它每次跑都钉住。
*
* 决策顺序要紧:**先看退出码,再看正则**。
* 反过来的话,盲读/部分可读时 `summary.py` 照样打得出一行**看着正常**的
* `RESULT mutants=<数字>`,正则一匹配就走真分支,退出码**从没被读**。
*/
function summarizeMutants(stdout, status, stderr) {
const m = /(RESULT mutants=\d+ ran=\d+ skipped=\d+ on_new_criteria=\d+)/.exec(stdout || '');
// "为什么没读数"只有 stdout 知道,而读者只看得到套件的输出 ⇒ 转印这些行。
const whyLines = (stdout || '').split('\n')
.filter(l => /✗✗|⚠️|★ 清单与磁盘不一致|在但读不到|未列入清单|清单里有、磁盘上没有/.test(l))
.map(l => l.trim());
if (status === 2) {
return {
line: ' mutants=(**环境:没读数**,summary.py status=2 —— 上面的 mutants 数字**不可信**)',
whyLines,
};
}
/*
* ★ status=1:**清单与磁盘不一致**(`unlisted`/`ghosts`)—— pi 2026-09-18 的第三例。
*
* 与 status=2 **分开报**,理由与 `summary.py` 里那条一样:修法不同。
* 2 = 权限/环境 ⇒ 去修权限;1 = 清单没跟上 ⇒ 去改 `jobs.manifest.json`。
* 而这一格的**数字仍然是打出来的、正则也匹配**(`mutants=48 …`)——
* 数字按清单算**是对的**,错的是"读的人不知道它不是全集"。
* ⇒ 所以这里**不改数字**,只在它后面挂一句"不是全集",并把 `未列入清单:X` 那行转印出来。
* (只靠"放宽转印条件"能把这行打出来,但**治不了"清单外不红"** —— 数字仍然绿、
* 退出码仍然是 0。所以根因修在 `summary.py` 的退出码上,这里只负责把它说清楚。)
*/
if (status === 1) {
/*
* ⚠️ 顺序要紧:这条**必须**在 `if (m)` 里面 ——
* 否则"没打出 RESULT 且 status=1"(例如脚本不存在、python 报错退 1)会被它抢先吃掉,
* 套件就会说"清单与磁盘不一致",而真相是"summary.py 根本没跑起来"。
* (我第一版就把这条放在 `if (m)` 之前,自检⑤当场红 —— 顺序错了。
* 自检在这里又替我挡了一次。)
*/
if (m) {
return {
line: ` ${m[1]}` +
'(**注意:清单与磁盘不一致** —— 上面的数字**不代表磁盘上现在有多少个变异体**)',
whyLines,
};
}
}
if (m) {
/* status 非 0 但没被上面两条抓住(例如未知退出码)⇒ 也要说,不许静默 */
return {
line: ` ${m[1]}` + (status !== 0 ? `(注意:summary.py status=${status})` : ''),
whyLines: status !== 0 ? whyLines : [],
};
}
return {
line: ` mutants=(summary.py 没打出 RESULT:status=${status} ` +
`${(stderr || '').trim().split('\n').slice(-1)[0] || ''})`,
whyLines,
};
}
let mutantsLine = '';
try {
// ★ 路径要 join(HERE, …):`HERE` 是 `test/`(不是 `client/electron/`)——
// 我第一版写成相对 `client/electron/` 的 'test/mutants/summary.py',
// 于是 python 报 "can't open file …/test/test/mutants/summary.py"(status=2),
// 而这里只看到"没打出 RESULT"。**错误信息被吞掉**正是这行注释要提醒的事:
// 播报失败时要把 stderr 一起说出来,别只说"未知"。
const sp = spawnSync('python3', [join(HERE, 'mutants', 'summary.py')],
{ cwd: HERE, encoding: 'utf8', timeout: 30000 });
/*
* ★★ 先看**退出码**,再看正则(pi 2026-09-18 实测的口子)。
*
* 原来只在正则不匹配时才提 `status` —— 而 `summary.py` **盲读时照样打
* `RESULT mutants=0 ran=0 …`**(它读不到 jobs/,却把没读过的东西算成了 0),
* 于是正则匹配、走真分支、`status=2` **从没被读**;而**完整 stdout 从不打印**
* (`sp.stdout` 全文件只有这一处引用)⇒ 它那三行 `✗✗ 读不到 jobs/ 目录 …`
* **一个字都到不了读者眼前**。
*
* ⇒ 套件汇总里显示 `mutants=0 ran=0 skipped=0 on_new_criteria=0` ——
* 与"这棵树真的一个变异体都没有"**长得一模一样**,而真相是"**没读数**"。
* (我自己在 `summary.py` 那段注释里还写过"那边也会看到 mutants=0"并当成可接受 ——
* 那是因为我以为"这一行自己带 ✗✗ 说明",可**那几行不在套件的输出里**。)
*
* 两层修法互为兜底:
* ① `summary.py` 盲读时改打 `mutants=NO-READING`(不匹配该正则);
* ② 这里**先判 status**,非 0 就把那些 ✗✗/⚠ 行**转印**出来。
* ② 还兜住另一种(我实测发现的,pi 那封没提):**部分可读** ——
* 目录能进、单个 job 文件读不到时,`summary.py` 返回 2,
* 却打出一行**看着完全正常**的 `mutants=47 ran=0 skipped=47 …`,**它匹配正则**。
* ⇒ 只靠修法① 会被它骗过,只有"先判 status"能兜住。
*/
const r = summarizeMutants(sp.stdout, sp.status, sp.stderr);
mutantsLine = r.line;
for (const l of r.whyLines) console.log(` ${l}`);
} catch (e) {
mutantsLine = ` mutants=(跑不了 summary.py:${e.code || e.message})`;
}
console.log(`RESULT phase=${PHASE} static=${STATIC_ONLY.length}${debtsLine} probe=${probeSummary}` +
(unknownProbes.length ? `(探针跑不了:${unknownProbes.join('、')} —— 已按到期处理)`
: '(只能验形态的判据:到期前提成立就自动变红)') + mutantsLine);
console.log(`\n========== 判据汇总 ==========`);
/*
* 判据**条数**的权威来源(pi 2026-09-15)。
* 格式与其它 `RESULT` 行同族:机器可读、一行、**套件自己打的**。
*
* ⚠️ 单位要写清:`checks` 是**逐文件自报条数之和** —— 也就是套件在每跑完一个文件时
* 真正解析过、并据此判"有没有少跑"的那个数。它**混两种单位**:
* `check()` 文件自报 `RESULT pass=N fail=M`(取 N+M 为总条数),
* `node:test` 文件自报 `# tests N`(总条数)。
* 两者都是"条",但**不是同一种条**,所以这里叫 `checks` 而不叫
* `tests`,免得下一个人把它跟 `node --test test/*.test.mjs` 的 glob 口径直接比 ——
* 那个口径只跑标准 `test/*.test.mjs`,**不跑套件里的构建相位**,也不含本次相位跳过。
* `pass + fail == checks` 现在是**真不变量**(两边各自独立测得,见 `totalFail` 的注释)——
* 它此前是定义式(`pass` 由 `checks - fail` 派生),所以**形同虚设**:
* 单位取成 `# pass` 时 `checks` 与 `pass` 一起少算,那个等式照样成立。
*/
/*
* ★ 这一行的**四个"坏/没跑"计数单位不同**,别混着读(pi 2026-09-15 指出缺一格;
* dsh 2026-09-18 补 `skip`):
*
* · `fail=N` —— **文件自报的失败条数**(`# fail N` / `RESULT fail=N` 累加)。
* · `skip=N` —— **文件自报的跳过条数**(`# skipped N` 累加)。`check()` 风格没有
* 跳过机制 ⇒ 那些文件恒 0。语义是"**这条本次没跑**"(设备不在 /
* 相位不判),**既不是通过也不是失败** —— 所以它从 `pass` 里减掉。
* · `red=N` —— **红的判据文件数**(`reds`,含**运行器自己**推的那些,见下)。
* · `broken=N` —— 跑不起来(exit 非 0 / 无输出)的文件数。
*
* 为什么必须把 `red` 显式打出来:那 3 条"自报条数 ≠ 登记数"的判据是**运行器自己**推的
* (`reds.push`),根本不经过文件 ⇒ **进不了 `fail`**。实测过 `fail=1` 而"红的判据(4/25)",
* 于是 `fail=1` 被读成"只有一处坏了"。两格都在,读的人才不用自己猜单位。
*
* ★ 为什么必须有 `skip`(dsh 2026-09-18,实测):给到期判据加行为层时,第一条行为判据
* 在"设备不在"时 `t.skip`。补这一格之前,设备在与设备不在两次运行的这一行**一字不差**
* (`checks=457 pass=453 fail=4 …`)—— 因为 `# tests` 把跳过的算进总数,而
* `pass = checks - fail` 又把它读成通过。**"看不到 ⇒ 绿"长在总数行上**,
* 与 `red` 那一格当初缺的理由完全同源:**余额里读不出来的东西等于不存在**。
*/
const totalsLine = (verdict) => `RESULT files=${SUITE.length} ran=${ranFilesCount} ` +
`checks=${totalTests} pass=${totalTests - totalFail - totalSkip} fail=${totalFail} ` +
`skip=${totalSkip} ` +
`red=${reds.length} broken=${brokens.length} unreported=${unreportedFiles} verdict=${verdict}`;
if (reds.length === 0 && brokens.length === 0 && !dueFailed && !selfCheckFailed) {
// 报**本相位实际跑了**几条,不报清单长度 —— 上面刚写完"不能声称自己做过的事",
// 这里如果打 SUITE.length 就是在同一行里犯同一个错(2 条构建相位判据被跳过了)。
const ranFiles = SUITE.map(([f]) => f).filter(f => !skippedByPhase.includes(f));
console.log(`全部通过(${ranFiles.length} 个判据文件:` +
`${ranFiles.map(f => f.replace('test/', '').replace('.test.mjs', '')).join('、')}` +
`${skippedByPhase.length ? `;另有 ${skippedByPhase.length} 条属构建相位,本相位不判` : ''})`);
console.log(totalsLine('green'));
process.exit(0);
}
// broken 先报:它比红更严重(红是"判据说不成立",broken 是"判据没说话")
if (brokens.length) {
console.error(`跑不起来的判据(${brokens.length}/${SUITE.length})—— 不是红,也不算过:`);
for (const b of brokens) console.error(` - ${b}`);
}
if (reds.length) {
console.error(`红的判据(${reds.length}/${SUITE.length}):`);
for (const r of reds) console.error(` - ${r}`);
}
/*
* ★★ 跳过的要**具名**(pi 2026-09-18 闸 (i) 前半)—— 与上面的红清单**同级**。
*
* 为什么必须并列:红的是"判据说 NO",跳过的是"判据没说话"。后者只在余额里有个数时,
* **一条真实回归可以零痕迹**(实测:设备不在时注入回归,红清单与基线逐条一致)。
* 红清单能点名,跳过清单也必须能点名 —— 否则"没跑"就永久藏在 `skip=1` 里。
*/
if (totalSkip > 0) {
const named = reportedRecords.filter(r => r.skip > 0);
console.error(`跳过的判据(${totalSkip} 条,分布在 ${named.length}/${SUITE.length} 个文件)` +
`—— **不是通过**,也不等于没问题:`);
for (const r of named) {
const list = (r.skips || []).map(s => `「${s.name}」${s.why ? `:${s.why}` : ''}`);
console.error(` - ${r.file}(跳过 ${r.skip} 条)${list.length ? `\n ${list.join('\n ')}`
: '(原因未能从输出里解析出来 —— 这条本身就是个缺口)'}`);
}
}
if (dueFailed && reds.length === 0 && brokens.length === 0) {
console.error('另外:静态判据**到期**(报文见上)—— 它现在**不再吃掉**上面的汇总与其它红。');
}
console.log(totalsLine('red'));
process.exit(1);