Files
MailUI4Agents/client/electron/test/run-all.mjs
JianFeeeee 16cbc02421 修复: 样本的**标签可以撒谎** —— 覆盖表声称覆盖箭头函数,而那份样本根本不是箭头函数
我自己变异出来的(M47),是 M45 的下一层。M45 修的是"样本**少了**要红",
但**名字对不上内容**当时无人管。

## 一、缺陷:标签是散文,内容没人核

实测:把 `['箭头函数', 'const fooSelfTest = () => {};']` 的**内容**换成
`'function fooSelfTest() {}'`(**标签不动**、样本个数不变)
⇒ 默认跑 **0 条 RED**、`red` 仍是 9(与干净树逐字相同)。

★ 后果我实测过,而且它**让报警自己少说话**(不只是少覆盖):
在上面那个撒谎标签的前提下,把 `const` 那条正则删掉(⇒ **箭头写法真的认不出来了**),
报警只点名 `const = async (…`,**"箭头函数"一个字都不提**(`grep -c` = **0**)。

⇒ 覆盖表**声称**覆盖了箭头函数,而报警里那一格**消失了**。
**该说话的地方没说话,而账面上看不出少了什么** —— 我们这一路最怕的那个形状。

★ 与前面几层的关系(**每修一层,缺口换一层**):
· `957ec5e`:发现规则**看不见某种写法**;
· `8691940`:样本表**可以静默缩水**(M45);
· 本次:样本表**可以撒谎**(M47)—— 表上的名字与内容**没有任何关系**。

## 二、修法:给每条样本配一条**见证**(结构,不是散文)

每个样本从 `[名字, 内容]` 变成 `[名字, 内容, 见证]`,见证是"这条样本**凭什么**算那种写法"的
**机械**判据,并判两个方向:
① 内容必须满足自己的见证(⇒ **标签与内容不符 = 红**);
② 见证必须**能区分**:至少要能否掉另一条样本(否则 `/./` 这种空见证也算"覆盖")。
⇒ 把"这条样本是哪一种写法"从**散文**挪进**结构**。

## 三、变异验证(都已还原)

| 变异 | 结果 |
|---|---|
| **M47** 箭头样本内容换成 function 声明(标签不动) | **红**:`这些样本标签与内容不符:箭头函数 ⇒ 覆盖表在说谎,而报警会因此少说话` |
| **M48** 见证退化成 `/./`(对谁都成立) | **红**:`这些见证区分不了任何东西:箭头函数 ⇒ 见证表自己退化成"永远通过"` |
| 复合:**M47 + 箭头支持真坏** | 两条红都出:先抓撒谎,再点名 `const = async (…`(⇒ 撒谎不再能掩盖覆盖) |
| 回归 **M45** 删样本 | 仍 **1 条报警** |
| 回归 **M43** 退化正则 | 仍 **1 条认不出** |

## 四、验证与状态

· 五个自检单独跑仍全 rc=0;默认跑 `checks=459 pass=455 fail=4 skip=0 red=9 … verdict=red`、
  `mutants=48 ran=47 skipped=1 on_new_criteria=35 diag=none baseline=7/7✓`;
  自检段现为 `…发现规则在 5 种写法上都有对照(要求 5 种,缺一即红;每条样本都带能区分它自己的见证)`。
· 残留全清;`git status` 仅本文件;`HEAD=8691940`。
· pi 的 `cc8beb7`(它插件那例)我已复核;它目录一行未动(那两处实验都在 `/tmp` 做)。
2026-09-18 07:29:32 +08:00

