Files
MailUI4Agents/client/electron/test/mutants/summary.py
JianFeeeee 4b841e019a 修复: "没读数"到不了读者 —— 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` 干净。
2026-09-18 05:17:35 +08:00

283 lines
18 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
"""
变异体统计 —— **口径的唯一权威**(此前数字只活在信里,换过 40/48/58/41 四种说法)。
为什么需要它:同一个变异体可能出现在多个 `jobs*.json` 里、挂着不同的 `test` 键
(跨批重锚留下的),于是"按判据文件分组求和"会把它算多次 —— 而"job 集合是什么"
以前没有定义,谁算都能得一个数。
口径(写死在这里,别在信里另说一套):
· 不同变异体 = 按 (file, pat, repl) 三元组去重(`retired: true` 的条目不计);
· 跑起来 = 该三元组的锚点在该文件里**恰好命中 1 次**(与 mut.py 同一条件);
· 跳过 = 锚点命中 ≠ 1;
· on_new_criteria = 该变异体的**每一个** test 键都指向本次新增的两个判据文件
(口径 A:回答"新判据抓住了多少",不因交叉归类虚高);
· 另报口径 B(任一 test 键挂新增文件)作参考 —— 它**只增不减**,别拿来报数。
用法:python3 client/electron/test/mutants/summary.py
"""
import glob
import hashlib
import json
import os
import re
import subprocess
import sys
from collections import defaultdict
HERE = os.path.dirname(os.path.abspath(__file__))
JOBS_DIR = os.path.join(HERE, 'jobs')
# mutants/ → test/ → electron/ → client/ → 仓库根
REPO = os.path.abspath(os.path.join(HERE, '..', '..', '..', '..'))
# test 键 → 判据文件:**唯一来源**是同目录的 test-keys.json(与 mut.py 共用一份)
_KEYS = json.load(open(os.path.join(HERE, 'test-keys.json'), encoding='utf-8'))
TESTFILE = {k: v for k, v in _KEYS.items() if k != '_'}
NEW_CRITERIA = {
'test/harmony-admin.test.mjs',
'test/harmony-imageprep.test.mjs',
}
def main():
entries = []
# ★★ job 集合是**清单**,不是 glob(pi 2026-09-18)。
#
# 原来这里是 `glob(JOBS_DIR + '/jobs*.json')` —— 于是**未跟踪的 job 文件会静默进入统计**:
# 同一段代码、同一台机,提交态算 48、工作树算 52,**而两者都没说读的是哪个集合**。
# pi 的话:"『提交里有多少个变异体』和『磁盘上现在有多少个』可以不一致,
# 且没有任何东西会告诉你。" 这与"写了判据忘了接线"是**同一族** ——
# 区别是 `run-all.mjs` 有自检 2 挡着(清单外的 `*.test.mjs` 直接红),而这边没有对等物。
# 本仓已经用"清单 + 清单外即红"解决过同一个问题**两次**(SUITE / 判据接线),这是第三次。
manifest = os.path.join(HERE, 'jobs.manifest.json')
# `_` 开头的条目是**说明**(JSON 没有注释;与 test-keys.json 同一约定)——
# 必须在读的时候就滤掉,否则它们会被当成文件名,`ghosts` 会假报一堆。
listed = [x for x in json.load(open(manifest, encoding='utf-8'))
if not x.startswith('_')]
on_disk = sorted(os.path.basename(p)
for p in glob.glob(os.path.join(JOBS_DIR, 'jobs*.json')))
unlisted = [f for f in on_disk if f not in listed]
ghosts = [f for f in listed if f not in on_disk]
# ★★ "读不到" ≠ "不存在"(pi 2026-09-18 实测的第二个洞)。
#
# 实测(隔离副本 + `runuser -u nobody` + `chmod 644 jobs/`):
# root(jobs/ 755):mutants=48 ran=0 skipped=48 … 原始条目 74 sha=02502771
# nobody(jobs/ 644):mutants=0 ran=0 skipped=0 … 原始条目 0 sha=e3b0c442 rc=0
# `e3b0c442` 是**空字符串的 sha256** ⇒ "什么都没读到"被报成"集合为空且指纹正常",
# **而且退出码 0**。它比 `PermissionError` 糟:读不到 = 数字全 0 + 看着像正常读数。
#
# 根因:`os.path.exists()` 对**不可进入目录里的文件**返回 **False**(实测),
# 于是清单里 12 个全被当成"不存在"跳过;而 `glob` 那一半照样列得出 12 个 ⇒
# `unlisted`/`ghosts` 都是空 ⇒ 下面那条"清单与磁盘不一致"的警告**一声不响**。
# 同一份权限,两个半边给出互相矛盾的结论。
#
# ⇒ 先判**目录**能不能读(这一层才判得准),再判单个文件:
# · 目录不可进入 ⇒ 磁盘上看不清,判不了"有没有"⇒ `blind`,**大声失败**;
# · 目录能进、某文件 exists 但读不了 ⇒ 单列 `unreadable`(不是 ghosts)。
# 这与 `check-file-modes.sh` 补的"目录可进入性"是**同一件事**:那个只在部署时跑,
# 而这里是套件自己的读数器 —— 同一个目录权限,不该在部署门禁里红、在套件里静默绿。
dir_readable = os.access(JOBS_DIR, os.R_OK | os.X_OK)
blind = not dir_readable
# `unreadable` **在读循环里**填(见下面 `except OSError`):先探后读会漏掉"TOCTOU"
# 且两处判定要一致 —— 判"读不到"的唯一可靠时机就是**真去读**的那一次。
unreadable = []
for f in listed: # 只读**清单里**的,顺序也按清单
p = os.path.join(JOBS_DIR, f)
if not os.path.exists(p):
continue
# ★ `open` 要接住 OSError:不接的话,"单个 job 文件不可读"会抛
# `PermissionError` 直接死在这儿,**下面那条 `unreadable` 报告永远走不到**
# —— 我第一版就是那样,等于留了一段不可达的死代码("判据在,但走不到",
# 这次长在报告分支上)。接住之后:读得到的照读,读不到的**攒起来一次报全**,
# 而不是死在第一个文件上(后者会让人以为"就这一个有问题")。
try:
with open(p, encoding='utf-8') as fh:
for j in json.load(fh):
j['_from'] = f
entries.append(j)
except OSError:
if f not in unreadable:
unreadable.append(f)
active = [e for e in entries if not e.get('retired')]
retired = len(entries) - len(active)
def anchor_hits(e):
path = e['file'] if os.path.isabs(e['file']) else os.path.join(REPO, e['file'])
try:
src = open(path, encoding='utf-8').read()
except OSError:
return -1
return len(list(re.finditer(e['pat'], src)))
by = defaultdict(list)
for e in active:
by[(e['file'], e['pat'], e['repl'])].append(e)
ran = skipped = only_new = any_new = 0
skipped_detail = []
for key, group in by.items():
h = anchor_hits(group[0])
if h != 1:
skipped += 1
skipped_detail.append((key, group, h))
continue
ran += 1
files = {TESTFILE.get(e['test'], e['test']) for e in group}
if files <= NEW_CRITERIA:
only_new += 1
if files & NEW_CRITERIA:
any_new += 1
# 基线自证:变异跑完必须**逐字节还原**。这一条以前只在信里说("sha256sum -c 7/7 OK")——
# 与"数字只在信里"同一个毛病。挪进 RESULT 行:万一某次变异把文件写坏了,
# 套件这一行会直接显形,而不是等下一个人去信里找。
baseline_ok = None
bl = os.path.join(HERE, 'baseline.sha')
if os.path.exists(bl):
try:
r = subprocess.run(['sha256sum', '-c', bl], cwd=REPO,
capture_output=True, text=True, timeout=60)
# 底本里允许 `#` 注释(记"为什么重算基线"用)—— 计数与校验都要跳过它们,
# 否则注释会被当成"一项没还原"(我第一次加注释就踩了这个)
total = sum(1 for ln in open(bl, encoding='utf-8')
if ln.strip() and not ln.lstrip().startswith('#'))
ok = sum(1 for ln in (r.stdout or '').splitlines() if ln.endswith(': OK'))
baseline_ok = (ok == total, ok, total)
except Exception:
baseline_ok = (False, -1, -1)
bl_note = ''
if baseline_ok is None:
bl_note = ' baseline=(没有 baseline.sha)'
else:
good, ok, total = baseline_ok
# ★ 措辞必须区分**两种完全不同的成因**(2026-09-18 实测):
# 底本不匹配既可能是"某次变异没还原"(危险,要立刻查),
# 也可能是"底本过期"(文件被**正常提交**改过,只是没重算底本)。
# 原来一律打"✗**有文件没还原**",把后者也报成前者 ——
# 而实测这三处**全部与 HEAD 逐字节相同**(`git diff HEAD` 空)⇒ 是过期,不是残留。
# ★ 方向要紧:这是**假警报指向最危险的结论**,会让人去翻变异、
# 而真因只是底本没跟上。所以现在两种都报,并给出**各自的判别方法**。
detail = [ln.split(':', 1)[0].strip() for ln in (r.stdout or '').splitlines()
if ln.endswith(': FAILED') or 'FAILED open or read' in ln]
dirty = [f for f in detail
if subprocess.run(['git', 'diff', '--quiet', 'HEAD', '--', f],
cwd=REPO, capture_output=True).returncode != 0]
if good:
bl_note = f' baseline={ok}/{total}✓'
elif detail and not dirty:
bl_note = (f' baseline={ok}/{total}⚠**底本过期**({len(detail)} 个文件与 HEAD 逐字节相同 ⇒'
f' 是正常提交改过、不是变异残留;重算 baseline.sha 并记一行"为什么")')
else:
bl_note = (f' baseline={ok}/{total}✗**{len(dirty) or len(detail)} 个文件既不在底本、'
f'也与 HEAD 不同 ⇒ 优先按"变异残留"查**')
# ★★ 盲读时**不许**打出可被套件正则匹配的 `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"变成可判的:集合没变而数变了 ⇒ 真算错;
# 集合变了 ⇒ 只是换了快照,**一眼看得出**,不用再互相复算一遍。
#
# ★★ 指纹必须是**集合的函数**(pi 2026-09-18 实测的两个洞,都当场复现):
# ① **顺序敏感**:原来直接按 `listed` 顺序拼 —— 实测"只把清单反序、集合/内容/计数全不变"
# ⇒ `sha=02502771` 变 `sha=3439e049`。于是"集合变了 ⇒ 一眼看得出"失效:
# **每次清单整理都假变**。(修:`sorted()`)
# ② **只含 name:size** ⇒ 同大小改内容抓不到。实测字节级等长改写
# (`jobs-one.json` 的 `name: 'image'` → `name: 'imoge'`,265 字节不变)
# ⇒ **指纹仍是 `02502771`**。更尖锐的是把 file 改成等长的 `ApiClienX.ets`
# (指向不存在的文件)时 `ran/skipped/on_new_criteria` 全变、**指纹不变** ——
# **"集合没变而数变了"恰恰是它声称要抓的情况,而它抓不到。**(修:内容哈希)
# ⇒ 指纹的全部存在理由就是"集合变了要看得出来",所以它必须对**顺序不敏感、
# 对内容敏感**。名字排序 + 内容哈希两格都补上。
#
# ⚠️ 但要记准它**不是**什么:它是**集合指纹**(读到的那些文件的身份),
# **不是"数字对不对"的证明**。数字由上面那套口径算,指纹只回答"读的是哪一堆"。
def _fp_of(names):
parts = []
for f in sorted(names):
p = os.path.join(JOBS_DIR, f)
if not os.path.exists(p):
continue
try:
h = hashlib.sha256(open(p, 'rb').read()).hexdigest()[:8]
except OSError:
h = 'UNREADABLE' # 读不到也要进指纹(否则"读不到"就静默等于"没有")
parts.append(f'{f}:{h}')
return hashlib.sha256('\n'.join(parts).encode()).hexdigest()[:8]
fp = _fp_of(listed)
# 一个 job 都没读到 ⇒ **不许**报成正常读数(`e3b0c442` 是空串的 sha256,
# 而"空集合 + 正常指纹 + rc=0"正是最坏的那种读数:看着像成功)。
print(f' 集合:清单 {len(listed)} 个 job 文件(未跟踪的**不会**静默进入统计)'
f' sha={fp} 集合指纹')
if blind:
print(f' ✗✗ **读不到 jobs/ 目录**({JOBS_DIR})—— 上面的数字**全部无效**:')
print(f' 清单里有 {len(listed)} 个文件,但目录不可读/不可进入,一个都没读成。')
print(f' 这一行**不是**"集合为空":`sha=e3b0c442` 是空字符串的 sha256。')
print(f' 修法:修 jobs/ 目录权限(至少要 r+x;`check-file-modes.sh` 判的同一件事),'
f'或确认是不是在错误的用户/挂载下跑。')
if len(entries) == 0 and len(listed) > 0:
print(f' ✗✗ 清单里有 {len(listed)} 个 job 文件,却读到 **0 条条目** —— '
f'别把 0 当成"没有变异体",先查上面那两条(读不到 / 全是空文件)。')
# ★ 清单与磁盘不一致 ⇒ 明说(与 `run-all.mjs` 自检 2 同形状:清单外即红)。
if unlisted or ghosts or unreadable:
print(' ★ 清单与磁盘不一致 —— 上面的数字**不代表"磁盘上现在有多少个变异体"**:')
for f in unlisted:
print(f' 未列入清单:{f}(新加的 job 文件必须显式加进 jobs.manifest.json)')
for f in ghosts:
print(f' 清单里有、磁盘上没有:{f}')
# ★ 与 `ghosts` **分开报**:这两个的修法完全不同 ——
# 一个是"文件真没了"(删清单条目),一个是"文件在、我读不到"(修权限)。
# 合在一起报会让人去删一个其实存在的条目(pi 说的是"读不到 ≠ 不存在")。
for f in unreadable:
print(f' 清单里有、**在但读不到**:{f}(权限问题,不是缺失 —— 改权限,别删条目)')
print(' 修法:把该加的文件加进 jobs.manifest.json,或把该删的条目删掉;'
'读不到的那种去修权限。')
print(' (为什么不能靠 glob:未跟踪的文件会**静默**进入统计,'
'同一段代码两个数、而没人知道读的是哪个集合。)')
if skipped_detail:
print('跳过(锚点命中≠1 ⇒ 跑不起来):')
for key, group, h in skipped_detail:
srcs = ', '.join(sorted({e['_from'] for e in group}))
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` 并排,
# 不再可能被读成"集合为空且一切正常"。)
if blind or unreadable:
return 2
return 0
if __name__ == '__main__':
sys.exit(main())