Files
MailUI4Agents/client/electron/test/run-all.mjs
JianFeeeee 125aec1191 跨端: 2in1 键盘可达(Ctrl+N 写信 / ↑↓ 换补全 / Enter 选中)+ 三处判据被实测改判
用户 2026-09-21:「接下来做一下 2in1 上的快捷键,比如快捷键打开发信页面」
「上下键切换发信目标」「回车展开输入框等」

## ① 打开发信页:Ctrl+N,用官方 `keyboardShortcut`

先查了官方文档再动手,避开两条静默失败的坑:

  · `keyboardShortcut`(组件快捷键事件,API 10+)「**无论组件是否获焦** ——
    只要窗口获焦,快捷键就会响应」。而 `onKeyEvent` 要求**组件先获焦**,
    邮件列表里焦点落在哪是不确定的(点一下就换)⇒ 用它做全局快捷键会时灵时不灵。
  · 「多个不同组件设置相同组合键 ⇒ 只响应节点树**深度最浅**的那个」。
    ⇒ 再给别处的"写信"按钮补一个 Ctrl+N 不是"多一个入口",而是**让后来那处永久失效**。
    判据因此钉"全仓只许绑一处"。

绑定位置在 `MainPage` 的**根 Stack**(窗口组件树的根)—— 绑在 FAB 上不行,
它在 `if` 分支里、窄屏/宽屏位置也不同,会随分支挂卸。

键位 `Ctrl+N` 避开了官方列出的五个**禁止绑定**组合(Alt+F4/Alt+Tab/Ctrl+Shift+Esc…)。
判据直接解析调用参数校验这三点。

## ② 根够不着 `openCompose()` ⇒ 照 `PushService` 的"两半"形状

`openCompose()` 住在**条件挂载**的 `CommPage` 上(`if (currentIndex === 0)`),
根组件拿不到它。新建 `common/ComposeIntent.ets`:

  · **格子**(`pending`)—— 用户此刻在别的页、`CommPage` 还没实例化;
  · **回调**(`listener`)—— 用户此刻就在通信页、页面早挂载完了。

缺任何一半都是**按键静默失效**:只有格子 ⇒ `aboutToAppear` 不重跑;
只有回调 ⇒ 没有监听者。这形状与 `api/PushService.ets` 处理"点通知跳转"时
踩的是同一个坑,那边注释里已写过解法 —— 这次是照着抄,不是重新踩。

## ③ 收件人三段式补全(↑↓ 切换、Enter/Tab 选中、Esc 收起)

WebUI 的逻辑原先**散在组件闭包里**(`parseParts` 没 export、`apply`/`onKeyDown`
直接改 React state)⇒ 判据根本 import 不到。先把它抽成一对纯逻辑:
`client/electron/src/lib/addressSuggest.ts` ↔ `model/AddressSuggest.ts`,
**抽的时候行为一字不改**(抽出来顺手"改进"会让判据比新行为、线上跑旧行为,两边都错)。
`AddressInput.tsx` 改接这份 lib。

`cross-client-logic` 新增 `AddressSuggest` 一对,22 个用例两边逐例比。
其中 `nextActiveIndex(0,3,-1)` 是**负下标陷阱**:JS 的 `%` 对负数返回负数
(`-1 % 5 === -1`),而负下标在数组访问里**不报错**(返回 `undefined`),
只表现为"按上键后没有任何一项高亮"。直接写 `(i-1) % n` 就会这样静默坏掉。

候选走**内联渲染**而不是 `bindPopup`/`bindMenu`:那两者各有焦点体系,
会先吃掉按键 ⇒ "↑↓ 换候选"落不到 `onToKey` 上。

## ④ 另修一个真 bug:`AddressSuggestion` 字段名整套写错

模型声明 `value`/`kind`,而服务端(`contacts.go:206`)给的是 `alias`/`title`/`source`
—— **从来没返回过** `value`/`kind`。按本仓纪律「声明了服务端从不返回的字段 ⇒ 删掉声明」改正。

顺带守住 `title` 的 `omitempty`:缺键时裸 cast 给 `undefined`(**不是**类里那个 `= ''`),
直接读会 `Cannot read property of undefined` —— 本仓在 `MailDetail.normalize()` 上
踩过同一形状(整页白屏)。判据钉住那句 `typeof … === 'string'` 的守。

## ⑤ 三处判据被**实测**改判(不是我挑一边,是拿数字定的)

### a. 卡片不该有模糊 —— 反转原断言
原判据断言「玻璃卡要走参数化 `backgroundEffect`」。那是**记录旧实现的副作用**
(重言式)。逐字读 WebUI 的 CSS:全仓 `backdrop-filter` **只有两处**
(`.glass-control` 8px/1.1、`.narrow-nav` 18px/1.5),而 `.glass-card`(`index.css:1629`)
**完全没有** —— 它的玻璃感是 `rgb(255 255 255 / 0.92)` 这个 alpha。
留着旧断言更坏:下次谁把卡片改成正确的"白 + alpha"会被判红,然后去**把模糊加回来**。

### b. 我试了"把模糊移到导航条",被实测否掉
推断「真归属是底栏」,于是把底栏改成 `backgroundEffect({radius:18, saturation:1.5})`。
实测(模拟器窄屏 1008×2232,底栏中心列 x=504):

    y      backgroundEffect        backgroundBlurStyle
    1960   rgb(191,199,209)        rgb(234,235,239)      ← 差 -36 亮度
    2060   rgb(206,211,219)        rgb(234,235,239)      ← 差 -24

⇒ `backgroundEffect` **只给模糊、不给底色**,壁纸原样透上来,底栏暗了 24~36 级。
WebUI 的 `.narrow-nav` 是**两条声明**组合的(`background-color` + `backdrop-filter`),
我只搬了后者。系统材质**同时含色调 + 模糊 + 深浅两套** ⇒ 在这里它才是正解
(§7.12 把它判成"有意差异"是对的,我的"改进"是退步)。
判据改成**反向钉住** `backgroundEffect`,并把这段实测数字留在 Theme.ets 里。

### c. 页签条判据记录的是被否掉的"胶囊"
原判据钉 `TAB_BAR_RADIUS`/`TAB_BAR_SIDE`/`TAB_BAR_TOP` —— 那正是用户否掉的形状
(「你又在内部套了一个胶囊」)。CDP 读 WebUI 的实测几何:

    .comm-pane   x=80 w=320 radius=14px overflow=hidden   ← 窗格,裁圆的是它
    tabstrip     x=80 w=320 radius=0px                    ← 条自己无圆角

设备实测(`uitest dumpLayout`):页签条 `[28,140][980,267]`、窗格 `[28,140][980,1957]`
⇒ 左右边缘逐像素相同。判据改为钉"与窗格齐平 + 只有左上角圆角"这两条**不变式**,
`TAB_BAR_RADIUS`/`TAB_BAR_SIDE` 一并**删除**(留着就是孤儿,会邀请人把胶囊拼回来)。

## ⑥ 顺带修两条判据自己的正则
`{6}` 看不见 8 位色 ⇒ 把 `glassCard`/`glassCardWall` 报成"清册过期",
**病因报错了**。改成 `{6}(?:[0-9A-Fa-f]{2})?`(不能写 `{6,8}`,那会连 7 位也放进来)。
半透明禁令改为**枚举白名单**(不是放宽:遮罩那个真实约束原样保留,
`overlay` 写成 8 位单色照样红)。

新增判据 `harmony-2in1.test.mjs` 12 条;`files=34 checks=543 pass=540 fail=2`(收尾前)。
2026-09-21 13:29:24 +08:00

