#!/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] for f in listed: # 只读**清单里**的,顺序也按清单 p = os.path.join(JOBS_DIR, f) if not os.path.exists(p): continue for j in json.load(open(p, encoding='utf-8')): j['_from'] = f entries.append(j) 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 不同 ⇒ 优先按"变异残留"查**') 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"变成可判的:集合没变而数变了 ⇒ 真算错; # 集合变了 ⇒ 只是换了快照,**一眼看得出**,不用再互相复算一遍。 fp = hashlib.sha256('\n'.join(f'{f}:{os.path.getsize(os.path.join(JOBS_DIR, f))}' for f in listed if os.path.exists(os.path.join(JOBS_DIR, f))) .encode()).hexdigest()[:8] print(f' 集合:清单 {len(listed)} 个 job 文件(未跟踪的**不会**静默进入统计)' f' sha={fp} 集合指纹') # ★ 清单与磁盘不一致 ⇒ 明说(与 `run-all.mjs` 自检 2 同形状:清单外即红)。 if unlisted or ghosts: print(' ★ 清单与磁盘不一致 —— 上面的数字**不代表"磁盘上现在有多少个变异体"**:') for f in unlisted: print(f' 未列入清单:{f}(新加的 job 文件必须显式加进 jobs.manifest.json)') for f in ghosts: 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 一直抬高(方向与"让欠账显形"相反)。') return 0 if __name__ == '__main__': sys.exit(main())