From 4b841e019a5b5c875f0419d83e650da593d1f931 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Fri, 18 Sep 2026 05:17:35 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D:=20"=E6=B2=A1=E8=AF=BB?= =?UTF-8?q?=E6=95=B0"=E5=88=B0=E4=B8=8D=E4=BA=86=E8=AF=BB=E8=80=85=20?= =?UTF-8?q?=E2=80=94=E2=80=94=20summary.py=20=E7=9A=84=20rc=3D2=20?= =?UTF-8?q?=E4=B8=8E=E4=B8=89=E8=A1=8C=20=E2=9C=97=E2=9C=97=20**=E4=B8=80?= =?UTF-8?q?=E4=B8=AA=E5=AD=97=E9=83=BD=E6=B2=A1=E8=BF=9B=E5=A5=97=E4=BB=B6?= =?UTF-8?q?=E8=BE=93=E5=87=BA**=EF=BC=88pi=20=E5=AE=9E=E6=B5=8B=E7=9A=84?= =?UTF-8?q?=E5=8F=A3=E5=AD=90=20+=20=E6=88=91=E5=AE=9E=E6=B5=8B=E7=9A=84?= =?UTF-8?q?=E7=AC=AC=E4=BA=8C=E4=BE=8B=EF=BC=9A=E9=83=A8=E5=88=86=E5=8F=AF?= =?UTF-8?q?=E8=AF=BB=E6=97=B6=E9=82=A3=E8=A1=8C**=E7=9C=8B=E7=9D=80?= =?UTF-8?q?=E5=AE=8C=E5=85=A8=E6=AD=A3=E5=B8=B8**=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pi 2026-09-18 报的是**我上一轮新引入**的口子。我复现了它,并实测出**第二种**它没提、 且**只靠它的修法①治不了**的情形。 ## 一、pi 那一例:盲读时 `mutants=0` 与"真的没有变异体"长得一样 `run-all.mjs` 原来只做: ```js const m = /(RESULT mutants=\d+ ran=\d+ skipped=\d+ on_new_criteria=\d+)/.exec(sp.stdout); mutantsLine = m ? ` ${m[1]}` : ` mutants=(… status=${sp.status} …)`; ``` ⇒ **只在正则不匹配时才看 `status`**。而盲读时 `summary.py`(我上一轮刚改的) **照样打 `RESULT mutants=0 ran=0 skipped=0 on_new_criteria=0`** ⇒ 正则匹配、走真分支、 `status=2` **从没被读**;而 `sp.stdout` **全文件只有这一处引用** ⇒ 它那三行 `✗✗ 读不到 jobs/ 目录 …` / `✗✗ 清单里有 12 个,却读到 0 条条目` **一个字都到不了读者眼前**。 **复现(跑出来的,不是读代码)**:`nobody` 下跑真 `summary.py`,rc=2、三行 ✗✗ 都在, 再拿 run-all 里**那一行**的正则去匹配它: ``` m 为真? true → 套件会播报: RESULT mutants=0 ran=0 skipped=0 on_new_criteria=0 ``` ⇒ 与"这棵树真的一个变异体都没有"**长得一模一样**,而真相是"**没读数**"。 ★ 我上一轮自己在 `summary.py` 注释里写过"那边也会看到 `mutants=0`"并当成**可接受** —— 理由是"这一行自己带 ✗✗ 说明"。**错了**:那几行不在套件的输出里,所以没有"自己带说明"。 这正是本仓反复消的形状("看不到 ⇒ 绿"),只是最后一跳长在**播报端**: stdout 说对了、退出码也说对了,**但没有通道把它们送到读者眼前**。 ## 二★ 我实测出的第二例:**部分可读**(pi 那封没提,且它的修法①兜不住) 目录**能**进入、但**单个 job 文件**读不到(`chmod 000 jobs-one.json`)时: ``` $ runuser -u nobody -- python3 summary.py RESULT mutants=47 ran=0 skipped=47 on_new_criteria=0(…原始条目 73,其中 retired 13) baseline=0/7✗ ★ 清单与磁盘不一致 —— 上面的数字**不代表"磁盘上现在有多少个变异体"**: 清单里有、**在但读不到**:jobs-one.json(权限问题,不是缺失 —— 改权限,别删条目) rc=2 ``` 那行 `mutants=47 …` **看着完全正常**,而且**它匹配正则**。⇒ pi 的修法① (盲读时改打 `mutants=NO-READING`)**对它无效**,因为这里 `blind` 为假、数字是真算出来的。 **只有"先判 status"能兜住它** —— 所以两层修法不是叠保险,是各治一例。 ## 三、修法:先判退出码,再把原因行**转印**出来 1. `summary.py` 盲读时改打 `RESULT mutants=NO-READING …`(不匹配该正则,让读数器 **自己说"我没读数"** —— 与我在 `summary.py` 里做的是同一件事,只是补上到套件这一段)。 2. `run-all.mjs` **先判 `status === 2`**,并把它 stdout 里那些 `✗✗`/`★ 清单与磁盘不一致`/ `在但读不到` 行**转印到套件输出** —— "为什么没读数"只有 stdout 知道,而读者只看得到套件输出。 非 0/2 的异常退出码也留痕(`(注意:summary.py status=N)`),不许静默。 **真端到端验证**(不是读代码):把工作树复制到 `/tmp`,`runuser -u nobody` 跑**整个套件**: | 场景 | 套件播报的 mutants 那格 | |---|---| | 正常(root) | `mutants=48 ran=47 skipped=1 on_new_criteria=35` | | 盲读(nobody) | `mutants=(**环境:没读数**,summary.py status=2 —— 上面的 mutants 数字**不可信**)` + 两行 ✗✗ 转印 | | 部分可读(nobody + 单文件 000) | 同上 + `在但读不到:jobs-one.json(权限问题,不是缺失)` 转印 | ## 四、抽出纯函数 + 自检:因为这个分支**在正常路径上走不到** 盲读只在**非 root + `jobs/` 不可读**时发生,而套件以 root 跑 ⇒ 那段决策**只在我手跑 `runuser` 时才经过**。"只在我手跑时才经过"的分支等于 **没有判据守着**:哪天顺序被调回"先看正则"、或 `status === 2` 被写成字符串比较, 没有任何东西会响,而它是"读数器说自己没读数"的**唯一通道**。 ⇒ 抽成纯函数 `summarizeMutants(stdout, status, stderr)` + `--mutants-line-selftest`(6 例), 每次跑都钉住:正常 / 盲读 / **部分可读(正则匹配)** / 非 0-2 退出码 / 没打出 RESULT / **反面对照**(直接验证"先看正则"的实现确实会把部分可读播报成 `mutants=47`)。 **变异验证**(都已还原): · M15 删掉 `status === 2` 前置分支(回到有洞的顺序)⇒ 自检②③ **红**,且③的失败输出 正是那个 bug 本身:`RESULT mutants=47 ran=0 skipped=47 …` 会被当成权威数字播报。 · M16 `whyLines` 置空(不转印原因)⇒ 自检②③ **红**(转印 0 行,期望 1/2 行)。 ## 五、验证与状态 · 全套件 `checks=459 pass=455 fail=4 skip=0 red=9 broken=0 unreported=0` —— 与改动前**同样 9 条** (并发会话的"自报>清单" + 4 条 exit 1),`mutants=48 ran=47 skipped=1 on_new_criteria=35` 不变。 · `--mutants-line-selftest` 6/6 绿、`--skip-selftest` 5/5 绿、`--probe-selftest` 3/3 绿。 · 实验残留全部还原:仓库内 `test/mutants/jobs/` 权限 **755**(与实验前一致)、 `/tmp` 隔离副本已 `rm -rf`、`git status` 干净。 --- client/electron/test/mutants/summary.py | 29 +++++- client/electron/test/run-all.mjs | 129 +++++++++++++++++++++++- 2 files changed, 149 insertions(+), 9 deletions(-) diff --git a/client/electron/test/mutants/summary.py b/client/electron/test/mutants/summary.py index f133ec2..4d73ed6 100644 --- a/client/electron/test/mutants/summary.py +++ b/client/electron/test/mutants/summary.py @@ -173,10 +173,31 @@ def main(): bl_note = (f' baseline={ok}/{total}✗**{len(dirty) or len(detail)} 个文件既不在底本、' f'也与 HEAD 不同 ⇒ 优先按"变异残留"查**') - print(f'RESULT mutants={len(by)} ran={ran} skipped={skipped} ' - f'on_new_criteria={only_new}' - f'(口径A=只挂新判据 {only_new} / 口径B=任一挂新判据 {any_new} / ' - f'原始条目 {len(entries)},其中 retired {retired})' + bl_note) + # ★★ 盲读时**不许**打出可被套件正则匹配的 `RESULT mutants=<数字>` 行 + # (pi 2026-09-18 实测的那个口子)。 + # + # 为什么:`run-all.mjs` 只用一条正则从那行里取数 —— + # const m = /(RESULT mutants=\d+ ran=\d+ skipped=\d+ on_new_criteria=\d+)/.exec(sp.stdout) + # mutantsLine = m ? ` ${m[1]}` : ` mutants=(… status=${sp.status} … )`; + # 于是:**盲读时这里照样打 `mutants=0 ran=0 …`** ⇒ `m` 为真 ⇒ 走真分支 ⇒ + # `status=2` **从没被读**,而它**下面那三行 ✗✗ 也从不打印**(run-all 只取那一格、 + # 从不转印 stdout)。结果套件汇总里显示 + # mutants=0 ran=0 skipped=0 on_new_criteria=0 + # —— 与"这棵树真的一个变异体都没有"**长得一模一样**,而真相是"**没读数**"。 + # + # 这正是本文件上面反复消的形状("看不到 ⇒ 绿"),只是最后一跳长在**播报端**: + # stdout 说对了、退出码也说对了,**但没有一条通道把它们送到读者眼前**。 + # ⇒ 让**读数器自己说"我没读数"**:盲读时不打可匹配的形状(`NO-READING` 不是数字, + # 正则不匹配)⇒ run-all 走 else 分支 ⇒ `status=2` 与 stderr 被一起播报出来。 + # 与我在下面 `blind` 那段做的是同一件事,只是补上到套件的那一段。 + if blind: + print('RESULT mutants=NO-READING ran=NO-READING skipped=NO-READING ' + 'on_new_criteria=NO-READING(**没读数**:读不到 jobs/ 目录 ⇒ 上面的数字全部无效)') + else: + print(f'RESULT mutants={len(by)} ran={ran} skipped={skipped} ' + f'on_new_criteria={only_new}' + f'(口径A=只挂新判据 {only_new} / 口径B=任一挂新判据 {any_new} / ' + f'原始条目 {len(entries)},其中 retired {retired})' + bl_note) # ★ 集合自证:数字必须能回答"读的是哪个集合"(pi 2026-09-18 的第二个建议)。 # 指纹让"48 还是 52"变成可判的:集合没变而数变了 ⇒ 真算错; # 集合变了 ⇒ 只是换了快照,**一眼看得出**,不用再互相复算一遍。 diff --git a/client/electron/test/run-all.mjs b/client/electron/test/run-all.mjs index a20a28f..4998447 100644 --- a/client/electron/test/run-all.mjs +++ b/client/electron/test/run-all.mjs @@ -778,7 +778,65 @@ if (process.argv.includes('--probe-selftest')) { } /* - * 跳过解析自检(`--skip-selftest`):与上面那条同形状 —— 判的是**解析器的分辨力**。 + * 变异体播报自检(`--mutants-line-selftest`):钉住"**先看退出码,再看正则**"。 + * + * 为什么需要:这条决策**在正常路径上走不到** —— 套件以 root 跑,而盲读只在 + * **非 root + `jobs/` 不可读**时才发生。只在我手跑 `runuser -u nobody` 时才经过的分支 + * 等于**没有判据守着**;哪天顺序被调回"先看正则",没有任何东西会响, + * 而它是"读数器说自己没读数"的**唯一通道**。 + * (我实测过:盲读/部分可读时 `summary.py` 照样打得出一行**看着正常**的 + * `RESULT mutants=<数字>`,所以这个顺序不是风格问题。) + */ +if (process.argv.includes('--mutants-line-selftest')) { + const blind = 'RESULT mutants=NO-READING ran=NO-READING skipped=NO-READING on_new_criteria=NO-READING(**没读数**)\n' + + ' ✗✗ **读不到 jobs/ 目录**(/x/jobs)—— 上面的数字**全部无效**:\n'; + const partial = 'RESULT mutants=47 ran=0 skipped=47 on_new_criteria=0(…原始条目 73…)\n' + + ' ★ 清单与磁盘不一致 —— 上面的数字**不代表"磁盘上现在有多少个变异体"**:\n' + + ' 清单里有、**在但读不到**:jobs-one.json(权限问题,不是缺失)\n'; + const normal = 'RESULT mutants=48 ran=47 skipped=1 on_new_criteria=35(口径A=只挂新判据 35 …) baseline=7/7✓\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: 1 }], + // ④ 异常退出码但正则匹配(既不是 0 也不是 2)⇒ 也要留痕,不许静默 + ['非 0/2 退出码', normal, 3, { line: 'status=3', why: 0 }], + // ⑤ 完全没打出 RESULT(如脚本不存在)⇒ 报 status 与 stderr 末行 + ['没打出 RESULT', '', 1, { line: '没打出 RESULT', 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); + // 检查转印条数:只数那些"应当被转印"的行 + const wantWhy = (stdout.match(/✗✗|★ 清单与磁盘不一致|在但读不到/g) || []) + .filter(() => status !== 0).length; + const okWhy = got.whyLines.length === wantWhy; + const ok = okLine && okWhy; + console.log(`${ok ? 'ok ' : 'RED '} ${what}:line="${got.line.trim().slice(0, 60)}" ` + + `转印 ${got.whyLines.length} 行${ok ? '' : `(期望含 "${want.line}"、转印 ${wantWhy} 行)`}`); + 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++; + } + process.exit(bad ? 1 : 0); +} + +/* + * 跳过解析自检(`--skip-selftest`):与上面那些同形状 —— 判的是**解析器的分辨力**。 * * 为什么需要:闸 (i) 的"具名跳过"完全依赖从 node:test 的 TAP 输出里取回 * 名字与原因。那个正则哪天写坏了(`# SKIP` 大小写、` - ` 分隔、原因前缀的破折号), @@ -1197,6 +1255,46 @@ try { * 跑不起来(summary.py 不在、python3 不在)**不静默** —— 明说"没跑", * 免得下一个人把"这行没提"当成"没问题"。 */ +/** + * 把 `summary.py` 的 (stdout, status) 变成**汇总行里那一格** + 要转印的原因行。 + * + * ★ 抽成**纯函数**的理由(与 `parseSkips` 同一条):这个决策**在正常路径上走不到** —— + * 套件以 root 跑,而盲读只在**非 root + jobs/ 不可读**时才发生。 + * "只在我手跑 `runuser -u nobody` 时才经过"的分支,等于**没有判据守着**: + * 哪天 `status === 2` 那格被写错(把 2 写成 '2' 的字符串比较、或把顺序调回 + * "先看正则"),没有任何东西会响,而它恰好是"读数器说自己没读数"的唯一通道。 + * ⇒ 纯函数 + `--mutants-line-selftest` 把它每次跑都钉住。 + * + * 决策顺序要紧:**先看退出码,再看正则**。 + * 反过来的话,盲读/部分可读时 `summary.py` 照样打得出一行**看着正常**的 + * `RESULT mutants=<数字>`,正则一匹配就走真分支,退出码**从没被读**。 + */ +function summarizeMutants(stdout, status, stderr) { + const m = /(RESULT mutants=\d+ ran=\d+ skipped=\d+ on_new_criteria=\d+)/.exec(stdout || ''); + // "为什么没读数"只有 stdout 知道,而读者只看得到套件的输出 ⇒ 转印这些行。 + const whyLines = (stdout || '').split('\n') + .filter(l => /✗✗|⚠️|★ 清单与磁盘不一致|在但读不到|未列入清单|清单里有、磁盘上没有/.test(l)) + .map(l => l.trim()); + if (status === 2) { + return { + line: ' mutants=(**环境:没读数**,summary.py status=2 —— 上面的 mutants 数字**不可信**)', + whyLines, + }; + } + if (m) { + /* status 非 0 但没被上一条抓住(例如异常退出码)⇒ 也要说,不许静默 */ + return { + line: ` ${m[1]}` + (status !== 0 ? `(注意:summary.py status=${status})` : ''), + whyLines: status !== 0 ? whyLines : [], + }; + } + return { + line: ` mutants=(summary.py 没打出 RESULT:status=${status} ` + + `${(stderr || '').trim().split('\n').slice(-1)[0] || ''})`, + whyLines, + }; +} + let mutantsLine = ''; try { // ★ 路径要 join(HERE, …):`HERE` 是 `test/`(不是 `client/electron/`)—— @@ -1206,10 +1304,31 @@ try { // 播报失败时要把 stderr 一起说出来,别只说"未知"。 const sp = spawnSync('python3', [join(HERE, 'mutants', 'summary.py')], { cwd: HERE, encoding: 'utf8', timeout: 30000 }); - const m = /(RESULT mutants=\d+ ran=\d+ skipped=\d+ on_new_criteria=\d+)/.exec(sp.stdout || ''); - mutantsLine = m - ? ` ${m[1]}` - : ` mutants=(summary.py 没打出 RESULT:status=${sp.status} ${(sp.stderr || '').trim().split('\n').slice(-1)[0] || ''})`; + /* + * ★★ 先看**退出码**,再看正则(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"能兜住。 + */ + const r = summarizeMutants(sp.stdout, sp.status, sp.stderr); + mutantsLine = r.line; + for (const l of r.whyLines) console.log(` ${l}`); } catch (e) { mutantsLine = ` mutants=(跑不了 summary.py:${e.code || e.message})`; }