2649 lines
167 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 { createHash } from 'node:crypto';
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;
}
/** 探针结论 → 是否等于"前提成立(到期)" */
/*
* ★★ **自检函数的发现规则** —— 唯一实现,供 `SELFTESTS` 接线防线与它自己的自检共用。
*
* ★ 为什么要有这个函数(pi 2026-09-18 实测报的覆盖缺口):
* 第一版直接内联了一条正则 `^function (\w*SelfTest)\(\)`,**只认一种写法**。
* 实测四种:
* · `function fooSelfTest() {}` ⇒ ✓ 认出
* · `const fooSelfTest = () => {}` ⇒ ✗ 漏
* · `async function fooSelfTest() {}` ⇒ ✗ 漏
* · `function fooSelfTest (a) {}`(名后有空格) ⇒ ✗ 漏
* 端到端后果(我复现):插一个**箭头形式**的未接线自检,其 body 若跑会打 RED ⇒
* · body 的 RED 出现 **0 次**(没跑)
* · 报警 **0 条**(也没说"没接线")
* ⇒ **既不跑、也不报警 —— 静默。** 而这正是这条防线**自己存在的理由**
* ("防写了自检却没接线"):它对**一种写法**有效,对另一种沉默。
*
* ⇒ 放宽成"名字以 `SelfTest` 结尾的函数式声明",覆盖几种常见写法。
* ★ 但它**自己**也会有看不见的写法 ⇒ 下面配一条**反面对照自检**:
* 拿这些写法做合成样本,要求**每一种都被认出**(否则下次放宽又会漏)。
* ⇒ 所以匹配规则**只有这一份**,样本与生产扫描调的是同一个函数
* (本仓反复消的"同一个事实多份实现")。
*/
function declaredSelfTests(code) {
const names = new Set();
// `function 名(` / `async function 名(`(名字与括号之间允许空格)
for (const m of code.matchAll(/^[ \t]*(?:async\s+)?function\s+(\w*SelfTest)\s*\(/gm)) names.add(m[1]);
// `const 名 = (` / `const 名 = async (` / `const 名 = () =>`
for (const m of code.matchAll(/^[ \t]*const\s+(\w*SelfTest)\s*=\s*(?:async\s*)?\(?/gm)) names.add(m[1]);
return [...names].sort();
}
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`
* 也没人会发现,而那正是"闸门锈死"的写法。
*/
function probeSelfTest() {
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++;
}
return bad;
}
/* ★ 单独跑(CLI)时行为不变:跑完这一个就退出。 */
if (process.argv.includes('--probe-selftest')) process.exit(probeSelfTest() ? 1 : 0);
/*
* ★★ **上游退出码的唯一权威**:`diag` 码 → `summary.py` 真跑出来的退出码。
*
* pi 2026-09-18 报的那条:`--verdict-selftest` 第 ⑤ 条**注释**说
* "把 `summary.py` 的退出码分类与 `run-all` 的表**绑在一起**验(**不在一边写死另一边的值**)",
* 而它的**实现**只遍历 `DIAG` 表 + 跳过三个**硬编码**的码名:
* `if (code === 'none' || code === 'baseline-absent' || code === 'baseline-stale') continue;`
* —— **一个字节都没读 `summary.py`**,它断言的其实是"除了我手列的这三个,其它码都必须红"。
* 即:**注释说"不写死",实现恰恰写死了一份码名单。**
*
* 两个方向都实测过(我按 pi 的构造各做了一次),但**两件事的性质不同**:
* · **假红**:加一个**上游退 0 的提示码**(与 `baseline-stale` 同类、完全合法)
* ⇒ 报 `上游退非零,下游却不红 ⇒ 两侧口径漂移`。**那个码的上游根本不存在**,
* `summary.py` 永远不会打出它 —— ⑤ 在这里**编造了一个上游事实**并据此判红。
* ⇒ 这是"**判据答错了**":它会**误导人去找一个不存在的问题**。
* · **假绿**:加一个 `blocksGreen: true` 的新码 ⇒ **rc=0,一条 RED 都没有**。
* ⚠️ 但**这一条不能说成"它也编了 / 它答错了"** —— pi 2026-09-18 更正了它自己
* 上一封的标注,我照实改:那个方向 ⑤ **一片沉默**(实测:输出里提到该码 **0 次**)。
* ⇒ 准确说法是"**判据没答**",不是"判据答错了"。区别要紧:
* 编造会让人去找不存在的问题;**沉默会让人以为它答过了**。
* ★ 我原先在这里写的是"同一句'上游退非零'也是编的" ——
* **那是把沉默说成了编造,我夸大了证据**(与 pi 犯的是同一个错,两人都错在这一条上)。
*
*
* ⇒ 修法(照 pi 的建议):把 `(diag, rc)` 抽成**唯一权威**本表,然后
* ① `--exitcode-selftest` **真跑** `summary.py`,逐条断言 `rc === UPSTREAM_RC[diag]`
* ⇒ 本表的**每一个值都被真实脚本验过**(不是我在表里写的希望值);
* ② `--verdict-selftest` 第 ⑤ 条拿本表与 `DIAG.blocksGreen` **双向**比对
* ⇒ 两个方向都真绑上,**白名单消失**(数据说话)。
* 链条:**真脚本 → UPSTREAM_RC → DIAG.blocksGreen**,每一跳都有自检守着。
*
* ★ 加一个新码 ⇒ 必须在 `UPSTREAM_RC` 里登记"它退几",
* 否则 ⑤ 报"没登记上游"(与 `blocksGreen` 那条"缺字段即红"是同一句纪律的另一半)。
*/
const UPSTREAM_RC = {
'none': 0,
'counts-unusable': 2,
'manifest-mismatch': 1,
'baseline-residue': 1,
'baseline-stale': 0,
'baseline-unrunnable': 2,
'baseline-unknown': 2,
'baseline-absent': 0,
'baseline-deleted': 1,
};
/*
* 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`,不是重写的替身。
*/
function exitcodeSelfTest() {
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;
})();
/*
* ★★ 临时目录必须是**一个真仓库的骨架**(我自己撞出来的坑)。
*
* `summary.py` 用 `REPO = HERE/../../../..` 定位底本比对的工作目录 ——
* 把脚本放在 `/tmp/xxx/` 时 `REPO` 解析成 **`/`**(实测),于是:
* · `sha256sum -c` 在 `/` 下找不到 `client/...` 那些文件 ⇒ 全部 FAILED;
* · `git` 在 `/` 里答不了 `rev-parse`(原来还被 `returncode != 0` 读成「有差异」)。
* ⇒ **`baseline=` 那一格在原来的四例里从未被真正走到**:它们期望 rc=0/1 却看着"通过",
* 只是因为旧代码把"判不了"当成了"有残留"**且 residue 当时退 0** ——
* 两个错误互相抵消。这与我这几轮反复消的形状同一个:**判据在,但走不到。**
*
* ⇒ 现在按脚本真实位置**嵌套**建目录,让 `REPO` 落在一个**迷你真仓库**的根上:
* d/client/electron/test/mutants/summary.py ← `__file__`
* d/client/harmony/.../Target.ts ← 底本比对的目标(可提交、可改脏)
* d/.git ← 让 `rev-parse` 答得了
* 这样 `baseline=` 的**四态**(对 / 过期 / 残留 / 判不了)**每一条前提都能构造**。
*
* `mode` 取值:
* null / 'blind' / 'unreadable' / 'no-sha256sum' —— 底本**正确**(rc 由清单/权限决定)
* 'stale' 底本哈希错、文件与 HEAD 逐字节相同 ⇒ `baseline-stale`,rc=0
* 'residue' 底本哈希错、文件**已改脏** ⇒ `baseline-residue`,rc=1
* 'nogit' 底本哈希错、**没有 git** ⇒ `baseline-unknown`,rc=2
* 'nobaseline' 不写 baseline.sha ⇒ `baseline-absent`,rc=0
* 'deleted' **写了并提交** baseline.sha,**然后删掉它**(受跟踪却被删)
* ⇒ `baseline-deleted`,rc=1 —— 这条就是 pi 2026-09-18 报的那个洞:
* 残留检测被一个 `rm` 关掉,而残留其实还在。
*/
const TARGET = 'client/harmony/entry/src/main/ets/model/Target.ts';
const runIn = (manifest, files, mode) => {
const root = mkdtempSync(join(tmpdir(), 'exitcode-'));
const d = join(root, 'client', 'electron', 'test', 'mutants'); // `REPO` 会解析成 `root`
let bareDir = null; // `finally` 里要清理,所以声明在 try 之外
/*
* ★ 清理必须走 `try/finally`(我自己踩过):原来清理是**顺序执行**的最后两步,
* `runIn` 中途抛错(我改这段时真抛过 `ReferenceError`)就**跳过清理** ——
* 实测发现 `/tmp` 里留下了 `exitcode-*` 目录。自检**不该在失败时留垃圾**,
* 因为失败恰恰是最常被重复跑的时候。
*/
try {
mkdirSync(join(d, 'jobs'), { recursive: true });
cpSync(src, join(d, 'summary.py')); // 每次都复制**真脚本**
cpSync(join(HERE, 'mutants', 'test-keys.json'), join(d, 'test-keys.json'));
for (const f of files) cpSync(realJob, join(d, 'jobs', f));
writeFileSync(join(d, 'jobs.manifest.json'), JSON.stringify(manifest, null, 2));
// 底本比对的目标文件(在 root 下,即 REPO 里)
const targetAbs = join(root, TARGET);
mkdirSync(dirname(targetAbs), { recursive: true });
const content = 'export const target = 1;\n';
writeFileSync(targetAbs, content);
if (mode !== 'nobaseline') {
const realHash = createHash('sha256').update(content).digest('hex');
const good = !['stale', 'residue', 'nogit'].includes(mode);
writeFileSync(join(d, 'baseline.sha'),
`${good ? realHash : '0'.repeat(64)} ${TARGET}\n`);
}
/*
* 迷你仓库:`stale`/`residue` 靠 `git diff HEAD` 区分,所以必须**真有一个 HEAD**。
* · stale:文件提交后**不动** ⇒ 与 HEAD 逐字节相同
* · residue:提交后**改脏** ⇒ 与 HEAD 不同
* `nogit` 故意**不建**仓库 ⇒ 验"git 答不了时不许声称 residue"。
*/
if (mode !== 'nogit') {
const g = (...a) => spawnSync('git', ['-C', root, ...a], { encoding: 'utf8' });
g('init', '-q', '.');
g('add', '-A');
g('-c', 'user.email=t@t', '-c', 'user.name=t', 'commit', '-qm', 'init');
if (mode === 'residue') writeFileSync(targetAbs, `${content}// dirty\n`);
/*
* ★ `deleted`:**提交之后**才删 —— 于是它"受跟踪却不在工作树里"(` D`)。
* 顺序要紧:先 `git add -A` + commit 把它纳入跟踪,再 unlink。
* 同时把目标文件改脏 ⇒ **残留是真的存在的**,验的正是
* "残留还在、而报告它的机制被关掉了"这个形状。
*/
if (mode === 'deleted') {
writeFileSync(targetAbs, `${content}// dirty\n`);
rmSync(join(d, 'baseline.sha'));
}
}
if (mode === 'blind' || mode === 'unreadable') {
// 整棵给 nobody 可达(mkdtemp 默认 700),最后一层按场景收紧
spawnSync('chmod', ['755', root]);
for (const p of [join(root, 'client'), join(root, 'client', 'electron')]) {
spawnSync('chmod', ['755', p]);
}
for (const p of [join(d, '..'), join(d, '..', '..')]) spawnSync('chmod', ['755', p]);
spawnSync('chmod', ['755', d]);
for (const f of ['summary.py', 'test-keys.json', 'jobs.manifest.json']) {
spawnSync('chmod', ['644', join(d, f)]);
}
spawnSync('chmod', ['755', join(d, 'jobs')]);
for (const f of files) spawnSync('chmod', ['644', join(d, 'jobs', f)]);
if (mode === 'blind') {
spawnSync('chmod', ['000', join(d, 'jobs')]);
} else {
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 (mode === '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 && (mode === 'blind' || mode === 'unreadable')
? 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', root]); // 先恢复权限再删(别依赖 root 一定能删)
try { rmSync(root, { recursive: true, force: true }); } catch { /* 删不掉也别盖住真因 */ }
}
};
const cases = [
// 清单与磁盘一致 ⇒ 0
['一致 ⇒ 0(底本对)', ['jobs-one.json'], ['jobs-one.json'], 0, null, null],
// 磁盘上多一个、清单里没有(**unlisted**)⇒ 1(清单该改,**不是**环境)
['未列入清单 ⇒ 1', ['jobs-one.json'], ['jobs-one.json', 'jobs-extra.json'], 1, '未列入清单', null],
// 清单里有、磁盘上没有(**ghosts**)⇒ 1
['清单有磁盘无 ⇒ 1', ['jobs-one.json', 'jobs-gone.json'], ['jobs-one.json'], 1, '磁盘上没有', null],
// `_` 开头的说明条目不算 ghosts ⇒ 0(否则这份清单永远红)
['说明条目不算 ghosts ⇒ 0', ['_note', 'jobs-one.json'], ['jobs-one.json'], 0, null, null],
// ★★ 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` 行;③ 那一格必须说"**跑不了**",不许说成"底本对"或"有残留"。
* —— 正是"**没读数** ≠ **读数正常**",同一条纪律第四次。
*/
/*
* ★★ pi 2026-09-18 本封的 ②:`bl_unrunnable` 原来退 **0**,与"没读数不是成功 ⇒ 2"
* 的原则**自相矛盾**(那一格同样是没读数)。现在退 **2**,且 `diag=baseline-unrunnable`
* 会走 diag 通道说明"**只有 baseline 那一格**没读数"(不是"数字不可信")。
* 期望里两样都判:rc=2 **且** 输出里有那个诊断码。
*/
['PATH 里没有 sha256sum ⇒ rc=2 且 diag=baseline-unrunnable',
['jobs-one.json'], ['jobs-one.json'], 2, 'diag=baseline-unrunnable', 'no-sha256sum'],
/*
* ★★ pi 2026-09-18 本封的 ①:**真·变异残留**必须**红**(原来 rc=0、套件里 grep 0 次)。
* 这里构造"既不在底本、也与 HEAD 不同":把 `baseline.sha` 里塞一条
* **本仓真实存在、且工作树已改过**的文件 —— 用 `docs/` 下一个被临时追加内容的文件,
* 配合一条错的哈希 ⇒ `sha256sum -c` 报 FAILED,而 `git diff HEAD` 非空 ⇒ residue。
* (stale 与 residue 的区别正在 `git diff HEAD`,所以这条必须真改一个跟踪文件;
* `runIn` 用 `mutate` 参数支持它,见下。)
*/
['真·变异残留 ⇒ rc=1 且 diag=baseline-residue',
['jobs-one.json'], ['jobs-one.json'], 1, 'diag=baseline-residue', 'residue'],
/*
* ★★ `baseline=` 那一格的**四态**(pi 2026-09-18 本封的 ① 的另一半)。
* 原来这条路**一例都没有**,而"四态"里有两态(过期 / 判不了)**当时根本走不到** ——
* 因为旧 `runIn` 的临时目录让 `REPO=/`,`baseline=` 从未被真正读成"对"。
* ⇒ 现在每态一条,**前提都构造得出来**(照 pi 那句"每条分支的前提也要能构造")。
*/
['底本过期(与 HEAD 相同)⇒ rc=0 且 diag=baseline-stale',
['jobs-one.json'], ['jobs-one.json'], 0, 'diag=baseline-stale', 'stale'],
['git 答不了 ⇒ rc=2 且 diag=baseline-unknown(**不许**说成 residue)',
['jobs-one.json'], ['jobs-one.json'], 2, 'diag=baseline-unknown', 'nogit'],
['没有 baseline.sha ⇒ rc=0 且 diag=baseline-absent',
['jobs-one.json'], ['jobs-one.json'], 0, 'diag=baseline-absent', 'nobaseline'],
/*
* ★★ pi 2026-09-18 报的洞,**这条是它的端到端锚点**:
* `baseline.sha` 受版本控制、被提交后又删掉 ⇒ 以前退 0、`note: null`、**静默**,
* 而残留其实还在(目标文件同时被改脏)⇒ **检测被一个 `rm` 关掉了**。
* 现在:`baseline-deleted`、**退 1**("该改的是仓库/数据",与 `manifest-mismatch` 同类)。
* —— 与 `nobaseline`(本来就没跟踪它 ⇒ 真·配置缺席 ⇒ 0)**分家**。
*/
['底本受跟踪却被删 ⇒ rc=1 且 diag=baseline-deleted',
['jobs-one.json'], ['jobs-one.json'], 1, 'diag=baseline-deleted', 'deleted'],
];
let bad = 0;
const seenDiags = []; // 反向覆盖用:本组案例**真跑出来**的码(不是声明的)
// 前提构造不出来 ⇒ 明说 + 计红(不许静默跳过)
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);
/*
* ★★ 顺带把 `UPSTREAM_RC` **逐个真跑验一遍**(pi 2026-09-18 本封)。
*
* 这一跳是整条链的地基:`UPSTREAM_RC` 名义上是"上游退出码的唯一权威",
* 但若它只是我**手抄**的一张表,那 ⑤ 拿它去比 `blocksGreen` 仍然可能两边一起错
* (而且会**自恰**地绿 —— 本仓消过很多次的那种)。
* ⇒ 每条案例若声明了 `diag=<码>`,就用**真脚本真跑出来的 rc** 去核对那张表。
* 于是表里**每一个值都是被真脚本验过的**,不是我希望它等于几。
* (案例没带 `diag=` 的,只判上面的 rc/输出 —— 它们守的是别的性质,如 ghosts/unlisted。)
*/
/*
* ⚠️ 第一版我按**声明**的 `wantOut` 去找码 —— 于是 `wantOut` 是 `null` 的那四条
* (一致 / 说明条目 / 未列入清单 / 清单有磁盘无)**一个码都没验到**,
* 反向覆盖当场报出 `none`、`counts-unusable`、`manifest-mismatch` 三个码"没人验过"。
* 而这三个码真跑出来是 0 / 2 / 1(我单独量过)—— 表里写的是对的,**是验法漏了**。
* ⇒ 改成从**脚本真打出来的 stdout** 里取码:这样"验的是它真说的",而不是"我以为它会说的",
* 且对 `wantOut=null` 的案例同样有效。
*/
let rcCheck = '';
const seenDiag = (got.out.match(/diag=([\w-]+)/) || [])[1];
if (seenDiag) seenDiags.push(seenDiag);
if (seenDiag) {
if (!(seenDiag in UPSTREAM_RC)) {
rcCheck = `;**新码 \`${seenDiag}\` 没在 UPSTREAM_RC 里登记上游退出码**`;
bad++;
} else if (UPSTREAM_RC[seenDiag] !== got.status) {
rcCheck = `;**UPSTREAM_RC[${seenDiag}]=${UPSTREAM_RC[seenDiag]} 与真跑出来的 rc=${got.status} 不符**`;
bad++;
} else {
rcCheck = `(真打出 diag=${seenDiag},UPSTREAM_RC=${got.status} ✓)`;
}
} else if (!/Traceback/.test(got.err)) {
// 打得出来却没有 `diag=` ⇒ 契约被破坏(诊断码是判据的一部分)
rcCheck = ';**脚本打出了 RESULT 却没带 `diag=`**(诊断码是判据的一部分,不许漏)';
bad++;
}
const ok = okStatus && okOut && !rcCheck.includes('**');
console.log(`${ok ? 'ok ' : 'RED '} ${what}:rc=${got.status}` +
`${ok ? rcCheck : `(期望 rc=${want}${wantOut ? ` 且输出含 "${wantOut}"` : ''}${rcCheck})`}`);
if (!ok) bad++;
}
/*
* ★ 反向覆盖:`UPSTREAM_RC` 里**每个码**都得有案例真跑过 ——
* 只登记不验,就等于表里有个"我希望它是几"的格子(正是上面那段要防的)。
*/
{
const covered = new Set(seenDiags);
const uncovered = Object.keys(UPSTREAM_RC).filter(c => !covered.has(c));
if (uncovered.length) {
console.log(`RED UPSTREAM_RC 里这些码**没有案例真跑过** ⇒ 表里的值是没人验过的:${uncovered.join('、')}`);
bad++;
} else {
console.log(`ok UPSTREAM_RC 的 ${Object.keys(UPSTREAM_RC).length} 个码都有案例真跑验过`);
}
}
return bad;
}
/* ★ 单独跑(CLI)时行为不变:跑完这一个就退出。 */
if (process.argv.includes('--exitcode-selftest')) process.exit(exitcodeSelfTest() ? 1 : 0);
/*
* 跳过解析自检(`--skip-selftest`):与上面那些同形状 —— 判的是**解析器的分辨力**。
*
* 为什么需要:闸 (i) 的"具名跳过"完全依赖从 node:test 的 TAP 输出里取回
* 名字与原因。那个正则哪天写坏了(`# SKIP` 大小写、` - ` 分隔、原因前缀的破折号),
* **表现是"跳过的判据"那一行不打印** —— 而"没打印"和"没有跳过"长得一模一样。
* ⇒ 拿合成 TAP 行把三种情形钉住:带原因的、不带原因的、以及**不该被当成跳过的**普通通过行。
*/
function skipSelfTest() {
// ★ 调**生产那一个**函数,不抄副本 —— 抄副本的话自检验的是副本(见 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++;
}
return bad;
}
/* ★ 单独跑(CLI)时行为不变:跑完这一个就退出。 */
if (process.argv.includes('--skip-selftest')) process.exit(skipSelfTest() ? 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-18 报的,我独立复现)。
*
* 缺陷:这五个自检(`probe`/`exitcode`/`skip`/`verdict`/`mutants-line`)原来**只**由
* `process.argv.includes('--…')` 守着,而**全仓没有任何调用方传那个 flag** ——
* `npm test` = `node test/run-all.mjs && vitest run && npm run typecheck`,**一个 flag 都不带**。
* 实测(读数与 pi 逐字相同):
* · 默认跑输出里这些自检的行数 = **0**;
* · 把 `UPSTREAM_RC['baseline-residue']` 改坏(表与真脚本不符)后**默认跑**
* `checks=459 pass=455 fail=4 skip=0 red=9 …` —— 与干净树**逐字相同**;
* 带 `--exitcode-selftest` 才 `rc=1`。
* ⇒ **"跑得动的锚点"确实存在,但它不在任何一条会被人跑的路径上。**
*
* ★ 与这几轮那些形状同族,但**伪装得最好**:guard 看起来就像在跑、注释还声称
* "每次跑都钉住"、`grep` 也能找到那一大段代码 —— 三者都不是"在路径上"的证据。
* 前几轮是"结论到不了读者 / 到不了 `verdict` / 被关掉";
* **这次是"自检本身到不了任何入口"** ⇒ 即 **"判据存在 vs 判据在路径上"**。
*
* ★ 为什么用**进程内调用**,而不是"子进程调自己带 flag"(pi 建议 ① 的原形):
* 我实测过代价 —— **每个 flag 都会先把整个套件跑完**(guard 在 SUITE 循环之后),
* 所以默认 ~4.3s、带任意 flag ~4.3–4.9s ⇒ **5 个子进程 ≈ 22s**。
* pi 说"多几次进程启动(可忽略)"**不准确**,这条我按实测改了做法。
* 而自检 body 本身合计只 ~950ms ⇒ 把 guard 改成 `function …SelfTest()` + **直接调**才对。
* (上面那五块已改成函数;**单独跑时行为完全不变** —— 每个函数后面紧跟一个 CLI 守卫。)
*
* ★ 红要并入 `reds`(与 `applyMutants` 同一条路径)⇒ 它进 `verdict`,
* 而不是"只打印、不改颜色"(那正是 `diag` 那一轮修过的病)。
* ⚠️ `reds` 的语义是"某个判据文件红了",这几条没有文件记录可挂,
* 所以措辞里写明**是哪一层**红的,免得读者去找一个不存在的文件。
*/
/*
* 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` 把它钉住。
*
* ⚠️⚠️ **这句原来写的是"每次跑都钉住",而它与实现相反**(pi 2026-09-18 报的):
* 那时这个自检只由 `process.argv.includes('--mutants-line-selftest')` 守着,
* 而**全仓没有任何入口传那个 flag** ⇒ 它**一次都没在默认路径上跑过**。
* "每次跑都钉住"当时是**假的**。现在才成立:五个自检已被接进默认路径
* (见 `SELFTESTS` 那段),并且有一条结构防线要求"写了自检就必须接线"。
*
* 决策顺序要紧:**先看退出码,再看正则**。
* 反过来的话,盲读/部分可读时 `summary.py` 照样打得出一行**看着正常**的
* `RESULT mutants=<数字>`,正则一匹配就走真分支,退出码**从没被读**。
*/
/*
* 诊断码 → 套件里那句话。**码是判据的一部分,不是文案。**
*
* ★ 为什么要有这张表(pi 2026-09-18 本封的 ①):原来这一格的说明只靠**正则捕获组**,
* 而捕获组是**前缀**(`(RESULT mutants=… on_new_criteria=\d+)`)⇒ `baseline=…` 那一段
* **从一开始就不在 `m[1]` 里**(实测 `'baseline' in m[1]` = False),
* 而 `whyLines` 的过滤器**又不含** `baseline=` ⇒ 两条路都不通。
* 最重的一例:**真·变异残留**时
* `baseline=6/7✗**1 个文件既不在底本、也与 HEAD 不同 ⇒ 优先按"变异残留"查**`
* 在套件输出里 grep **0 次**(我端到端复现过),而 `rc=0`、
* `mutants=48 ran=47 skipped=1` **看着完全正常**。
*
* ★ 为什么用**码**而不是往过滤器里加关键词:加关键词是"按字面量裁射程",
* 本仓已经栽过两次(`criteria-hygiene` 的 import 硬匹配、`prose()` 判"用没用")。
* 码表**闭合**:不认识的码**报警**(见下),于是"上游加了新状态而下游不知道"
* 会**立刻显形**,不会静静变成"没问题"。
*
* ★ 另一件要紧事:**status 不能单独决定措辞**。`baseline-unrunnable` 现在退 2,
* 若照 `status === 2` 那句通用话播报,会说"上面的 mutants 数字**不可信**" ——
* 那是**假话**:数字照常有效,**只有 baseline 那一格没读数**。
* ⇒ 措辞一律由 `diag` 决定,`status` 只作兜底(且**先判 `diag`**)。
*/
/*
* ★★ 诊断码表:**一个码一行,两个字段一次定完**。
*
* `blocksGreen` 是本轮(pi 2026-09-18)补的那一半:**"该红的红"必须到达 `verdict`**。
* 上一轮我只做到"到达读者" —— `diag` 与 `baseline=` 都印出来了,但 `sp.status`
* 只喂了 `mutantsLine`(一行文本)和 `whyLines`(几行打印),
* 而 `reds`/`brokens`/`dueFailed`/`selfCheckFailed` **没有一处**被它喂过
* ⇒ `summary.py` 退 1 或 2 **只改措辞、不改颜色**。pi 的端到端对照:残留态与正常态
* `RESULT` 尾部**逐字相同**(都是 `red=9 … verdict=red`);把别的红清掉之后就会出现
* `verdict=green` 与 `diag=baseline-residue`("优先按变异残留查")**并排**。
*
* ★ 为什么把"提示"与"要不要红"放进**同一张表**:分开写就是"同一事实两份实现",
* 而本仓那族缺陷(`blurStyleFor`、`stripComments`、`want.why`、`.tmp` 台账路径)
* 已经反复消过。放一起的结构性好处:**加一个新码就必须同时决定它红不红** ——
* 少填一个字段,下面那条"每个码都得有 blocksGreen"的自检会立刻响。
*
* ★ 三个口径(与 `summary.py` 的退出码分类一一对应,别让它们漂移):
* · **该红的**(blocksGreen=true):`manifest-mismatch`(1)、`baseline-residue`(1)、
* `counts-unusable`(2)、`baseline-unrunnable`(2)、`baseline-unknown`(2)。
* —— 退非零的**全部**在这里,即"上游说我不该被当成一切正常"**真的有票**。
* · **不该红的**:`baseline-stale`(0)、`baseline-absent`(0)、`none`(0)。
* stale 是"底本没重算"而非缺陷(pi 与我 §七 同意:退非零会让正常提交天天假红),
* 它靠 `diag` 通道被**看见**但不改颜色。
* · **不认识的码**:红(报警),见下面那条。
*/
const DIAG = {
'none': {
checked: true,
note: null, // 一切正常 ⇒ 不需要说话(这是"检查了、结论无害"的极限情形)
blocksGreen: false,
},
'counts-unusable': {
checked: false,
note: '**环境:没读数** —— 上面的 mutants 数字**不可信**(读不到 jobs/ 目录)',
blocksGreen: true,
},
'manifest-mismatch': {
checked: true,
note: '**注意:清单与磁盘不一致** —— 上面的数字**不代表磁盘上现在有多少个变异体**',
blocksGreen: true,
},
'baseline-unrunnable': {
checked: false,
note: '**环境:baseline 那一格没读数**(跑不了 `sha256sum`)—— '
+ 'mutants 数字**仍然有效**,但"底本对不对"**没答案**',
blocksGreen: true,
},
'baseline-residue': {
checked: true,
note: '**注意:有文件既不在底本、也与 HEAD 不同 ⇒ 优先按"变异残留"查**'
+ '(数字按清单算是有效的,但**可能有变异没还原**)',
blocksGreen: true,
},
'baseline-stale': {
checked: true,
note: '底本过期(文件与 HEAD 逐字节相同 ⇒ 是正常提交改过,不是残留)',
blocksGreen: false, // 不是缺陷;退非零会让"正常提交"天天假红(pi 与我 §七 同意)
},
'baseline-unknown': {
checked: false,
note: '**环境:判不了 baseline 那一格**(git 答不了 ⇒ 判不出是残留还是过期)—— '
+ '这一格**没读数**,**不是**"有残留"',
blocksGreen: true,
},
// 没装 baseline.sha 是可接受的配置,不必刷屏 —— 但**仍算"知道这个状态"**(与"不认识的码"分开)
/*
* ★★ `baseline-deleted`(pi 2026-09-18 报的洞,我复现并修):` baseline.sha` **受跟踪却被删**。
*
* 它与 `baseline-absent` 的分家是本轮的要害 —— 实测(同一处残留,只切 `baseline.sha` 在不在):
* 在 ⇒ `diag=baseline-residue`、`red=10`、红清单里**有**那条 residue 红
* 删掉 ⇒ `diag=baseline-absent`、`red=9`、那条红**整条消失**、`rc=0`、静默
* 而 `git status` 里 ` D baseline.sha` 与 ` M <被改的文件>` **同时都在**。
* ⇒ **残留还在,唯一能报告它的机制被一个 `rm` 关掉了**,不需要特权、不会红、也不说话。
*
* ★ 口径(pi 的建议,我采纳):`blocksGreen=false` 的码**应当分两类** ——
* "**检查了、结论无害**"(`baseline-stale`:可以静默)与
* "**根本没检查**"(`baseline-absent`:**必须说话**)。
* 前者仍然逐条比对了、只是结论是"过期";后者**不是"检查了、结论是无害"**。
* 而"受跟踪却被删"更重一档:它连"配置"都不是,是**缺文件**(该恢复它)⇒ 红。
*/
'baseline-deleted': {
checked: true,
note: '底本 `baseline.sha` **受版本控制却被删** ⇒ **残留检测已失效**(不是"没有残留")',
blocksGreen: true,
},
'baseline-absent': {
checked: false,
note: '没有底本 ⇒ **这一格没检查**(残留检测关着);不是"检查了、结论无害"',
blocksGreen: false,
},
};
function summarizeMutants(stdout, status, stderr) {
const m = /(RESULT mutants=\d+ ran=\d+ skipped=\d+ on_new_criteria=\d+)(?: diag=([\w-]+))?/
.exec(stdout || '');
const diag = m && m[2] ? m[2] : null;
/*
* "为什么这个数不是全集 / 为什么没读数"只有 stdout 知道,而读者只看得到套件的输出 ⇒ 转印。
*
* ★ 转印条件**不再只看 status**(pi 说"放宽能治到达、治不了不红")——
* 两头都修:**该红的已经在 `summary.py` 里红了**(residue⇒1、unrunnable⇒2),
* 而**不该红但必须看得见**的那一类(`baseline-stale`,退 0)就靠这里转印。
* ⇒ 条件是 `diag` 非 none/absent。
*
* ★ **排除 `RESULT` 行本身**:`baseline=` 那一段就长在 `RESULT` 行里,若不排除,
* 它会被当成"原因行"再转印一遍(同一句话印两次)。而 `RESULT` 行的内容
* 由 `diag` + `DIAG` 的说明负责传达 —— 两条通道**各管一段**,不重叠。
*/
const whyLines = (stdout || '').split('\n')
.filter(l => !/^RESULT /.test(l.trim()))
.filter(l => /✗✗|⚠️|★ 清单与磁盘不一致|在但读不到|未列入清单|清单里有、磁盘上没有/.test(l))
.map(l => l.trim());
/*
* 上游加了新码而这里不认识 ⇒ **报警,并且红**(不是静静当成"没问题")。
* ★ 上一轮这里只改了**措辞**(报警文案)却仍然 `blocksGreen` 无从谈起 ⇒
* 一个不认识的码会打出"请同步 DIAG",而 `verdict` 照样可以绿。**同一条缝的又一跳。**
*/
if (diag && !(diag in DIAG)) {
return {
line: ` mutants=(**套件不认识诊断码 \`${diag}\`** —— summary.py 加了新状态,`
+ '请同步 run-all.mjs 的 DIAG,不要让它静静变成"没问题")',
whyLines,
red: `(summary.py)不认识的诊断码 \`${diag}\` —— 上游加了状态而套件不认识它,`
+ '这一格**没读数**(不是"没问题")',
};
}
const entry = diag ? DIAG[diag] : null;
const note = entry ? entry.note : null;
/*
* ★ 把 `baseline=` 的**读数本身**也带上(pi 2026-09-18 上一封的 ① 的另一半)。
*
* 原来 `baseline=…` 只活在 `summary.py` 的 stdout 里:捕获组是前缀、过滤器又不含它
* ⇒ 套件输出里 `grep -c baseline=` = **0**(我三态各测过)。诊断码解决了"**结论**到不了",
* 但"**读数到不了**"是另一件事:读者看不到 `6/7` 就复核不了"为什么它是 residue"。
* ⇒ 这里把 `baseline=<值>` 摘出来,与 `diag=` 并列放在同一格。
*
* 取值用**字符类排除**(`(`/`*`/`⚠` 都是 `baseline=` 后面那句话的一部分),
* 只留数字型的读数(`7/7✓`、`6/7`);"跑不了"那种没有数字 ⇒ 空,就不重复说了
* (`DIAG` 的说明已经讲过)。**不扩捕获组**是有意的:扩了就得连整句一起搬,
* 那才是"按字面量裁射程"。
*/
const blv = /baseline=([^\s(*⚠]*)/.exec(stdout || '');
const blPart = blv && blv[1] ? ` baseline=${blv[1]}` : '';
/*
* ★★ `red`:**这一格要不要把套件判红**(pi 2026-09-18 本封的要害)。
* 判据来自 `DIAG` 的 `blocksGreen`,**不在这里另写一套**(同一事实一份实现)。
* 没打出 RESULT(状态非 0 且没有码)⇒ 也算红:那是"**读数器根本没跑出来**",
* 与 `brokens` 的语义同族,但它没有文件记录可挂,所以走 `reds`(运行器自己推的红)。
*/
let red = null;
if (m) {
if (entry && entry.blocksGreen) {
red = `(summary.py)${diag} —— ${entry.note}`;
} else if (!diag && status !== 0) {
// 有 RESULT 行但没诊断码、退出码却非 0 ⇒ 契约被破坏(新码没带 `diag=`),也要红
red = `(summary.py)退出码 ${status} 但那一格没带 \`diag=\`(诊断码是判据的一部分,`
+ '不许漏)—— 这一格**没读数**';
}
} else {
red = `(summary.py)没打出 RESULT(status=${status})—— 变异体这一族**根本没被读数**`;
}
if (m) {
/*
* ⚠️ `m[1]` 是**前缀**(不含 `diag=`/`baseline=`)—— 这正是原来那一格"到不了"的成因。
* 所以现在**显式**把 `diag=` 与 `baseline=` 拼回去,让读者看得到读数本身
* (可复核、可 grep)。
*/
const head = `${m[1]}${diag ? ` diag=${diag}` : ''}${blPart}`;
return {
line: ` ${head}` +
(note ? `(${note})` : (status !== 0 ? `(注意:summary.py status=${status})` : '')),
whyLines: (note || status !== 0) ? whyLines : [],
red,
};
}
if (status === 2) {
return {
line: ' mutants=(**环境:没读数**,summary.py status=2 —— 上面的 mutants 数字**不可信**)',
whyLines,
red,
};
}
return {
line: ` mutants=(summary.py 没打出 RESULT:status=${status} ` +
`${(stderr || '').trim().split('\n').slice(-1)[0] || ''})`,
whyLines,
red,
};
}
/*
* ★★ `verdict` 的**判定本身**抽成纯函数(pi 2026-09-18 本封)。
*
* 为什么必须抽出来:pi 说它没有把 §四 做成"清掉全部红后 `verdict` 真变绿"的完整实验,
* 因为那要动 SUITE 登记数、会撞接线自检 —— **代价不值,所以只有静态证据**。
* 抽成纯函数之后,那个实验**不需要动任何登记**:把 `reds` 传成空数组就行。
* ⇒ "结论到不了决定颜色的那一格"这类缺陷,从此**有判据守着**,
* 而不是靠下一轮的人再读一遍源码。
*/
function verdictOf({ reds, brokens, dueFailed, selfCheckFailed }) {
return (reds.length === 0 && brokens.length === 0 && !dueFailed && !selfCheckFailed)
? 'green' : 'red';
}
/*
* ★★ **唯一的接线点**:把 `summary.py` 的读数接进汇总。
*
* 为什么要有这个函数(我自己变异出来的):上一版的自检**自己**做了接线 ——
* 它调 `summarizeMutants` 拿 `red`,然后**自己**写 `const reds = r.red ? [r.red] : []`。
* 于是我把生产那行 `if (r.red) reds.push(r.red);` **整行删掉**,自检**照样全绿**(实测 M24)——
* 因为自检验的是**它自己复刻的那份接线**,不是生产那份。
* 这正是本仓反复消的老形状(自检验副本:`parseSkips` 抄份、`want.why` 现算一遍)。
*
* ⇒ 修法不是"再加一条断言",而是**让接线本身住进被判的那个函数**:
* `applyMutants` 收 `sp` 与 `reds`(**不捕外部变量**),生产与自检**调同一个它**。
* 这样"忘了 push"就落在被测函数体内 ⇒ M24 会被抓住。
* (剩下"生产到底有没有调它"这一跳,由下面 `--verdict-selftest` 的结构锚点守。)
*/
function applyMutants(sp, reds) {
const r = summarizeMutants(sp.stdout, sp.status, sp.stderr);
for (const l of r.whyLines) console.log(` ${l}`);
/*
* ★★ **"该红的红"到达 `verdict`**(pi 2026-09-18 本封)。
*
* 上一轮我只做到"到达读者":`diag`/`baseline=` 都印得出来,但 `sp.status` 只喂了
* `mutantsLine`(一行文本)与 `whyLines`(几行打印)—— `reds`/`brokens`/`dueFailed`/
* `selfCheckFailed` **没有一处**被它喂过 ⇒ 退 1 或 2 **只改措辞、不改颜色**。
* pi 的端到端对照:残留态与正常态的 `RESULT` 尾部**逐字相同**;把别的红清掉之后,
* 会出现 `verdict=green` 与 `diag=baseline-residue`("优先按变异残留查")**并排**。
*
* ⇒ 沿用 pi 建议的那条路径(`reds.push` 本来就是"运行器自己推的红",
* 注释里已写明这类红**进不了 `fail`**,所以两个格都必须打)。
*/
if (r.red) reds.push(r.red);
return r.line;
}
/*
* ★★ 汇总判定自检(`--verdict-selftest`):**pi 2026-09-18 本封要的那个实验**。
*
* pi 说它没有把"清掉全部红之后 `verdict` 会不会真变绿"做成完整实验 ——
* 那要动 SUITE 登记数、会撞接线自检,"代价不值",所以只给了两条静态证据
* (`verdict` 的条件表达式、`sp.status` 只有两个消费者)。
* **它是对的**,而且这正是"判据在,但走不到"的又一例:结论本身没错,但**没有判据守着它**。
*
* ⇒ 把判定抽成纯函数 `verdictOf`(已做),于是那个实验**不需要动任何登记**:
* 把 `reds` 传成空数组即可。这一格就钉住"**`summary.py` 说该红时,`verdict` 真的会红**"。
*
* 判的是**端到端的那一跳**,不是 `DIAG` 表本身的取值:
* ① 先调**真** `summarizeMutants` 拿 `red`(走真正则、真码表);
* ② 再把那个 `red` 放进 `reds` 交给**真** `verdictOf`;
* ③ 要求在"其它红全清掉"的情形下 `verdict !== 'green'`。
* —— 这比"断言 `DIAG[diag].blocksGreen === true`"强:它证明的是**接线真的通了**,
* 而不是"表里写了个 true"(后者在 `r.red` 忘了 push 时照样绿 —— 那正是上一轮的缺陷)。
*/
function verdictSelfTest() {
const clean = { brokens: [], dueFailed: false, selfCheckFailed: false };
const cases = [
/*
* ① 正常 ⇒ 绿(反面对照:证明这条判据**不是永远报红**)
*/
['正常(diag=none)⇒ 清掉别的红之后应为 green',
'RESULT mutants=48 ran=47 skipped=1 on_new_criteria=35 diag=none baseline=7/7✓\n', 0, true],
/*
* ②★ 要害:**残留 ⇒ 即使别的红全清了,也不许绿**
*/
['baseline-residue(status 1)⇒ 别的红全清也**不许绿**',
'RESULT mutants=48 ran=47 skipped=1 on_new_criteria=35 diag=baseline-residue baseline=6/7✗\n',
1, false],
['counts-unusable(status 2)⇒ 不许绿',
'RESULT mutants=NO-READING ran=NO-READING skipped=NO-READING on_new_criteria=NO-READING '
+ 'diag=counts-unusable(**没读数**)\n', 2, false],
['manifest-mismatch(status 1)⇒ 不许绿',
'RESULT mutants=48 ran=47 skipped=1 on_new_criteria=35 diag=manifest-mismatch\n'
+ ' 未列入清单:jobs-x.json\n', 1, false],
['baseline-unrunnable(status 2)⇒ 不许绿',
'RESULT mutants=1 ran=0 skipped=1 on_new_criteria=0 diag=baseline-unrunnable baseline='
+ '(**跑不了 sha256sum 校验**)\n', 2, false],
['baseline-unknown(status 2)⇒ 不许绿',
'RESULT mutants=1 ran=0 skipped=1 on_new_criteria=0 diag=baseline-unknown baseline=0/7\n',
2, false],
/*
* ③ `baseline-stale`(status **0**)⇒ **可以为绿**。
* pi 与我 §七 口径一致:stale 不是缺陷,退非零会让"正常提交"天天假红。
* 这一条同时是②的反面对照:证明**不是"有 diag 就红"**(否则 stale 会被一起误红)。
*/
['baseline-stale(status 0)⇒ **可以**绿(不是缺陷)',
'RESULT mutants=48 ran=47 skipped=1 on_new_criteria=35 diag=baseline-stale baseline=6/7⚠\n',
0, true],
['不认识的诊断码 ⇒ 不许绿(报警)',
'RESULT mutants=1 ran=0 skipped=1 on_new_criteria=0 diag=brand-new-state\n', 0, false],
['没打出 RESULT(status 1)⇒ 不许绿',
'', 1, false],
];
let bad = 0;
for (const [what, stdout, status, mayBeGreen] of cases) {
/*
* ★★ 走**生产那一个** `applyMutants`(不是我在这里复刻一份接线)。
* 我第一版自检自己写 `const reds = r.red ? [r.red] : []` ⇒ 把生产那行
* `if (r.red) reds.push(r.red);` 整行删掉,自检**照样全绿**(实测 M24)——
* 因为它验的是**自己复刻的那份**。现在接线只在 `applyMutants` 里,
* 生产与自检调同一个它 ⇒ "忘了 push"必然被抓。
* (`whyLines` 会打到 stdout,这里吞掉,免得自检输出被几百行原因淹没。)
*/
const reds = [];
const swallow = console.log;
console.log = () => {};
try { applyMutants({ stdout, status, stderr: 'boom' }, reds); } finally { console.log = swallow; }
// ★ 别的红**全清掉** —— 这就是 pi 说"代价不值"的那个实验,现在只要传空数组
const v = verdictOf({ reds, ...clean });
const ok = mayBeGreen ? v === 'green' : v !== 'green';
console.log(`${ok ? 'ok ' : 'RED '} ${what}:verdict=${v}` +
`${ok ? '' : `(期望 ${mayBeGreen ? 'green' : '非 green'};reds=${reds.length})`}`);
if (!ok) bad++;
}
/*
* ④ 结构性对照:**表里每个码都得显式决定"红不红"**。
* 缺字段 ⇒ 立刻红 —— 这是"加码必须同时决定颜色"的机械保证
* (与"不认识的码要报警"互补:那管**下游不认识**,这管**上游新加时漏填**)。
*/
for (const [code, e] of Object.entries(DIAG)) {
if (typeof e.blocksGreen !== 'boolean') {
console.log(`RED 诊断码 \`${code}\` 没显式声明 blocksGreen ⇒ **加码时必须同时决定它红不红**`);
bad++;
}
}
console.log(`ok 码表每个码都有 blocksGreen(${Object.keys(DIAG).length} 个码)`);
/*
* ⑤★ 口径一致性(pi 2026-09-18 本封报的那条)。
*
* ⚠️ **上一版这条是坏的,两个方向都坏**,pi 报了、我复现了:
* 注释写"把 `summary.py` 的退出码分类与 `run-all` 的表**绑在一起**验
* (**不在一边写死另一边的值**)",而实现只遍历 `DIAG` 表 + 跳过三个**硬编码**码名 ——
* **一个字节都没读 `summary.py`**。它其实在断言"**除了我手列的这三个,其它码都必须红**":
* **注释说"不写死",实现恰恰写死了一份码名单。**
*
* 实测两个方向,但**两件事的性质不同**:
* · **假红**:加一个上游退 0 的**提示码**(与 `baseline-stale` 同类、完全合法)
* ⇒ 报"上游退非零,下游却不红 ⇒ 两侧口径漂移"。
* 而那个码的上游**根本不存在**(`summary.py` 永远不会打出它)——
* ⑤ 在这里**编造了一个上游事实**并据此判红 ⇒ 这是"**判据答错了**"。
* · **假绿**:加一个 `blocksGreen: true` 的新码 ⇒ **rc=0,一条 RED 都没有**。
* ⚠️ 那是"**判据没答**",**不是**"判据答错了" —— 实测提到该码 **0 次**,
* 那个方向 ⑤ **一片沉默**(并没有编造任何东西)。
* ★ 我原先写成"同一句'上游退非零'也是编的" ⇒ **把沉默说成了编造、夸大了证据**
* (pi 2026-09-18 更正了它自己同样的标注;这是**我们两人都犯在这条上**的错)。
* 区别要紧:编造让人去找不存在的问题;**沉默让人以为它答过了**。
*
* ⇒ 现在拿**唯一权威** `UPSTREAM_RC`(它的每个值都在 `--exitcode-selftest` 里被**真脚本**
* 逐条验过,见那一段的反向覆盖检查),与 `DIAG.blocksGreen` **双向**比对:
* · 上游退非零、下游却不红 ⇒ 漂移(该红的没红);
* · 上游退 0、下游却标阻断 ⇒ 漂移(反之亦然,上一版根本查不到这个方向);
* · 码没在 `UPSTREAM_RC` 里登记 ⇒ 红("加码要同时登记它退几")。
* **白名单随之消失**(数据说话)—— 这也是 `DIAG` 那条"一个码一行、一次定完"的延伸:
* 上游退出码也该是那一行的一部分。
*/
/*
* ★★ ⑦ **"根本没检查"的码必须说话**(pi 2026-09-18 报的那条的真实诉求)。
*
* pi 的原话是让 `blocksGreen=false` 分成两类:
* · "**检查了、结论无害**"(`baseline-stale`)⇒ **可以静默**;
* · "**根本没检查**"(`baseline-absent`)⇒ **必须说话**。
* ⇒ 这不是文案问题,是**结构**问题:用 `checked` 字段把它写死,并在这里守住 ——
* 凡是 `checked: false` 的码,**必须有 note**(否则读者看到的就是一片空白,
* 而空白会被读成"没问题")。
*
* ★ 为什么这条判据非有不可(我自己变异出来的 M34):
* 我把 `baseline-absent` 的 note 改回 `null`(= pi 说的"最小版没做"的状态),
* `--verdict-selftest` **全绿** —— 因为此前的判据只查 `blocksGreen` 存不存在、
* 两侧口径对不对,**没有一条管"该说话的那个码说没说"**。
* ⇒ 又一条"判据在,但走不到":`note` 这个字段的**内容**当时没有任何判据守着。
*/
{
const mute = Object.entries(DIAG).filter(([c, e]) => e.checked === false && !e.note).map(([c]) => c);
const undeclared = Object.keys(DIAG).filter(c => typeof DIAG[c].checked !== 'boolean');
if (undeclared.length) {
console.log(`RED 这些码没声明 \`checked\`:${undeclared.join('、')}`
+ ' ⇒ 必须显式说明"这一格到底检查了没有"(不声明就会被当成"没问题")');
bad++;
}
if (mute.length) {
console.log(`RED 这些码**根本没检查、却一句话都不说**:${mute.join('、')}`
+ ' ⇒ 空白会被读成"没问题"("没读数"必须看得见)');
bad++;
}
if (!undeclared.length && !mute.length) {
const nUnchecked = Object.values(DIAG).filter(e => e.checked === false).length;
console.log(`ok "没检查的码必须说话":${Object.keys(DIAG).length} 个码都声明了 \`checked\`,`
+ `其中 ${nUnchecked} 个是"没检查"且都有说明`);
}
}
{
const missing = Object.keys(DIAG).filter(c => !(c in UPSTREAM_RC));
const extra = Object.keys(UPSTREAM_RC).filter(c => !(c in DIAG));
if (missing.length) {
console.log(`RED DIAG 里这些码**没在 UPSTREAM_RC 登记上游退出码**:${missing.join('、')}`
+ ' ⇒ 加一个码必须同时登记"它退几",否则⑤无从比对');
bad++;
}
if (extra.length) {
console.log(`RED UPSTREAM_RC 里这些码在 DIAG 里不存在:${extra.join('、')} ⇒ 两张表漂移`);
bad++;
}
const drift = [];
for (const [code, e] of Object.entries(DIAG)) {
const rc = UPSTREAM_RC[code];
if (rc === undefined) continue;
if (rc !== 0 && !e.blocksGreen) drift.push(`${code}(上游 rc=${rc},却 blocksGreen=false)`);
if (rc === 0 && e.blocksGreen) drift.push(`${code}(上游 rc=0,却 blocksGreen=true)`);
}
if (drift.length) {
console.log(`RED 两侧口径漂移(**双向**比对 UPSTREAM_RC ↔ blocksGreen):${drift.join(';')}`);
bad++;
}
/*
* ⚠️ `ok` 只在**这一格完全没红**时打。我第一版无条件打 ⇒ 变异 (a) 的输出长成
* `RED DIAG 里这些码没在 UPSTREAM_RC 登记…` 紧跟 `ok 口径一致(双向):9 个码…` ——
* **同一格的报文自相矛盾**(读者会以为没红)。与结构锚点那次是同一个错,我又犯了一遍。
*/
if (!missing.length && !extra.length && !drift.length) {
console.log(`ok 口径一致(双向):${Object.keys(DIAG).length} 个码的`
+ '`UPSTREAM_RC ↔ blocksGreen` 逐条相符,且无未登记/多余码');
}
}
/*
* ⑥★ **结构锚点:生产路径必须真的经过 `applyMutants`**。
*
* 上面①–③判的是 `applyMutants` **内部**的行为("接线忘了 push"已被 M24 证明抓得住)。
* 但还剩一跳:**生产到底有没有调它** —— 若哪天有人绕过它、
* 自己写 `summarizeMutants(...)` 而忘了推进 `reds`,本自检照样全绿。
* ⇒ 扫**自己**的源码(去掉注释与字符串,免得咬到本文档里那些字面量),
* 要求:① `mutantsLine = applyMutants(` 出现**恰好一次**;
* ② 除了函数定义那一行,**没有**别处直接调 `summarizeMutants(`。
* 位置用 `prose()`/`stripComments()`(本仓既有的读取器),不自己搓正则。
*/
/*
* ⚠️ 第一版我写的是"全文件 `summarizeMutants(` 只许出现 1 处" ⇒ 实测**报 3 处**而红。
* 三处都合法:**函数定义**、`applyMutants` 内部、以及 `--mutants-line-selftest`
* (它拿合成 stdout 测那个纯函数)。**我的期望值写错了**,不是代码错了。
* —— 硬编码"3"又是脆的(加一个自检就红),且那个数字**不表达我想守的性质**。
*
* ⇒ 改扫**生产区段**(从 `let mutantsLine` 到文件末尾 —— 本文件里自检都在中段,
* 生产在最后)。要守的性质只有一句:**生产不许绕过 `applyMutants`**。
*/
const src = stripComments(prose(fileURLToPath(import.meta.url)));
/*
* ⚠️ 必须用 `lastIndexOf`(我第一版用 `indexOf` ⇒ **假红**):
* `indexOf('let mutantsLine')` 先命中的是**上面那段自检注释里提到这串字的地方**,
* 不是真正的声明(1955 行)⇒ 扫的区间多包了一个自检块 ⇒ 报"1 处直接调"。
* —— 与自检 4 当年踩的是**同一个坑**(锚点自匹配),那里的结论照抄:
* **生产在最后,自检在中段**,所以取 `lastIndexOf`;再加一条"区间不许为空"的兜底。
*/
const prodFrom = src.lastIndexOf('let mutantsLine');
const prod = prodFrom >= 0 ? src.slice(prodFrom) : '';
if (!prod) {
console.log('RED 找不到生产区段(`let mutantsLine`)⇒ 这条结构锚点**自己失效**,必须红');
bad++;
} else {
const calls = (prod.match(/applyMutants\(/g) || []).length;
const direct = (prod.match(/summarizeMutants\(/g) || []).length;
if (calls !== 1) {
console.log(`RED 生产区段对 \`applyMutants\` 的调用有 ${calls} 处(应为**恰好 1**)`
+ ' ⇒ 接线可能被绕过或重复');
bad++;
}
if (direct !== 0) {
console.log(`RED 生产区段有 ${direct} 处**直接**调 \`summarizeMutants\``
+ ' ⇒ 那条路绕过了接线,红的票会丢(这正是 M24 的形状)');
bad++;
}
/*
* ⚠️ 只在**这一格没报红**时打 ok —— 我第一版无条件打,于是 M27 的输出长成
* `RED …` / `RED …` / `ok 结构锚点:生产区段经由 applyMutants 接线`,
* 一个 `ok` 跟在自己的红后面 ⇒ 读者会以为"没红"(**同一格的报文自相矛盾**)。
*/
if (calls === 1 && direct === 0) {
console.log('ok 结构锚点:生产区段经由 `applyMutants` 接线'
+ `(applyMutants ${calls} 处、直接调 summarizeMutants ${direct} 处)`);
}
}
return bad;
}
/* ★ 单独跑(CLI)时行为不变:跑完这一个就退出。 */
if (process.argv.includes('--verdict-selftest')) process.exit(verdictSelfTest() ? 1 : 0);
/*
* 变异体播报自检(`--mutants-line-selftest`):钉住"**先看退出码,再看正则**"。
*
* 为什么需要:这条决策**在正常路径上走不到** —— 套件以 root 跑,而盲读只在
* **非 root + `jobs/` 不可读**时才发生。只在我手跑 `runuser -u nobody` 时才经过的分支
* 等于**没有判据守着**;哪天顺序被调回"先看正则",没有任何东西会响,
* 而它是"读数器说自己没读数"的**唯一通道**。
* (我实测过:盲读/部分可读时 `summary.py` 照样打得出一行**看着正常**的
* `RESULT mutants=<数字>`,所以这个顺序不是风格问题。)
*/
function mutantsLineSelfTest() {
const blind = 'RESULT mutants=NO-READING ran=NO-READING skipped=NO-READING on_new_criteria=NO-READING diag=counts-unusable(**没读数**)\n'
+ ' ✗✗ **读不到 jobs/ 目录**(/x/jobs)—— 上面的数字**全部无效**:\n';
const partial = 'RESULT mutants=47 ran=0 skipped=47 on_new_criteria=0 diag=counts-unusable(…原始条目 73…)\n'
+ ' ★ 清单与磁盘不一致 —— 上面的数字**不代表"磁盘上现在有多少个变异体"**:\n'
+ ' 清单里有、**在但读不到**:jobs-one.json(权限问题,不是缺失)\n';
const normal = 'RESULT mutants=48 ran=47 skipped=1 on_new_criteria=35 diag=none(口径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 diag=manifest-mismatch(…原始条目 73…)\n'
+ ' ★ 清单与磁盘不一致 —— 上面的数字**不代表"磁盘上现在有多少个变异体"**:\n'
+ ' 未列入清单:jobs-UNLISTED-probe.json(新加的 job 文件必须显式加进 jobs.manifest.json)\n'
+ ' 清单里有、磁盘上没有:jobs-ghost.json\n';
/*
* `baseline-stale`:底本过期(与 HEAD 逐字节相同)⇒ **退 0**(不是缺陷),
* 但那一格必须能被读者看见。注意它在 `RESULT` 行里有 `baseline=` 段落。
*/
const stale = 'RESULT mutants=48 ran=47 skipped=1 on_new_criteria=35 diag=baseline-stale'
+ '(口径A=只挂新判据 35 …) baseline=6/7⚠**底本过期**(1 个文件与 HEAD 逐字节相同 ⇒ 是正常提交改过、不是变异残留)\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 }],
/*
* ⑥★ **`baseline-stale`:不许红,但必须看得见**(pi 2026-09-18 本封的要害)。
*
* 它是"底本过期"(文件与 HEAD 逐字节相同 ⇒ 正常提交改过),**不是缺陷** ⇒ 退 **0**;
* 但"这一格说的什么"必须到达读者。⇒ 这一条把**转印与 status 解耦**钉住:
* `status === 0` 且 `diag=baseline-stale` ⇒ **仍然播报那一格**(走 `diag` + `DIAG_NOTE`)。
* (旧实现是 `status !== 0 ? whyLines : []` ⇒ 这一格当年就是被它丢掉的。)
*
* ⚠️ 转印条数是 **0**:`baseline=…` 那一段**长在 `RESULT` 行上**,而 `RESULT` 行
* 由 `diag` + `DIAG_NOTE` 传达,`whyLines` **显式排除它**(否则同一句话印两次)。
* ⇒ 判据看 `line` 里有 `diag=baseline-stale` 与那句说明,而不是看转印。
*/
['baseline-stale(status 0 也要播报)', stale, 0, { line: 'diag=baseline-stale', why: 0 }],
/*
* ⑦★ **诊断码表是闭合的**:上游加了新码、下游不认识 ⇒ **报警**,不许静静当成"没问题"。
* 这是"按字面量裁射程"的解药:不靠关键词列表,靠**枚举**。
*/
['不认识的诊断码 ⇒ 报警', 'RESULT mutants=1 ran=0 skipped=1 on_new_criteria=0 diag=brand-new-state\n',
0, { line: '不认识诊断码', 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++;
}
return bad;
}
/* ★ 单独跑(CLI)时行为不变:跑完这一个就退出。 */
if (process.argv.includes('--mutants-line-selftest')) process.exit(mutantsLineSelfTest() ? 1 : 0);
/*
* ★★ 五个自检的**接线放在这里**(不是定义旁边)—— 这是个我踩过的坑,必须写明:
*
* `verdictSelfTest` 与 `mutantsLineSelfTest` 读 `DIAG` 表,而 `DIAG` 是 `const`
* ⇒ **`const` 不提升**(TDZ)。我第一版把接线块放在 `selfCheckFailed` 那一段旁边
* (行 ~1448),而 `DIAG` 定义在 ~1721 ⇒ 两个自检当场
* `ReferenceError: Cannot access 'DIAG' before initialization`。
* —— 这个坑我在 `--mutants-line-selftest` 上**已经踩过一次**(见那段注释),这是第二次。
* ⇒ 任何"调用自检"的代码都必须排在**所有** `const` 表与自检函数定义之后。
* (所以这里紧贴生产区段入口:`let mutantsLine` 之前。)
*/
let selfCheckRuns = 0;
{
const SELFTESTS = [
['probe-selftest', probeSelfTest],
['exitcode-selftest', exitcodeSelfTest],
['skip-selftest', skipSelfTest],
['verdict-selftest', verdictSelfTest],
['mutants-line-selftest', mutantsLineSelfTest],
];
/*
* 自检的输出**不是**这个阶段的正常输出(各打几十行 `ok …`)⇒ 吞掉 stdout,
* 只在红时把它自己的 RED 行带进报告。
* ⚠️ 只吞 `console.log`,**`console.error` 不动** —— 有东西往 stderr 说话时
* 不许被我盖住(本仓"错误信息被吞掉"那条老教训)。
*/
const swallow = console.log;
for (const [name, fn] of SELFTESTS) {
const seen = [];
console.log = (...a) => seen.push(a.join(' '));
let failed = 0;
try {
failed = fn();
} catch (e) {
failed = 1;
console.error(`✗ 自检 ${name} 抛了异常:${e && e.message}`);
seen.push(`**自检自己抛了异常**:${e && e.message}`);
} finally {
console.log = swallow;
}
if (failed) {
const why = seen.filter(l => /^RED /.test(l)).map(l => l.replace(/^RED\s+/, ''));
reds.push(`(自检)\`--${name}\` 红了:${why.length ? why.join(';') : '(它没打出 RED 行,真因见上)'}`
+ ' —— **自检自己不该红**(它守的是"判据有没有分辨力")');
}
}
selfCheckRuns = SELFTESTS.length;
/*
* ★★ 结构性防线:**"写了自检但没接线"必须红**(pi 建议 ②,我做成硬的)。
*
* pi 的原话值得抄下来:本文件**自检 2** 的存在理由就是"防**写好了没接线**",
* 而它只扫 `readdirSync(test).filter(f => f.endsWith('.test.mjs'))`
* ⇒ 这五个自检因为**不是 `*.test.mjs`**(它们是"自检",不是判据文件)**正好扫不到**。
* **同一句话,换了个对象就漏了。**
*
* ⇒ 扫自己的源码:**每一个 `function …SelfTest()` 都必须在 `SELFTESTS` 里被调用**。
* 将来谁再加第 6 个自检却忘了登记,这里立刻红 —— 不必等人去 `grep` 调用方。
* 用 `prose`/`stripComments`(本仓既有读取器),不自己搓正则。
*/
/*
* ★★ **只用 `stripComments`,不加 `stripStrings`** —— 这是我实测后**回退**的一步,理由必须留下。
*
* 我第一版为"别咬到字符串里的同名文字"加了 `stripStrings`。实测它**更坏**:
* `read.mjs` 自己写明 `stripStrings` **不区分正则字面量**,一个**不配对**的引号就能
* 把后面一大段当字符串吞掉(方向是**假绿**)。实测插一条 `const QUOTE_RE = /["']/;`
* 在自检声明之前 ⇒ 后面**所有真声明都消失** ⇒ 未接线的 `hiddenSelfTest` **静默漏掉**,
* 而 5 个真函数反过来变成"幽灵"(报了红,但**报的是错的那件事**)。
* ⇒ 为了防"假红"引入了一个"假绿 + 误报"的机制,**不划算**。
*
* 只用 `stripComments` 时的代价只是"字符串里若恰好写着 `function xSelfTest()` 会**假红**"——
* 而按本仓口径,**假红是噪音,假绿是绕过**。选噪音。
*/
const declared = declaredSelfTests(stripComments(prose(fileURLToPath(import.meta.url))));
/*
* ★★ **反面对照:发现规则自己的覆盖**(pi 建议的那条,我照做)。
*
* 为什么必须有:放宽正则这件事**自己就会再漏一种写法** —— 我们这轮踩的就是
* "守着一个格子的判据,对另一种写法沉默"。所以这里拿**合成样本**要求
* **每一种常见写法都被认出来**,而不是"我以为它认得"。
* ⇒ 样本与生产扫描调的是**同一个** `declaredSelfTests`(不是抄一份)。
*/
const FORM_SAMPLES = [
['function 声明', 'function fooSelfTest() {}\n', /^function fooSelfTest\(\)/],
['箭头函数', 'const fooSelfTest = () => {};\n', /^const fooSelfTest = \(\) =>/],
['async function', 'async function fooSelfTest() {}\n', /^async function fooSelfTest\(\)/],
['名与括号间有空格', 'function fooSelfTest (a) {}\n', /^function fooSelfTest \(/],
['const = async (…', 'const fooSelfTest = async (a) => {};\n', /^const fooSelfTest = async \(/],
];
/*
* ★★ **样本表本身也必须被钉住**(我自己变异出来的 M45)。
*
* 实测:把 5 个样本**删掉 3 个**(只留 `function` 声明与 `const = async`)
* ⇒ 默认跑 **0 条 RED**、`red` 仍是 9,只是成功报文里那半句从
* "在 **5** 种写法上都有对照" 变成 "在 **2** 种写法上…"。
* ⇒ 也就是说 **"发现规则在 N 种写法上都有对照" 当时是散文,不是判据**:
* 它把 N 打出来,而 N 变小**没有任何东西拦**。
* 而"样本齐不齐"正是这条防线**唯一的防线** —— 样本可以被悄悄删光,
* 防线的覆盖就静默地退回到"只验我恰好留下的那一种"。
* **这是"判据存在 vs 判据在路径上"的又一个变体**:样本在,但"样本够不够"没判据。
*
* ⇒ 修法:把**要求覆盖的写法**列成一份**具名的**清单(`REQUIRED_FORMS`),
* 然后两个方向都判:
* ① 每种**要求**的写法都必须有样本(⇒ 删样本 = 红,不再是静默);
* ② 每个样本都必须能被认出来(原有方向)。
* ⇒ 删样本现在会红,而且**指名**删的是哪一种。
* (为什么不是"断言 `length >= 5`":那个数**自己**就是可以随手改小的常量;
* 具名清单至少让"我要放弃支持箭头函数写法"变成一次**看得见的声明**。)
*/
const REQUIRED_FORMS = ['function 声明', '箭头函数', 'async function', '名与括号间有空格', 'const = async (…'];
const sampleNames = FORM_SAMPLES.map(([w]) => w);
const noSample = REQUIRED_FORMS.filter(w => !sampleNames.includes(w));
if (noSample.length) {
console.log(`RED 这些写法**要求覆盖、却没有样本**:${noSample.join('、')}`
+ ' ⇒ 覆盖静默缩水(发现规则对那种写法可能就是瞎的)');
reds.push(`(自检)\`FORM_SAMPLES\` 缺了要求的样本:${noSample.join('、')}`
+ ' —— 覆盖缩水必须看得见,不能只改一个数字');
}
const stray = sampleNames.filter(w => !REQUIRED_FORMS.includes(w));
if (stray.length) {
console.log(`RED 这些样本不在要求清单里(加了样本却没说是为了守哪种写法):${stray.join('、')}`);
reds.push(`(自检)\`FORM_SAMPLES\` 有多余样本未登记进 \`REQUIRED_FORMS\`:${stray.join('、')}`);
}
/*
* ★★ **标签不许撒谎**(我自己变异出来的 M47,比 M45 更深一层)。
*
* 实测:把 `['箭头函数', 'const fooSelfTest = () => {};']` 的**内容**换成
* `'function fooSelfTest() {}'`(**标签不动**、样本个数不变)⇒ 默认跑 **0 条 RED**、`red` 仍是 9。
* ⇒ M45 修的是"样本**个数/名字**少了要红",但**名字对不上内容**当时无人管:
* 表上写着覆盖"箭头函数",而那份样本**根本不是箭头函数**。
*
* ★ 后果我实测过,而且它**让报警自己少说话**(不只是少覆盖):
* 在上面那个撒谎标签的前提下,把 `const` 那条正则删掉(⇒ **箭头写法真的认不出来了**),
* 报警只点名 `const = async (…`,**"箭头函数"一个字都不提**(`grep -c` = **0**)。
* ⇒ 覆盖表**声称**覆盖了箭头函数,而报警里那一格**消失了**。
* 这正是我们这一路最怕的形状:**该说话的地方没说话,而账面上看不出少了什么。**
*
* ⇒ 修法:每个样本再带一条**见证**(witness)——"这条样本为什么算那种写法"的**机械**判据:
* ① 样本内容必须满足自己的见证(⇒ 标签与内容不符 = 红);
* ② 见证必须**能区分**:至少要能否掉另一条样本(否则 `/./` 这种空见证也算"覆盖")。
* ⇒ 标签是**散文**,见证是**结构**;把"这条样本是哪一种写法"从散文挪进结构。
*/
const liar = FORM_SAMPLES.filter(([, src, w]) => !w.test(src)).map(([name]) => name);
if (liar.length) {
console.log(`RED 这些样本**标签与内容不符**(内容不满足它自己的见证):${liar.join('、')}`
+ ' ⇒ 覆盖表在说谎,而报警会因此**少说话**');
reds.push(`(自检)\`FORM_SAMPLES\` 标签与内容不符:${liar.join('、')}`
+ ' —— 声称覆盖了那种写法,而那份样本不是它');
}
const vacuous = FORM_SAMPLES.filter(([name, , w]) =>
!FORM_SAMPLES.some(([other, otherSrc]) => other !== name && !w.test(otherSrc))).map(([n]) => n);
if (vacuous.length) {
console.log(`RED 这些见证**区分不了任何东西**(对别的样本也成立):${vacuous.join('、')}`
+ ' ⇒ 见证表自己退化成"永远通过"');
reds.push(`(自检)\`FORM_SAMPLES\` 的见证对别的样本也成立:${vacuous.join('、')}`
+ ' —— 空见证等于没覆盖');
}
const missed = FORM_SAMPLES.filter(([, src]) => !declaredSelfTests(src).includes('fooSelfTest'));
if (missed.length) {
console.log(`RED 发现规则认不出这些写法(⇒ 那种写法写了自检也不会被接线防线看见):`
+ missed.map(([w]) => w).join('、'));
}
if (missed.length) {
reds.push(`(自检)\`declaredSelfTests\` 认不出:${missed.map(([w]) => w).join('、')}`
+ ' —— 写了那种形式的自检会**既不跑也不报警**');
}
/*
* ★ 反方向:**合成样本之外**,真源码里也要求"认出来的名字都在 SELFTESTS 或确实被调用"。
* 这里只做一个廉价的一致性检查:认出来的集合里,凡不以 SelfTest 结尾的都不该进来。
*/
const badName = declared.filter(n => !/SelfTest$/.test(n));
if (badName.length) {
console.log(`RED 发现规则收进了不以 \`SelfTest\` 结尾的名字:${badName.join('、')}`);
reds.push(`(自检)\`declaredSelfTests\` 收进了不符合命名约定的名字:${badName.join('、')}`);
}
const wired = new Set(SELFTESTS.map(([, fn]) => fn.name));
const unwired = declared.filter(n => !wired.has(n));
const phantom = [...wired].filter(n => !declared.includes(n));
if (unwired.length) {
console.log(`RED 这些自检**写了但没接线**(不在 SELFTESTS 里)⇒ 默认路径不会跑它们:${unwired.join('、')}`);
reds.push(`(自检)**写了没接线**:${unwired.join('、')} —— 它们只由 CLI flag 守着,`
+ '而全仓没有任何入口传那个 flag(这正是本轮修的那个形状,不许再长出来)');
}
if (phantom.length) {
console.log(`RED SELFTESTS 里登记了不存在的函数:${phantom.join('、')}`);
reds.push(`(自检)SELFTESTS 里登记了不存在的函数:${phantom.join('、')}`);
}
if (!unwired.length && !phantom.length) {
console.log(`自检段:${SELFTESTS.length} 个自检已在**默认路径**内跑过`
+ `(${SELFTESTS.map(([n]) => `--${n}`).join('、')});`
+ `源码里 ${declared.length} 个 \`*SelfTest\` 函数**全部**在册(无"写了没接线");`
+ `发现规则在 ${FORM_SAMPLES.length} 种写法上都有对照(要求 ${REQUIRED_FORMS.length} 种,缺一即红;`
+ '每条样本都带能区分它自己的见证)');
}
}
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"能兜住。
*/
mutantsLine = applyMutants(sp, reds);
} catch (e) {
mutantsLine = ` mutants=(跑不了 summary.py:${e.code || e.message})`;
/*
* ★ **连 `summary.py` 都起不来时也要红**(我上一版漏了):原来这里只改文案 ⇒
* "整个读数器没跑起来"会让 `verdict` 照样可以绿。与上面 `r.red` 是同一族。
*
* ⚠️⚠️ **这一条分支本机覆盖不到,只能靠代码审查** —— 照 pi 那句
* "哪条前提构造不出来,就写明'这条分支本机自检覆盖不到,只能人手验',
* 别让它默认看起来是被守着的"。我实测过两条最像的前提,**都不走这里**:
* · `summary.py` 不存在 ⇒ `spawnSync` 返回 `status=2`(python 自己报错)⇒ 走 `!m` 那一支;
* · `PATH` 里没有 `python3` ⇒ **`spawnSync` 不抛**,返回 `status=null, error.code='ENOENT'`
* ⇒ 也不走这里,同样走 `!m` 那一支。
* `spawnSync` 只在**参数非法**(options 类型不对之类)时才同步抛 —— 那不是运行期会遇到的输入。
*
* ★ 所以这条红**是兜底**,不是我实测过的通路。真正的通路是 `!m`:
* 上面那两种情形端到端都验过 —— 两条都得到 `red=10 verdict=red`,
* 那一格打 `mutants=(summary.py 没打出 RESULT:status=null …)`。
* ⇒ 两个都要保留(兜底 + 实测通路),但**注释必须说清哪条是实测的** ——
* 否则下一个人会以为 catch 这条路有判据守着。
*/
reds.push(`(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}`;
/*
* ★ 判定走**纯函数** `verdictOf`(定义在 `summarizeMutants` 那边)——
* 这样"清掉全部红之后会不会绿"是**可构造**的实验(见 `--verdict-selftest`),
* 不必动 SUITE 登记数。原来这条条件表达式是**内联**的,
* 于是"结论到不了这一格"只能靠人读源码发现(pi 本封就是读出来的)。
*/
/*
* ★★ **判官坏了也要看得见**(我自己变异出来的 M39,这条是独立的安全网)。
*
* 实测:把 `verdictOf` 改成**恒返回 `'green'`**,默认跑得到
* `red=10 … verdict=green`、**exit=0**、而且**红清单一行都不打**
* (`green` 分支提前 `process.exit(0)`)。
* ⇒ 自检红**确实进了 `reds`**,但**决定"要不要报红"的那一格**正是被变异的那一个:
* 于是 10 条红被静默吞掉。**这条形状的老朋友** —— 只是这次坏的是**判官自己**。
*
* ⇒ 不靠 `verdictOf` 自证:这里用**独立**的一行把矛盾挑出来。
* `reds`/`brokens`/`selfCheckFailed` 非空而 `verdictOf` 却说绿 ⇒ 判官与账本不一致,
* 必须红(而不是相信判官)。**判据与判官互为对照,谁单独说了都不算数。**
*/
const ledgerBusy = reds.length > 0 || brokens.length > 0 || selfCheckFailed || dueFailed;
const judged = verdictOf({ reds, brokens, dueFailed, selfCheckFailed });
if (judged === 'green' && ledgerBusy) {
console.error('✗ **判官与账本不一致**:`verdictOf` 说 green,但账本上' +
`reds=${reds.length} brokens=${brokens.length} selfCheckFailed=${selfCheckFailed} ` +
`dueFailed=${dueFailed} ⇒ **不许静默通过**(判官自己可能就是被改坏的那个)。`);
process.exit(1);
}
if (judged === 'green') {
// 报**本相位实际跑了**几条,不报清单长度 —— 上面刚写完"不能声称自己做过的事",
// 这里如果打 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);