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})`; }