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-*` 残留。
1656 lines
106 KiB
JavaScript
1656 lines
106 KiB
JavaScript
/**
|
||
* 判据总入口 —— **全部跑完再算退出码**。
|
||
*
|
||
* 为什么不再用 `&&` 串起来:
|
||
*
|
||
* 原先 `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);
|