pi 2026-09-18 报的三件,逐条实测后处置。
## 一、job 集合是 glob ⇒ "权威"是**树的函数**(同一段代码两个数)
pi 独立复算对上了(48/48/0、A=36/B=41 与我一致),但发现 48 与 52 **都不是错的** ——
它们读的是**不同的集合**,而**没有任何东西说明读的是哪个**:
| 快照 | job 文件 | 输出 |
|---|---|---|
| 提交态 | 9 个(全跟踪) | `mutants=48 …` |
| 工作树 | 11 个(2 个未跟踪) | `mutants=52 …` |
真因:`glob(JOBS_DIR + '/jobs*.json')` ⇒ **未跟踪的 job 文件静默进入统计**。
这与"写了判据忘了接线"同族,区别是 `run-all.mjs` 有**自检 2** 挡着(清单外的 `*.test.mjs` 直接红),
这边没有对等物。**本仓用"清单 + 清单外即红"解决过同一个问题两次,这是第三次。**
**处置(pi 建议的前者)**:新增 `jobs.manifest.json` 显式列 job 集合,`summary.py` 只读清单;
清单与磁盘不一致时**明说**(两个方向都报:未列入清单 / 清单里有但磁盘没有),
并声明"上面的数字**不代表磁盘上现在有多少个变异体**"。
**另加集合指纹**(pi 建议的后者,两条都做了):`sha=…` —— 让"48 还是 52"变成可判的:
集合没变而数变了 ⇒ 真算错;集合变了 ⇒ 一眼看出是换了快照,不必再互相复算一遍。
**变异验证**(两个方向都试):
```
加一个未列入清单的 job 文件 ⇒ 报「未列入清单:jobs-UNLISTED-probe.json」
且 mutants=**48**(未被静默计入)✓
清单里加个不存在的文件 ⇒ 报「清单里有、磁盘上没有:jobs-GHOST.json」✓
```
(`_` 开头的条目是说明,读时滤掉 —— 否则会被当成文件名,`ghosts` 假报一堆。)
## 二、`baseline=4/7✗**有文件没还原**` 是**假警报指向最危险的结论**
实测三个不匹配的文件 **全部与 HEAD 逐字节相同**(`git diff --quiet HEAD` 为空):
`AdminUsersPage.ets`/`SettingsPage.ets` 是提交 `6861934`(09-17 21:15)改的、
`ApiClient.ets` 是 `9c6e9c6` 改的 ⇒ **底本过期,不是变异残留**。
★ 而原来一律打"✗**有文件没还原**" —— 那会让人去翻变异,而真因只是底本没跟上提交。
**两种成因在 `sha256sum -c` 眼里一模一样**,所以播报必须分开,并各给判别方法:
```
baseline=4/7⚠**底本过期**(3 个文件与 HEAD 逐字节相同 ⇒ 是正常提交改过、不是变异残留)
```
**底本已重算**,并按要求在文件里记一行"为什么"(含"重算前必须先证是提交态"这个前提)。
**重算后仍在校验**(变异验证):给 `AppearanceApi.ets` 追加一行 ⇒ 立刻 `FAILED`;还原 ⇒ `7/7 OK`。
## 三、`summary.py` 对非 root 是坏的:`jobs/` 缺 `x` 位
```
drw-r--r-- client/electron/test/mutants/jobs ← 缺 x
$ runuser -u nobody -- python3 …/summary.py
PermissionError: [Errno 13] Permission denied: '…/jobs/jobs-all.json'
```
⇒ 那份"口径的唯一权威"**只有 root 跑得起来**。已 `chmod 755`,非 root 复跑输出一致。
★ pi 的深层判断成立且我核实了:**这条落在任何判据的射程之外** ——
`deploy/check-file-modes.sh` 的政策是"**源文件**不得比 0644 更严",
而它遍历的是 `git ls-files`,**只看文件**;"目录缺 x"比"文件 0644 更严"更严重
(连 `stat` 都进不去)。
⇒ **给该政策补上"目录可进入性"**(只判 `u+x`、只判"仓库内容所在的目录",
不去管 node_modules/dist —— 那会把这判据淹掉;也不判 group/other —— 那取决于本机 umask)。
**★ 我第一版这里又写错了,而且错得正好是被测的那个病**:
用 `[ -x "$d" ]` 判可进入 ⇒ **恒为真**,因为 **root 无视权限位**(`test -x` 对 uid=0 永远返回 0),
于是变异验证"撤掉 x"**根本不触发**,而**输出看起来完全正常**。
⇒ 改成**直接读权限位**(与同文件里文件那段的位运算同一做法)。
改后变异验证:撤 x ⇒ `[FAIL] 目录不可进入:…(drw-r--r--,缺属主 x 位)`、退出码 1;还原 ⇒ 0。
## 四、顺带修好三处既有权限违规(`check-file-modes.sh` 原来一直红着)
```
.githooks/pre-push 711 ⇒ 755(保留执行位,**不是**去掉)
test/harmony-arkts.test.mjs 600 ⇒ 644
model/DeviceProbe.ts 600 ⇒ 644
```
三者都是**提交态**就这样(不是并发会话弄的)。按脚本自己的"药方按类型分岔"修的。
现在该政策 **退出码 0**。★ git **不存** 600/644 的区别(只记 exec 位)⇒ 这三处是**本地状态**修复,
不随提交走,新克隆不受影响 —— 与 pi 对目录权限的说明同一条。
## 五、pi 报的另两件:**在 HEAD 上已经不存在了**(是过时读数)
- **ArkTS 两处阻断**:`MainPage.ets` 的 import 现在在 71-74 行、最后一个 `const` 在 88 行
⇒ **位置正确**;`AdminUsersPage.ets` 的 `Chip` 签名已是 `ResourceColor`(`:410`)
⇒ 两处**都已在 HEAD 修好并提交**(工作树 `client/harmony/` 干净)。`harmony-arkts` 判据 3/3 绿。
- **套件现状**:现在 **29** 个判据文件(不是 23),`unreported=0 broken=0`。
## 六、测量
| 相位 | RESULT |
|---|---|
| build | `files=29 ran=29 checks=456 pass=453 fail=3 red=9 broken=0 unreported=0` |
| install | `files=29 ran=27 checks=444 pass=442 fail=2 red=8 broken=0 unreported=0` |
三种 cwd 仍逐字节一致(上一轮的修复保持)。
177 lines
9.4 KiB
Python
177 lines
9.4 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]
|
||
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())
|