pi 2026-09-15 实测出来的,**这次长在判据自己身上** —— 正是我们前几轮一直在消的那个形状。
## 一、`const ROOT = '/home/program/agentmail'`:规则进来了,对象没进来
`harmony-arkts.test.mjs` 把仓库根写成了绝对路径。后果我按 pi 的步骤亲手复现了:
```
$ git worktree add --detach /tmp/wt-verify 7f4fa26 # 那个检出里 import 顺序**确实**违规
(核对:最后 import 在第 80 行,而第 63 行已是 `const NAV_MATERIAL_OF…`)
$ cd /tmp/wt-verify/client/electron && node --test test/harmony-arkts.test.mjs
ok 1 / ok 2 / ok 3 # pass 3 # fail 0 ← **在一个明显违规的检出上 3/3 全绿**
```
因为它读的不是 `/tmp/wt-verify`,是 `/home/program/agentmail`(那份早已修好)。
两层后果,第二层最糟:
① 它**永远无法验证任何别的 checkout / CI / 镜像** —— 换目录不是"红",是 `readdirSync` 直接抛;
② 在本机做 worktree 复核时,它**静默读另一棵树并报绿**。
**判据的逻辑是对的、对象是错的** —— 这比"判据写错了"更难发现,因为它在原地永远是绿的。
同一个毛病在 4 个文件里,**恰好全是最近这几笔新写的**(另 10 个鸿蒙判据写法是对的):
```
harmony-admin / harmony-imageprep / harmony-presets / harmony-arkts → const ROOT = '/home/program/agentmail';
其余 10 个 → const ROOT = join(HERE, '..', '..', '..');
```
已全部照邻居改掉。**修好之后在同一个违规检出上:`# fail 1`** —— 它终于会红了。
## 二、修这条时又牵出一个:`stripComments` **改变了行号**
修好路径后,判据报出"最后一个 import 在第 64 行、第 47 行已是语句",
而**真实文件里是第 80 / 63 行**。成因:`stripComments` 把块注释整块抹成 `''`,
而块注释**自带换行** ⇒ 它之后所有行号整体前移。
这不是小节:全仓判据都用 `文件:行号` 定位(`grep -n`、编辑器跳转、`git show` 核对),
**报出来的行号必须能直接用**,否则读者第一步得先猜"这是剥过的还是没剥的"。
改成"块注释里的每个换行换成等量空行"。修完报的就是 **80 / 63**,与文件逐字对上。
## 三、新增两条判据,让这两个形状不能再回来
1. **`★ 判据不许把仓库根硬编码成绝对路径`** —— 扫判据目录里**真代码**
(`code()` 剥注释,否则本文件自己的说明文字就会误报),找
`const X = '/绝对路径'` 且**看着像仓库内**的声明。
**例外按名字放行**(含 `TOOLCHAIN`/`SDK`/`HDC` 的常量)—— 工具链本来就不在仓库里、推不出来;
按**值**做白名单会逼着下一个人为了过判据去改那个路径的写法。
2. **`★ stripComments 必须保持行号`** —— 造含多行块注释的样本,断言剥完
**行数不变**、且第 N 行仍是原来的第 N 行;**同时**断言注释内容确实被去掉了
(别为了保行号把注释留下)。
两条都做了**变异验证**:
- 把 `harmony-admin` 的 ROOT 改回硬编码 ⇒ 新判据**红**,并点名那个文件;还原后绿。
- 在 `MainPage.ets` **import 之前**插一条语句 ⇒ `harmony-arkts` **红**
(第 79 行 vs 第 1 行);还原后绿。**这条同时证明了"读的是自己那棵树"** ——
同样这个变异,在修路径**之前**是绿的。
## 四、未做 / 未验
- 到期闸门那 7 条**没动**(要真装真点,是另一件活)。
- **"把 build 做成一条判据"我探了,两个硬障碍**(详见给 pi 的回信):
① `client/harmony/oh_modules` 被 `.gitignore` 排除且未入库 ⇒ **全新检出没有它**,
构建会先死在装依赖上;② 本沙箱**拒写 `/root/.hvigor`**(`mkdir` Permission denied),
`hvigorw` 在 worktree 里直接 `EACCES: mkdir '/root/.hvigor/project_caches/…'`。
所以它在本仓能编过、在干净检出编不过 —— 作为判据它现在会**假红**。
694 lines
41 KiB
JavaScript
694 lines
41 KiB
JavaScript
/**
|
||
* 判据总入口 —— **全部跑完再算退出码**。
|
||
*
|
||
* 为什么不再用 `&&` 串起来:
|
||
*
|
||
* 原先 `npm test` 是 `a && b && c …`。这种行为有个不起眼但很贵的后果 ——
|
||
* **前面红一条,后面全部不跑**。于是"只红了一条"看起来像"只有一个问题",
|
||
* 实际上后面那些判据连跑都没跑(这次就真发生了:`background` 红着,
|
||
* `packaging` 从来没跑到过,而它正是能发现"界面改了没重打包"的那条)。
|
||
* 换句话说:`&&` 链下的"全绿"是可信的,**"红"是不可信的**。
|
||
*
|
||
* 现在:每条判据都跑,红的收集起来,最后一起报、一起退出。
|
||
*
|
||
* 另外两条防"判据自己不会跑"的自检(与 process.exit 之后写判据是同一族问题):
|
||
* 1. 清单里的文件必须存在(名字写错 = 静默跳过一条判据);
|
||
* 2. `test/` 下的每个 `*.test.mjs` 都必须在清单里
|
||
* —— 这次 `cross-client-theme.test.mjs` 就是"写好了但没接进套件",
|
||
* 在它进套件之前一直是隐身状态。加了这条,**新增判据忘了接线会直接红**。
|
||
* 3. 判据规范 `test/CRITERIA.md` 要在、且要点到那几条规则
|
||
* —— 写判据的规矩本身也会被"忘了带"(形状记在某个人的脑子里等于没有)。
|
||
*
|
||
* 写判据之前先读 `test/CRITERIA.md`(判结构与行为,不判字面与邻接)。
|
||
*/
|
||
import { prose } from './lib/read.mjs';
|
||
import { spawnSync } from 'node:child_process';
|
||
import { existsSync, readdirSync } from 'node:fs';
|
||
import { dirname, join } from 'node:path';
|
||
import { fileURLToPath } from 'node:url';
|
||
|
||
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
|
||
['test/theme.test.mjs', [], 30],
|
||
['test/background.test.mjs', [], 42],
|
||
['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'], 26],
|
||
// P5 悬浮玻璃导航:点击配对 / index 决定挂载 / 命中区 ≥44vp / 悬浮与让位
|
||
['test/harmony-nav.test.mjs', ['--experimental-strip-types', '--no-warnings'], 11],
|
||
// 外观契约:默认值去 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'], 5],
|
||
['test/harmony-push.test.mjs', ['--experimental-strip-types', '--no-warnings'], 13],
|
||
['test/harmony-calendar.test.mjs', ['--experimental-strip-types', '--no-warnings'], 10],
|
||
['test/debt-visibility.test.mjs', [], 1],
|
||
['test/commit-hygiene.test.mjs', ['--experimental-strip-types', '--no-warnings'], 2],
|
||
// 判据目录自身的卫生:读文本必须走 test/lib/read.mjs 的具名入口
|
||
['test/criteria-hygiene.test.mjs', [], 5],
|
||
// 用户管理页(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]
|
||
];
|
||
|
||
// 自检 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('、')}`);
|
||
}
|
||
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 反而把它们跑绿了)。⇒ 权威来源必须由**套件自己**给出:
|
||
* 它只累加**它自己实际跑过并解析到自报条数**的那些文件,不猜、不扫目录。
|
||
*/
|
||
let totalTests = 0;
|
||
/** 逐文件自报的**失败**条数之和(`fail=` 那一格)。`pass + fail == checks` 是恒等式 */
|
||
let totalFail = 0;
|
||
/**
|
||
* **实际跑了但没能自报条数**的文件数 —— 这个数必须显式打出来。
|
||
* 没有它,`checks=` 会**静默少算**(跑过的文件里有多少条没被计入,读者看不出来)。
|
||
* 我第一次量就撞上了:`checks=379` 而逐文件手算合计 386,差的 7 条正是这些文件里的。
|
||
*/
|
||
let unreportedFiles = 0;
|
||
/** 实际跑过(并解析到自报条数)的文件数 —— 与 `SUITE.length` 分开报,差额看得见 */
|
||
let ranFilesCount = 0;
|
||
/*
|
||
* ─── 相位(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 = [];
|
||
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' 的等价物),但能顺手做"跑了吗"的检查
|
||
const r = spawnSync(process.execPath, [...all, join(ROOT, file)], { encoding: 'utf8' });
|
||
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 —— 差的那些正是红文件里的。
|
||
* **"红"只说明判据说"不成立",不说明它没跑**;跑过的条数一样要算进来。
|
||
*
|
||
* 单位是自报的条数本身(`RESULT pass=N fail=M` 的 N+M,或 `# tests N`),
|
||
* 两种文件的"条"不是同一种条,所以汇总里那一格叫 `checks` 而不叫 `tests`。
|
||
*/
|
||
const marker = /RESULT pass=(\d+) fail=(\d+)/.exec(out);
|
||
const nodeTest = /^# pass (\d+)/m.exec(out);
|
||
const nodeFail = /^# fail (\d+)/m.exec(out);
|
||
const ran = marker ? Number(marker[1]) : (nodeTest ? Number(nodeTest[1]) : null);
|
||
if (ran !== null) {
|
||
totalTests += ran;
|
||
ranFilesCount += 1;
|
||
totalFail += marker ? Number(marker[2]) : (nodeFail ? Number(nodeFail[1]) : 0);
|
||
} else {
|
||
unreportedFiles += 1;
|
||
}
|
||
// 不 break:后面每条都要跑出来,否则"红了几条"这个信息本身是假的
|
||
if (crashed || empty) {
|
||
const firstErr = (out.match(/^.*(Error|error:).*$/m) || [''])[0].trim().slice(0, 160);
|
||
brokens.push(`${file}(${empty ? '跑完没有任何输出' : `退出码 ${r.status},但没有一句断言失败`})` +
|
||
(firstErr ? `\n ↳ ${firstErr}` : '') +
|
||
'\n ↳ 这是 **broken(跑不起来)**,不是 red:它证明不了任何判据成立或不成立。' +
|
||
'\n 常见成因:语法/标识符错(`X is not defined`)、import 写错、编译不过。' +
|
||
'\n 用于变体验证时:broken **不算这次变异有效**,要重做。');
|
||
} else if (r.status !== 0) reds.push(`${file}(退出码 ${r.status})`);
|
||
else {
|
||
/*
|
||
* 自报条数(闭环):自定义 check() 打 `RESULT pass=N fail=M`,node:test 打 `# pass N`。
|
||
* 只解析**固定 marker**,不去猜口语汇总(「窄屏布局:全部通过」里没有数字,
|
||
* 靠猜数字的写法会误报 —— pi 提过,我也先贴过真实样本)。
|
||
*/
|
||
const marker = /RESULT pass=(\d+) fail=(\d+)/.exec(out);
|
||
const nodeTest = /^# pass (\d+)/m.exec(out);
|
||
const nodeFail = /^# fail (\d+)/m.exec(out);
|
||
const ran = marker ? Number(marker[1]) : (nodeTest ? Number(nodeTest[1]) : null);
|
||
// 累计总数(见汇总末尾的 `RESULT files=…`)—— 解析成功就加,无论该文件红绿
|
||
if (ran !== null) {
|
||
totalTests += ran;
|
||
ranFilesCount += 1;
|
||
totalFail += marker ? Number(marker[2]) : (nodeFail ? Number(nodeFail[1]) : 0);
|
||
} if (ran === null) {
|
||
unreportedFiles += 1;
|
||
/*
|
||
* 报错**自带修法**(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 && ran !== expected) {
|
||
// **相等**,不是"不少于"(pi 邮件 `9fed4386`):只判下界时,**多加的条数不受保护** ——
|
||
// 下一个人删掉那条"多出来的"判据不会有任何东西变红,于是"被删会红"这个性质只覆盖前缀。
|
||
// 两个方向的成因与处置都不同,所以分开写。
|
||
const why = ran < expected
|
||
? `自报 ${ran} 条 < 清单里登记的 ${expected} 条。常见成因:判据被删/被跳过(写在 process.exit() 之后、
|
||
条件里提前 return)、check() 的实现被改坏(合并冲突)、marker 打在了汇总之前但计数没接上。
|
||
确认确实该减少条数时,把清单里那个数字一起改掉(那是一次显式、可复核的编辑)。`
|
||
: `自报 ${ran} 条 > 清单里登记的 ${expected} 条 —— 新加的那几条**不在"被删会红"的保护内**:
|
||
只判下界时,删掉多出来的部分不会有任何东西变红。请把清单里这个数字改成 ${ran}(一次显式编辑),
|
||
新判据才真正被登记保护。`;
|
||
reds.push(`${file}\n ↳ ${why}`);
|
||
}
|
||
}
|
||
}
|
||
|
||
/*
|
||
* ─── 静态判据的**欠账**与到期机制(pi 2026-09-14 提议) ───
|
||
*
|
||
* 有些判据只能验**形态**(读 `.ets` 源码),因为它们要验的东西在本机跑不起来:
|
||
* 鸿蒙侧编译要 hvigorw、运行要设备/模拟器。这类判据登记在下面,并各自写清
|
||
* **什么前提一旦成立它就过期**。
|
||
*
|
||
* 为什么不能只写一句"暂时":**"暂时"不是一种状态,是一个待办** —— 规范里写下的
|
||
* "暂时"没有任何机制会回来读它,于是永远留在原地。这里把它变成可机检的形状:
|
||
* 1. `UNBLOCK` 必须是**可检测的前提**(这里就是"有没有可用设备"),不是陈述;
|
||
* 2. 汇总里打 `RESULT static=N` —— 这是**欠账余额**,涨了要看得见;
|
||
* 3. **前提一旦为真,欠账当场变红**:不等人想起来,设备可用的那天这些判据必须
|
||
* 改成行为判据(或明确降级并写理由)。
|
||
*
|
||
* 这条是"自报 0 条 < 登记条数"的**时间版本**:那条管"判据还在不在",这条管
|
||
* "它该升级了没有"。
|
||
*/
|
||
/**
|
||
* 探针:判定静态判据的"到期前提"是否成立。
|
||
*
|
||
* # 三值,不是两值(pi 2026-09-14 §2)
|
||
*
|
||
* 原先只有"成立/不成立"两种结果,于是**"探针跑不了"和"设备不可用"被归成同一格**:
|
||
* 设备那天真可用了,闸门也永远不会开 —— 机制在,闸门锈死,而且看起来完全健康
|
||
* (这是"自报 0 条 < 登记 30 条"的第三种形状:探针自己坏了,没人知道)。
|
||
*
|
||
* 所以:
|
||
* - `true` 可用(前提成立 → 依赖它的静态判据**到期**,必须处理);
|
||
* - `false` 不可用(探针**确实跑成了**,结论是没有目标);
|
||
* - `'unknown'` 拿不准(**命令在但跑不成**:超时、非零退出、抛异常)→ **按到期处理**。
|
||
*
|
||
* # 这条设计当场抓到了什么(写下来,因为它是"闸门锈死"的实证)
|
||
*
|
||
* 换三值之后第一次运行就报了 `probe=unknown` —— 一查:探针里调的是 `execFileSync`,
|
||
* 而这个文件 import 的是 `spawnSync`(**名字根本没定义**)。也就是说
|
||
* **探针从写下的那天起一次都没跑成过**,而旧的二值设计把 `ReferenceError`
|
||
* 连同"没有设备"一起吞掉、统一报成"设备不可用"——**机制在、闸门从来没开过,
|
||
* 而它看起来完全健康**。三值把它变成了一声明确的红:探针自己坏了,必须有人看一眼。
|
||
*
|
||
* 另外,`'unknown'` 只留给"命令在、但跑不成";**所有候选都不存在**(本机没装 hdc)
|
||
* 是可判的事实(没有工具就不可能有设备),报 `false`,否则没装 SDK 的机器会天天假红。
|
||
* 拿不准就红,让人看一眼 —— 这条判断比"猜一个"便宜得多。
|
||
*
|
||
* 测试用 `AGENTMAIL_PROBE_DEVICE=ok|none|unknown` 覆盖(判据自检要用:
|
||
* 没有这个开关就没法验证"unknown 会不会红")。
|
||
*/
|
||
const PROBES = {
|
||
device: {
|
||
// 前提写成"**本工作区**能装能点设备",不是"机器上有设备"(pi 2026-09-14):
|
||
// 同一台机器上设备对 gui-lab 那条通道可用、对我这条不可用 —— 写"机器上有设备"的话,
|
||
// 探针变真会让 5 条判据同时红,而**我修不了**(要写 /run、/var/log,在工作区外)。
|
||
// 写成"本工作区能装能点",红就只在我能动手时出现。
|
||
desc: '本工作区能装、能点设备(hdc 服务健康 + 看得到目标)',
|
||
need: [
|
||
'设备/模拟器要在本工作区里起得来:模拟器启动要写 /run/harmony-emulator.pid 与 /var/log/harmony-emulator.log',
|
||
'hdc 的**服务端**要健康(`hdc -m` 在跑);服务端起不来时 `list targets` 也会打印 [Empty],那是"拿不准"不是"没设备"',
|
||
'要有 hdc 目标;能装上 hap 并能点(hvigorw assembleHap + hdc install)'
|
||
],
|
||
run() {
|
||
const override = process.env.AGENTMAIL_PROBE_DEVICE;
|
||
if (override === 'ok') return true;
|
||
if (override === 'none') return false;
|
||
if (override === 'unknown') return 'unknown';
|
||
|
||
/*
|
||
* # 候选清单的权威性(pi 2026-09-14 §2:硬编码清单今天咬了两次)
|
||
*
|
||
* 上一版在**固定两条路径**里找 hdc,全不在就判"本机没装 → 没有设备"(false)。
|
||
* 这与 build.sh 在固定几个根里找 SDK 源码、第一个命中就用,是**同一个形状**:
|
||
* 硬编码清单 + "命中/未命中"当真值。那次命中了 /root/ha-test 的老 checkout,
|
||
* 于是把错误结论当成了事实。所以这里:
|
||
* ① 候选来源写清楚(下面 TOOLCHAIN_ROOT 是华为命令行工具的**文档安装根**);
|
||
* ② **工具链根在、里面却没有 hdc** → `unknown`(不是"没装",是"装了但长得不一样");
|
||
* ③ `hdc` 也走 PATH(spawnSync 自己会查 PATH),不再只认那一条写死的路径。
|
||
*/
|
||
const TOOLCHAIN_ROOT = '/opt/huawei/command-line-tools';
|
||
const sdkHdc = `${TOOLCHAIN_ROOT}/sdk/default/openharmony/toolchains/hdc`;
|
||
const candidates = [sdkHdc, 'hdc'];
|
||
|
||
let ranOnce = false; // 至少有一条命令**跑成过**(哪怕是"没有目标")
|
||
let sawEmpty = false;
|
||
let anomaly = ''; // 命令在,但跑不成(超时/非零退出/…)—— "拿不准"
|
||
let allMissing = true; // 所有候选都不存在
|
||
for (const bin of candidates) {
|
||
const r = spawnSync(bin, ['list', 'targets'], { encoding: 'utf8', timeout: 15000 });
|
||
if (r.error && r.error.code === 'ENOENT') continue;
|
||
allMissing = false;
|
||
if (r.error) { anomaly = r.error.code || String(r.error.message || r.error); continue; }
|
||
if (r.status !== 0) { anomaly = `exit ${r.status}`; continue; }
|
||
ranOnce = true;
|
||
const t = (r.stdout || '').trim();
|
||
if (t && !/\[Empty\]/.test(t)) return true; // 明确可用
|
||
sawEmpty = true; // "没有目标"(下面还要问服务端健不健康)
|
||
}
|
||
|
||
/*
|
||
* `[Empty]` **不等于**"没有设备"(pi 2026-09-14 §1)。
|
||
*
|
||
* 它至少混三种原因:① 真没有设备;② **hdc 自己的服务端起不来**
|
||
* (socket/权限 —— 而"要写工作区外的目录"正是我这轮撞上的限制);
|
||
* ③ 被打印成空集的权限拒。所以先证服务端健康,才敢把空集当成"可判的没有":
|
||
* 服务端进程在(`hdc -m`,含它在别的路径下)→ 空集是可判事实 → false;
|
||
* 服务端找不到 → 空集可能正是"探针自己被环境弄坏了" → unknown(红)。
|
||
*/
|
||
if (ranOnce && sawEmpty) {
|
||
return hdcServerLooksHealthy() ? false : 'unknown';
|
||
}
|
||
if (anomaly) return 'unknown'; // 命令在、跑不成 → 拿不准
|
||
if (allMissing) {
|
||
// 工具链根在、里面却没有 hdc → "装了但长得不一样",不是"没装"
|
||
return existsSync(TOOLCHAIN_ROOT) ? 'unknown' : false;
|
||
}
|
||
return 'unknown';
|
||
}
|
||
}
|
||
};
|
||
|
||
/**
|
||
* hdc 的服务端是不是在跑。只读 /proc/<pid>/cmdline(不依赖 `ps` 也不起子进程
|
||
* —— 探针自己不该再引入"命令跑不起来"这种不确定性。
|
||
* 判据:命令行里第二个参数是 `-m`(hdc 的 server 模式)。
|
||
*/
|
||
function hdcServerLooksHealthy() {
|
||
let pids = [];
|
||
try { pids = readdirSync('/proc').filter(d => /^\d+$/.test(d)); } catch { return false; }
|
||
for (const pid of pids) {
|
||
try {
|
||
// 走 lib/read.mjs 的 prose(判据目录不许裸 readFileSync —— 那条判据也管这里)
|
||
const cmd = prose(`/proc/${pid}/cmdline`).split('\0').filter(Boolean);
|
||
if (cmd.length && /(^|\/)hdc$/.test(cmd[0]) && cmd.includes('-m')) return true;
|
||
} catch { /* 进程刚退出 / 没权限读 —— 换下一个 */ }
|
||
}
|
||
return false;
|
||
}
|
||
|
||
|
||
/** 探针结论 → 是否等于"前提成立(到期)" */
|
||
const probeIsDue = v => v === true || v === 'unknown';
|
||
|
||
/** 只能验形态的判据:文件 + 为什么只能静态 + 到期前提 */
|
||
/*
|
||
* 探针自检(`--probe-selftest`):它判的是**判据自己的分辨力** ——
|
||
* "unknown 到底会不会红"。没有这条,`probeIsDue` 哪天被改成 `v === true`
|
||
* 也没人会发现,而那正是"闸门锈死"的写法。
|
||
*/
|
||
if (process.argv.includes('--probe-selftest')) {
|
||
const cases = [
|
||
['ok 可用 → 到期', true, true],
|
||
['none 不可用 → 不到期', false, false],
|
||
['unknown 拿不准 → 到期(必须红)', 'unknown', true],
|
||
];
|
||
let bad = 0;
|
||
for (const [what, value, want] of cases) {
|
||
const got = probeIsDue(value);
|
||
console.log(`${got === want ? 'ok ' : 'RED '} ${what}(probeIsDue(${JSON.stringify(value)}) = ${got})`);
|
||
if (got !== want) bad++;
|
||
}
|
||
process.exit(bad ? 1 : 0);
|
||
}
|
||
const STATIC_ONLY = [
|
||
['test/harmony-nav.test.mjs', '底栏结构/命中区常量/挂载关系:`.ets` 要 hvigorw 才能编译、要设备才能点', 'device'],
|
||
['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();
|
||
}
|
||
|
||
const dueStatic = STATIC_ONLY.filter(([, , probe]) => probeIsDue(probeResults[probe]));
|
||
const unknownProbes = Object.entries(probeResults).filter(([, v]) => v === 'unknown').map(([k]) => k);
|
||
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(' 这些都在工作区外(需要人放行或提权)—— 所以这条红**不要求你现在修**,' +
|
||
'要求的是:别把它当噪音,并在打通后回来把静态那条升级掉。');
|
||
}
|
||
process.exit(1);
|
||
}
|
||
/*
|
||
* 汇总里"欠账余额"和"探针是否健康"是两个不同的数字(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 不在)**不静默** —— 明说"没跑",
|
||
* 免得下一个人把"这行没提"当成"没问题"。
|
||
*/
|
||
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 });
|
||
const m = /(RESULT mutants=\d+ ran=\d+ skipped=\d+ on_new_criteria=\d+)/.exec(sp.stdout || '');
|
||
mutantsLine = m
|
||
? ` ${m[1]}`
|
||
: ` mutants=(summary.py 没打出 RESULT:status=${sp.status} ${(sp.stderr || '').trim().split('\n').slice(-1)[0] || ''})`;
|
||
} catch (e) {
|
||
mutantsLine = ` mutants=(跑不了 summary.py:${e.code || e.message})`;
|
||
}
|
||
|
||
console.log(`RESULT phase=${PHASE} static=${STATIC_ONLY.length}${debtsLine} probe=${probeSummary}` +
|
||
(unknownProbes.length ? `(探针跑不了:${unknownProbes.join('、')} —— 已按到期处理)`
|
||
: '(只能验形态的判据:到期前提成立就自动变红)') + mutantsLine);
|
||
|
||
console.log(`\n========== 判据汇总 ==========`);
|
||
/*
|
||
* 判据**条数**的权威来源(pi 2026-09-15)。
|
||
* 格式与其它 `RESULT` 行同族:机器可读、一行、**套件自己打的**。
|
||
*
|
||
* ⚠️ 单位要写清:`checks` 是**逐文件自报条数之和** —— 也就是套件在每跑完一个文件时
|
||
* 真正解析过、并据此判"有没有少跑"的那个数。它**混两种单位**:
|
||
* `check()` 文件自报 `RESULT pass=N`(N 条判据),`node:test` 文件自报 `# pass N`
|
||
* (N 个 test 用例)。两者都是"条",但**不是同一种条**,所以这里叫 `checks` 而不叫
|
||
* `tests`,免得下一个人把它跟 `node --test test/*.test.mjs` 的 glob 口径直接比 ——
|
||
* 那个口径只跑标准 `test/*.test.mjs`,**不跑套件里的构建相位**,也不含本次相位跳过。
|
||
* `pass + fail == checks` 恒成立(fail 是那些文件里的红条数)。
|
||
*/
|
||
const totalsLine = (verdict) => `RESULT files=${SUITE.length} ran=${ranFilesCount} ` +
|
||
`checks=${totalTests} pass=${totalTests - totalFail} fail=${totalFail} ` +
|
||
`broken=${brokens.length} unreported=${unreportedFiles} verdict=${verdict}`;
|
||
if (reds.length === 0 && brokens.length === 0) {
|
||
// 报**本相位实际跑了**几条,不报清单长度 —— 上面刚写完"不能声称自己做过的事",
|
||
// 这里如果打 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}`);
|
||
}
|
||
console.log(totalsLine('red'));
|
||
process.exit(1);
|