3447 lines
222 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', [], 88],
['test/nav-merge.test.mjs', [], 9],
['test/animation-audit.test.mjs', [], 4], // 动画全量盘点:死动画/过宽作用域/弹层接线/reduced-motion
// 深色模式:色板反转 + 玻璃 alpha 档 + 底必须是暗的(2026-09-17 那次
// 「只有通信页深色正常」的回归锁 —— 38 条里后 8 条是这次新增)。
['test/theme.test.mjs', [], 38],
['test/background.test.mjs', [], 44],
['test/cross-client-theme.test.mjs', [], 21],
// 左右滑动翻页的**语义契约**(两端逐项相同、数值各自定)—— 这是
// `docs/DEBTS.json` 的 `gesture-semantics` 那条债:它写着「鸿蒙侧出现滑动
// 手势代码时**立即建**(此前建 = 只有一端存在的假判据)」。
// 2026-09-19 鸿蒙侧加了手势 ⇒ 现在建。8 条:两端都真有手势 / 方向映射 /
// 纵向优先 / 快滑窗口 / 复用同一翻页函数 / 无边界回弹 / 有意差异被记录 / 自检。
['test/cross-client-gesture.test.mjs', ['--experimental-strip-types', '--no-warnings'], 9],
/*
* ★ 纯逻辑跨端一致性(用户要做的 B):同一张用例表喂两边比结果。
* `--experimental-strip-types` 是必需的 —— 它直接在 node 里**执行**
* electron 的 `.ts` 与 harmony 的 `.ts` 两侧源码(两边的 model 层都是
* 纯逻辑、无 SDK 依赖)。首轮就在表里报出两处真分叉:
* `formatAddress(undefined)` 抛 与 `monthGrid` 行数 4~6 vs 固定 6。
*/
['test/cross-client-logic.test.mjs', ['--experimental-strip-types', '--no-warnings'], 6],
// 预设的**行为**判据:每一档都真的画得出来(能真跑,不需要设备 ⇒ 不进 static 欠账)。
// 与 appearance-defaults 那条「清单 id/顺序相等」配对:值判据管清单,行为判据管渲染器。
['test/harmony-presets.test.mjs', ['--experimental-strip-types', '--no-warnings'], 6],
['test/harmony-logic.test.mjs', ['--experimental-strip-types', '--no-warnings'], 34],
['test/harmony-system-api.test.mjs', [], 5],
// P4 外观同步:跑 model/Appearance.ts(纯逻辑),所以也要 strip-types
['test/harmony-appearance.test.mjs', ['--experimental-strip-types', '--no-warnings'], 27],
// P5 悬浮玻璃导航:点击配对 / index 决定挂载 / 命中区 ≥44vp / 悬浮与让位
['test/harmony-nav.test.mjs', ['--experimental-strip-types', '--no-warnings'], 21],
// 上下黑边(用户 2026-09-17/18 报过两次)——钉的是一整套东西的两半:
// `setWindowLayoutFullScreen(true)`(消黑边)+ `getWindowAvoidArea`(让开时钟/手势条)。
// 只做前半 ⇒ 页签被时钟盖住(上一次就是这样退回去的,黑边于是留了三天);
// 只做后半 ⇒ 黑边照旧。**两半缺哪一半都要判红**,所以变异自检两个方向都跑过。
['test/harmony-window.test.mjs', ['--experimental-strip-types', '--no-warnings'], 9],
// 外观契约:默认值去 Go 源码里读(服务端 DefaultAppearance 是权威)+ 缓存键按账号
['test/appearance-defaults.test.mjs', [], 7],
['test/build-stamp.test.mjs', [], 7],
['test/packaging.test.mjs', [], 5],
['test/align-refs.test.mjs', [], 3],
// 11 条里后 3 条是 2026-09-18 的**包名漂移**回归锁(判据里不许再出现包名字面量 ——
// 那次漂移让 `harmony-nav` 的行为判据永远走"设备忙"跳过,数到 42 轮才被闹钟顶出来)。
['test/harmony-deviceprobe.test.mjs', ['--experimental-strip-types', '--no-warnings'], 11],
// 契约/形状 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'], 30],
['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', [], 7],
// 用户管理页(P4c 同批):动作↔服务端调用同名 / 门禁只认严格 admin /
// 启停只发 status / 「受限」徽标口径 / 页面零写死色值 / 接线(纯逻辑真被调用)
['test/harmony-admin.test.mjs', ['--experimental-strip-types', '--no-warnings'], 30],
// P4c 图片上传:阈值与两档策略 / 失败必带原因 / 退档判定只有一处 /
// release 都 await / 解码按目标尺寸 / multipart 字段名 / 上传后重新同步
['test/harmony-imageprep.test.mjs', ['--experimental-strip-types', '--no-warnings'], 31],
// ArkTS **编译期**硬规则(纯文本可判、不需要设备)。这一条是构建撞出来的:
// 我把常量表插在了既有 import 之前 ⇒ arkts-no-misplaced-imports,而当时没有任何判据会跑它。
['test/harmony-arkts.test.mjs', [], 8],
/*
* 2in1 键盘可达(用户 2026-09-21「快捷键打开发信页面 / 上下键切换发信目标 /
* 回车展开输入框」)。见该文件头部说明:为什么单开一个文件、为什么不端到端验。
*/
['test/harmony-2in1.test.mjs', [], 12],
['test/harmony-contacts.test.mjs', [], 5],
// ★★ 下面三条是**补接线**,不是新写的判据(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', [], 7]
];
// 自检 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 = [];
/*
* ★★ 「条数登记」校验的**行为锚点**(dsh 2026-09-19)。
*
* 为什么需要它:那条校验原来写在**最后一个 `else`**("退出码 0")里,
* 而 `r.status !== 0` 会先在 `else if` 里 `reds.push` 并**跳过它** ⇒
* **文件越红,它的登记数越没人守**(实测:`harmony-admin` 登记 22/实际 27 且为红 ⇒
* 校验 0 命中;同刻 4 个绿文件的条数不符全都正常报了)。
* 缺口**只在文件恰好是红的时候隐形**,所以它潜伏到了 `f4b8bc1`
* (该提交自己写着 "+5" 却没同步登记数)—— 平时根本看不出来。
*
* ⇒ 只把校验挪进 `else { … }` 还不够:下一个人挪回去**什么都不会红**。
* 所以加这个锚点,落在**实际发生的比较**上(不许落在源码文本上,§16.3):
* 凡"自报了条数却与登记不符"的文件,**无论红绿**,都必须在这张表里留一条记录;
* 汇总处断言 `countCheckRan ⊇ 所有条数不符的文件`。
*/
const countCheckRan = new Set();
/**
* 各计数器**不在这里累加** —— 它们由下面的 `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 = [];
const ONLY_SELFTEST = (process.argv.find(a => a.startsWith('--only-selftest=')) || '').split('=')[1] || '';
for (const [file, flags, expected] of SUITE) {
if (ONLY_SELFTEST) { skippedByPhase.push(file); continue; }
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+)/`(node v24 起前缀变成 `ℹ`,现已两种都认) ⇒ `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})`);
countCheckRan.add(file); // ★ 行为锚点:这条校验**被评估过**(红绿都记,见 `countCheckRan` 的说明)
/*
* ★★ 「条数登记」校验必须在**这里**(红绿都跑),**不能留在下面那个 `else` 里**。
*
* 实测(dsh 2026-09-19,设备在场时跑全套发现的):
* `test/harmony-admin.test.mjs` **登记 22 条、实际 27 条**(差 5 条,
* 那 5 条**不在"被删会红"的保护内**),而**它当时是红的**(1 fail)⇒
* 走的是上面 `退出码 1` 那条分支 ⇒ **这条校验一次都没被评估**:
* `grep '自报.*harmony-admin' dev.log` ⇒ **0 命中**
* (同时刻另有 4 个**绿的**文件条数不符,它们**全都正常报了** ⇒
* 说明不是校验写错,是**它在红的那条路上到不了**。)
*
* ★ 这正是本仓反复消的那个形状:**"判据在,但走不到"** ——
* 而且方向是**假绿**:一个文件越红,它的登记数越没人守;
* 等到它被修绿那天,那条校验才第一次生效 —— 那时"多出来的几条"可能早被删了。
* (同族:`unlisted`/`blind`/`baseline=` 的结论到不了 `verdict`,§16.1。)
*
* ⚠️ 但 broken(崩了/没自报)**不在这里**判:`diedWithoutReporting` 已把它吃掉,
* 再叠一条"没找到自报条数"只是噪音 ——"判据没答 ≠ 判据答错了"(§17)。
*/
/*
* ★ 这里**只保留"没自报条数"的那条红**;**任何累加都不在这里做** ——
* 投影之后累加根本不作为语句存在,上面那条 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,
'mutant-anchor-stale': 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'));
}
}
/*
* `anchorstale`:**变异条目的锚点失效**(`hits == 0`)——
* 标准夹具 `jobs-one.json` 指向 `client/harmony/.../api/ApiClient.ets`。
* 在迷你仓库里建一个**同名但不含那个锚点**的文件 ⇒ 锚点命中 **0** 次
* (`hits=-1`(文件不存在)是**另一回事**,见 `summary.py` 里那句射程说明)
* ⇒ `diag=mutant-anchor-stale`、`rc=1`:**守具没挂上必须显形**。
*/
if (mode === 'anchorstale') {
const anchorAbs = join(root, 'client/harmony/entry/src/main/ets/api/ApiClient.ets');
mkdirSync(dirname(anchorAbs), { recursive: true });
writeFileSync(anchorAbs, '// 故意不含锚点:模拟"锚点写法漂移后命中 0 次"\n');
}
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'],
/*
* ★★ pi 2026-09-18 本封报的**组合(off-diagonal)**口子 —— 这条案例就是它的锚点。
*
* 修前 `summary.py` 里有两份优先级表且**顺序相反**:
* `diag` 先看 `unlisted/ghosts`、`rc` 先看 `blind/unreadable/baseline-*`
* ⇒ 两类**同时**成立时(这里:`unlisted` + 跑不了 `sha256sum`),
* `diag=manifest-mismatch`(表里 **1**)而**真 rc=2** ⇒
* 上面那条不变式**在其上为假**,而**12 个案例个个只动一维** ⇒ 只在**对角线**上验过。
*
* ⇒ 修法是**一条链推 `(diag, rc)` 一对**(`summary.py` 里 `severity`),
* 并在这里**显式走一遍 off-diagonal**:期望 `diag=baseline-unrunnable` + `rc=2`
* (严重度**按 2 档取**,因为它更重 —— "连数都没读成"盖过"清单没登记")。
* ⚠️ 这条**不能**只靠"每跑必断不变式"代替:组合态下若 `diag` 又被低档码占住,
* 那条断言就**永远验不到 2 这一档**(它只会验到手边那个码的期望值)。
*/
['组合:未列入清单 + 跑不了 sha256sum ⇒ diag=baseline-unrunnable(2 档优先)且 rc=2',
['jobs-one.json'], ['jobs-one.json', 'jobs-extra.json'], 2, 'diag=baseline-unrunnable', 'no-sha256sum'],
/*
* ★★ 第五类(dsh 2026-09-19):**变异条目的锚点失效**(`hits == 0`)。
* 这条就是它的端到端锚点 —— 也是"反向覆盖"要求的那条真跑案例
* (加新码若没有案例真跑过,`--exitcode-selftest` 会当场点名)。
* 期望 `rc=1`(数据/清单该改),**不是 2**(那不是环境问题)。
*/
['变异条目锚点失效(hits=0)⇒ rc=1 且 diag=mutant-anchor-stale',
['jobs-one.json'], ['jobs-one.json'], 1, 'diag=mutant-anchor-stale', 'anchorstale'],
];
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` 的案例同样有效。
*/
/*
* ★★ pi 2026-09-18 本封报的新口子:**组合(off-diagonal)没人可达**。
*
* `summary.py` 里两条链的**排序不同**:
* `diag`(`:321-328`):`unlisted/ghosts` **排第一**
* `rc`(`:438-445`):`blind/unreadable/baseline-unrunnable|unknown` **排第一**
* ⇒ 两类**同时**成立时 `diag=manifest-mismatch`(表里 1)而**真 rc=2**,
* 于是上面这条不变式**在其上为假**。
* 实测(`unlisted` + `baseline-unrunnable`,裁 PATH,root 可达):
* `diag=manifest-mismatch`、真 `rc=2`、`UPSTREAM_RC['manifest-mismatch']=1` ⇒ 不一致。
*
* 根因不是"漏了一个案例",是**上面 12 个案例个个只动一维** ⇒ 不变式只在**对角线**上被验过。
* ★ 所以修法**不能只是"再加一条固定案例"**(那只挪一格对角线)——
* 要让它对**每一个真跑出来的码**都成立:把不变式**提出来对每次真跑都断言**。
* ⇒ 这样以后 `summary.py` 新加任何组合,只要 `diag` 与 `rc` 不同步,这里立刻红。
*
* ⚠️ 但**光靠"每跑必断"还不够**:真跑出来的 diag 若是"被盖住的那一个"
* (组合态下永远是 `manifest-mismatch`),这条断言就**永远验不到 2 那一档**
* ⇒ 必须**显式有一条组合案例**把 off-diagonal 走一遍(下面 `combos`)。
*/
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` 里的页面/组件,本机没有设备也没有模拟器
/*
* ★★ 2026-09-19 **移除** `harmony-admin` 这条(它在这里待了很久)。
*
* 它当初的理由是「用户管理页:`.ets` 页面要 hvigorw 才能编译、要设备才能点
* (本机两者都没有)」。现在设备可用,那条判据**已经升级为设备判据**:
* 「管理页能从「我的」页打开,且列表真的渲染出用户」
* —— 自己拉起应用、滚到底、点进去、断言用户行真的渲染。
*
* ⇒ 它不再是"只能静态"的判据,**留在这里会让欠账余额虚高**:
* 余额是给"还欠着的"记账的,还完了就该划掉。
* (这正是这个机制的意义:前提一旦成立,欠账必须当场处理,而不是永远躺着。)
*
* ⚠️ 移除 ≠ 那条判据消失:它仍在套件里(`SUITE` 里 28 条),
* 只是不再计入"静态欠账"。
*/
/*
* ★ 第 2 列的**事实修正**(dsh 2026-09-19,把这一列变成每次运行都可见之后的第一件事
* 就是去读它,结果读到一句假话):
* 原文是「要 `@ohos.multimedia.image` + 相册 + **服务端**,三样本机都没有」——
* **"服务端"这一项不成立**:`server/internal/handler/attachments.go` 在,
* 且它的 Go 测试在本机能跑通(`go test ./internal/handler/ -run Attach` ⇒ ok)。
* 而且这条判据**根本没连服务端**:它读的是 `appearance.go` 里
* `appearanceMaxBytes()` 的默认值(本笔刚把它从硬编码改成**真读 Go 源**)。
* ⇒ 服务端那一半由本判据**静态覆盖**,欠的只有**设备侧的相册/解码真跑**。
*/
['test/harmony-imageprep.test.mjs', '上传链的设备侧:`@ohos.multimedia.image` + 相册要设备才能真跑(服务端那半本判据直接读 `appearance.go` 的源码,已覆盖)', 'device']
];
for (const [file, why, 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);
}
/*
* ★★ pi 2026-09-18 本封的【发现 1】:第 2 列「当初只能静态的原因」**没有任何判据在读**。
* 我同刻 A/B 复现了它:把这一列改成一句假话,`red` 与红清单**没有一处**因它而变;
* 它唯一被读到的场合是**到期闸点名时打印** —— 而那正是它最不需要被检验的时刻。
*
* ⚠️ 我第一版修法**是错的,已撤**:我要求这一列必须点到探针 `desc`/`need` 里的某个
* ASCII 标识符(如 `hdc`/`hvigorw`)。实测**6 条里红了 5 条** ——
* 而它们的理由都是**真话**("观感与运行期换肤要设备"这种中文描述本来就不含标识符)。
* ⇒ 那是一条**在正确数据上误报**的判据:**假红是噪音**,比不设判据更糟。
* ★ 教训:**"让字段可证伪"不等于"给它加一条会红的规则"** ——
* 先拿**现有数据**试一遍,红了就说明规则错了,不是数据错了。
*
* ⇒ 正确的修法(见下方 `STATIC_ONLY` 逐条播报 + 钉住它的自检 4b):
* **给它一个读者** —— 每次运行都把它播报出来,并加一条自检**断言它真的被播报**。
* 这样"没人读"这件事消失,而**不需要**给"理由是否为真"造一条会误报的判据。
* (这与本仓那条老纪律一致:结论要**到达**读者,而不是"更严格地评判它"。)
*/
}
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 的子集,这不可能成立`);
}
/*
* ★★ 自检 5b(dsh 2026-09-19):**条数登记校验必须在红绿两条路上都被评估**。
*
* 独立重算:从 `records`(谁自报了条数)与 `brokens`(谁没读起来)**直接算出**
* "应当被那条校验评估过"的文件集合 —— **不读** `reds`、**不看**那条校验的输出
* (那会变成"读数器自作证")。
*
* ⚠️ **第一版这里是空的**(dsh 自捉,如实记):我原来只比"**条数不符**的文件",
* 而这条修复本身就把 `harmony-admin` 的登记数对齐了 ⇒ `mismatched` 恒为空
* ⇒ **`∀x∈∅` 恒真,5b 永远不会红**(正是本仓那条"空真"陷阱)。
* 变异测试当场抓到:把记录挪回"只绿才走",5b **不响**。
* ⇒ 改成比**"所有自报了条数且没坏的文件"**(`shouldBeRecorded`,红绿都含):
* 红文件(`harmony-admin` 等)**一定在里面** ⇒ 非空、且"红文件被漏记"必被抓。
*/
{
const brokenFiles = new Set(brokens.map(b => String(b).split('(')[0].trim()));
const shouldBeRecorded = records
.filter(r => r.checks !== null && !brokenFiles.has(r.file))
.map(r => r.file);
/* 反空转:除非"整个套件都跑不起来",否则这个集合必须非空 —— 空集合会让下面的断言恒真 */
if (shouldBeRecorded.length === 0 && brokens.length < SUITE.length) {
probs.push('自检 5b 自身失效:`shouldBeRecorded` 为空(却并非所有文件都 broken)'
+ ' ⇒ 下面的覆盖断言会**恒真**(`∀x∈∅`)。');
}
const unguarded = shouldBeRecorded.filter(f => !countCheckRan.has(f));
if (unguarded.length) {
probs.push(`这些文件**自报了条数**、却没被"条数登记校验"评估过:`
+ `${unguarded.join('、')} —— 校验被挪到某条走不到的路径上了`
+ `(典型:挪回"退出码 0"那条分支 ⇒ **文件越红越没人守**,方向是假绿)。`
+ `修法:校验必须在 \`else { … }\`(红绿都跑)里,并保留 \`countCheckRan.add(file)\`。`);
}
}
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]));
/*
* ★★ pi 2026-09-18 报的【发现 1】的正确修法:**给第 2 列一个读者**。
*
* 「当初只能静态的原因」原来**没有任何判据在读** —— 它只在**到期点名**时被打印,
* 也就是**只有在它已经不重要时**才被读到。我同刻 A/B 复现:把这条理由改成一句假话,
* `red` 与红清单**零变化**。
*
* ⚠️ 我第一版修法是"给它加一条会红的规则"(理由必须点到探针的某个标识符)——
* **实测在 6 条真理由上红了 5 条 ⇒ 已撤**。**假红是噪音**:
* 在正确数据上误报的判据,比不设判据更糟。
* ⇒ 正确的修法不是"更严格地评判它",而是**让它到达读者**(本仓的老纪律):
* 每次运行都把它**全表播报**出来(含"是否到期"并排),
* 并由**自检 4b** 钉住"每条都真的被播报"。
*
* ★ 定义放在这里(`dueStatic` 之后、自检之前)是**必须的**:
* 自检 4b 要读 `staticDetail`;若它定义在自检之后,会 `Cannot access before initialization`
* 当场抛错(我第一版就是这样,实跑撞到)。
*/
const staticDetail = STATIC_ONLY.map(([file, why, probe]) => {
const due = dueStatic.some(([f]) => f === file);
return ` ${due ? '⏰到期' : ' —— '} ${file}\n`
+ ` 当初只能静态的原因:${why}\n`
+ ` 到期前提(${probe}):${PROBES[probe].desc}`;
}).join('\n');
/*
* ★★ 自检 4b(pi 2026-09-18 报的【发现 1】:第 2 列是**死重**):
* **"当初只能静态的原因"必须真的被播报出来。**
*
* 起因:这一列**没有任何判据在读** —— 它唯一被读到的场合是到期点名时打印,
* 而那正是它**最不需要被检验**的时刻。我把 6 条之一的理由改成一句假话,
* `red` 与红清单**零变化**(同刻 A/B 实测)。
*
* ⚠️ 我第一版修法是"给它加一条会红的规则"(理由必须点到探针的某个标识符)——
* **实测在 6 条真理由上红了 5 条 ⇒ 已撤**。**假红是噪音**:
* 一条在正确数据上误报的判据,比不设判据更糟。
* ⇒ 正确的修法不是"更严格地评判它",而是**让它到达读者**(本仓的老纪律)。
*
* ⚠️★ 我**第二版修法也是错的,也当场被变异抓出来**:那一版断言的是
* **`staticDetail` 这个变量的内容**(每条文件名与理由都在里面)。
* 实测变异:**只把那条 `console.log` 删掉**(变量仍完整)⇒ 4b **照样报 ok**,
* 而真实输出里播报段 **0 次** ⇒ 一个"理由根本没到达读者"的世界,被判成绿的。
* ⇒ 这正是本仓反复消的那条:**自检守的是"它读的那个值",不是"那个值怎么来的"。**
* 变量对 ≠ 打出去了。**唯一能证明"到达"的,是读者真正收到的那份东西。**
*
* ⇒ 现在这条判据读的是**真实 stdout 里那一行**(本进程自己已经打过的话会被
* `spawnSync` 的父进程输出捕获,但自检在打印**之前**跑 —— 所以这里改读
* **源码里那句 `console.log` 是否真的带着 `staticDetail`**,并**再加一条**:
* "不能只有变量、没有打印"。两条合起来才等价于"到达"。
*/
try {
const probs = [];
/*
* 判据形状(两段,缺一不可):
* ① **结构**:源码里必须存在一句把 `staticDetail` 打出去的 `console.log`。
* 这是"值 → 读者"的那一跳;第 ②段单独存在时它正是漏掉的那一跳。
* ② **内容**:那个变量必须**完整**(每条的文件名与理由都在、长度合理)。
* ★ 变异对照(我实测):
* · 只删 `console.log` ⇒ ①红(第 ②段仍绿)—— 这就是第二版漏掉的那格;
* · 把 `staticDetail` 定义成空串 ⇒ ②红。
*/
const selfSource = prose(fileURLToPath(import.meta.url));
/*
* ① 「变量真的被打出去」——**锚点不许自匹配**(自检 4 的老教训,我这次又踩了):
* 第一版把锚点写成 `'${' + 'staticDetail}'`,而**这句拼法本身就出现在这段自检的注释里**
* ("这里断言:… `${staticDetail}` …")⇒ `includes` 命中的是**自检自己的注释**,
* 于是"删掉真正的 console.log"那个变异**照样报 ok**(实测 0 次播报 + 4b ok)。
* ⇒ 修法:① 注释里**不写出那个字面形状**;② 判据改成**扫描代码区**(`stripComments`),
* 注释被剥掉后就不会自匹配;③ 再要求"出现次数 ≥ 1"。
* ★ 这也顺带把判据的**范围**说清了:它验的是**可执行代码里**有那一跳,不是注释里提过。
*/
const codeOnly = stripComments(selfSource);
// ★ 锚点取"真实的打印拼接",并**在运行时拼**以免在注释里自匹配;
// 剥掉注释后再数,命中 0 ⇒ 那一跳不在可执行代码里。
const printAnchor = '+ static' + 'Detail);';
const printHits = codeOnly.split(printAnchor).length - 1;
if (printHits === 0) {
probs.push('可执行代码里没有任何一处把 `staticDetail` 拼进输出 ⇒ '
+ '第 2 列**没有读者**(变量算出来了,但没打出去)');
}
/*
* ② 「内容完整」——`staticDetail` 由 `STATIC_ONLY` 逐条 map ⇒ 每条都要在里面。
*/
const missing = STATIC_ONLY.filter(([file, why]) => !staticDetail.includes(file) || !staticDetail.includes(why));
if (missing.length) {
probs.push(`${missing.length} 条没进播报文本:${missing.map((m) => m[0]).join('、')}`);
}
/*
* 反空转:`staticDetail` 不许短于"所有理由之和"(空了也会让上面两条都命中)。
*/
const whyTotal = STATIC_ONLY.reduce((n, [, why]) => n + (why || '').length, 0);
if (staticDetail.length <= whyTotal) {
probs.push(`播报文本长度 ${staticDetail.length} ≤ 所有理由长度之和 ${whyTotal} ⇒ 疑似空转/截断`);
}
if (probs.length) {
console.error('✗ 套件自检 4b(只能静态的原因必须被播报):');
for (const x of probs) console.error(` - ${x}`);
selfCheckFailed = true;
} else {
console.log(`ok 4b - ${STATIC_ONLY.length} 条「只能静态」的原因**每条都被播报**`
+ '(且源码里真有那句把它打出去的 `console.log`)');
}
} catch (e) {
console.error(`✗ 套件自检 4b 跑不了:${e.message || e}`);
selfCheckFailed = true;
}
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 与我 §七 同意)
},
/*
* ★★ 第五类(dsh 2026-09-19):**变异条目的锚点失效**(`hits == 0`)。
*
* 这一格原来只出现在 `summary.py` 的 stdout 里,**不进严重度链** ⇒ `rc=0`、
* `diag=none` ⇒ 套件那边 `whyLines` 的转印条件 `(note || status!==0)` 为假
* ⇒ **整段丢掉**。端到端实测(真树、干净工作树):`summary.py` 说「hits=0 …
* 过期条目」· 套件输出里 grep **0 次** · `rc=0`。
*
* 后果是**一个变异守具被静默关掉**:真树那一条(`SettingsPage.ets` 的
* 「管理入口不做门禁」)锚点是 6 空格,而 `f4b8bc1` 把该文件重排成 4 空格
* ⇒ 命中 0 次、`ran` 少 1、**没有任何东西红**;但手工施加那个变异**仍能让
* `harmony-admin.test.mjs` 红** —— 有齿,只是没挂上。
*
* `blocksGreen: true`:锚点过期是**清单该改**(去改锚点或标 `retired`),
* 与 `manifest-mismatch`(1 档)同类 —— **不是**环境问题(那才是 2 档)。
*/
'mutant-anchor-stale': {
checked: true,
note: '**注意:有变异条目的锚点已失效(`hits=0`)** —— 那个变异**根本没被挂上**'
+ '(守具有齿但不在位)⇒ 请改锚点或标 `retired`;`ran` 比登记的少',
blocksGreen: true,
},
'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()))
/*
* ★ 加 `hits=` 这一支(dsh 2026-09-19):只说"有锚点过期"不够 ——
* 读者要知道**是哪一条**(哪个文件、哪句 why)才能去改。
* 与 `未列入清单`/`清单里有、磁盘上没有` 同形状:**点名的才是可行动的**。
*/
.filter(l => /✗✗|⚠️|★ 清单与磁盘不一致|在但读不到|未列入清单|清单里有、磁盘上没有|^\s*hits=/.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';
/*
* ⑤(dsh 2026-09-19)**变异条目锚点失效**:`hits=0` ⇒ 那个变异根本没被挂上。
* `whyLines` 的过滤器里 `hits=0` 那两行含 `⚠️` ⇒ 会被收进来;
* 关键是**转印条件必须为真**(`diag=mutant-anchor-stale` ⇒ `blocksGreen` ⇒ 红 ⇒ 转印)。
*/
const anchorstale = 'RESULT mutants=48 ran=47 skipped=1 on_new_criteria=35 diag=mutant-anchor-stale\n'
+ '跳过(锚点命中≠1 ⇒ 跑不起来):\n'
+ ' hits=0 client/harmony/entry/src/main/ets/pages/SettingsPage.ets 「管理入口不做门禁」 (1 条条目:jobs-all.json)\n'
+ ' ⚠️ hits=0 通常是**过期条目**(锚点是旧写法)—— 请标 retired 或删除,\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 }],
/*
* ⑤(dsh 2026-09-19)**变异条目锚点失效**:`status=1`、正则匹配。
* 这一格是"守具没挂上"—— 必须把「锚点已失效」转印给读者,
* 否则 `ran` 少 1 而读者只看得到 `mutants=… skipped=1` 这个**中性数字**。
*/
['变异条目锚点失效(status 1,正则匹配)', anchorstale, 1,
{ line: '锚点已失效', why: 2 }],
// ④ 异常退出码但正则匹配(既不是 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` 之前。)
*/
/*
* ★★★ **唯一的一份登记**(pi 2026-09-18 报的层 9)。
*
* 层 7 我为了把因果判据的成本从 4.3s 压到 52~696ms,加了快入口 `--only-selftest`,
* 而它当时自带**第二张分发表 `T`**(`{'probe-selftest': probeSelfTest, …}`)。
* 我在上一封自己把那个入口标成"副作用、不是防线"—— **层 9 就长在那个副作用上**:
*
* · `T` 全换成 `() => 1` 桩 ⇒ `red=9`,与基线**逐字相同**、因果 RED **0 条**;
* · `T` 全桩 **+ 假 ok×13**(层 7 的攻击)⇒ `red=9`、**红清单 diff 0 行**。
* ⇒ **层 7 的攻击被完整放回,而代价只是"把 `T` 里 5 个值换成 `() => 1`"。**
*
* 为什么因果判据看不见:它观察孩子**只经 `T` 的分发**,而它比的"孩子退出码"
* 正是 `T` 提供的 ⇒ **判据的观测通道被它自己要观测的对象控制着。**
* ⇒ 层 7 是"**被测者**的话可信吗",层 9 是"**测量者**的话可信吗"。
*
* ⇒ 修法(pi ①,我采用):**让 `T` 不存在** —— 一处定义(本表)、一处派生。
* 那么"桩化 `T`"这个动作**没有落点**(改本表会被层 5/8 的判据抓住)。
* ⚠️ 本表因此**上移到 `--only-selftest` 之前**(原来是块内的 `const`,在它之后)。
* 五个自检都是 `function` 声明(**提升**),所以引用它们没有 TDZ 问题;
* 而**调用**它们仍必须排在 `DIAG` 等 `const` 表之后 —— 下面那个 `if` 就在原位,
* 这正是它位置的理由(见上面那段"const 不提升"的注释)。
* ★ 一般化(pi 建议写进 `CRITERIA.md`):
* **每加一条防线,要同时回答两问:① 它守什么?② 它新增了哪些"必须被守"的东西?**
* 层 9 就是第②问没问的产物。
*/
const SELFTESTS = [
['probe-selftest', probeSelfTest],
['exitcode-selftest', exitcodeSelfTest],
['skip-selftest', skipSelfTest],
['verdict-selftest', verdictSelfTest],
['mutants-line-selftest', mutantsLineSelfTest],
];
if (ONLY_SELFTEST) {
const entry = SELFTESTS.find(([n]) => n === ONLY_SELFTEST);
if (!entry) { console.error(`✗ 未知自检 ${ONLY_SELFTEST}`); process.exit(2); }
process.exit(entry[1]() > 0 ? 1 : 0);
}
let selfCheckRuns = 0;
{
/*
* 自检的输出**不是**这个阶段的正常输出(各打几十行 `ok …`)⇒ 吞掉 stdout,
* 只在红时把它自己的 RED 行带进报告。
* ⚠️ 只吞 `console.log`,**`console.error` 不动** —— 有东西往 stderr 说话时
* 不许被我盖住(本仓"错误信息被吞掉"那条老教训)。
*/
const swallow = console.log;
const selfCheckEmitted = new Map();
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;
}
/*
* ★★ **"它做了没有"**(pi 2026-09-18 报的层 6)。
*
* 上面收的 `seen` 是这条自检**真跑出来**的断言行 —— 把它记下来,
* 等一下与 `CRITERIA.md` 里声明的**下界**比。理由见下面那段长注释。
*/
selfCheckEmitted.set(name, seen.filter(l => /^(ok|RED) /.test(l)).length);
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\s+\w+SelfTest\s*\(/],
['箭头函数', 'const fooSelfTest = () => {};\n', /^const\s+\w+SelfTest\s*=\s*\(\s*\)\s*=>/],
['async function', 'async function fooSelfTest() {}\n', /^async\s+function\s+\w+SelfTest\s*\(/],
['名与括号间有空格', 'function fooSelfTest (a) {}\n', /^function\s+\w+SelfTest\s+\(/],
['const = async (…', 'const fooSelfTest = async (a) => {};\n', /^const\s+\w+SelfTest\s*=\s*async\s*\(/],
];
/*
* ★★ **样本表本身也必须被钉住**(我自己变异出来的 M45)。
*
* 实测:把 5 个样本**删掉 3 个**(只留 `function` 声明与 `const = async`)
* ⇒ 默认跑 **0 条 RED**、`red` 仍是 9,只是成功报文里那半句从
* "在 **5** 种写法上都有对照" 变成 "在 **2** 种写法上…"。
* ⇒ 也就是说 **"发现规则在 N 种写法上都有对照" 当时是散文,不是判据**:
* 它把 N 打出来,而 N 变小**没有任何东西拦**。
* 而"样本齐不齐"正是这条防线**唯一的防线** —— 样本可以被悄悄删光,
* 防线的覆盖就静默地退回到"只验我恰好留下的那一种"。
* **这是"判据存在 vs 判据在路径上"的又一个变体**:样本在,但"样本够不够"没判据。
*
* ⇒ 修法:把**要求覆盖的写法**列成一份**具名的**清单(`REQUIRED_FORMS`),
* 然后两个方向都判:
* ① 每种**要求**的写法都必须有样本(⇒ 删样本 = 红,不再是静默);
* ② 每个样本都必须能被认出来(原有方向)。
* ⇒ 删样本现在会红,而且**指名**删的是哪一种。
* (为什么不是"断言 `length >= 5`":那个数**自己**就是可以随手改小的常量。)
*
* ⚠️⚠️ **我原来在这里写的是"具名清单至少让'我要放弃支持箭头函数写法'变成一次
* 看得见的声明"—— 这句被 pi 实测推翻,我复现,读数逐字相同:**
* 把「箭头函数」从 `REQUIRED_FORMS` 与 `FORM_SAMPLES` **同步**删掉(合法地少支持一种)
* ⇒ `red=9` 与基线**逐字相同**、相关 RED **0 条**,只有成功报文里 5 变成 4。
* 连 `declaredSelfTests` 里 `const` 那条规则**一起**删 ⇒ 同样 **0 条**。
* ⇒ **"具名"这个形式救不了它**:`length >= 5` 与 `REQUIRED_FORMS` 在被同步修改
* 这一点上**难度相同**(都是一次编辑)。
* ★ 真正的锚点不是"具名",是**"要求必须落到一个非手写的锚点上"**(见下面的名字级扫描)。
*/
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 selfSource = stripComments(prose(fileURLToPath(import.meta.url)));
const wired = new Set(SELFTESTS.map(([, fn]) => fn.name));
/*
* ★★ **名字级锚点**(pi 2026-09-18 报的那条:三张**手写表**互相比对,没有外部锚点)。
*
* pi 的实验(我复现,读数逐字相同):把「箭头函数」从 `REQUIRED_FORMS` 与 `FORM_SAMPLES`
* **同步**删掉 ⇒ `red=9` 与基线**逐字相同**、相关 RED **0 条**,只有成功报文里
* 的数字从 5 变 4。更尖的形式:连 `declaredSelfTests` 里 `const` 那条规则**一起**删
* (四字段全同步)⇒ 同样 **0 条**,而"那种写法将来既不跑也不报警"的后果是实的。
*
* ★ 根因(pi 的概括我认同):上面这几张表**都是手写的、互相比对**——
* 名单 ↔ 样本 ↔ 见证,三张同步改就一致地"对",**没有任何外部锚点**。
* 对照 `UPSTREAM_RC`:它当年止住了共漂移,是因为它比对的是**真脚本真跑出来的 rc**,
* 不是另一张手写表。**"具名"这个形式救不了它** ——
* `length >= 5` 与 `REQUIRED_FORMS` 在被同步修改这一点上难度相同(都是一次编辑)。
* ⇒ 所以我 M45 注释里那句"具名清单让放弃支持变成**看得见的声明**"
* **被实测推翻**,这里改正:**只有当"要求"能落到一个非手写的锚点上,它才看得见。**
*
* ⇒ 锚点用**名字级扫描**:源码里出现的**任何** `\w+SelfTest` 标识符,
* 都必须已经接线(在 `SELFTESTS` 里)——**不解析声明语法**。
* 于是它与 `function`/`const`/`async`/箭头/空格**全部无关**:
* 发现规则认不出某种写法时,那种写法的自检名**仍然会被这个名字级判据看见**。
* 实测(我做过):删掉 `const` 规则 + 四字段同步 + 一个**未接线**的箭头自检
* ⇒ 旧的规则级扫描 **0 条 RED、一字不提**;名字级扫描**看得见** `silentArrowSelfTest`。
*
* ★ 唯一需要登记的例外:`FORM_SAMPLES` 里样本内容用的假名(`fooSelfTest`)。
* 它是一个**显式登记的**例外,不是"扫不到就算了"——这样它自己也看得见。
*/
/*
* ★★ **存在性锚点**(pi 2026-09-18 报的层 5):`SELFTESTS` 原本**没有下界**。
*
* 实测(我复现,与 pi 读数逐字相同):把**一整条自检**(函数体 + `SELFTESTS` 登记 +
* CLI guard)三处同步删掉 ⇒ `red=9 … verdict=red` 与基线**逐字相同**、相关 RED **0 条**,
* 只有报文里 `5 个自检` 变 `4 个自检`。最尖的形式是删掉 `exitcodeSelfTest`
* —— **守着 `UPSTREAM_RC`"真脚本真跑"锚点的那个** —— 同样零痕迹。
*
* ⇒ 根因:上面三道防线(名字级扫描、"写了没接线"、样本/见证/要求)
* 问的都是"**出现的东西对不对**",**没有一条问"该出现的东西在不在"**。
* 名字级扫描尤其说明问题:整条删掉时那个名字**两边都不出现** ⇒ 无从对比。
*
* ⇒ 锚点必须落在 `SELFTESTS` **之外**,且是**别的文件**(不是又一张内部清单):
* 读 `CRITERIA.md` §6.1 下方那行 `<!-- selftests: … -->`,要求与 `SELFTESTS` 的键
* **恰好相等**(两个方向都判)。`CRITERIA.md` 自己已被**自检 3** 守着
* (存在性 + 关键条目在)⇒ 链条:**被外部守着的文本 → flag 名 → guard → SELFTESTS**。
* ★ 删掉一条自检 ⇒ 本文件的名单没跟着删 ⇒ **红**(而"名单也一起同步删"是**跨文件**编辑,
* 不再是"改我自己的一张内部表")。
*/
/*
* ⚠️ 契约行必须**独占一行**且**只能有一条** —— 我第一次写这条判据时就踩了:
* 上面那段注释里**举例提到了**这个标记(行内),而当时的正则没锚行首行尾,
* 于是它**先命中了注释里那次**(捕获到 `…`)⇒ 干净树上立刻出现 2 条假红。
* ★ 这正是本文件反复写的那个形状的又一例:**"提到"与"就是"在文本上长得一样。**
* ⇒ 修法:锚 `^…$`(`m` 标志)+ **要求恰好一条**(多出来也红)。
*/
const SELFTEST_CONTRACT_RE = /^<!--\s*selftests:\s*([^>]*?)\s*-->\s*$/gm;
const contractMs = [...criteriaDoc.matchAll(SELFTEST_CONTRACT_RE)];
/*
* ⚠️ `contractEntries` 必须与 `contractMs` **同作用域**声明(不是嵌在下面那个 `if` 里)。
* 我第一版把它放在 `if (contractMs.length === 1) { … }` 内部,然后在块外用
* `typeof entries === 'undefined' ? [] : entries` 兜 —— 那**永远取到 `[]`**,
* 于是下界判据**静默不跑**。★ 这正是本文件反复写的形状:
* **一个"以防万一"的兜底写法,把判据本身变成了空判据**(比不写更坏:它看起来在)。
* ⇒ 改成同作用域声明;缺项由下面两条"没有/多余"的红覆盖,不靠兜底。
*/
const contractEntries = contractMs.length === 1
? contractMs[0][1].split(/\s+/).filter(Boolean).map(t => {
const i = t.lastIndexOf(':');
return i < 0 ? { name: t, floor: null } : { name: t.slice(0, i), floor: t.slice(i + 1) };
})
: [];
if (contractMs.length !== 1) {
console.log(`RED \`CRITERIA.md\` 里的 \`selftests:\` 契约行有 ${contractMs.length} 条`
+ '(要求**恰好 1 条**、独占一行)⇒ 自检的**存在性**没有唯一锚点');
reds.push(`(自检)\`CRITERIA.md\` 的契约行应为 1 条,实为 ${contractMs.length} 条`);
}
if (contractMs.length === 1) {
/*
* 契约项写成 `名字:下界` —— 下界是这条自检**至少该打出多少条断言**。
* ★ 语义照本仓 §6.6 的棘轮:**只增不减**(加断言不用改数字,掉下来才红)。
*/
const entries = contractEntries;
const contract = entries.map(e => e.name).sort();
const keys = SELFTESTS.map(([n]) => n).sort();
const missing = contract.filter(n => !keys.includes(n));
const undeclared = keys.filter(n => !contract.includes(n));
const badFloor = entries.filter(e => !/^\d+$/.test(e.floor || '') || Number(e.floor) < 1)
.map(e => e.name);
if (badFloor.length) {
console.log(`RED 契约里这些自检**没写下界**(或下界不是 ≥1 的整数):${badFloor.join('、')}`
+ ' ⇒ 没有下界就等于"它做了没有"没人问');
reds.push(`(自检)契约里缺/坏下界:${badFloor.join('、')}`);
}
if (missing.length) {
console.log(`RED 契约里要求存在的自检**在 \`SELFTESTS\` 里没有**:${missing.join('、')}`
+ ' ⇒ 那条判据被删掉了(它的存在性有外部锚点,躲不掉)');
reds.push(`(自检)契约要求而 \`SELFTESTS\` 缺:${missing.join('、')}`);
}
if (undeclared.length) {
console.log(`RED \`SELFTESTS\` 里有契约没登记的自检:${undeclared.join('、')}`
+ ' ⇒ 新增判据要同时写进 `CRITERIA.md` §6.1 的契约行');
reds.push(`(自检)\`SELFTESTS\` 有而契约没登记:${undeclared.join('、')}`);
}
}
/*
* ★★★ **"它做了没有"** —— 给判据的工作量一个**行为**下界(pi 2026-09-18 报的层 6)。
*
* 层 5 修的是"整条判据被删"(存在性)。存在性有**两种**失去方式:
* **名字没了**,和 **名字在、里面是空的**。后者零痕迹 —— 我实测:
*
* 把 `exitcodeSelfTest` 的**函数体**(14616 字节)换成 `{ return 0; }`
* (名字、`SELFTESTS` 登记、CLI guard、`CRITERIA.md` 契约行**一个都不动**)
* ⇒ `checks=459 pass=455 fail=4 skip=0 red=9 broken=0 unreported=0 verdict=red`
* 与基线**逐字相同**、`^RED ` **0 条**。
*
* ★ 最尖的形式(我认为这条最能说明问题):**掏空 + 把 `UPSTREAM_RC['baseline-residue']` 改成 99**
* (= 它本该抓的那个)⇒ `red=9`、**0 行提到 UPSTREAM**。
* **对照**(只改值、不掏空)⇒ `red=10` 且真报出
* `UPSTREAM_RC[baseline-residue]=99 与真跑出来的 rc=1 不符`。
* ⇒ **我们花三轮把 `UPSTREAM_RC` 锚到"真脚本真跑"上,这个锚点可以被一次"清空函数体"无声撤掉**,
* 而那条自检的**所有外部特征一个都不缺**。
*
* ⇒ 根因一句话:**上面所有防线问的都是"它**在不在**",没有一条问"它**做了没有**"。**
*
* ⇒ 修法:每跑完一条自检,记下它**真跑出来**的断言条数(`^(ok|RED) ` 行数),
* 与 `CRITERIA.md` 契约行里声明的**下界**比 —— 掏空 ⇒ 条数掉到 0 ⇒ **红**。
* ★ 下界在 `CRITERIA.md`(**外部文件**、被 4 个文件引用、自己已被自检 3 守着),
* 不在表自己身上、也不在函数体里 —— 照 §6.1 那条"锚点必须在表之外"。
*
* ⚠️ **下界只在"该自检自称没红"时判**,理由是我实测出来的一个真实约束:
* `exitcodeSelfTest` 在**拿不到降权工具**的机器上会 `continue` 掉两个案例、
* 多打一条说明(它那条是**故意的**:前提构造不出来就报红,不许静默跳过)。
* 那种机器上条数**本来就不同** ⇒ 若不管"红不红"都比下界,会在**别的机器上假红**。
* ⇒ 所以判据是:**自称绿的,工作量必须够**;自称红的,由上面那条"自检自己不该红"管。
* (这样"掏空"必被抓:掏空后 `bad=0` ⇒ 自称绿 ⇒ 但条数 0 ⇒ 红。)
* ★ 我没有把下界设成"恰好等于实测值":那是**又一张可以同步改小的手写表**。
* 取的是 `min(实测, 契约)` 的下界语义,且故意留出余量(见下)。
*/
for (const e of contractEntries) {
if (e.floor === null || !/^\d+$/.test(e.floor)) continue;
const emitted = selfCheckEmitted.get(e.name);
if (emitted === undefined) continue; // 缺项已由上面那条红覆盖
const failedThis = reds.some(r => r.includes(`\`--${e.name}\``));
if (failedThis) continue; // 自称红 ⇒ 由"自检自己不该红"那条管
if (emitted < Number(e.floor)) {
console.log(`RED 自检 \`--${e.name}\` **自称绿,却只打出 ${emitted} 条断言**`
+ `(契约下界 ${e.floor})⇒ 它可能被**掏空**了:名字、登记、guard、契约行都在,`
+ '但里面不检查任何东西');
reds.push(`(自检)\`--${e.name}\` 的工作量低于契约下界:${emitted} < ${e.floor}`
+ ' —— **存在性不等于在做事情**');
}
}
/*
* ★★★ **因果锚点**:把被守的**生产符号**真的改坏,那条自检**必须红**
* (pi 2026-09-18 报的层 7)。
*
* 层 6 我给的是"比条数下界",而条数是从**输出**里数的 ——
* **数的是它"打了几条",不是它"验了几条"**。实测:
*
* 把 `exitcodeSelfTest` 的体换成"循环打 13 条空 `ok`、`return 0`"(不打任何真断言)
* ⇒ `checks=459 pass=455 fail=4 skip=0 red=9 broken=0 unreported=0 verdict=red`
* 与基线**逐字相同**、下界相关 RED **0 条**(条数 13 ≥ 下界 12 ⇒ 相等,无从对比)。
* 最尖形式(假 ok×13 + `UPSTREAM_RC['baseline-residue']=99`)⇒ 0 条 RED、0 次提及 UPSTREAM;
* **对照**(不掏空、只改值)⇒ `red=9→10` 且真报"与真跑出来的 rc=1 不符"。
*
* ⇒ 根因一句话:**下界判据的输入是"自我报告"** —— 判据自己说它做了多少,而它可以撒谎。
* 这与这条线是同一句:**锚点不能落在"被守对象的自述"上,必须落在它管不着的东西上。**
*
* ★ 我**没有**采用 pi 提的三种替代(黄金输出 / 扫源码关键词 / 毒化环境变量):
* · 黄金输出 = 又一份手写数据(刚被证过那族);
* · 扫源码关键词 = "提到 vs 就是"(本文件反复证过它脆);
* · 毒化模式 = 把"必须报红"的断言写在**自检函数体内** ⇒ **假体不打它**。
* 我实测过:模拟毒化后假体 `red=9`、下界红 0 —— 与层 7 同一问题(仍是自述)。
* ⇒ 三种都仍在"被守对象自己说了算"这一侧。所以锚点改成**外部因果**:
*
* **改坏生产符号 → 那条自检必须红。**
* · 假体(空 `ok`)在符号被改坏时**仍然打 ok** ⇒ 它不红 ⇒ **红**(判据抓它);
* · 真自检读了那个符号 ⇒ 符号坏 ⇒ 它报 RED ⇒ 绿。
* ⇒ 这是"**自检真的读了那个符号**"的**行为**证据,不是它的自述。
*
* ★ 成本:实测单条自检的**快入口** `--only-selftest=<名>` 是 **52~696ms**
* (对比 `--X-selftest` 要跑整套 suite 的 4.3~4.9s),`cp -a test` 是 6ms。
* 隔离用 `mkdtempSync` + `cpSync`(**只拷 `test/`,1.6MB**)——
* 照本仓那条"隔离要用最小夹具"(整棵复制会换掉判据的输入)。
* ⚠️ 但这里**必须**拷 `test/` 整份:run-all.mjs 的**自检 1/2** 要 `readdirSync(test)`
* 与 `SUITE` 对齐,缺文件会让它在跑自检**之前**就 exit(1) ⇒ 读到的是假红。
* (我第一次少拷东西时就这么读错过:rc=1 看着像"改坏生效",其实是清单自检停了整套。)
*
* ★ 关系表(自检 ↔ 它守的生产符号)也是手写数据 —— **但它现在被真跑锚住了**:
* 表里每一对都必须实测"改坏了它真会红";写错了(比如把符号写成它不读的)
* ⇒ 那一条自检在改坏后**不会红** ⇒ **红**。⇒ 表自己也被因果判据守着。
*/
const CAUSAL = [
['probe-selftest', "const probeIsDue = v => v === true || v === 'unknown';",
'const probeIsDue = v => v === true;'],
['skip-selftest', 'return [...(out || \'\').matchAll(/^(?:ok|not ok) \\d+ - (.+?) # SKIP(.*)$/gm)]',
'return [...(out || \'\').matchAll(/^(?:ok|not ok) \\d+ - (.+?) # SKIPPED(.*)$/gm)]'],
['verdict-selftest', "? 'green' : 'red';", "? 'red' : 'red';"],
/*
* ★ 这一条是 pi 点名的**最尖形式**:`UPSTREAM_RC` 是"真脚本真跑"那个锚点的表。
* 把 `baseline-residue` 的值改坏 ⇒ `--exitcode-selftest` **必须红**
* (它真跑 `summary.py` 拿 rc 来比)。假体不读表 ⇒ 仍然全绿 ⇒ 被这条抓住。
*/
['exitcode-selftest', " 'baseline-residue': 1,", " 'baseline-residue': 99,"],
['mutants-line-selftest',
'/(RESULT mutants=\\d+ ran=\\d+ skipped=\\d+ on_new_criteria=\\d+)(?: diag=([\\w-]+))?/',
'/XXXNEVERMATCHXXX/'],
];
for (const [name, from, to] of CAUSAL) {
const tmp = mkdtempSync(join(tmpdir(), 'am-causal-'));
try {
cpSync(join(HERE), join(tmp, 'test'), { recursive: true });
const target = join(tmp, 'test', 'run-all.mjs');
const src = prose(target);
/*
* ⚠️ 必须在**除去这张因果表本身**的源码里数唯一性 ——
* 否则锚文本会**匹配到它自己那一行**(我第一版就踩了:probe/verdict 两条
* 立刻报"锚文本不唯一",而真因是**表里的字面量**,不是生产里有两处)。
* ★ 又是本文件那句:"提到"与"就是"在文本上长得一样。
*/
const CAUSAL_BLOCK_RE = /const CAUSAL = \[[\s\S]*?\n \];/;
const searchable = src.replace(CAUSAL_BLOCK_RE, (m) => m.replace(/[^\n]/g, ' '));
if (searchable.split(from).length - 1 !== 1) {
console.log(`RED 因果判据的锚文本在源码里不唯一(${name})⇒ 这条判据没在测它以为的东西`);
reds.push(`(自检)因果锚文本不唯一:${name}`);
continue;
}
/*
* ★★★ **对照:未改坏时必须 rc=0**(层 9 的另一半,也是更硬的那一半)。
*
* pi 报的层 9 是"**观测通道与被测样本住在同一张可编辑的表里**"(我那张 `T`)。
* 我把 `T` 消掉了(唯一登记),但那只堵住了**那一个形状**:
* 只要判据**只有"改坏 ⇒ 必须 rc=1"这一问**,任何"恒 rc=1 的通道"
* (不管是表、是 wrapper、还是环境变量)都能让它绿。
* ⇒ 加一问:**没改坏的时候,它必须 rc=0。** 通道坏了 ⇒ 这一问就红。
* ★ 这就是"锚点必须落在被守对象管不着的地方"的**行为**版本:
* 我不需要知道通道长什么样,只要求它在**两种输入下给出两种答案**。
* ⚠️ 代价:每对一个自检跑**两次**(实测量级 52~696ms/次 ⇒ 可接受)。
*/
const base = spawnSync(process.execPath, [target, `--only-selftest=${name}`],
{ encoding: 'utf8', cwd: ROOT });
if (base.status !== 0) {
console.log(`RED 因果判据的**对照**不过:\`${name}\` 在**没改坏**时退出码是 ${base.status}`
+ '(应为 0)⇒ **观测通道本身有问题**(观测到的"红"不能归因给改坏)');
reds.push(`(自检)因果对照失败:\`${name}\` 未改坏时 rc=${base.status}(通道坏了)`);
continue;
}
/*
* ★★★ **层 10 的修法:红必须是"报出来的",不是"退出来的"**(pi 2026-09-18 报)。
*
* pi 的构造:快入口**读自己的源码**、发现"我被改坏了"就 `process.exit(1)`。
* 那个通道在**两种输入下都答对**(原样走真实现 ⇒ 绿;被改坏 ⇒ 自己退 1)⇒
* 上一版那条"对照"(未改坏必须 rc=0)**通过**,而层 7 的假体照旧静默。
* ⇒ 根因:**对照只问"输入→输出是否非平凡",没问"那个输出是不是被守对象产生的"。**
*
* ⇒ 修法:**把"红"从退出码改成可归因的报告** ——
* ① 干净孩子必须 **rc=0 且 0 条 `RED ` 行**(健康的自检不该喊红);
* ② 改坏后的孩子必须 **rc=1 且 ≥1 条 `RED ` 行**(它得说出**为什么**)。
* ⇒ 只会 `process.exit(1)` 的通道**什么也没说** ⇒ ①过②不过 ⇒ **红**。
* ⇒ 而且它**不能靠常量骗过两问**:常量 rc=1 挂在①、常量 RED 也挂在①。
* ★ 一般化:**"它红了吗"必须落在"它说了什么"上** ——
* 退出码是**任何人**都能给的(连崩了都给),而 RED 行是被守对象**自己的判断**。
* ⚠️ 已知残留(见 `CRITERIA.md` §6.1.4):通道**同时**伪造"0 条 RED + rc 0"与
* "≥1 条 RED + rc 1"仍然能过 —— 但那已经等于**重新实现那条自检的检测**,
* 代价远高于它替代的东西。**我不声称层 10 封死。**
*/
const baseReds = (base.stdout || '').split('\n').filter(l => l.startsWith('RED ')).length;
if (baseReds !== 0) {
console.log(`RED 因果判据的**干净孩子说了红**:\`${name}\` 未改坏时打了 ${baseReds} 条 \`RED \``
+ ' ⇒ 这个读数不能归因给改坏(它平时就在喊红)');
reds.push(`(自检)因果对照失败:\`${name}\` 未改坏时已有 ${baseReds} 条 RED`);
continue;
}
writeFileSync(target, src.replace(from, to));
const got = spawnSync(process.execPath, [target, `--only-selftest=${name}`],
{ encoding: 'utf8', cwd: ROOT });
const gotReds = (got.stdout || '').split('\n').filter(l => l.startsWith('RED ')).length;
if (got.status === 1 && gotReds === 0) {
console.log(`RED 因果判据的**红不是报出来的**:\`${name}\` 改坏后 rc=1 却**一条 \`RED \` 行都没有**`
+ ' ⇒ 这个"红"可能是**观测通道自己退的**(或它崩了),不是那条自检判出来的');
reds.push(`(自检)因果判据:\`${name}\` 改坏后 rc=1 但**没有 RED 行**(红不可归因)`);
continue;
}
if (got.status === 0) {
console.log(`RED 把 \`${name}\` 守的生产符号改坏之后,它**仍然全绿**`
+ ' ⇒ 它没在读那个符号(或它是个空壳)');
reds.push(`(自检)**因果判据**:改坏生产符号后 \`${name}\` 不红 —— 它没在读它`);
} else if (got.status !== 1) {
console.log(`RED 因果判据跑 \`${name}\` 时退出码是 ${got.status}(不是 1)`
+ ' ⇒ 它不是"报红",是崩了/环境不对,这个读数无效');
reds.push(`(自检)因果判据里 \`${name}\` 退出码异常:${got.status}`);
}
} finally {
rmSync(tmp, { recursive: true, force: true });
}
}
/*
* ★★★ **层 8**:守着锚的那张表,自己没人守(pi 2026-09-18 报)。
*
* 上一条我写"表本身也被真跑锚住了" —— 那句话**只对了一半**:
* 被守的是"表里的锚文本在源码里唯一",而**"表里该有几条"没有任何东西在问**。实测:
*
* `CAUSAL = []`(整表清空) ⇒ red=10,与基线**逐字相同**、因果 RED 0 条
* 只留 1 对(删掉另外 3 对) ⇒ red=10,那三条自检**从此失去因果锚**、无任何提示
* `CAUSAL = []` **+ 假 ok×13** ⇒ red=10、因果/空壳 RED **0 条**
* ⇒ **层 7 的攻击被完整地放回来了,而代价从"改函数体"变成"删掉表里一行"。**
*
* ⇒ 根因:**锚点不能落在"被守对象的自述"上**(层 7),
* 而这里是 **"守着锚的那张表自己的内容"没有锚** ——
* 上一轮是"判据自述",这一轮是"**锚表的自述**"。
*
* ★ 修法的要点:覆盖面必须有一个**不在表里**的来源。这里用**两个**,都是已有的:
* ① `contractEntries` —— `CRITERIA.md` 契约行(**外部文件**,与层 5 同一个锚点,
* 一个外部来源同时守两件事,不必再写第二张表);
* ② `wired` —— 源码里 `SELFTESTS` 登记的自检名(**源码结构**)。
* 两条都要满足:① 让"清空/缩短表"变红;② 让"新加了一条自检却没给它因果对"变红
* (契约行没同步更新时,只有 ② 抓得住)。
*
* ⚠️ 空真的坑:只写"每个 CAUSAL 项都对应一个真自检"(反向)**挡不住 `[]`** ——
* `[]` 恰好满足那个空真("每个"在空集上恒真)。⇒ 必须**正向**要求覆盖。
* ★ 与本仓那句一致:**判据存在 vs 判据在路径上**;这里是 **"表非空" vs "表够长"**。
*/
/*
* ⚠️ **两个命名空间别混**(我第一版就混了,被自己刚写的判据抓住):
* `wired` 装的是**函数名**(`probeSelfTest`),`CAUSAL`/契约行装的是**旗标名**(`probe-selftest`)。
* 第一版我拿 `wired` 比 `CAUSAL` ⇒ 5 条**全部**报成"幽灵名"、另外 5 条全部报成"没有因果锚"
* (一次红 2 条,方向相反)⇒ 判据没错,是我把两套名字当成了一套。
* ⇒ 正确的来源是 `SELFTESTS` 的**键**(旗标名),它和 `CAUSAL` 在同一命名空间里。
*/
const causalNames = new Set(CAUSAL.map(([n]) => n));
const registered = SELFTESTS.map(([n]) => n);
const causalGhosts = [...causalNames].filter(n => !registered.includes(n));
if (causalGhosts.length) {
console.log(`RED 因果表里有 ${causalGhosts.length} 个**不对应任何已登记自检**的名字:`
+ `${causalGhosts.join('、')} ⇒ 这些"锚"锚在空气上(改了也没人管)`);
reds.push(`(自检)因果表里有幽灵名字:${causalGhosts.join('、')}`);
}
const causalRequired = [...new Set([...registered, ...contractEntries.map(e => e.name)])];
const causalMissing = causalRequired.filter(n => !causalNames.has(n));
if (causalMissing.length) {
console.log(`RED 这些自检**没有因果锚**(改坏它守的生产符号也没人会红):`
+ `${causalMissing.join('、')} —— 要求 ${causalRequired.length} 条,因果表只有 ${CAUSAL.length} 对`
+ ' ⇒ 表被清空/缩短了,或者新自检没配因果对');
reds.push(`(自检)**因果覆盖不全**:缺 ${causalMissing.join('、')}`);
}
const SAMPLE_FAKE_NAMES = ['fooSelfTest'];
const allNames = [...new Set([...selfSource.matchAll(/\b(\w+SelfTest)\b/g)].map(m => m[1]))];
const nameUnwired = allNames.filter(n => !wired.has(n) && !SAMPLE_FAKE_NAMES.includes(n));
if (nameUnwired.length) {
console.log(`RED 这些 \`*SelfTest\` 标识符**出现在源码里却没接线**(名字级扫描,与写法无关):`
+ `${nameUnwired.join('、')} ⇒ 默认路径不会跑它们`);
reds.push(`(自检)**名字级扫描发现没接线**:${nameUnwired.join('、')}`
+ ' —— 这条判据不看声明语法,所以换写法也躲不掉');
}
const ghostSample = SAMPLE_FAKE_NAMES.filter(n => !allNames.includes(n));
if (ghostSample.length) {
console.log(`RED 登记的样本假名在源码里找不到:${ghostSample.join('、')}`);
reds.push(`(自检)\`SAMPLE_FAKE_NAMES\` 里的 ${ghostSample.join('、')} 在源码里找不到(登记该清了)`);
}
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})—— `
+ '变异体这一族**根本没被读数**,不是"没有问题"');
}
/*
* ★★ pi 2026-09-18 本封报的**两条 `STATIC_ONLY` 发现**(我在当前 HEAD 上重测复现)。
*
* 【发现 2】`static=` 与"到期"是**两个量** ⇒ 探针一改指,闸**静默关闭**:
* `static=${STATIC_ONLY.length}`(余额,永远 6)与 `dueStatic`(到期,会变 0)分开报,
* 而**只有后者**决定"到期"这件事发不发生。实测(同刻 A/B,把 6 条探针全改指恒 `false`):
* 到期点名 1 → **0**,而 `static=6` **一字不变**、数字那一格看着完全正常。
* ⇒ 于是"**还欠着 6 条**"这句话仍然成立、而"**它们到期了**"这句话消失了。
* ★ 这条口子**是闸自己邀请的**:它的报文选 (b) 写着"并改换一个更准的到期前提"
* ⇒ 换探针是被鼓励的动作,而"新探针是否真的适用于这条判据"**没有任何判据在问**。
* ⇒ 修法:`static=` 那一格**同时**报两个数 —— "欠 X 条、其中 Y 条已到期"。
* 这样"关闸"这件事**读得出来**(Y 从 6 变 0 时,X 仍是 6 ⇒ 一眼看得出)。
*/
const staticDebtLine = `static=${STATIC_ONLY.length}(其中已到期 ${dueStatic.length} 条)`;
// ★ 第 2 列的**读者**(【发现 1】的修法):每次运行都全表播报。
// 构造在 `dueStatic` 之后(自检 4b 要读它),这里只负责打印。
/*
* ★★ 自检 4c(dsh 2026-09-19;补 4b 的漏格。pi 报,我同刻复现)。
*
* 4b 扫的是**源码文本**(剥注释后数 `+ staticDetail);` 的命中)。于是有一个它看不见的变异:
* **把这条 `console.log` 包进恒假条件** —— 锚点文本还在源码里,而**播报 0 次**。
* 实测(完整跑,同刻 A/B):baseline 播报段出现 **1** 次、变异后 **0** 次,
* 而 **4b 两次都报 ok**、红清单**逐条相同**。
*
* 根因是**自检先于打印**:4b 在 `:1613` 跑(`staticDetail` 定义之后、打印之前),
* 打印在 `:3129` ⇒ 它物理上到不了那次调用,只读得了文本。
*
* ⇒ 修法:这里不猜源码,改**记录这次调用真的往 `console.log` 送了什么**。
* 锚点落在「**实际发生的那次调用**」上 —— 这是它的自述够不着的东西(本仓老纪律)。
* 包进恒假条件 ⇒ 记录为空 ⇒ 4c 红。
* ⚠️ 不能做成 `SELFTESTS` 的一条:快入口 `--only-selftest=` 在 `:2503` 就 `exit` 了,
* 那条路径**永远到不了这里**,于是它只会"看不见"而不会红。
*/
const staticBroadcastSeen = [];
{
const realLog = console.log;
console.log = (...a) => { staticBroadcastSeen.push(a.join(' ')); realLog(...a); };
console.log(`\n---------- 只能静态验形态的判据(每次运行都列全,第 2 列不再是死重)----------\n`
+ staticDetail);
console.log = realLog;
}
if (!staticBroadcastSeen.some(t => STATIC_ONLY.every(([f, why]) => t.includes(f) && t.includes(why)))) {
reds.push('(自检)4c:`STATIC_ONLY` 的播报段**没有真的进 `console.log`**'
+ `(只记录到 ${staticBroadcastSeen.length} 次调用)⇒ 第 2 列**仍然没有读者**。`
+ '4b 只扫源码文本,"包进恒假条件"它看不见 —— 这条就是补那一格的。');
}
console.log(`RESULT phase=${PHASE} ${staticDebtLine}${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);