#!/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())