Files
MailUI4Agents/client/electron/test/mutants/summary.py
JianFeeeee 93dabb435d 修复: baseline= 那一格到不了套件(捕获组是前缀 + 过滤器不含它);baseline-unrunnable 退回 rc=0 的自相矛盾;并把「脏/干净/git 答不了」从两态改回三态
pi 2026-09-18 报的两条我都端到端复现了,都成立。修的过程中又发现**第三条**(我自己引入的),以及**自检自己的一处"判据在但走不到"**。

## 一、① `baseline=` 那一格从来不进套件输出(pi 报的第 ① 条)

两处都实测确认:
· 捕获组是**前缀**:`m[1]` = `RESULT mutants=48 ran=47 skipped=1 on_new_criteria=35`,`'baseline' in m[1]` = **False**;
· `whyLines` 的过滤器不含 `baseline=`。
⇒ 两条路都不通。**真·变异残留**时 `baseline=6/7✗**1 个文件既不在底本、也与 HEAD 不同 ⇒ 优先按"变异残留"查**`
在套件输出里 grep = **0 次**,而 `rc=0`、`mutants=48 ran=47 skipped=1` **看着完全正常**(我按你给的形状复现)。

**修法**:不扩正则、也不往过滤器里加关键词(那是"按字面量裁射程",本仓已栽过两次:
`criteria-hygiene` 的 import 硬匹配、`prose()` 判"用没用")——
改为 `RESULT` 行末尾加**结构化诊断码** `diag=<码>`,由 `run-all` 的 `DIAG_NOTE` 表逐码给一句准确的话:

| `diag` | rc | 含义 |
|---|---|---|
| `none` | 0 | 正常 |
| `manifest-mismatch` | 1 | 清单与磁盘不一致 |
| `baseline-residue` | **1** | **真·变异残留**(与底本不同**且**与 HEAD 不同) |
| `baseline-stale` | **0** | 底本过期(与 HEAD 逐字节相同,**不是缺陷**) |
| `baseline-unrunnable` | **2** | 跑不了 `sha256sum`(没读数) |
| `baseline-unknown` | **2** | git 答不了 ⇒ 判不出残留还是过期(没读数) |
| `counts-unusable` | 2 | 读不到 `jobs/` |

码表是**闭合**的:**不认识的码会报警**,所以"上游加了新状态而下游不知道"会立刻显形,
不会静静变成"没问题"。同时把 `baseline=` 的**读数本身**(`6/7✗`)也摘进那一格 ——
端到端实测:残留时 `grep baseline=` 从 **0 → 1**,rc 从 0 → **1**。

## 二、② `baseline-unrunnable` 原来 rc=0,与我自己的原则相反(pi 报的第 ② 条)

确认。我在 `blind/unreadable` 上定的是"**没读数不是成功 ⇒ 2**",而"跑不了 `sha256sum`"**是同一件事**,
却退 0 ⇒ 走正常路径 ⇒ 那句"是没读数"同样到不了读者。**我把"跑不了"和"没过"分成了两种状态(对),
但第三种状态(跑不了)没给它传播通道** —— 你这句说得准。

⇒ 现在 `baseline-unrunnable` 与 `baseline-unknown` 都退 **2**,
且措辞由 `diag` 决定而**不是**由 `status` 决定:`status === 2` 那句通用话会说"上面的 mutants 数字**不可信**",
而这里是**假话**(数字照常有效,**只有 baseline 那一格**没读数)。所以措辞一律走 `DIAG_NOTE`。

## 三★★ 我自己引入的第三条:`git` 三态被读成两态(假警报指向最危险结论)

修 ① 时我要构造 residue,才发现:`git diff --quiet` 退 **1** 有**两种**含义 ——
实测 · 仓库内干净 = **0** · 仓库内已改 = **1** · **非仓库/HEAD 取不到 = 也是 1**(stderr `error: Could not access 'HEAD'`)。
而原代码是 `.returncode != 0` ⇒ **"git 根本没答"被读成"有差异" ⇒ residue**,
也就是这一格**最危险**的读数。我自己在临时目录里跑时就撞到了:`REPO` 解析成 `/` ⇒ 一律报 residue。

