Files
MailUI4Agents/client/electron/test/mutants/summary.py
JianFeeeee 590a72a715 修复: job 集合从 glob 改成**清单**(+ 清单外即报,与 SUITE 同形状)+ RESULT 行带集合指纹;baseline 播报区分"底本过期"与"变异残留";权限政策补上**目录可进入性**(原来只管文件)
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 仍逐字节一致(上一轮的修复保持)。
2026-09-18 04:07:26 +08:00

177 lines
9.4 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]
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())