pi 用 `/tmp/mut/` 复算后指出:48 也不对。他是对的,而且**不是记性问题、是口径问题**——
我把 9 个 `jobs*.json` 的条目**直接相加**,没做归一:同一个变异体在跨批重锚时被键了多次
(13 组重复、15 条冗余),最典型的是「计划不搬运模糊值」同时挂在 `jobs-b3`/`jobs-blur`/`jobs-blur2`
三个文件、三个不同 `test` 键上 —— 于是"按判据文件分组求和"必然把它算三次,
而"跑在新增判据上的是多少"在交叉归类下**没有唯一答案**(41 或 35)。
更根本的是 pi 指出的第二层:**那个统计脚本根本不在 `/tmp/mut/` 里**(他为了复算是现写的),
而且 `/tmp` 会被清、不在仓库里 ⇒ 变异体数字**只活在信里**。
这一路已经立过同形状的规则(余额打在 `RESULT` 行、权威源在文件里),这条当时漏了。
## 做了什么
- `client/electron/test/mutants/`:把 `mut.py`、`run.sh`、`jobs*.json`、`baseline.sha`
从 `/tmp` 挪进仓库(`/tmp` 会清、复核方够不着)。
- `mutants/summary.py`:**口径的唯一权威**,定义写死在代码里:
· 不同变异体 = 按 (file, pat, repl) 去重(`retired` 不计);
· 跑起来 = 锚点在该文件里**恰好命中 1 次**(与 mut.py 同一条件);
· `on_new_criteria` = 该变异体的**每一个** test 键都指向本批新增的两个判据文件
(口径 A —— 不因交叉归类虚高;另报口径 B 作参考,它只增不减,不拿来报数)。
- `run-all.mjs` 的 `RESULT` 行播报它,并**顺带自证基线**:跑不起 `summary.py` 时
**不静默**(打印 status 与 stderr 末行)——我第一版路径写错,只看到"计数未知",
真因(`can't open file …/test/test/mutants/summary.py`)被吞掉了。
- `mutants/test-keys.json`:`test` 键 → 判据文件的**唯一来源**(`mut.py` 与 `summary.py`
共用)。此前两处各写一份,分叉过一次:键名从旧名换成 API 名后 `summary.py` 那份没跟上,
于是所有锚点被判 `hits=-1`、报出"51 个变异体全部 skipped"。
- 无歧义口径下的**当前真值**:`mutants=48 ran=48 skipped=0 on_new_criteria=36 baseline=7/7✓`
(口径 B = 41;原始条目 66,其中 `retired` 5)。
- 清掉 pi 指出的三类脏数据:
· **过期条目**(锚点是修复前的旧写法,`hits=0`)标 `retired` 5 条 ——
它们**不是"没跑成的变异体"**,重锚后都跑过、都红了;留着只会把 skipped 一直抬高;
· **重复计数**(multipart 那条在两个文件里各一次)去重;
· **真 skip** 的 multipart 锚点切片成 `name: 'file',\n contentType: mimeType` ⇒ 真的跑起来了
(此前命中 2 次,因为 `ApiClient.ets` 有两个 multipart 构造器)。
- 修两处并发/竞争:`mut.py` 的 `tempfile.mktemp()`(Py3 起 deprecated,**TOCTOU**)→ `mkstemp`;
`run.sh` 的固定 `/tmp/mut/bak` → 按 `$$-$RANDOM` 唯一(并行跑会互相覆盖备份)。
## 未做(如实说)
- **`AdminUsersPage.ets` 有一处不是我做的改动留在工作树里**(11:20:48,我 11:21 的提交之后):
`Chip(text, bg, fg: string)` → `ResourceColor`。核实过是**正确的 ArkTS 修法**
(`Theme.surfaceMuted`/`textSubtle` 是 `Resource`、`chipNeutralBg` 是 `string`,
第 368 行的三目因此是 `Resource | string` ⇒ 旧签名**编译不过**)。
我**没有提交也没有回退**它 —— 工作树是共享的,不该替别人提交别人的活。
基线因此重算了(`baseline.sha` 顶部记了原因与哈希来源,重算本身是**有意动作**:
随手重算会把"某次变异没还原"永久掩盖掉)。
- Go 侧 `debt_registry_test.go` 仍未跑(沙箱无 Go 模块缓存),只做了 `gofmt`。
119 lines
5.0 KiB
Python
119 lines
5.0 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 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 = []
|
||
for f in sorted(glob.glob(os.path.join(JOBS_DIR, 'jobs*.json'))):
|
||
for j in json.load(open(f, encoding='utf-8')):
|
||
j['_from'] = os.path.basename(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
|
||
bl_note = f' baseline={ok}/{total}' + ('✓' if good 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)
|
||
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())
|