/** * 判据总入口 —— **全部跑完再算退出码**。 * * 为什么不再用 `&&` 串起来: * * 原先 `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], /* * 4 → 10(显式编辑)。新增 6 条是**鸿蒙侧共享元素转场**(geometryTransition) * 的接线判据 —— 用户「webui 行为是按钮变成对应的写邮件页面或输入框吧」。 * 详细理由见该文件里那段「这三条第一版写错了」的注释(两版错法都记了)。 */ ['test/animation-audit.test.mjs', [], 12], // 动画全量盘点:死动画/过宽作用域/弹层接线/reduced-motion/系统弹簧曲线 // 深色模式:色板反转 + 玻璃 alpha 档 + 底必须是暗的(2026-09-17 那次 // 「只有通信页深色正常」的回归锁 —— 38 条里后 8 条是这次新增)。 ['test/theme.test.mjs', [], 38], ['test/background.test.mjs', [], 47], ['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。 */ /* * ★★ 2026-09-21 **6 → 7**(显式编辑,不是自动跟涨)。 * * 新增的一组是 `AddressSuggest`(三维地址补全的纯逻辑,22 个用例), * 为 2in1 键盘能力服务 —— 详细理由见 `cross-client-logic.test.mjs` 里 * 那一对 `PAIRS` 条目的注释。 * * ★ 为什么必须**手改**这个数字(这是本仓的一条纪律的现场): * 套件只判**下界**("自报 ≥ 登记"),所以新加判据若不同步登记, * 将来**删掉**那一组不会有任何东西变红 —— 新判据等于没被保护。 * 它就是靠这个数字把"新加的判据已经进保护圈"这件事说清楚的。 */ ['test/cross-client-logic.test.mjs', ['--experimental-strip-types', '--no-warnings'], 7], // 预设的**行为**判据:每一档都真的画得出来(能真跑,不需要设备 ⇒ 不进 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'], 22], // 上下黑边(用户 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'], 31], // 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//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` 的键 * **恰好相等**(两个方向都判)。`CRITERIA.md` 自己已被**自检 3** 守着 * (存在性 + 关键条目在)⇒ 链条:**被外部守着的文本 → flag 名 → guard → SELFTESTS**。 * ★ 删掉一条自检 ⇒ 本文件的名单没跟着删 ⇒ **红**(而"名单也一起同步删"是**跨文件**编辑, * 不再是"改我自己的一张内部表")。 */ /* * ⚠️ 契约行必须**独占一行**且**只能有一条** —— 我第一次写这条判据时就踩了: * 上面那段注释里**举例提到了**这个标记(行内),而当时的正则没锚行首行尾, * 于是它**先命中了注释里那次**(捕获到 `…`)⇒ 干净树上立刻出现 2 条假红。 * ★ 这正是本文件反复写的那个形状的又一例:**"提到"与"就是"在文本上长得一样。** * ⇒ 修法:锚 `^…$`(`m` 标志)+ **要求恰好一条**(多出来也红)。 */ const SELFTEST_CONTRACT_RE = /^\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);