⇒ 加 `can_ask`(`git rev-parse --is-inside-work-tree HEAD`,非仓库退 **128**,可区分):
答不了就报 `baseline-unknown`(**没读数**),**不许**声称 residue。这与上面 §一 那段注释警告的方向一致:
**假警报指向最危险的结论**。

## 四★★ 自检自己的一处"判据在,但走不到"

补 `baseline` 四态锚点时发现:旧 `runIn` 的临时目录是 `/tmp/xxx/`,
而 `summary.py` 用 `REPO = HERE/../../../..` ⇒ **`REPO` 解析成 `/`**(实测打印过)。
于是 `baseline=` 那一格**在原来的四例里从未被真正走成"对"**:`sha256sum -c` 在 `/` 下全部 FAILED、
`git` 也答不了。它们期望 rc=0/1 却看着"通过",是因为**两个错误互相抵消**
("判不了"被读成"有残留" **且** residue 当时退 0)。

⇒ 重建 `runIn`:按脚本真实位置**嵌套**建目录 + 造一个**迷你真仓库**(`git init` + 一次 commit +
一个可提交可改脏的目标文件),让 `REPO` 落在仓库根上。现在 `baseline=` 的**四态**
(对 / 过期 / 残留 / 判不了)**每一条前提都构造得出来**。

## 五、变异验证(都已还原)

| 变异 | 期望 | 结果 |
|---|---|---|
| **M21** residue 退回 rc=0(pi 报的原始行为) | `真·变异残留⇒1` 红 | **红**,`stale` 照旧绿(两条独立) |
| **M22** `unrunnable`/`unknown` 退回 rc=0(pi 本封 ② 的原始行为) | 两条都红 | **都红** |
| **M23** `can_ask` 恒真(三态退回两态) | `git 答不了` 红(会把"判不了"说成 residue) | **红**,rc 报 1(正是那个假警报) |

## 六、验证与状态

· **11 条锚点全绿**:一致⇒0 / unlisted⇒1 / ghosts⇒1 / 说明条目⇒0 / 盲读⇒2 / 单文件⇒2 /
  **无 sha256sum⇒2** / **残留⇒1** / **过期⇒0** / **git 答不了⇒2** / 没有 baseline⇒0。
· `--mutants-line-selftest` **9 例**(含新增 `baseline-stale` 与"不认识的码⇒报警");四个自检全 exit 0。
· 全套件 `checks=459 pass=455 fail=4 skip=0 red=9 broken=0 unreported=0`、`mutants=48 ran=47 skipped=1 diag=none`(与你读数一致)。
· 端到端:残留时 `grep baseline=` **0→1**、rc **0→1**;正常态 `diag=none`、rc=0。
· 残留全清(`/tmp` 无 `exitcode-*`/`barepath-*`);`git status` 仅这两个文件;`HEAD=e99a657`。

## 七、你 §三 那句补充

> "能构造"是**上游锚点**的条件,不是**传播**的保证。

认同,而且这封正好是它的**正面用法**:`baseline` 那一格的前提能构造(我造了迷你真仓库),
但**锚点原本不在"到达"那一端** ⇒ 构造得出来 ≠ 到得了读者。
§四 我那句"崩点在打出 RESULT 之前…同一条缝"你说"修完之后它换了形态:不再崩、但结论仍然到不了"——
**同意,而且换到了第三个位置**(前两个:`whyLines` 的 `status !== 0` 关、`unlisted/ghosts` 退 0;
这一个:**正则捕获组**)。现在这条通道两半都钉住了:**结论**走 `diag`、**读数**走 `baseline=`。
2026-09-18 06:00:02 +08:00

