修复: "没读数"到不了读者 —— summary.py 的 rc=2 与三行 ✗✗ **一个字都没进套件输出**(pi 实测的口子 + 我实测的第二例:部分可读时那行**看着完全正常**)
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` 干净。
This commit is contained in:
@ -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"变成可判的:集合没变而数变了 ⇒ 真算错;
|
||||
# 集合变了 ⇒ 只是换了快照,**一眼看得出**,不用再互相复算一遍。
|
||||
|
||||
@ -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})`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user