Files
MailUI4Agents/client/electron/test/mutants/summary.py
JianFeeeee efb1c1d2ca 修复: 第四个落点 —— **变异条目的锚点失效**(hits=0):守具有齿,却不在位
pi `6aa2b17f` 报的第三例(`unlisted`/`ghosts` 退 0)**我复核后已经在 `6ee9902` 修好了**
—— 而它的**父提交正是 pi 报信时读的 `4b841e0`**(差 11 分钟)。又是同一形状的竞态。

但顺着同一条线**审计全部 61 个变异条目的锚点**,发现**还有一个同形状的落点,
而且它在真树上是活的**(不是构造的):

    hits=0  SettingsPage.ets 「管理入口不做门禁」(jobs-all.json)

根因:该锚点写的是 **6 空格**,而 `c523c21`(09-17 17:43)把该文件**重排成 4 空格**
⇒ 锚点从此命中 0 次(写进清单时 `bcd4f97`(09-15)它是**对的**)。

为什么没人发现:`summary.py` 把「hits=0 … 过期条目」**只打印**、**不进严重度链**
⇒ `rc=0`、`diag=none` ⇒ 套件 `whyLines: (note || status!==0) ? … : []` 为假 ⇒
**整段丢掉**。端到端实测(真树、干净工作树):套件输出里 grep「过期条目」= **0 次**。

★ 危害是"**一个变异守具被静默关掉**",不是"数字错了":`ran` 少 1、`skipped=1`
是个**中性数字**,读者看不出少了哪一个。而**手工施加那个变异仍能让判据红**
(`harmony-admin.test.mjs` `# fail 1`)⇒ **有齿,只是没挂上**。

修法(沿用 `summary.py` 的**唯一严重度链**):
  · 新增 `mutant-anchor-stale` ⇒ **1 档**(清单/数据该改;**不是** 2 档 ——
    照 `env-defaults.sh:25` 的反方向:别让"清单没跟上"冒充环境)。
  · 修锚点 6→4 空格 ⇒ `ran` 47→48、`skipped` 1→0,守具重新挂上(实测变异能红)。
  · `whyLines` 过滤器加 `^\s*hits=`:只说"有锚点过期"不够,**点名的才是可行动的**。
  · 两张表都登记(`UPSTREAM_RC` + `DIAG`,`blocksGreen: true`)+ 真跑案例
    (迷你仓库里放一个不含锚点的同名文件 ⇒ `hits=0`)+ 读者侧具名案例。

★ 射程如实标出:判据只看 **`hits == 0`**,**不看 `hits == -1`**(文件打不开是另一回事,
且迷你夹具里目标文件本来就不在 ⇒ 算进来会造假红)。**`-1` 那一半无判据守着**(真树 0 条)。

变异验证(4 个方向全抓):
  ① 新档关闭(回到"只打印")⇒ exitcode-selftest rc=1、2 条红 ✓
  ② 从 UPSTREAM_RC 删掉新码 ⇒ 反向覆盖点名 ✓
  ③ blocksGreen true→false ⇒ 双向口径漂移红 ✓
  ④ 放宽成任何 skipped_detail(含 hits=-1)⇒ rc=1、**5 条红**(夹具假红)
     ⇒ **`h == 0` 这个射程是承重的** ✓

端到端 A/B(隔离 worktree,同刻对照):
  A 锚点已修  ⇒ rc=0、diag=none、ran=48/skipped=0、"锚点已失效" grep **0**
  B 锚点退 6 格 ⇒ rc=1、diag=mutant-anchor-stale、ran=47/skipped=1、grep **2**

★ 通用规则(本仓第 4 次同一形状):**"跑了多少个"与"该跑多少个"之间也要有判据。**
被跳过时 `ran` 只少 1、`skipped` 只多 1 —— 都是中性数字,而"少了哪一个"没有通道。
凡"登记一批东西、再逐个挂上"的结构(变异条目、判据、样本表)都要问:
**挂不上的那一个,谁来说?**

自检 5 个各 rc=0;全套 checks=516 pass=511 fail=5 red=5 verdict=red;
mutants=48 ran=48 skipped=0 on_new_criteria=36 diag=none baseline=7/7✓。
CRITERIA.md §16.1.1 记这一笔(含 4 个变异与 A/B 表)。
2026-09-20 04:49:50 +08:00

