pi 2026-09-18 报的 M18。我复现了它,补锚点时又**意外撞出一个提交态就有的真 bug**(不是 pi 报的、也不是我预想的)。
## 一、M18 复现:rc=2 只有下游锚点、没有上游锚点
`--exitcode-selftest` 原来四例期望只覆盖 **0/1**(实测读出 `, 0,` `, 1,` `, 1,` `, 0,`)。
把 `summary.py` 的 `if blind or unreadable: return 2` 改成 `return 0`:
| 自检 | rc | 该不该响 |
|---|---|---|
| `--exitcode-selftest`(上游) | **0(绿)** | **该红** |
| `--mutants-line-selftest`(下游) | **0(绿)** | 不该(它喂合成 status) |
真跑 blind 确认后果:`nobody + chmod 000 jobs/` ⇒ 变异版 **rc=0**(应 2)
⇒ "环境:没读数"整条链又断回"看着正常",**两个自检都不响**。与 pi 逐字一致。
★ 根因是**结构性障碍**,不是疏忽:`summary.py` 用 `os.access(JOBS_DIR, R_OK|X_OK)` 判 blind,
而 **`os.access` 对 uid=0 恒为 True**(实测:`chmod 000` 后 root `access()==True`、nobody 才 False)
—— 与我早先在 `check-file-modes.sh` 踩的 `[ -x ]` 是**同一个 root 陷阱**,只是换成了 `os.access`。
所以自检以 root 跑时 blind 分支**物理上不可达**。
## 二、补法:降权构造前提(三格都有锚点:0 / 1 / **2**)
`runIn()` 加降权模式:临时目录整棵放开到 nobody 可达 → 按场景收紧
(`blind`:`chmod 000 jobs/`;`unreadable`:目录 755 + 单文件 000)→ 用
`runuser -u nobody`(无则退 `setpriv --reuid=65534`)跑**真脚本**。两条路我都实测得 rc=2。
新增两条案例:`盲读⇒2`、`单文件读不到⇒2`。
★ **前提构造不出来时**(拿不到降权工具)报 **RED 并明说"本机覆盖不到、这不是通过是没有锚点"** ——
照 pi 那句"哪条前提构造不出来就写明,别让它默认看起来是被守着的"。不静默跳过。
**变异验证**(都已还原):
· **M18**(`blind ⇒ return 0`)⇒ 两条降权案例**都红**、exit 1(改前两个自检全绿)。
· **M19**(只把 `unreadable` 那一半改成 0)⇒ **只有**"单文件读不到"那条红 ——
证明两条锚点是**独立**的,不是互相冗余。
## 三★★ 补锚点时撞出的**真 bug**(提交态就有,不是本笔引入)
构造"前提 B(单文件不可读)"时跑出 `UnboundLocalError: cannot access local variable 'r'`:
```python
try:
r = subprocess.run(['sha256sum', '-c', bl], ...) # ← 在 try 内绑定
...
except Exception:
baseline_ok = (False, -1, -1) # ← 设了非 None
...
else: # baseline_ok 非 None ⇒ 走这里
detail = [... for ln in (r.stdout or '').splitlines() ...] # ← r 从未绑定 ⇒ 崩
```
崩点在**打出 `RESULT` 行之前** ⇒ 上游只看到"没打出 RESULT",而真因(一个外部命令缺失)
埋在 traceback 里。**触发条件很实在**:`sha256sum` 不在 PATH(精简镜像 / PATH 被裁的 CI)。
实测:正常 rc=0;`PATH` 里去掉 `sha256sum` ⇒ `UnboundLocalError`、rc=1。
⇒ 两件事一起修:
① `r` 不再依赖 try 内绑定;
② 把"**跑不了这项检查**"与"**检查了、没过**"**分成两种状态**(合并正是这个 bug 的成因)——
现在那一格打 `baseline=(**跑不了 sha256sum 校验**:FileNotFoundError: ...)—— 这一格**不是**"底本对"也**不是**"有残留",是**没读数**`。
并补第 7 条锚点:**故意让 PATH 里没有 `sha256sum`**,要求 ① 不崩 ② 仍打出 `RESULT` ③ 那格说"跑不了"。
**M20**(把 `r` 绑定退回 try 内)⇒ 该案例红、exit 1,报的正是那个 `UnboundLocalError`。
## 四、自检自身的两个问题(照实记)
1. **`--exitcode-selftest` 清理漏了**:清理原来是顺序执行的最后两步,`runIn` 中途抛错就跳过 ——
实测 `/tmp` 里留下 `exitcode-*`。改成 `try/finally`(失败正是最常被重跑的路径),
并加了"抛错时也清理"的验证(模拟中途 throw ⇒ 残留 0)。
2. **PATH 一裁连解释器都找不到**:`no-sha256sum` 那条如果不把 `python3` 解析成绝对路径,
会变成"脚本没跑起来"(退 1),而 **1 又是别的案例的期望值** ⇒ 又一次"没跑起来长得像通过"。
已先解析绝对路径。
## 五、验证与状态
· 7 条锚点全绿:一致⇒0 / unlisted⇒1 / ghosts⇒1 / 说明条目⇒0 / **盲读⇒2** / **单文件⇒2** / **无 sha256sum⇒不崩**。
· 四个自检全 exit 0:`--mutants-line-selftest` 8 例、`--exitcode-selftest` **7 例**、`--skip-selftest` 5 例、`--probe-selftest` 3 例。
· 全套件 `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`。
· 变异:M18 / M19 / M20 全部按预期红并已还原;`/tmp` 无 `exitcode-*`/`barepath-*` 残留。
318 lines
20 KiB
Python
318 lines
20 KiB
Python
#!/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 = ''
|
||
if bl_unrunnable:
|
||
# ★ "**跑不了这项检查**" ≠ "**检查了、没过**"(原来合并了,才让 `r` 未绑定也能走到 162 行)。
|
||
# 这里明说跑不了,并给出原因 —— 与"底本过期/残留"两种成因各自的措辞并列。
|
||
bl_note = (f' baseline=(**跑不了 sha256sum 校验**:{bl_unrunnable})'
|
||
f'—— 这一格**不是**"底本对"也**不是**"有残留",是**没读数**')
|
||
elif 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())
|