From 6ee99021943fe6918e4332395735bea212382292 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Fri, 18 Sep 2026 05:29:09 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D:=20=E7=AC=AC=E4=B8=89?= =?UTF-8?q?=E4=BE=8B=E8=90=BD=E5=9C=A8=E4=B8=A4=E5=B1=82=E4=B9=8B=E9=97=B4?= =?UTF-8?q?=20=E2=80=94=E2=80=94=20`unlisted`/`ghosts`=20=E9=80=80=20**0**?= =?UTF-8?q?=20=E2=87=92=20=E5=B7=B2=E7=AE=97=E5=87=BA=E7=9A=84=E8=AD=A6?= =?UTF-8?q?=E5=91=8A=E8=A2=AB=E4=B8=8B=E4=B8=80=E5=B1=82=E4=B8=A2=E6=8E=89?= =?UTF-8?q?=EF=BC=9B=E5=B9=B6=E6=8A=8A**=E4=B8=8A=E6=B8=B8=E9=80=80?= =?UTF-8?q?=E5=87=BA=E7=A0=81**=E4=B9=9F=E9=92=89=E8=BF=9B=E8=87=AA?= =?UTF-8?q?=E6=A3=80=EF=BC=88=E6=88=91=E5=8F=98=E5=BC=82=E6=97=B6=E5=8F=91?= =?UTF-8?q?=E7=8E=B0=E4=B8=8B=E6=B8=B8=E8=87=AA=E6=A3=80=E5=AE=88=E4=B8=8D?= =?UTF-8?q?=E4=BD=8F=E5=AE=83=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pi 2026-09-18 报的第三例。我复现了它,修完后又**自己变异出一个更值得记的问题**:修完之后 `--mutants-line-selftest` 仍守不住上游。 ## 一、第三例:复现(pi 报的那一例,端到端) 加一个未列入清单的 job 文件,改前实测: ``` 磁盘上 job 文件数 13 summary.py 自己说"未列入清单" 1 次 summary.py 退出码 0 ← 就是这里 套件报的 mutants mutants=48 ran=47 skipped=1 on_new_criteria=35 套件输出里 grep "未列入清单" 0 次 ← 一个字都没到读者眼前 ``` 根因两层:①`summary.py` 里只有 `blind or unreadable` 退 2,`unlisted`/`ghosts` **只打印、然后 `return 0`**; ②`run-all` 的 `whyLines: status !== 0 ? whyLines : []` 把**已经算出来**的警告又丢掉。 ⇒ 数字按清单算是**对的**,错的是**读的人不知道它不是全集**。 ★ 这**正是** `590a72a` 标题那句承诺("清单外即报,与 SUITE 同形状")和那边注释 ("与 run-all.mjs 自检 2 同形状:**清单外即红**")说的东西:自检 2 是**真的红**, 而这边只打印、退 0、打印被下一层丢掉 —— **"同形状"当时只同了前一半**。 ## 二、修法:退出码按**修法不同**分两类(照 pi 的提醒) | 情形 | 退出码 | 含义 | 读者该做什么 | |---|---|---|---| | `blind` / `unreadable` | **2** | 环境 | 去修权限 | | **`unlisted` / `ghosts`** | **1** | 清单/数据 | 去改 `jobs.manifest.json` | 按 `env-defaults.sh:25` 那条"别让环境问题冒充代码缺陷"的**反方向**:**也别让"清单没跟上"冒充环境**。 两类都退非零 ⇒ `run-all` 那边**既有的** `status !== 0` 路径自动把 `whyLines` 转印出来, `run-all` 只需把 status=1 那类的**措辞**说准(数字照播 —— 它没错 —— 但挂上"不代表磁盘上现在有多少个变异体")。 **端到端复验(跑出来的)**: | 场景 | summary.py rc | 套件输出 | |---|---|---| | 一致 | 0 | `mutants=48 …`(原样) | | 未列入清单 | **1** | `…(**注意:清单与磁盘不一致** —— 上面的数字**不代表磁盘上现在有多少个变异体**)` + 警告行转印 | | 清单有、磁盘无(ghosts) | **1** | 同上,`磁盘上没有:jobs-GHOST-probe.json` 转印 | ## 三★ 我修完后自己变异,发现**下游自检守不住上游** 把 `summary.py` 里 `if unlisted or ghosts: return 1` 整段删掉(=**退回第三例**), `--mutants-line-selftest` **照样全绿** —— 因为下游收到的是我**喂给它的** `status`, 上游到底退几,它管不着。**同一个缝换了个位置**:结论到达套件的那条通道,上游没有判据守着。 ⇒ 补 `--exitcode-selftest`:把**仓库里那份 `summary.py`** 逐字节复制进临时目录、 配上构造的 `jobs/` 与清单,**跑真脚本**验退出码契约(4 例:一致⇒0 / unlisted⇒1 / ghosts⇒1 / `_` 说明条目不算 ghosts⇒0)。重做 M17(删掉那段)⇒ **自检红、exit 1**,缝在两层都封住。 ★ 这个自检我第一版**只拷了一半依赖**(`summary.py` + `jobs/`,漏了 `test-keys.json` 与 `baseline.sha`)⇒ 每次都 `FileNotFoundError` 退 1。危险之处在于**四个案例里有两个期望 本来就是 1**,于是"没跑起来"**长得像**那两个通过。只有期望 0 的两条把它揭出来。 ⇒ 现在先判 stderr 里有没有 `Traceback`,有就单独报"**脚本没跑起来**,别把它当成退出码不对"。 ## 四、自检自身的两处错(照实记) 1. **声明值与现算值两份实现**:我既在案例里写 `want.why`,又用一条正则从 stdout **现算**一遍 期望条数 ⇒ `want.why` **从没被读**,且现算那条一旦与 `whyLines` 的过滤器不同步, 自检会**自证自恰**地绿。改成只读声明值 —— 立刻暴露出我两个声明值都写错了 (`partial` 真值 2 我写 1、`unlisted` 真值 3 我写 2)。**这正是本仓反复消的"同一事实多份实现"**。 2. **分支顺序错**:status=1 那条我第一版放在 `if (m)` **之前** ⇒ "没打出 RESULT 且 rc=1" (脚本没起来)会被它抢答成"清单与磁盘不一致"。自检⑤当场红,已收进 `if (m)` 内。 ## 五、验证与状态 · 四个自检全绿:`--mutants-line-selftest` **8/8**、`--exitcode-selftest` **4/4**、 `--skip-selftest` 5/5、`--probe-selftest` 3/3,都 exit 0。 · 全套件 `checks=459 pass=455 fail=4 skip=0 red=9 broken=0 unreported=0`(与改动前**同样 9 条**)、 `mutants=48 ran=47 skipped=1 on_new_criteria=35`。 · 变异:M17(删上游 `return 1`)⇒ 退出码自检红、exit 1;已还原(`sha256` 比对)。 · 实验残留全还原:`jobs/` 12 个、`jobs.manifest.json` 17 条且无 GHOST、 `/tmp` 隔离副本已删、`git status` 只剩本笔两个文件。 --- client/electron/test/mutants/summary.py | 32 +++-- client/electron/test/run-all.mjs | 154 ++++++++++++++++++++++-- 2 files changed, 168 insertions(+), 18 deletions(-) diff --git a/client/electron/test/mutants/summary.py b/client/electron/test/mutants/summary.py index 4d73ed6..56e0491 100644 --- a/client/electron/test/mutants/summary.py +++ b/client/electron/test/mutants/summary.py @@ -265,16 +265,32 @@ def main(): print(f' hits={h} {key[0]} 「{group[0].get("why", "")}」 ({len(group)} 条条目:{srcs})') print(' ⚠️ hits=0 通常是**过期条目**(锚点是旧写法)—— 请标 retired 或删除,') print(' 否则它会把 skipped 一直抬高(方向与"让欠账显形"相反)。') - # ★ 读不到 ⇒ **非零退出**(pi 2026-09-18 的洞 2 的关键:原来 rc=0)。 - # 读不到就是"没读数",而没读数**不是成功** —— 与本仓"失败要说清是环境问题、 - # 不要让它冒充代码缺陷"是同一套:这里更该退非零,因为它连"是环境还是代码"都判不了。 - # 退出码按本仓约定用 **2 = 环境问题**(见 `env-defaults.sh:25`): - # 目录不可进入 / 有 job 文件读不到,都是环境,不是"变异体少了"。 - # (`run-all.mjs` 只 grep `RESULT mutants=` 片段、不看退出码,所以那边也会看到 - # `mutants=0` —— 但这一行现在自己带 ✗✗ 说明,且 `原始条目 0` 与 `清单 12` 并排, - # 不再可能被读成"集合为空且一切正常"。) + # ★ 退出码要说实话,而且**要说清是哪一类**(pi 2026-09-18 的第三例)。 + # + # ⚠️ 这一格原来只有 `blind or unreadable` ⇒ 非零;`unlisted`/`ghosts` **只打印、 + # 然后 `return 0`**。于是套件那边 `whyLines: status !== 0 ? whyLines : []` + # 把已经**算出来**的警告又丢掉了 ⇒ 端到端实测(真加一个未列入清单的 job 文件): + # 磁盘 13 个 job 文件 · summary.py 自己说"未列入清单" 1 次 · 退出码 **0** + # 套件报 `mutants=48 ran=47 skipped=1 on_new_criteria=35` + # 套件输出里 grep "未列入清单" = **0 次** + # ⇒ 数字按清单算是**对的**,错的是**读的人不知道它不是全集**。 + # + # ★ 这**正是** `f632de4` 标题里那句承诺("清单外即报,与 SUITE 同形状")与 + # 上面 `245` 那行注释("与 run-all.mjs 自检 2 同形状:清单外即红")说的东西: + # 自检 2 是**真的红**,而这边只打印、退 0、打印又被下一层丢掉 —— + # **"同形状"当时只同了前一半**(有清单、有检查),"即红"那一半没落地。 + # + # 退出码按**修法不同**分两类(照 pi 的提醒,也照 `env-defaults.sh:25` 那条 + # "别让环境问题冒充代码缺陷"的**反方向**:也别让"清单没跟上"冒充环境): + # · 2 = **环境**:目录不可进入 / 有 job 文件读不到 ⇒ 去修权限。 + # · 1 = **该改的是清单/数据**:`unlisted`(新加的 job 文件没登记)/ + # `ghosts`(清单里有、磁盘上没有)⇒ 去改 `jobs.manifest.json`。 + # 两类都退非零 ⇒ `run-all` 那边既有的 `status !== 0` 路径会自动把 `whyLines` + # 转印出来,**不需要动 run-all**(那一层早就能承接,只是上游没把状态传上来)。 if blind or unreadable: return 2 + if unlisted or ghosts: + return 1 return 0 diff --git a/client/electron/test/run-all.mjs b/client/electron/test/run-all.mjs index 4998447..763e3db 100644 --- a/client/electron/test/run-all.mjs +++ b/client/electron/test/run-all.mjs @@ -23,9 +23,10 @@ */ import { prose, stripComments } from './lib/read.mjs'; import { spawnSync } from 'node:child_process'; -import { existsSync, readdirSync } from 'node:fs'; +import { existsSync, readdirSync, mkdtempSync, mkdirSync, cpSync, writeFileSync, rmSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; +import { tmpdir } from 'node:os'; const HERE = dirname(fileURLToPath(import.meta.url)); const ROOT = join(HERE, '..'); @@ -794,6 +795,14 @@ if (process.argv.includes('--mutants-line-selftest')) { + ' ★ 清单与磁盘不一致 —— 上面的数字**不代表"磁盘上现在有多少个变异体"**:\n' + ' 清单里有、**在但读不到**:jobs-one.json(权限问题,不是缺失)\n'; const normal = 'RESULT mutants=48 ran=47 skipped=1 on_new_criteria=35(口径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(…原始条目 73…)\n' + + ' ★ 清单与磁盘不一致 —— 上面的数字**不代表"磁盘上现在有多少个变异体"**:\n' + + ' 未列入清单:jobs-UNLISTED-probe.json(新加的 job 文件必须显式加进 jobs.manifest.json)\n' + + ' 清单里有、磁盘上没有:jobs-ghost.json\n'; const cases = [ // ① 正常:原样播报、不转印任何东西 ['正常(status 0)', normal, 0, { line: ' RESULT mutants=48 ran=47 skipped=1 on_new_criteria=35', why: 0 }], @@ -804,23 +813,44 @@ if (process.argv.includes('--mutants-line-selftest')) { * `mutants=47 …`。这一条是整条自检的**要害** —— 它证明"先判 status"不是多余: * 只按正则走就会把 47 播报成权威数字,而真相是"有一个 job 文件没读到"。 */ - ['部分可读(status 2,正则**匹配**)', partial, 2, { line: '没读数', why: 1 }], - // ④ 异常退出码但正则匹配(既不是 0 也不是 2)⇒ 也要留痕,不许静默 - ['非 0/2 退出码', normal, 3, { line: 'status=3', why: 0 }], + ['部分可读(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 }], + // ④ 异常退出码但正则匹配(既不是 0/1/2)⇒ 也要留痕,不许静默 + ['未知退出码', normal, 3, { line: 'status=3', why: 0 }], // ⑤ 完全没打出 RESULT(如脚本不存在)⇒ 报 status 与 stderr 末行 ['没打出 RESULT', '', 1, { line: '没打出 RESULT', why: 0 }], + /* + * ⑥★ **封缝**:status=0 且 stdout 里**确实带着**警告行 —— 这是第三例的**形状**, + * 只是把"上游忘了退非零"这一半也模拟出来。 + * 期望:**转印 0 行**(因为按契约 status=0 = 一切正常,不该有警告)。 + * 这条不是"想要这个行为",而是把**缝的位置**钉在测试里: + * 如果哪天又出现"检查存在、结论到不了套件"的组合,它会与③形成对照 —— + * ③ 要求转印、⑥ 要求不转印,**两者同时绿才算"退出码这条通道是活的"**。 + */ + ['status 0 但带着警告行(缝的形状)', unlisted, 0, { line: 'mutants=48', 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; + /* + * ★ 期望值用**案例里声明的** `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}"、转印 ${wantWhy} 行)`}`); + `转印 ${got.whyLines.length} 行${ok ? '' : `(期望含 "${want.line}"、转印 ${want.why} 行)`}`); if (!ok) bad++; } // ⑥ 反面对照:把顺序调回"先看正则"会怎样 —— 直接验证那个错误实现确实会被骗 @@ -835,6 +865,83 @@ if (process.argv.includes('--mutants-line-selftest')) { process.exit(bad ? 1 : 0); } +/* + * 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`,不是重写的替身。 + */ +if (process.argv.includes('--exitcode-selftest')) { + 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']; + const runIn = (manifest, files) => { + const d = mkdtempSync(join(tmpdir(), 'exitcode-')); + cpSync(src, join(d, 'summary.py')); // 每次都复制**真脚本** + for (const f of deps) cpSync(join(HERE, 'mutants', f), join(d, f)); + mkdirSync(join(d, 'jobs'), { recursive: true }); + for (const f of files) cpSync(realJob, join(d, 'jobs', f)); + writeFileSync(join(d, 'jobs.manifest.json'), JSON.stringify(manifest, null, 2)); + const sp = spawnSync('python3', [join(d, 'summary.py')], { encoding: 'utf8' }); + rmSync(d, { recursive: true, force: true }); + return { status: sp.status, out: sp.stdout || '', err: sp.stderr || '' }; + }; + const cases = [ + // 清单与磁盘一致 ⇒ 0 + ['一致 ⇒ 0', ['jobs-one.json'], ['jobs-one.json'], 0, null], + // 磁盘上多一个、清单里没有(**unlisted**)⇒ 1(清单该改,**不是**环境) + ['未列入清单 ⇒ 1', ['jobs-one.json'], ['jobs-one.json', 'jobs-extra.json'], 1, '未列入清单'], + // 清单里有、磁盘上没有(**ghosts**)⇒ 1 + ['清单有磁盘无 ⇒ 1', ['jobs-one.json', 'jobs-gone.json'], ['jobs-one.json'], 1, '磁盘上没有'], + // `_` 开头的说明条目不算 ghosts ⇒ 0(否则这份清单永远红) + ['说明条目不算 ghosts ⇒ 0', ['_note', 'jobs-one.json'], ['jobs-one.json'], 0, null], + ]; + let bad = 0; + for (const [what, manifest, files, want, wantOut] of cases) { + const got = runIn(manifest, files); + /* + * ★ 先判"是不是脚本根本没跑起来":`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); + const ok = okStatus && okOut; + console.log(`${ok ? 'ok ' : 'RED '} ${what}:rc=${got.status}` + + `${ok ? '' : `(期望 rc=${want}${wantOut ? ` 且输出含 "${wantOut}"` : ''})`}`); + if (!ok) bad++; + } + process.exit(bad ? 1 : 0); +} + /* * 跳过解析自检(`--skip-selftest`):与上面那些同形状 —— 判的是**解析器的分辨力**。 * @@ -1281,8 +1388,35 @@ function summarizeMutants(stdout, status, stderr) { whyLines, }; } + /* + * ★ status=1:**清单与磁盘不一致**(`unlisted`/`ghosts`)—— pi 2026-09-18 的第三例。 + * + * 与 status=2 **分开报**,理由与 `summary.py` 里那条一样:修法不同。 + * 2 = 权限/环境 ⇒ 去修权限;1 = 清单没跟上 ⇒ 去改 `jobs.manifest.json`。 + * 而这一格的**数字仍然是打出来的、正则也匹配**(`mutants=48 …`)—— + * 数字按清单算**是对的**,错的是"读的人不知道它不是全集"。 + * ⇒ 所以这里**不改数字**,只在它后面挂一句"不是全集",并把 `未列入清单:X` 那行转印出来。 + * (只靠"放宽转印条件"能把这行打出来,但**治不了"清单外不红"** —— 数字仍然绿、 + * 退出码仍然是 0。所以根因修在 `summary.py` 的退出码上,这里只负责把它说清楚。) + */ + if (status === 1) { + /* + * ⚠️ 顺序要紧:这条**必须**在 `if (m)` 里面 —— + * 否则"没打出 RESULT 且 status=1"(例如脚本不存在、python 报错退 1)会被它抢先吃掉, + * 套件就会说"清单与磁盘不一致",而真相是"summary.py 根本没跑起来"。 + * (我第一版就把这条放在 `if (m)` 之前,自检⑤当场红 —— 顺序错了。 + * 自检在这里又替我挡了一次。) + */ + if (m) { + return { + line: ` ${m[1]}` + + '(**注意:清单与磁盘不一致** —— 上面的数字**不代表磁盘上现在有多少个变异体**)', + whyLines, + }; + } + } if (m) { - /* status 非 0 但没被上一条抓住(例如异常退出码)⇒ 也要说,不许静默 */ + /* status 非 0 但没被上面两条抓住(例如未知退出码)⇒ 也要说,不许静默 */ return { line: ` ${m[1]}` + (status !== 0 ? `(注意:summary.py status=${status})` : ''), whyLines: status !== 0 ? whyLines : [],