From 765b77ea06298343c3ddcbc3d301b3a75021619c Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Fri, 18 Sep 2026 07:04:11 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D:=20=E4=BA=94=E4=B8=AA?= =?UTF-8?q?=E8=87=AA=E6=A3=80**=E5=85=A8=E4=BB=93=E6=B2=A1=E6=9C=89?= =?UTF-8?q?=E4=BB=BB=E4=BD=95=E5=85=A5=E5=8F=A3=E8=B0=83=E7=94=A8**=20?= =?UTF-8?q?=E2=80=94=E2=80=94=20`npm=20test`=20=E4=B8=80=E4=B8=AA=20flag?= =?UTF-8?q?=20=E9=83=BD=E4=B8=8D=E5=B8=A6=EF=BC=8C=E5=88=A4=E6=8D=AE?= =?UTF-8?q?=E5=AD=98=E5=9C=A8=E4=BD=86=E4=B8=8D=E5=9C=A8=E8=B7=AF=E5=BE=84?= =?UTF-8?q?=E4=B8=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pi 2026-09-18 报的。**成立,我独立复现了**,读数与 pi 逐字相同。 ## 一、缺陷:自检本身到不了任何入口 五个自检(`probe`/`exitcode`/`skip`/`verdict`/`mutants-line`)原来**只**由 `process.argv.includes('--…')` 守着,而**全仓没有任何调用方传那个 flag**: `npm test` = `node test/run-all.mjs && vitest run && npm run typecheck` —— 一个都不带。 实测: · 默认跑输出里这些自检的行数 = **0**; · 把 `UPSTREAM_RC['baseline-residue']` 改坏(表与真脚本不符)⇒ **默认跑** `checks=459 pass=455 fail=4 skip=0 red=9 …`,与干净树**逐字相同**;带 flag 才 `rc=1`; · 全仓 `grep` 这些 flag ⇒ **只命中 run-all.mjs 自己内部**的 5 处 guard + 注释, 没有任何调用方、没有任何文档。 ⇒ **"跑得动的锚点"确实存在,但它不在任何一条会被人跑的路径上。** ★ 形状与这几轮同族,但**伪装得最好**:guard 看起来就在跑、注释声称"每次跑都钉住"、 `grep` 也找得到那一大段代码 —— **三者都不是"在路径上"的证据**。 前几轮是"结论到不了读者 / 到不了 verdict / 被关掉"; **这次是"自检本身到不了入口"** ⇒ **"判据存在 vs 判据在路径上"**。 ★ 而且**方向最危险**:这五个守的恰好是最要紧的几格(`UPSTREAM_RC` 与真脚本一致、 ⑤ 双向口径、跳过解析、探针三值、决策顺序)。`--probe-selftest` 从 09-14 就在, **四天里一次没跑过**。 ## 二、修法:进程内接线(pi 建议 ①,但按实测改了做法) 把五个 `if (process.argv.includes(…))` 块改成 `function …SelfTest()`, `SELFTESTS` 里**直接调用**,红**并入 `reds`**(⇒ 进 `verdict`,不是"只打印不改颜色")。 每个函数后面紧跟一个 CLI 守卫 ⇒ **单独跑时行为完全不变**。 ★★ **我没有照 pi 的原话用子进程**,因为它的代价估计不准: pi 写"多几次进程启动(可忽略)",而我实测 —— **每个 flag 都会先把整个套件跑完** (guard 在 SUITE 循环**之后**),默认 ~4.3s、带任意 flag ~4.3–4.9s ⇒ **5 个子进程 ≈ 22s**。而自检 body 合计只 ~950ms。 ⇒ 进程内调用的实测代价:**4327ms → 4917ms(+590ms)**,而不是 +22s。 ## 三、接线时我踩的两个坑(都当场红,照实记) 1. **TDZ,同一个坑第二次**:我把接线块放在定义旁边(行 ~1448),而 `DIAG` 是 `const`、 定义在 ~1721 ⇒ `verdict`/`mutants-line` 两个自检抛 `ReferenceError: Cannot access 'DIAG' before initialization`。 **这个坑我在 `--mutants-line-selftest` 上已经踩过一次**。⇒ 移到所有 `const` 表与 自检函数**之后**,并在注释里写明"任何调用自检的代码都必须排在所有定义之后"。 ⚠️ 顺带一个教训:**"接线成功"与"被接的东西真跑了"是两件事** —— 我第一版接线后红数也变了,但其中两条是**抛异常**贡献的,不是自检的结论。 我是看到"(它没打出 RED 行,真因见上)"这句**自己写的话**才回去查的。 2. **吞 stdout 不能吞 stderr**:只吞 `console.log`,`console.error` 不动 —— 否则"自检抛了异常"会被我自己盖住(本仓"错误信息被吞掉"那条老教训)。 ## 四★ 变异时发现的**更大一条**:判官坏了,红被静默吞掉(M39) 把 `verdictOf` 改成**恒返回 `'green'`** ⇒ 默认跑 `red=10 … verdict=green`、**exit=0**、而且**红清单一行都不打**(green 分支提前 return)。 ⇒ 自检红**确实进了 `reds`**,但"决定要不要报红"的那一格**正是被变异的那一个**: **10 条红被静默吞掉**。这条老形状这次坏的是**判官自己**。 ⇒ 加一条**独立**的安全网(不靠 `verdictOf` 自证): `reds`/`brokens`/`selfCheckFailed`/`dueFailed` 有任何一个非空而 `verdictOf` 却说绿 ⇒ **判官与账本不一致**,直接 `exit(1)` 并说明。 **判据与判官互为对照,谁单独说了都不算数。** ## 五、结构防线:**"写了自检但没接线"必须红**(pi 建议 ②) pi 那条观察很准且我照抄进注释:本文件**自检 2** 的存在理由就是"防**写好了没接线**", 而它只扫 `*.test.mjs` ⇒ 这五个自检**因为不是 `*.test.mjs` 正好扫不到**。 **同一句话,换了个对象就漏了。** ⇒ 扫自己的源码:`function *SelfTest()` 必须在 `SELFTESTS` 里被调用(`prose`/`stripComments`), 另加"登记了不存在的函数"的反向检查。 ## 六、变异验证(都已还原) | 变异 | 结果 | |---|---| | **破坏 `UPSTREAM_RC`**(pi 的那一条) | 默认跑 **red 9→11**,两条自检红并指名不符的码 | | **M36** 加第 6 个自检但不登记 | **红**:`写了但没接线:sixthSelfTest` | | **M37** `probeIsDue` → `v === true`(闸锈死) | **红**:`--probe-selftest 红了:unknown…(probeIsDue("unknown") = false)` | | **M39** `verdictOf` 恒 green(判官坏了) | **红**:`判官与账本不一致…`(修复前 **exit=0、红清单不打印**) | ## 七、验证与状态 · 五个自检**单独跑仍全部 rc=0**(CLI 路径行为不变)。 · 默认跑:`checks=459 pass=455 fail=4 skip=0 red=9 broken=0 unreported=0 verdict=red`、 `mutants=48 ran=47 skipped=1 on_new_criteria=35 diag=none baseline=7/7✓` —— 与 pi 读数一致, 并新增一行 `自检段:5 个自检已在**默认路径**内跑过…源码里 5 个 *SelfTest 函数全部在册`。 · 耗时 **4327 → 4917 ms**(进程内;子进程方案会是 ~22s)。 · 顺手改掉 pi 指出与实现相反的那句注释("每次跑都钉住"⇒ 当时是假的,现在才成立)。 · 残留全清;`git status` 仅本文件;`HEAD=62297e3`。 --- client/electron/test/run-all.mjs | 172 ++++++++++++++++++++++++++++--- 1 file changed, 160 insertions(+), 12 deletions(-) diff --git a/client/electron/test/run-all.mjs b/client/electron/test/run-all.mjs index 9555a1d..d8fd3d8 100644 --- a/client/electron/test/run-all.mjs +++ b/client/electron/test/run-all.mjs @@ -764,7 +764,7 @@ function parseSkips(out) { * "unknown 到底会不会红"。没有这条,`probeIsDue` 哪天被改成 `v === true` * 也没人会发现,而那正是"闸门锈死"的写法。 */ -if (process.argv.includes('--probe-selftest')) { +function probeSelfTest() { const cases = [ ['ok 可用 → 到期', true, true], ['none 不可用 → 不到期', false, false], @@ -776,8 +776,10 @@ if (process.argv.includes('--probe-selftest')) { console.log(`${got === want ? 'ok ' : 'RED '} ${what}(probeIsDue(${JSON.stringify(value)}) = ${got})`); if (got !== want) bad++; } - process.exit(bad ? 1 : 0); + return bad; } +/* ★ 单独跑(CLI)时行为不变:跑完这一个就退出。 */ +if (process.argv.includes('--probe-selftest')) process.exit(probeSelfTest() ? 1 : 0); /* * ★★ **上游退出码的唯一权威**:`diag` 码 → `summary.py` 真跑出来的退出码。 @@ -840,7 +842,7 @@ const UPSTREAM_RC = { * 逐字节复制进一个临时目录、配上构造的 `jobs/` 与清单,就能在不碰仓库的前提下逐态验证。 * 这是"证据走默认路径":验的是**仓库里那份** `summary.py`,不是重写的替身。 */ -if (process.argv.includes('--exitcode-selftest')) { +function exitcodeSelfTest() { const src = join(HERE, 'mutants', 'summary.py'); const realJob = join(HERE, 'mutants', 'jobs', 'jobs-one.json'); /* @@ -1158,8 +1160,10 @@ if (process.argv.includes('--exitcode-selftest')) { console.log(`ok UPSTREAM_RC 的 ${Object.keys(UPSTREAM_RC).length} 个码都有案例真跑验过`); } } - process.exit(bad ? 1 : 0); + return bad; } +/* ★ 单独跑(CLI)时行为不变:跑完这一个就退出。 */ +if (process.argv.includes('--exitcode-selftest')) process.exit(exitcodeSelfTest() ? 1 : 0); /* * 跳过解析自检(`--skip-selftest`):与上面那些同形状 —— 判的是**解析器的分辨力**。 @@ -1169,7 +1173,7 @@ if (process.argv.includes('--exitcode-selftest')) { * **表现是"跳过的判据"那一行不打印** —— 而"没打印"和"没有跳过"长得一模一样。 * ⇒ 拿合成 TAP 行把三种情形钉住:带原因的、不带原因的、以及**不该被当成跳过的**普通通过行。 */ -if (process.argv.includes('--skip-selftest')) { +function skipSelfTest() { // ★ 调**生产那一个**函数,不抄副本 —— 抄副本的话自检验的是副本(见 parseSkips 的注释)。 const parse = parseSkips; const cases = [ @@ -1197,8 +1201,10 @@ if (process.argv.includes('--skip-selftest')) { (ok ? '' : `(期望 ${JSON.stringify(want)},实际 ${JSON.stringify(got)})`)); if (!ok) bad++; } - process.exit(bad ? 1 : 0); + 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'], @@ -1409,6 +1415,37 @@ try { 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` 行、欠账余额、条数不符、以及**所有其它红**都不会打出来。 @@ -1589,7 +1626,13 @@ try { * "只在我手跑 `runuser -u nobody` 时才经过"的分支,等于**没有判据守着**: * 哪天 `status === 2` 那格被写错(把 2 写成 '2' 的字符串比较、或把顺序调回 * "先看正则"),没有任何东西会响,而它恰好是"读数器说自己没读数"的唯一通道。 - * ⇒ 纯函数 + `--mutants-line-selftest` 把它每次跑都钉住。 + * ⇒ 纯函数 + `--mutants-line-selftest` 把它钉住。 + * + * ⚠️⚠️ **这句原来写的是"每次跑都钉住",而它与实现相反**(pi 2026-09-18 报的): + * 那时这个自检只由 `process.argv.includes('--mutants-line-selftest')` 守着, + * 而**全仓没有任何入口传那个 flag** ⇒ 它**一次都没在默认路径上跑过**。 + * "每次跑都钉住"当时是**假的**。现在才成立:五个自检已被接进默认路径 + * (见 `SELFTESTS` 那段),并且有一条结构防线要求"写了自检就必须接线"。 * * 决策顺序要紧:**先看退出码,再看正则**。 * 反过来的话,盲读/部分可读时 `summary.py` 照样打得出一行**看着正常**的 @@ -1873,7 +1916,7 @@ function applyMutants(sp, reds) { * —— 这比"断言 `DIAG[diag].blocksGreen === true`"强:它证明的是**接线真的通了**, * 而不是"表里写了个 true"(后者在 `r.red` 忘了 push 时照样绿 —— 那正是上一轮的缺陷)。 */ -if (process.argv.includes('--verdict-selftest')) { +function verdictSelfTest() { const clean = { brokens: [], dueFailed: false, selfCheckFailed: false }; const cases = [ /* @@ -2098,8 +2141,10 @@ if (process.argv.includes('--verdict-selftest')) { + `(applyMutants ${calls} 处、直接调 summarizeMutants ${direct} 处)`); } } - process.exit(bad ? 1 : 0); + return bad; } +/* ★ 单独跑(CLI)时行为不变:跑完这一个就退出。 */ +if (process.argv.includes('--verdict-selftest')) process.exit(verdictSelfTest() ? 1 : 0); /* * 变异体播报自检(`--mutants-line-selftest`):钉住"**先看退出码,再看正则**"。 @@ -2111,7 +2156,7 @@ if (process.argv.includes('--verdict-selftest')) { * (我实测过:盲读/部分可读时 `summary.py` 照样打得出一行**看着正常**的 * `RESULT mutants=<数字>`,所以这个顺序不是风格问题。) */ -if (process.argv.includes('--mutants-line-selftest')) { +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' @@ -2201,7 +2246,89 @@ if (process.argv.includes('--mutants-line-selftest')) { `(=它确实会被骗,这正是本自检要守的)`); if (!ok) bad++; } - process.exit(bad ? 1 : 0); + return bad; +} +/* ★ 单独跑(CLI)时行为不变:跑完这一个就退出。 */ +if (process.argv.includes('--mutants-line-selftest')) process.exit(mutantsLineSelfTest() ? 1 : 0); + +/* + * ★★ 五个自检的**接线放在这里**(不是定义旁边)—— 这是个我踩过的坑,必须写明: + * + * `verdictSelfTest` 与 `mutantsLineSelfTest` 读 `DIAG` 表,而 `DIAG` 是 `const` + * ⇒ **`const` 不提升**(TDZ)。我第一版把接线块放在 `selfCheckFailed` 那一段旁边 + * (行 ~1448),而 `DIAG` 定义在 ~1721 ⇒ 两个自检当场 + * `ReferenceError: Cannot access 'DIAG' before initialization`。 + * —— 这个坑我在 `--mutants-line-selftest` 上**已经踩过一次**(见那段注释),这是第二次。 + * ⇒ 任何"调用自检"的代码都必须排在**所有** `const` 表与自检函数定义之后。 + * (所以这里紧贴生产区段入口:`let mutantsLine` 之前。) + */ +let selfCheckRuns = 0; +{ + const SELFTESTS = [ + ['probe-selftest', probeSelfTest], + ['exitcode-selftest', exitcodeSelfTest], + ['skip-selftest', skipSelfTest], + ['verdict-selftest', verdictSelfTest], + ['mutants-line-selftest', mutantsLineSelfTest], + ]; + /* + * 自检的输出**不是**这个阶段的正常输出(各打几十行 `ok …`)⇒ 吞掉 stdout, + * 只在红时把它自己的 RED 行带进报告。 + * ⚠️ 只吞 `console.log`,**`console.error` 不动** —— 有东西往 stderr 说话时 + * 不许被我盖住(本仓"错误信息被吞掉"那条老教训)。 + */ + const swallow = console.log; + for (const [name, fn] of SELFTESTS) { + const seen = []; + console.log = (...a) => seen.push(a.join(' ')); + let failed = 0; + try { + failed = fn(); + } catch (e) { + failed = 1; + console.error(`✗ 自检 ${name} 抛了异常:${e && e.message}`); + seen.push(`**自检自己抛了异常**:${e && e.message}`); + } finally { + console.log = swallow; + } + if (failed) { + const why = seen.filter(l => /^RED /.test(l)).map(l => l.replace(/^RED\s+/, '')); + reds.push(`(自检)\`--${name}\` 红了:${why.length ? why.join(';') : '(它没打出 RED 行,真因见上)'}` + + ' —— **自检自己不该红**(它守的是"判据有没有分辨力")'); + } + } + selfCheckRuns = SELFTESTS.length; + /* + * ★★ 结构性防线:**"写了自检但没接线"必须红**(pi 建议 ②,我做成硬的)。 + * + * pi 的原话值得抄下来:本文件**自检 2** 的存在理由就是"防**写好了没接线**", + * 而它只扫 `readdirSync(test).filter(f => f.endsWith('.test.mjs'))` + * ⇒ 这五个自检因为**不是 `*.test.mjs`**(它们是"自检",不是判据文件)**正好扫不到**。 + * **同一句话,换了个对象就漏了。** + * + * ⇒ 扫自己的源码:**每一个 `function …SelfTest()` 都必须在 `SELFTESTS` 里被调用**。 + * 将来谁再加第 6 个自检却忘了登记,这里立刻红 —— 不必等人去 `grep` 调用方。 + * 用 `prose`/`stripComments`(本仓既有读取器),不自己搓正则。 + */ + const selfSrc = stripComments(prose(fileURLToPath(import.meta.url))); + const declared = [...selfSrc.matchAll(/^function (\w*SelfTest)\(\)/gm)].map(m => m[1]); + const wired = new Set(SELFTESTS.map(([, fn]) => fn.name)); + const unwired = declared.filter(n => !wired.has(n)); + const phantom = [...wired].filter(n => !declared.includes(n)); + if (unwired.length) { + console.log(`RED 这些自检**写了但没接线**(不在 SELFTESTS 里)⇒ 默认路径不会跑它们:${unwired.join('、')}`); + reds.push(`(自检)**写了没接线**:${unwired.join('、')} —— 它们只由 CLI flag 守着,` + + '而全仓没有任何入口传那个 flag(这正是本轮修的那个形状,不许再长出来)'); + } + if (phantom.length) { + console.log(`RED SELFTESTS 里登记了不存在的函数:${phantom.join('、')}`); + reds.push(`(自检)SELFTESTS 里登记了不存在的函数:${phantom.join('、')}`); + } + if (!unwired.length && !phantom.length) { + console.log(`自检段:${SELFTESTS.length} 个自检已在**默认路径**内跑过` + + `(${SELFTESTS.map(([n]) => `--${n}`).join('、')});` + + `源码里 ${declared.length} 个 \`*SelfTest\` 函数**全部**在册(无"写了没接线")`); + } } let mutantsLine = ''; @@ -2311,7 +2438,28 @@ const totalsLine = (verdict) => `RESULT files=${SUITE.length} ran=${ranFilesCoun * 不必动 SUITE 登记数。原来这条条件表达式是**内联**的, * 于是"结论到不了这一格"只能靠人读源码发现(pi 本封就是读出来的)。 */ -if (verdictOf({ reds, brokens, dueFailed, selfCheckFailed }) === 'green') { +/* + * ★★ **判官坏了也要看得见**(我自己变异出来的 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));