395 lines
26 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')
# ★★ `r` 必须先绑定(我加自检时**意外**撞出来的真 bug)。
#
# 原来 `r = subprocess.run(...)` 在 `try` 里,而 `except` 设
# `baseline_ok = (False, -1, -1)` ⇒ `baseline_ok` **非 None** ⇒ 走下面 `else` 分支
# ⇒ 第 162 行读 `r.stdout`,**而 `r` 从没绑定** ⇒ `UnboundLocalError` 崩在
# **打出 `RESULT` 行之前**。
#
# 触发条件不是奇想:`sha256sum` 不在 PATH(精简镜像、`PATH` 被裁过的 CI)就会命中。
# 后果与这几天修的那一族**同形**:**崩了 ⇒ 没有 `RESULT` 行 ⇒ 上游只能看到
# "没打出 RESULT"**,而真因(一个外部命令缺失)在 traceback 里。
#
# ⇒ 两件事一起修:① `r` 先绑 None;② "**跑不了这项检查**"与"**检查了、没过**"
# 分成两种状态,不许合并(合并正是这个 bug 的成因)。
bl_unrunnable = False
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 as e: # noqa: BLE001 —— 任何异常都只该降级,不该崩
bl_unrunnable = f'{type(e).__name__}: {e}'
bl_note = ''
# ★ 与 `bl_note` 并列的**结构化**诊断码(见下面 `diag` 那段的总说明)。
bl_diag = 'none'
if bl_unrunnable:
# ★ "**跑不了这项检查**" ≠ "**检查了、没过**"(原来合并了,才让 `r` 未绑定也能走到 162 行)。
# 这里明说跑不了,并给出原因 —— 与"底本过期/残留"两种成因各自的措辞并列。
bl_diag = 'baseline-unrunnable'
bl_note = (f' baseline=(**跑不了 sha256sum 校验**:{bl_unrunnable})'
f'—— 这一格**不是**"底本对"也**不是**"有残留",是**没读数**')
elif baseline_ok is None:
bl_diag = 'baseline-absent'
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]
# ★★ "脏 / 干净 / **git 答不了**"是**三态**,`returncode != 0` 是**两态**(我原先就写错了)。
#
# 实测(本机):
# · 仓库内、文件干净 ⇒ `git diff --quiet` 退 **0**
# · 仓库内、文件已改 ⇒ 退 **1**
# · **非仓库 / HEAD 取不到 ⇒ 也退 1**(stderr: `error: Could not access 'HEAD'`)
# ⇒ 照 `!= 0` 判 dirty,会把"git 根本没答"**读成"有差异" ⇒ residue**,
# 而 residue 是这一格**最危险**的读数("可能真有变异没还原")。
# 这正是上面那段注释警告的方向:**假警报指向最危险的结论**。
# (我自己撞到过:在临时目录里跑,`REPO` 指向别处 ⇒ 一律报 residue,
# 连"底本过期"这种无害情形都报成"按变异残留查"。)
#
# ⇒ 先用 `rev-parse` 问"git 现在能不能答",答不了就**不许**声称 residue:
# 报 `baseline-unknown`(**没读数**,不是"有残留")。
can_ask = subprocess.run(['git', 'rev-parse', '--is-inside-work-tree', 'HEAD'],
cwd=REPO, capture_output=True).returncode == 0
dirty = []
if can_ask:
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 not can_ask:
bl_diag = 'baseline-unknown'
bl_note = (f' baseline={ok}/{total}(**判不了是残留还是过期**:git 答不了 '
f'`rev-parse` ⇒ 这一格**没读数**,不是"有残留")')
elif detail and not dirty:
bl_diag = 'baseline-stale'
bl_note = (f' baseline={ok}/{total}⚠**底本过期**({len(detail)} 个文件与 HEAD 逐字节相同 ⇒'
f' 是正常提交改过、不是变异残留;重算 baseline.sha 并记一行"为什么")')
else:
bl_diag = 'baseline-residue'
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` 那段做的是同一件事,只是补上到套件的那一段。
#
# ★★ 但"不打数字"只是**一半** —— pi 2026-09-18 又指出:**`baseline=` 那一格
# 从来到不了套件输出**(捕获组是**前缀**,`'baseline' in m[1]` 实测 False;
# 而 `whyLines` 的过滤器也不含 `baseline=`)⇒ 两条路都不通。
# 后果最重的一例:**真·变异残留**时
# `baseline=6/7✗**1 个文件既不在底本、也与 HEAD 不同 ⇒ 优先按"变异残留"查**`
# 在套件输出里 grep **0 次**,而 `rc=0`、`mutants=48 ran=47 skipped=1` **看着完全正常**。
# —— 这正是同一条缝的**第三个位置**:前两个丢在 `whyLines: status !== 0 ? … : []`,
# 这一个丢在**正则捕获组**里。
#
# ⇒ 结构性修法(**不靠**扩正则、也不靠往 `whyLines` 里加关键词 —— 那是"按字面量裁射程",
# 本仓已经栽过:`criteria-hygiene` 的 import 硬匹配、`prose()` 判"用没用"):
# `RESULT` 行末尾加**机器可读的诊断码** `diag=<码>`,由 `run-all` 解析后
# **逐码给出一句准确的话**并决定是否转印。诊断码是**判据的一部分**,
# 不是文案 —— 文案可以改,码改了套件会立刻不认(下面 `diagNote` 有 default 报警)。
#
# 码表(与下面的退出码分类**一一对应**,见文件末那段):
# none 正常(也可能带"底本过期"这类**不需要红**的提示)
# manifest-mismatch 清单与磁盘不一致(unlisted/ghosts)⇒ 1
# baseline-residue 真·变异残留(与 HEAD 也不同)⇒ 1
# baseline-stale 底本过期(与 HEAD 相同)⇒ 0,但**必须让读者看见**
# baseline-unrunnable 跑不了 sha256sum ⇒ 2(没读数)
# baseline-absent 没有 baseline.sha ⇒ 0(这是可接受的配置)
if blind:
print('RESULT mutants=NO-READING ran=NO-READING skipped=NO-READING '
'on_new_criteria=NO-READING diag=counts-unusable'
'(**没读数**:读不到 jobs/ 目录 ⇒ 上面的数字全部无效)')
else:
# 诊断码按"最该被看见的那一个"取(一个 RESULT 行只挂一个码,避免读者要看两处)
if unlisted or ghosts:
diag = 'manifest-mismatch'
elif unreadable:
diag = 'counts-unusable'
elif bl_diag != 'none':
diag = bl_diag
else:
diag = 'none'
print(f'RESULT mutants={len(by)} ran={ran} skipped={skipped} '
f'on_new_criteria={only_new} diag={diag}'
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**(那一层早就能承接,只是上游没把状态传上来)。
#
# ★★ 第四类(pi 2026-09-18 本封):**`baseline` 那一格的两件事** ——
# ① `baseline-unrunnable`(跑不了 sha256sum)**原来退 0**,与我在
# `blind/unreadable` 上定的原则**自相矛盾**:那一格同样是**没读数**
# ("连是环境还是代码都判不了"),所以该退 **2**(缺外部命令 = 环境)。
# 修前实测:`rc=0` ⇒ 走正常路径 ⇒ 那句"是没读数"**同样到不了读者**。
# —— 我把"跑不了"和"没过"分成了两种状态(对),但**第三种状态没给它传播通道**。
# ② `baseline-residue`(真·变异残留:与底本不同**且**与 HEAD 也不同)**原来退 0**
# ⇒ 最危险的读数在套件里 grep 0 次却"看着正常"。它**本来就该是红的**,
# 只是现在没人读那一格。⇒ 退 **1**(数据该修,与 `manifest-mismatch` 同类)。
#
# `baseline-stale`(底本过期,但与 HEAD 逐字节相同)**保持 0** ——
# 它不是缺陷(只是底本没重算),退非零会让"正常提交"天天假红。
# 但它**必须能被读者看见**,所以走 `diag=` 通道(`run-all` 会转印它的说明行)。
# `baseline-unknown`(git 答不了 ⇒ 判不了残留还是过期)也归 **2**:
# 与 `baseline-unrunnable` 同类 —— **没读数**,不是"有残留"。
if blind or unreadable or bl_diag in ('baseline-unrunnable', 'baseline-unknown'):
return 2
if unlisted or ghosts or bl_diag == 'baseline-residue':
return 1
return 0
if __name__ == '__main__':
sys.exit(main())