504 lines
34 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
# ★★ 缺席的**两种成因**必须先分开(pi 2026-09-18 实测到的那个洞)。
#
# 原来 `baseline.sha` 不在就一律 `baseline-absent` ⇒ 退 0、`note: null`、**连读者都看不到**。
# 而它同时是"**关掉残留检测**"的开关 —— 实测(pi 报、我复现):
# 同一处残留(`ImagePrep.ts` 末尾追加一行),**只切 `baseline.sha` 在不在**:
# 在 ⇒ `diag=baseline-residue`、`red=10`、红清单里**有**那条 `(summary.py)baseline-residue`
# 删掉 ⇒ `diag=baseline-absent`、`red=9`、那条红**整条消失**、`rc=0`、静默
# 而 `git status` 里 ` D baseline.sha` 与 ` M ImagePrep.ts` **同时都在**。
# ⇒ **残留还在,唯一能报告它的机制被一个 `rm` 关掉了**,不需要任何特权、不会红、也不说话。
#
# ⇒ 判法(pi 建议,且**不必新造**):`baseline.sha` 是**受版本控制**的
# (`git ls-files --error-unmatch` 有它)⇒ "跟踪着、却不在工作树里"**本身就是可疑**,
# 那是 ` D`(被人删了),不是"可接受的配置"。
# 只有"**本来就没跟踪它**"(别人 clone 的变体 / future)才保留"缺席即配置"的语义。
#
# ★ 判别是三态(与我修 `git diff --quiet` 那次同形):
# rc=0 ⇒ 受跟踪 ⇒ 删除 ⇒ **缺文件**(该改的是仓库/数据)
# rc=1 ⇒ git 说没跟踪它 ⇒ 真·配置缺席
# rc=128 ⇒ git 答不了(非仓库)⇒ **不许猜**,退回"配置缺席"但**必须说话**
bl_tracked = None # None=判不了;True=受跟踪;False=没跟踪
if not os.path.exists(bl):
try:
tr = subprocess.run(['git', 'ls-files', '--error-unmatch', '--',
os.path.relpath(bl, REPO)],
cwd=REPO, capture_output=True, timeout=30)
if tr.returncode == 0:
bl_tracked = True
elif tr.returncode == 1:
bl_tracked = False
# 其它(128 等)⇒ 保持 None = 判不了
except Exception: # noqa: BLE001 —— 判不了就退化为"配置缺席 + 说话"
bl_tracked = None
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_tracked` 那段的总说明)。
if bl_tracked is True:
# 受跟踪却被删 ⇒ **不是配置,是缺文件**(谁把它删了?残留检测因此关掉了)
bl_diag = 'baseline-deleted'
bl_note = (' baseline=(**底本被删了**:`baseline.sha` 受版本控制,'
'却不在工作树里 ⇒ **残留检测已失效**)')
else:
bl_diag = 'baseline-absent'
# ★ 即使是"真·配置缺席",也**必须说话** —— 原来这里是 `note: null`、连读者都看不到。
# "这一格没检查"与"检查了、结论无害"**不是一回事**(本仓反复立的那条:
# "跑不了" ≠ "检查了没过"),所以它不能再靠沉默通过。
bl_note = (' baseline=(**没有底本 ⇒ 这一格没检查**:'
+ ('判不出它是不是该受跟踪' if bl_tracked is None else '本来就没跟踪它')
+ ',但**残留检测是关着的**)')
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-deleted `baseline.sha` **受跟踪却被删** ⇒ 1(缺文件,不是配置)
# baseline-absent **本来就没跟踪它** ⇒ 0,但**必须说话**
#
# ★★ `baseline-deleted` 与 `baseline-absent` 是这一轮(pi 2026-09-18)新分出来的:
# `baseline-absent` 原来同时是"**关掉残留检测**"的开关 —— 一个 `rm` 就能让
# 残留仍在、而唯一能报告它的那条红**整条消失**(实测 `red 10→9`、`rc=0`、静默)。
# ⇒ 按本仓反复立的那条口径分家:**"检查了、结论无害"**(`baseline-stale`)
# 与 **"根本没检查"**(`baseline-absent`)**不该同格**;
# 而"受跟踪却被删"更重一档 —— 它连"配置"都不是,是**缺文件**。
# ★★ 第五类(pi 2026-09-18 本封报的**组合口子**):**两条链排序不同**。
#
# 修前同一个文件里有**两份**优先级表,而且顺序相反:
# diag(原选择处):`unlisted/ghosts` **排第一** ⇒ 组合态报 `manifest-mismatch`
# rc (原返回处):`blind/unreadable/baseline-unrunnable|unknown` **排第一** ⇒ 组合态退 2
# ⇒ 两类**同时**成立时(`unlisted` + 单文件不可读 / + 跑不了 sha256sum / + git 答不了),
# `diag` 说 `manifest-mismatch`(`UPSTREAM_RC` 里 **1**)而**真 rc=2** ⇒
# `run-all` 那条不变式 `UPSTREAM_RC[diag] === rc`(`:1173`)**在其上为假**。
# 实测(裁 PATH,root 可达):`diag=manifest-mismatch`、`rc=2`、表值 1 ⇒ 不一致。
#
# 后果**不是假绿**(`manifest-mismatch.blocksGreen=true` ⇒ 照样红、note 也转印),
# 而是**严重度被低估**:`counts-unusable`(2) 与 `baseline-unrunnable/unknown`(2)
# 被 `manifest-mismatch`(1) **盖住** ⇒ 读者以为"只要改清单",
# 而真相是"**连数都没读成**"。rc 通道从此不可信。
#
# ⇒ 结构性修法(**一条链推两个结果**,而不是把两个顺序改成一致):
# 严重度**只有一个权威来源** —— 下面这段按同一顺序算出 `(diag, rc)` **一对**,
# `RESULT` 行用它、退出码也用它 ⇒ **排序不可能再漂移**。
# ★ 为什么不是"把 diag 链的顺序也改一致":那还是**两份**优先级表,
# 下次有人加一个条件,两处又会各自漂移(这正是本仓反复消的"一份事实两处实现")。
#
# 顺序按**严重度递减**:
# 2 档(**没读数**,连是环境还是代码都判不了):blind / unreadable / baseline-unrunnable|unknown
# 1 档(**数据/清单该修**):unlisted / ghosts / mutant-anchor-stale / baseline-residue|deleted
# 0 档:其余(`baseline-stale`/`baseline-absent` 仍带码,但**不必红**)
if blind:
severity = ('counts-unusable', 2)
elif unreadable:
severity = ('counts-unusable', 2)
elif bl_diag in ('baseline-unrunnable', 'baseline-unknown'):
severity = (bl_diag, 2)
elif unlisted or ghosts:
severity = ('manifest-mismatch', 1)
elif bl_diag in ('baseline-residue', 'baseline-deleted'):
severity = (bl_diag, 1)
# ★★ 第五类(dsh 2026-09-19,**同一形状的第四个落点**):**变异条目的锚点失效**。
#
# 这一格原来**只打印**(上面那段 `hits=0 … 过期条目`)、**完全不进严重度链**
# ⇒ `rc=0`、`diag=none` ⇒ 套件那边 `whyLines: (note || status!==0) ? … : []`
# 把它整段丢掉。端到端实测(真树、干净工作树):
# `summary.py` 说「hits=0 … 过期条目」 · 套件输出里 grep **0 次** · `rc=0`
# ⇒ 后果不是"数字错",是**一个变异守具被静默关掉**:
# 实测那一条(`SettingsPage.ets` 的「管理入口不做门禁」)锚点写成 6 空格,
# 而 `f4b8bc1`(09-17 17:43)把该文件重排成 4 空格 ⇒ 锚点从此命中 **0** 次、
# `ran` 少 1(47 而非 48)、`skipped=1`、**没有任何东西变红**;而手工施加那个
# 变异**仍能让判据红**(`harmony-admin.test.mjs` `# fail 1`)
# —— 也就是说**守具有齿,只是不再被挂上**。
#
# 归 **1** 档(不是 2):锚点过期是**清单/数据该改**(去改 `jobs/jobs-*.json`
# 里的锚点,或标 `retired`),与 `manifest-mismatch` 同类;
# 与"权限不够读不到"(2 档)**不是一回事** —— 照 `env-defaults.sh:25` 的
# 反方向:**别让"清单没跟上"冒充环境**。
# ★ 放在 `residue`/`deleted` **之后**:同为 1 档时,让最危险的那两个码优先报出来
# (锚点过期只是"守具没挂上",残留是"守具正在被绕过")。
# ★ 判据只看 **`hits == 0`**(旧的锚点写法漂移了 ⇒ 去改锚点),**不看 `hits == -1`**:
# `-1` 是"目标文件打不开"(文件没了/权限),与"锚点写法过期"是两回事,
# 而且 `--exitcode-selftest` 的迷你夹具里 `jobs-one.json` 指向的文件**本来就不在**
# (那是夹具的设计,不是仓库的缺陷)⇒ 把 `-1` 也算进来会造出**假红**。
# ⚠️ 如实标出射程:**`-1` 那一半没有判据守着**(真树实测 0 条,故暂不覆盖)。
elif any(h == 0 for _, _, h in skipped_detail):
severity = ('mutant-anchor-stale', 1)
elif bl_diag in ('baseline-residue', 'baseline-deleted'):
severity = (bl_diag, 1)
elif bl_diag != 'none':
severity = (bl_diag, 0)
else:
severity = ('none', 0)
diag, rc_want = severity
if blind:
print('RESULT mutants=NO-READING ran=NO-READING skipped=NO-READING '
f'on_new_criteria=NO-READING diag={diag}'
'(**没读数**:读不到 jobs/ 目录 ⇒ 上面的数字全部无效)')
else:
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` 同类 —— **没读数**,不是"有残留"。
# 严重度已在上面按**单链**算好(`(diag, rc_want)`),这里只把同一结果交回调用方;
# `sys.exit(main())` 直接用它当退出码 ⇒ **不存在第二份优先级表**(这正是本笔修的口子)。
return rc_want
if __name__ == '__main__':
sys.exit(main())