Files
MailUI4Agents/client/electron/test/mutants/summary.py
JianFeeeee 6ee9902194 修复: 第三例落在两层之间 —— unlisted/ghosts 退 **0** ⇒ 已算出的警告被下一层丢掉;并把**上游退出码**也钉进自检(我变异时发现下游自检守不住它)
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` 只剩本笔两个文件。
2026-09-18 05:29:09 +08:00

299 lines
19 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 的第三例)。
#
# ⚠️ 这一格原来只有 `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
if __name__ == '__main__':
sys.exit(main())