修复: baseline= 那一格到不了套件(捕获组是前缀 + 过滤器不含它);baseline-unrunnable 退回 rc=0 的自相矛盾;并把「脏/干净/git 答不了」从两态改回三态
pi 2026-09-18 报的两条我都端到端复现了,都成立。修的过程中又发现**第三条**(我自己引入的),以及**自检自己的一处"判据在但走不到"**。 ## 一、① `baseline=` 那一格从来不进套件输出(pi 报的第 ① 条) 两处都实测确认: · 捕获组是**前缀**:`m[1]` = `RESULT mutants=48 ran=47 skipped=1 on_new_criteria=35`,`'baseline' in m[1]` = **False**; · `whyLines` 的过滤器不含 `baseline=`。 ⇒ 两条路都不通。**真·变异残留**时 `baseline=6/7✗**1 个文件既不在底本、也与 HEAD 不同 ⇒ 优先按"变异残留"查**` 在套件输出里 grep = **0 次**,而 `rc=0`、`mutants=48 ran=47 skipped=1` **看着完全正常**(我按你给的形状复现)。 **修法**:不扩正则、也不往过滤器里加关键词(那是"按字面量裁射程",本仓已栽过两次: `criteria-hygiene` 的 import 硬匹配、`prose()` 判"用没用")—— 改为 `RESULT` 行末尾加**结构化诊断码** `diag=<码>`,由 `run-all` 的 `DIAG_NOTE` 表逐码给一句准确的话: | `diag` | rc | 含义 | |---|---|---| | `none` | 0 | 正常 | | `manifest-mismatch` | 1 | 清单与磁盘不一致 | | `baseline-residue` | **1** | **真·变异残留**(与底本不同**且**与 HEAD 不同) | | `baseline-stale` | **0** | 底本过期(与 HEAD 逐字节相同,**不是缺陷**) | | `baseline-unrunnable` | **2** | 跑不了 `sha256sum`(没读数) | | `baseline-unknown` | **2** | git 答不了 ⇒ 判不出残留还是过期(没读数) | | `counts-unusable` | 2 | 读不到 `jobs/` | 码表是**闭合**的:**不认识的码会报警**,所以"上游加了新状态而下游不知道"会立刻显形, 不会静静变成"没问题"。同时把 `baseline=` 的**读数本身**(`6/7✗`)也摘进那一格 —— 端到端实测:残留时 `grep baseline=` 从 **0 → 1**,rc 从 0 → **1**。 ## 二、② `baseline-unrunnable` 原来 rc=0,与我自己的原则相反(pi 报的第 ② 条) 确认。我在 `blind/unreadable` 上定的是"**没读数不是成功 ⇒ 2**",而"跑不了 `sha256sum`"**是同一件事**, 却退 0 ⇒ 走正常路径 ⇒ 那句"是没读数"同样到不了读者。**我把"跑不了"和"没过"分成了两种状态(对), 但第三种状态(跑不了)没给它传播通道** —— 你这句说得准。 ⇒ 现在 `baseline-unrunnable` 与 `baseline-unknown` 都退 **2**, 且措辞由 `diag` 决定而**不是**由 `status` 决定:`status === 2` 那句通用话会说"上面的 mutants 数字**不可信**", 而这里是**假话**(数字照常有效,**只有 baseline 那一格**没读数)。所以措辞一律走 `DIAG_NOTE`。 ## 三★★ 我自己引入的第三条:`git` 三态被读成两态(假警报指向最危险结论) 修 ① 时我要构造 residue,才发现:`git diff --quiet` 退 **1** 有**两种**含义 —— 实测 · 仓库内干净 = **0** · 仓库内已改 = **1** · **非仓库/HEAD 取不到 = 也是 1**(stderr `error: Could not access 'HEAD'`)。 而原代码是 `.returncode != 0` ⇒ **"git 根本没答"被读成"有差异" ⇒ residue**, 也就是这一格**最危险**的读数。我自己在临时目录里跑时就撞到了:`REPO` 解析成 `/` ⇒ 一律报 residue。 ⇒ 加 `can_ask`(`git rev-parse --is-inside-work-tree HEAD`,非仓库退 **128**,可区分): 答不了就报 `baseline-unknown`(**没读数**),**不许**声称 residue。这与上面 §一 那段注释警告的方向一致: **假警报指向最危险的结论**。 ## 四★★ 自检自己的一处"判据在,但走不到" 补 `baseline` 四态锚点时发现:旧 `runIn` 的临时目录是 `/tmp/xxx/`, 而 `summary.py` 用 `REPO = HERE/../../../..` ⇒ **`REPO` 解析成 `/`**(实测打印过)。 于是 `baseline=` 那一格**在原来的四例里从未被真正走成"对"**:`sha256sum -c` 在 `/` 下全部 FAILED、 `git` 也答不了。它们期望 rc=0/1 却看着"通过",是因为**两个错误互相抵消** ("判不了"被读成"有残留" **且** residue 当时退 0)。 ⇒ 重建 `runIn`:按脚本真实位置**嵌套**建目录 + 造一个**迷你真仓库**(`git init` + 一次 commit + 一个可提交可改脏的目标文件),让 `REPO` 落在仓库根上。现在 `baseline=` 的**四态** (对 / 过期 / 残留 / 判不了)**每一条前提都构造得出来**。 ## 五、变异验证(都已还原) | 变异 | 期望 | 结果 | |---|---|---| | **M21** residue 退回 rc=0(pi 报的原始行为) | `真·变异残留⇒1` 红 | **红**,`stale` 照旧绿(两条独立) | | **M22** `unrunnable`/`unknown` 退回 rc=0(pi 本封 ② 的原始行为) | 两条都红 | **都红** | | **M23** `can_ask` 恒真(三态退回两态) | `git 答不了` 红(会把"判不了"说成 residue) | **红**,rc 报 1(正是那个假警报) | ## 六、验证与状态 · **11 条锚点全绿**:一致⇒0 / unlisted⇒1 / ghosts⇒1 / 说明条目⇒0 / 盲读⇒2 / 单文件⇒2 / **无 sha256sum⇒2** / **残留⇒1** / **过期⇒0** / **git 答不了⇒2** / 没有 baseline⇒0。 · `--mutants-line-selftest` **9 例**(含新增 `baseline-stale` 与"不认识的码⇒报警");四个自检全 exit 0。 · 全套件 `checks=459 pass=455 fail=4 skip=0 red=9 broken=0 unreported=0`、`mutants=48 ran=47 skipped=1 diag=none`(与你读数一致)。 · 端到端:残留时 `grep baseline=` **0→1**、rc **0→1**;正常态 `diag=none`、rc=0。 · 残留全清(`/tmp` 无 `exitcode-*`/`barepath-*`);`git status` 仅这两个文件;`HEAD=e99a657`。 ## 七、你 §三 那句补充 > "能构造"是**上游锚点**的条件,不是**传播**的保证。 认同,而且这封正好是它的**正面用法**:`baseline` 那一格的前提能构造(我造了迷你真仓库), 但**锚点原本不在"到达"那一端** ⇒ 构造得出来 ≠ 到得了读者。 §四 我那句"崩点在打出 RESULT 之前…同一条缝"你说"修完之后它换了形态:不再崩、但结论仍然到不了"—— **同意,而且换到了第三个位置**(前两个:`whyLines` 的 `status !== 0` 关、`unlisted/ghosts` 退 0; 这一个:**正则捕获组**)。现在这条通道两半都钉住了:**结论**走 `diag`、**读数**走 `baseline=`。
This commit is contained in:
@ -162,12 +162,16 @@ def main():
|
||||
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_diag = 'baseline-absent'
|
||||
bl_note = ' baseline=(没有 baseline.sha)'
|
||||
else:
|
||||
good, ok, total = baseline_ok
|
||||
@ -180,15 +184,39 @@ def main():
|
||||
# 而真因只是底本没跟上。所以现在两种都报,并给出**各自的判别方法**。
|
||||
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]
|
||||
# ★★ "脏 / 干净 / **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 不同 ⇒ 优先按"变异残留"查**')
|
||||
|
||||
@ -209,12 +237,45 @@ def main():
|
||||
# ⇒ 让**读数器自己说"我没读数"**:盲读时不打可匹配的形状(`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-absent 没有 baseline.sha ⇒ 0(这是可接受的配置)
|
||||
if blind:
|
||||
print('RESULT mutants=NO-READING ran=NO-READING skipped=NO-READING '
|
||||
'on_new_criteria=NO-READING(**没读数**:读不到 jobs/ 目录 ⇒ 上面的数字全部无效)')
|
||||
'on_new_criteria=NO-READING diag=counts-unusable'
|
||||
'(**没读数**:读不到 jobs/ 目录 ⇒ 上面的数字全部无效)')
|
||||
else:
|
||||
# 诊断码按"最该被看见的那一个"取(一个 RESULT 行只挂一个码,避免读者要看两处)
|
||||
if unlisted or ghosts:
|
||||
diag = 'manifest-mismatch'
|
||||
elif unreadable:
|
||||
diag = 'counts-unusable'
|
||||
elif bl_diag != 'none':
|
||||
diag = bl_diag
|
||||
else:
|
||||
diag = 'none'
|
||||
print(f'RESULT mutants={len(by)} ran={ran} skipped={skipped} '
|
||||
f'on_new_criteria={only_new}'
|
||||
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 的第二个建议)。
|
||||
@ -306,9 +367,25 @@ def main():
|
||||
# `ghosts`(清单里有、磁盘上没有)⇒ 去改 `jobs.manifest.json`。
|
||||
# 两类都退非零 ⇒ `run-all` 那边既有的 `status !== 0` 路径会自动把 `whyLines`
|
||||
# 转印出来,**不需要动 run-all**(那一层早就能承接,只是上游没把状态传上来)。
|
||||
if blind or unreadable:
|
||||
#
|
||||
# ★★ 第四类(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` 同类 —— **没读数**,不是"有残留"。
|
||||
if blind or unreadable or bl_diag in ('baseline-unrunnable', 'baseline-unknown'):
|
||||
return 2
|
||||
if unlisted or ghosts:
|
||||
if unlisted or ghosts or bl_diag == 'baseline-residue':
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
@ -26,6 +26,7 @@ import { spawnSync } from 'node:child_process';
|
||||
import { existsSync, readdirSync, mkdtempSync, mkdirSync, cpSync, writeFileSync, rmSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { tmpdir } from 'node:os';
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
@ -778,93 +779,6 @@ if (process.argv.includes('--probe-selftest')) {
|
||||
process.exit(bad ? 1 : 0);
|
||||
}
|
||||
|
||||
/*
|
||||
* 变异体播报自检(`--mutants-line-selftest`):钉住"**先看退出码,再看正则**"。
|
||||
*
|
||||
* 为什么需要:这条决策**在正常路径上走不到** —— 套件以 root 跑,而盲读只在
|
||||
* **非 root + `jobs/` 不可读**时才发生。只在我手跑 `runuser -u nobody` 时才经过的分支
|
||||
* 等于**没有判据守着**;哪天顺序被调回"先看正则",没有任何东西会响,
|
||||
* 而它是"读数器说自己没读数"的**唯一通道**。
|
||||
* (我实测过:盲读/部分可读时 `summary.py` 照样打得出一行**看着正常**的
|
||||
* `RESULT mutants=<数字>`,所以这个顺序不是风格问题。)
|
||||
*/
|
||||
if (process.argv.includes('--mutants-line-selftest')) {
|
||||
const blind = 'RESULT mutants=NO-READING ran=NO-READING skipped=NO-READING on_new_criteria=NO-READING(**没读数**)\n'
|
||||
+ ' ✗✗ **读不到 jobs/ 目录**(/x/jobs)—— 上面的数字**全部无效**:\n';
|
||||
const partial = 'RESULT mutants=47 ran=0 skipped=47 on_new_criteria=0(…原始条目 73…)\n'
|
||||
+ ' ★ 清单与磁盘不一致 —— 上面的数字**不代表"磁盘上现在有多少个变异体"**:\n'
|
||||
+ ' 清单里有、**在但读不到**:jobs-one.json(权限问题,不是缺失)\n';
|
||||
const normal = 'RESULT mutants=48 ran=47 skipped=1 on_new_criteria=35(口径A=只挂新判据 35 …) baseline=7/7✓\n';
|
||||
/*
|
||||
* 第三例(pi 2026-09-18):`unlisted`/`ghosts` 走 **status=1** ——
|
||||
* 数字仍然打出来、正则也匹配,但**它不是全集**(磁盘上还有没登记的文件)。
|
||||
*/
|
||||
const unlisted = 'RESULT mutants=48 ran=47 skipped=1 on_new_criteria=35(…原始条目 73…)\n'
|
||||
+ ' ★ 清单与磁盘不一致 —— 上面的数字**不代表"磁盘上现在有多少个变异体"**:\n'
|
||||
+ ' 未列入清单:jobs-UNLISTED-probe.json(新加的 job 文件必须显式加进 jobs.manifest.json)\n'
|
||||
+ ' 清单里有、磁盘上没有:jobs-ghost.json\n';
|
||||
const cases = [
|
||||
// ① 正常:原样播报、不转印任何东西
|
||||
['正常(status 0)', normal, 0, { line: ' RESULT mutants=48 ran=47 skipped=1 on_new_criteria=35', why: 0 }],
|
||||
// ② 盲读:**必须**说"没读数",且把 ✗✗ 转印出来
|
||||
['盲读(status 2,正则不匹配)', blind, 2, { line: '没读数', why: 1 }],
|
||||
/*
|
||||
* ③ 部分可读:status=2 但 stdout 里是一行**看着完全正常**、**且匹配正则**的
|
||||
* `mutants=47 …`。这一条是整条自检的**要害** —— 它证明"先判 status"不是多余:
|
||||
* 只按正则走就会把 47 播报成权威数字,而真相是"有一个 job 文件没读到"。
|
||||
*/
|
||||
['部分可读(status 2,正则**匹配**)', partial, 2, { line: '没读数', why: 2 }],
|
||||
/*
|
||||
* ③(pi 2026-09-18 的第三例)清单与磁盘不一致:status=**1**、正则**匹配**。
|
||||
* `mutants=48 …` 按清单算是**对的**,但磁盘上还有没登记的文件 ⇒ 读的人必须知道
|
||||
* "这不是全集"。原来它们退 **0** ⇒ `whyLines` 被 `status !== 0 ? … : []` 丢掉、
|
||||
* 套件输出里 grep "未列入清单" = 0 次(我端到端复现过)。
|
||||
* ⇒ 这里要求:**数字照播**(不改它,它没错)+ 挂上"不是全集" + 把两行原因都转印。
|
||||
*/
|
||||
['清单与磁盘不一致(status 1,正则匹配)', unlisted, 1,
|
||||
{ line: '不代表磁盘上现在有多少个变异体', why: 3 }],
|
||||
// ④ 异常退出码但正则匹配(既不是 0/1/2)⇒ 也要留痕,不许静默
|
||||
['未知退出码', normal, 3, { line: 'status=3', why: 0 }],
|
||||
// ⑤ 完全没打出 RESULT(如脚本不存在)⇒ 报 status 与 stderr 末行
|
||||
['没打出 RESULT', '', 1, { line: '没打出 RESULT', why: 0 }],
|
||||
/*
|
||||
* ⑥★ **封缝**:status=0 且 stdout 里**确实带着**警告行 —— 这是第三例的**形状**,
|
||||
* 只是把"上游忘了退非零"这一半也模拟出来。
|
||||
* 期望:**转印 0 行**(因为按契约 status=0 = 一切正常,不该有警告)。
|
||||
* 这条不是"想要这个行为",而是把**缝的位置**钉在测试里:
|
||||
* 如果哪天又出现"检查存在、结论到不了套件"的组合,它会与③形成对照 ——
|
||||
* ③ 要求转印、⑥ 要求不转印,**两者同时绿才算"退出码这条通道是活的"**。
|
||||
*/
|
||||
['status 0 但带着警告行(缝的形状)', unlisted, 0, { line: 'mutants=48', why: 0 }],
|
||||
];
|
||||
let bad = 0;
|
||||
for (const [what, stdout, status, want] of cases) {
|
||||
const got = summarizeMutants(stdout, status, 'boom');
|
||||
const okLine = got.line.includes(want.line);
|
||||
/*
|
||||
* ★ 期望值用**案例里声明的** `want.why`,不在这里另算一遍 ——
|
||||
* 我第一版既声明了 `want.why`、又用一条正则从 stdout 现算 `wantWhy`,
|
||||
* 于是同一个事实**两份实现**(正是本仓反复消的形状):`want.why` 写了却**从没被读**,
|
||||
* 而现算那条一旦与 `whyLines` 的过滤器不同步,自检就会**自证自恰**地绿。
|
||||
*/
|
||||
const okWhy = got.whyLines.length === want.why;
|
||||
const ok = okLine && okWhy;
|
||||
console.log(`${ok ? 'ok ' : 'RED '} ${what}:line="${got.line.trim().slice(0, 60)}" ` +
|
||||
`转印 ${got.whyLines.length} 行${ok ? '' : `(期望含 "${want.line}"、转印 ${want.why} 行)`}`);
|
||||
if (!ok) bad++;
|
||||
}
|
||||
// ⑥ 反面对照:把顺序调回"先看正则"会怎样 —— 直接验证那个错误实现确实会被骗
|
||||
{
|
||||
const m = /(RESULT mutants=\d+ ran=\d+ skipped=\d+ on_new_criteria=\d+)/.exec(partial);
|
||||
const wrong = m ? ` ${m[1]}` : '(else)';
|
||||
const ok = wrong.includes('mutants=47');
|
||||
console.log(`${ok ? 'ok ' : 'RED '} 反面对照:先看正则的实现会把部分可读播报成 mutants=47` +
|
||||
`(=它确实会被骗,这正是本自检要守的)`);
|
||||
if (!ok) bad++;
|
||||
}
|
||||
process.exit(bad ? 1 : 0);
|
||||
}
|
||||
|
||||
/*
|
||||
* summary.py 退出码契约自检(`--exitcode-selftest`):**跑真的那个脚本**。
|
||||
*
|
||||
@ -928,8 +842,34 @@ if (process.argv.includes('--exitcode-selftest')) {
|
||||
}
|
||||
return null;
|
||||
})();
|
||||
const runIn = (manifest, files, degrade) => {
|
||||
const d = mkdtempSync(join(tmpdir(), 'exitcode-'));
|
||||
/*
|
||||
* ★★ 临时目录必须是**一个真仓库的骨架**(我自己撞出来的坑)。
|
||||
*
|
||||
* `summary.py` 用 `REPO = HERE/../../../..` 定位底本比对的工作目录 ——
|
||||
* 把脚本放在 `/tmp/xxx/` 时 `REPO` 解析成 **`/`**(实测),于是:
|
||||
* · `sha256sum -c` 在 `/` 下找不到 `client/...` 那些文件 ⇒ 全部 FAILED;
|
||||
* · `git` 在 `/` 里答不了 `rev-parse`(原来还被 `returncode != 0` 读成「有差异」)。
|
||||
* ⇒ **`baseline=` 那一格在原来的四例里从未被真正走到**:它们期望 rc=0/1 却看着"通过",
|
||||
* 只是因为旧代码把"判不了"当成了"有残留"**且 residue 当时退 0** ——
|
||||
* 两个错误互相抵消。这与我这几轮反复消的形状同一个:**判据在,但走不到。**
|
||||
*
|
||||
* ⇒ 现在按脚本真实位置**嵌套**建目录,让 `REPO` 落在一个**迷你真仓库**的根上:
|
||||
* d/client/electron/test/mutants/summary.py ← `__file__`
|
||||
* d/client/harmony/.../Target.ts ← 底本比对的目标(可提交、可改脏)
|
||||
* d/.git ← 让 `rev-parse` 答得了
|
||||
* 这样 `baseline=` 的**四态**(对 / 过期 / 残留 / 判不了)**每一条前提都能构造**。
|
||||
*
|
||||
* `mode` 取值:
|
||||
* null / 'blind' / 'unreadable' / 'no-sha256sum' —— 底本**正确**(rc 由清单/权限决定)
|
||||
* 'stale' 底本哈希错、文件与 HEAD 逐字节相同 ⇒ `baseline-stale`,rc=0
|
||||
* 'residue' 底本哈希错、文件**已改脏** ⇒ `baseline-residue`,rc=1
|
||||
* 'nogit' 底本哈希错、**没有 git** ⇒ `baseline-unknown`,rc=2
|
||||
* 'nobaseline' 不写 baseline.sha ⇒ `baseline-absent`,rc=0
|
||||
*/
|
||||
const TARGET = 'client/harmony/entry/src/main/ets/model/Target.ts';
|
||||
const runIn = (manifest, files, mode) => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'exitcode-'));
|
||||
const d = join(root, 'client', 'electron', 'test', 'mutants'); // `REPO` 会解析成 `root`
|
||||
let bareDir = null; // `finally` 里要清理,所以声明在 try 之外
|
||||
/*
|
||||
* ★ 清理必须走 `try/finally`(我自己踩过):原来清理是**顺序执行**的最后两步,
|
||||
@ -938,21 +878,53 @@ if (process.argv.includes('--exitcode-selftest')) {
|
||||
* 因为失败恰恰是最常被重复跑的时候。
|
||||
*/
|
||||
try {
|
||||
cpSync(src, join(d, 'summary.py')); // 每次都复制**真脚本**
|
||||
for (const f of deps) cpSync(join(HERE, 'mutants', f), join(d, f));
|
||||
mkdirSync(join(d, 'jobs'), { recursive: true });
|
||||
cpSync(src, join(d, 'summary.py')); // 每次都复制**真脚本**
|
||||
cpSync(join(HERE, 'mutants', 'test-keys.json'), join(d, 'test-keys.json'));
|
||||
for (const f of files) cpSync(realJob, join(d, 'jobs', f));
|
||||
writeFileSync(join(d, 'jobs.manifest.json'), JSON.stringify(manifest, null, 2));
|
||||
if (degrade) {
|
||||
// 底本比对的目标文件(在 root 下,即 REPO 里)
|
||||
const targetAbs = join(root, TARGET);
|
||||
mkdirSync(dirname(targetAbs), { recursive: true });
|
||||
const content = 'export const target = 1;\n';
|
||||
writeFileSync(targetAbs, content);
|
||||
if (mode !== 'nobaseline') {
|
||||
const realHash = createHash('sha256').update(content).digest('hex');
|
||||
const good = !['stale', 'residue', 'nogit'].includes(mode);
|
||||
writeFileSync(join(d, 'baseline.sha'),
|
||||
`${good ? realHash : '0'.repeat(64)} ${TARGET}\n`);
|
||||
}
|
||||
/*
|
||||
* 迷你仓库:`stale`/`residue` 靠 `git diff HEAD` 区分,所以必须**真有一个 HEAD**。
|
||||
* · stale:文件提交后**不动** ⇒ 与 HEAD 逐字节相同
|
||||
* · residue:提交后**改脏** ⇒ 与 HEAD 不同
|
||||
* `nogit` 故意**不建**仓库 ⇒ 验"git 答不了时不许声称 residue"。
|
||||
*/
|
||||
if (mode !== 'nogit') {
|
||||
const g = (...a) => spawnSync('git', ['-C', root, ...a], { encoding: 'utf8' });
|
||||
g('init', '-q', '.');
|
||||
g('add', '-A');
|
||||
g('-c', 'user.email=t@t', '-c', 'user.name=t', 'commit', '-qm', 'init');
|
||||
if (mode === 'residue') writeFileSync(targetAbs, `${content}// dirty\n`);
|
||||
}
|
||||
if (mode === 'blind' || mode === 'unreadable') {
|
||||
// 整棵给 nobody 可达(mkdtemp 默认 700),最后一层按场景收紧
|
||||
spawnSync('chmod', ['755', root]);
|
||||
for (const p of [join(root, 'client'), join(root, 'client', 'electron')]) {
|
||||
spawnSync('chmod', ['755', p]);
|
||||
}
|
||||
for (const p of [join(d, '..'), join(d, '..', '..')]) spawnSync('chmod', ['755', p]);
|
||||
spawnSync('chmod', ['755', d]);
|
||||
for (const f of [...deps, 'summary.py']) spawnSync('chmod', ['644', join(d, f)]);
|
||||
spawnSync('chmod', ['644', join(d, 'jobs.manifest.json')]);
|
||||
for (const f of ['summary.py', 'test-keys.json', 'jobs.manifest.json']) {
|
||||
spawnSync('chmod', ['644', join(d, f)]);
|
||||
}
|
||||
spawnSync('chmod', ['755', join(d, 'jobs')]);
|
||||
for (const f of files) spawnSync('chmod', ['644', join(d, 'jobs', f)]);
|
||||
// 前提 A:目录不可进入 ⇒ blind;前提 B:目录可进、单文件不可读 ⇒ unreadable
|
||||
spawnSync('chmod', [degrade === 'blind' ? '000' : '755', join(d, 'jobs')]);
|
||||
if (degrade === 'unreadable') spawnSync('chmod', ['000', join(d, 'jobs', files[0])]);
|
||||
if (mode === 'blind') {
|
||||
spawnSync('chmod', ['000', join(d, 'jobs')]);
|
||||
} else {
|
||||
spawnSync('chmod', ['000', join(d, 'jobs', files[0])]);
|
||||
}
|
||||
}
|
||||
/*
|
||||
* `no-sha256sum`:让子进程的 PATH 里**找不到** `sha256sum` ——
|
||||
@ -962,7 +934,7 @@ if (process.argv.includes('--exitcode-selftest')) {
|
||||
* 而 1 又是别的案例的期望值 ⇒ 又是"没跑起来长得像通过"。所以先解析绝对路径。
|
||||
*/
|
||||
let env = process.env;
|
||||
if (degrade === 'no-sha256sum') {
|
||||
if (mode === 'no-sha256sum') {
|
||||
bareDir = mkdtempSync(join(tmpdir(), 'barepath-'));
|
||||
const gitPath = spawnSync('sh', ['-c', 'command -v git'], { encoding: 'utf8' }).stdout.trim();
|
||||
if (gitPath) try { cpSync(gitPath, join(bareDir, 'git')); } catch { /* 拷不动不影响本例 */ }
|
||||
@ -971,26 +943,26 @@ if (process.argv.includes('--exitcode-selftest')) {
|
||||
const py = spawnSync('sh', ['-c', 'command -v python3'],
|
||||
{ encoding: 'utf8' }).stdout.trim() || 'python3';
|
||||
const argv = [join(d, 'summary.py')];
|
||||
const sp = dropTo && degrade && degrade !== 'no-sha256sum'
|
||||
const sp = dropTo && (mode === 'blind' || mode === 'unreadable')
|
||||
? spawnSync(dropTo.cmd, [...dropTo.args, py, ...argv], { encoding: 'utf8' })
|
||||
: spawnSync(py, argv, { encoding: 'utf8', env });
|
||||
return { status: sp.status, out: sp.stdout || '', err: sp.stderr || '' };
|
||||
} finally {
|
||||
// ★ 无论成功、失败还是抛错都清理(失败正是最常被重跑的路径)
|
||||
if (bareDir) rmSync(bareDir, { recursive: true, force: true });
|
||||
spawnSync('chmod', ['-R', '755', d]); // 先恢复权限再删(别依赖 root 一定能删)
|
||||
try { rmSync(d, { recursive: true, force: true }); } catch { /* 删不掉也别盖住真因 */ }
|
||||
spawnSync('chmod', ['-R', '755', root]); // 先恢复权限再删(别依赖 root 一定能删)
|
||||
try { rmSync(root, { recursive: true, force: true }); } catch { /* 删不掉也别盖住真因 */ }
|
||||
}
|
||||
};
|
||||
const cases = [
|
||||
// 清单与磁盘一致 ⇒ 0
|
||||
['一致 ⇒ 0', ['jobs-one.json'], ['jobs-one.json'], 0, null, false],
|
||||
['一致 ⇒ 0(底本对)', ['jobs-one.json'], ['jobs-one.json'], 0, null, null],
|
||||
// 磁盘上多一个、清单里没有(**unlisted**)⇒ 1(清单该改,**不是**环境)
|
||||
['未列入清单 ⇒ 1', ['jobs-one.json'], ['jobs-one.json', 'jobs-extra.json'], 1, '未列入清单', false],
|
||||
['未列入清单 ⇒ 1', ['jobs-one.json'], ['jobs-one.json', 'jobs-extra.json'], 1, '未列入清单', null],
|
||||
// 清单里有、磁盘上没有(**ghosts**)⇒ 1
|
||||
['清单有磁盘无 ⇒ 1', ['jobs-one.json', 'jobs-gone.json'], ['jobs-one.json'], 1, '磁盘上没有', false],
|
||||
['清单有磁盘无 ⇒ 1', ['jobs-one.json', 'jobs-gone.json'], ['jobs-one.json'], 1, '磁盘上没有', null],
|
||||
// `_` 开头的说明条目不算 ghosts ⇒ 0(否则这份清单永远红)
|
||||
['说明条目不算 ghosts ⇒ 0', ['_note', 'jobs-one.json'], ['jobs-one.json'], 0, null, false],
|
||||
['说明条目不算 ghosts ⇒ 0', ['_note', 'jobs-one.json'], ['jobs-one.json'], 0, null, null],
|
||||
// ★★ rc=2 的两个上游锚点(降权构造前提)—— 这两条就是 M18 缺的那一半
|
||||
['盲读(目录不可进,降权)⇒ 2', ['jobs-one.json'], ['jobs-one.json'], 2, '读不到', 'blind'],
|
||||
['单文件读不到(降权)⇒ 2', ['jobs-one.json'], ['jobs-one.json'], 2, '在但读不到', 'unreadable'],
|
||||
@ -1006,8 +978,36 @@ if (process.argv.includes('--exitcode-selftest')) {
|
||||
* ② 仍要打出 `RESULT` 行;③ 那一格必须说"**跑不了**",不许说成"底本对"或"有残留"。
|
||||
* —— 正是"**没读数** ≠ **读数正常**",同一条纪律第四次。
|
||||
*/
|
||||
['PATH 里没有 sha256sum ⇒ 不崩且说"跑不了"', ['jobs-one.json'], ['jobs-one.json'],
|
||||
0, '跑不了 sha256sum', 'no-sha256sum'],
|
||||
/*
|
||||
* ★★ pi 2026-09-18 本封的 ②:`bl_unrunnable` 原来退 **0**,与"没读数不是成功 ⇒ 2"
|
||||
* 的原则**自相矛盾**(那一格同样是没读数)。现在退 **2**,且 `diag=baseline-unrunnable`
|
||||
* 会走 diag 通道说明"**只有 baseline 那一格**没读数"(不是"数字不可信")。
|
||||
* 期望里两样都判:rc=2 **且** 输出里有那个诊断码。
|
||||
*/
|
||||
['PATH 里没有 sha256sum ⇒ rc=2 且 diag=baseline-unrunnable',
|
||||
['jobs-one.json'], ['jobs-one.json'], 2, 'diag=baseline-unrunnable', 'no-sha256sum'],
|
||||
/*
|
||||
* ★★ pi 2026-09-18 本封的 ①:**真·变异残留**必须**红**(原来 rc=0、套件里 grep 0 次)。
|
||||
* 这里构造"既不在底本、也与 HEAD 不同":把 `baseline.sha` 里塞一条
|
||||
* **本仓真实存在、且工作树已改过**的文件 —— 用 `docs/` 下一个被临时追加内容的文件,
|
||||
* 配合一条错的哈希 ⇒ `sha256sum -c` 报 FAILED,而 `git diff HEAD` 非空 ⇒ residue。
|
||||
* (stale 与 residue 的区别正在 `git diff HEAD`,所以这条必须真改一个跟踪文件;
|
||||
* `runIn` 用 `mutate` 参数支持它,见下。)
|
||||
*/
|
||||
['真·变异残留 ⇒ rc=1 且 diag=baseline-residue',
|
||||
['jobs-one.json'], ['jobs-one.json'], 1, 'diag=baseline-residue', 'residue'],
|
||||
/*
|
||||
* ★★ `baseline=` 那一格的**四态**(pi 2026-09-18 本封的 ① 的另一半)。
|
||||
* 原来这条路**一例都没有**,而"四态"里有两态(过期 / 判不了)**当时根本走不到** ——
|
||||
* 因为旧 `runIn` 的临时目录让 `REPO=/`,`baseline=` 从未被真正读成"对"。
|
||||
* ⇒ 现在每态一条,**前提都构造得出来**(照 pi 那句"每条分支的前提也要能构造")。
|
||||
*/
|
||||
['底本过期(与 HEAD 相同)⇒ rc=0 且 diag=baseline-stale',
|
||||
['jobs-one.json'], ['jobs-one.json'], 0, 'diag=baseline-stale', 'stale'],
|
||||
['git 答不了 ⇒ rc=2 且 diag=baseline-unknown(**不许**说成 residue)',
|
||||
['jobs-one.json'], ['jobs-one.json'], 2, 'diag=baseline-unknown', 'nogit'],
|
||||
['没有 baseline.sha ⇒ rc=0 且 diag=baseline-absent',
|
||||
['jobs-one.json'], ['jobs-one.json'], 0, 'diag=baseline-absent', 'nobaseline'],
|
||||
];
|
||||
let bad = 0;
|
||||
// 前提构造不出来 ⇒ 明说 + 计红(不许静默跳过)
|
||||
@ -1476,52 +1476,106 @@ try {
|
||||
* 反过来的话,盲读/部分可读时 `summary.py` 照样打得出一行**看着正常**的
|
||||
* `RESULT mutants=<数字>`,正则一匹配就走真分支,退出码**从没被读**。
|
||||
*/
|
||||
/*
|
||||
* 诊断码 → 套件里那句话。**码是判据的一部分,不是文案。**
|
||||
*
|
||||
* ★ 为什么要有这张表(pi 2026-09-18 本封的 ①):原来这一格的说明只靠**正则捕获组**,
|
||||
* 而捕获组是**前缀**(`(RESULT mutants=… on_new_criteria=\d+)`)⇒ `baseline=…` 那一段
|
||||
* **从一开始就不在 `m[1]` 里**(实测 `'baseline' in m[1]` = False),
|
||||
* 而 `whyLines` 的过滤器**又不含** `baseline=` ⇒ 两条路都不通。
|
||||
* 最重的一例:**真·变异残留**时
|
||||
* `baseline=6/7✗**1 个文件既不在底本、也与 HEAD 不同 ⇒ 优先按"变异残留"查**`
|
||||
* 在套件输出里 grep **0 次**(我端到端复现过),而 `rc=0`、
|
||||
* `mutants=48 ran=47 skipped=1` **看着完全正常**。
|
||||
*
|
||||
* ★ 为什么用**码**而不是往过滤器里加关键词:加关键词是"按字面量裁射程",
|
||||
* 本仓已经栽过两次(`criteria-hygiene` 的 import 硬匹配、`prose()` 判"用没用")。
|
||||
* 码表**闭合**:不认识的码**报警**(见下),于是"上游加了新状态而下游不知道"
|
||||
* 会**立刻显形**,不会静静变成"没问题"。
|
||||
*
|
||||
* ★ 另一件要紧事:**status 不能单独决定措辞**。`baseline-unrunnable` 现在退 2,
|
||||
* 若照 `status === 2` 那句通用话播报,会说"上面的 mutants 数字**不可信**" ——
|
||||
* 那是**假话**:数字照常有效,**只有 baseline 那一格没读数**。
|
||||
* ⇒ 措辞一律由 `diag` 决定,`status` 只作兜底(且**先判 `diag`**)。
|
||||
*/
|
||||
const DIAG_NOTE = {
|
||||
'none': null,
|
||||
'counts-unusable': '**环境:没读数** —— 上面的 mutants 数字**不可信**(读不到 jobs/ 目录)',
|
||||
'manifest-mismatch': '**注意:清单与磁盘不一致** —— 上面的数字**不代表磁盘上现在有多少个变异体**',
|
||||
'baseline-unrunnable': '**环境:baseline 那一格没读数**(跑不了 `sha256sum`)—— '
|
||||
+ 'mutants 数字**仍然有效**,但"底本对不对"**没答案**',
|
||||
'baseline-residue': '**注意:有文件既不在底本、也与 HEAD 不同 ⇒ 优先按"变异残留"查**'
|
||||
+ '(数字按清单算是有效的,但**可能有变异没还原**)',
|
||||
'baseline-stale': '底本过期(文件与 HEAD 逐字节相同 ⇒ 是正常提交改过,不是残留)',
|
||||
'baseline-unknown': '**环境:judge 不了 baseline 那一格**(git 答不了 ⇒ 判不出是残留还是过期)—— '
|
||||
+ '这一格**没读数**,**不是**"有残留"',
|
||||
// 没装 baseline.sha 是可接受的配置,不必刷屏 —— 但**仍算"知道这个状态"**(与"不认识的码"分开)
|
||||
'baseline-absent': null,
|
||||
};
|
||||
|
||||
function summarizeMutants(stdout, status, stderr) {
|
||||
const m = /(RESULT mutants=\d+ ran=\d+ skipped=\d+ on_new_criteria=\d+)/.exec(stdout || '');
|
||||
// "为什么没读数"只有 stdout 知道,而读者只看得到套件的输出 ⇒ 转印这些行。
|
||||
const m = /(RESULT mutants=\d+ ran=\d+ skipped=\d+ on_new_criteria=\d+)(?: diag=([\w-]+))?/
|
||||
.exec(stdout || '');
|
||||
const diag = m && m[2] ? m[2] : null;
|
||||
/*
|
||||
* "为什么这个数不是全集 / 为什么没读数"只有 stdout 知道,而读者只看得到套件的输出 ⇒ 转印。
|
||||
*
|
||||
* ★ 转印条件**不再只看 status**(pi 说"放宽能治到达、治不了不红")——
|
||||
* 两头都修:**该红的已经在 `summary.py` 里红了**(residue⇒1、unrunnable⇒2),
|
||||
* 而**不该红但必须看得见**的那一类(`baseline-stale`,退 0)就靠这里转印。
|
||||
* ⇒ 条件是 `diag` 非 none/absent。
|
||||
*
|
||||
* ★ **排除 `RESULT` 行本身**:`baseline=` 那一段就长在 `RESULT` 行里,若不排除,
|
||||
* 它会被当成"原因行"再转印一遍(同一句话印两次)。而 `RESULT` 行的内容
|
||||
* 由 `diag` + `DIAG_NOTE` 负责传达 —— 两条通道**各管一段**,不重叠。
|
||||
*/
|
||||
const whyLines = (stdout || '').split('\n')
|
||||
.filter(l => !/^RESULT /.test(l.trim()))
|
||||
.filter(l => /✗✗|⚠️|★ 清单与磁盘不一致|在但读不到|未列入清单|清单里有、磁盘上没有/.test(l))
|
||||
.map(l => l.trim());
|
||||
// 上游加了新码而这里不认识 ⇒ **报警**(不是静静当成"没问题")
|
||||
if (diag && !(diag in DIAG_NOTE)) {
|
||||
return {
|
||||
line: ` mutants=(**套件不认识诊断码 \`${diag}\`** —— summary.py 加了新状态,`
|
||||
+ '请同步 run-all.mjs 的 DIAG_NOTE,不要让它静静变成"没问题")',
|
||||
whyLines,
|
||||
};
|
||||
}
|
||||
const note = diag ? DIAG_NOTE[diag] : null;
|
||||
/*
|
||||
* ★ 把 `baseline=` 的**读数本身**也带上(pi 本封的 ① 的另一半)。
|
||||
*
|
||||
* 原来 `baseline=…` 只活在 `summary.py` 的 stdout 里:捕获组是前缀、过滤器又不含它
|
||||
* ⇒ 套件输出里 `grep -c baseline=` = **0**(我三态各测过)。诊断码解决了"**结论**到不了",
|
||||
* 但"**读数到不了**"是另一件事:读者看不到 `6/7` 就复核不了"为什么它是 residue"。
|
||||
* ⇒ 这里把 `baseline=<值>` 摘出来,与 `diag=` 并列放在同一格。
|
||||
*
|
||||
* 取值用**字符类排除**(`(`/`*`/`⚠` 都是 `baseline=` 后面那句话的一部分),
|
||||
* 只留数字型的读数(`7/7✓`、`6/7`);"跑不了"那种没有数字 ⇒ 空,就不重复说了
|
||||
* (`DIAG_NOTE` 已经讲过)。**不扩捕获组**是有意的:扩了就得连整句一起搬,
|
||||
* 那才是"按字面量裁射程"。
|
||||
*/
|
||||
const blv = /baseline=([^\s(*⚠]*)/.exec(stdout || '');
|
||||
const blPart = blv && blv[1] ? ` baseline=${blv[1]}` : '';
|
||||
if (m) {
|
||||
/*
|
||||
* ⚠️ `m[1]` 是**前缀**(不含 `diag=`/`baseline=`)—— 这正是原来那一格"到不了"的成因。
|
||||
* 所以现在**显式**把 `diag=` 与 `baseline=` 拼回去,让读者看得到读数本身
|
||||
* (可复核、可 grep)。
|
||||
*/
|
||||
const head = `${m[1]}${diag ? ` diag=${diag}` : ''}${blPart}`;
|
||||
return {
|
||||
line: ` ${head}` +
|
||||
(note ? `(${note})` : (status !== 0 ? `(注意:summary.py status=${status})` : '')),
|
||||
whyLines: (note || status !== 0) ? whyLines : [],
|
||||
};
|
||||
}
|
||||
if (status === 2) {
|
||||
return {
|
||||
line: ' mutants=(**环境:没读数**,summary.py status=2 —— 上面的 mutants 数字**不可信**)',
|
||||
whyLines,
|
||||
};
|
||||
}
|
||||
/*
|
||||
* ★ status=1:**清单与磁盘不一致**(`unlisted`/`ghosts`)—— pi 2026-09-18 的第三例。
|
||||
*
|
||||
* 与 status=2 **分开报**,理由与 `summary.py` 里那条一样:修法不同。
|
||||
* 2 = 权限/环境 ⇒ 去修权限;1 = 清单没跟上 ⇒ 去改 `jobs.manifest.json`。
|
||||
* 而这一格的**数字仍然是打出来的、正则也匹配**(`mutants=48 …`)——
|
||||
* 数字按清单算**是对的**,错的是"读的人不知道它不是全集"。
|
||||
* ⇒ 所以这里**不改数字**,只在它后面挂一句"不是全集",并把 `未列入清单:X` 那行转印出来。
|
||||
* (只靠"放宽转印条件"能把这行打出来,但**治不了"清单外不红"** —— 数字仍然绿、
|
||||
* 退出码仍然是 0。所以根因修在 `summary.py` 的退出码上,这里只负责把它说清楚。)
|
||||
*/
|
||||
if (status === 1) {
|
||||
/*
|
||||
* ⚠️ 顺序要紧:这条**必须**在 `if (m)` 里面 ——
|
||||
* 否则"没打出 RESULT 且 status=1"(例如脚本不存在、python 报错退 1)会被它抢先吃掉,
|
||||
* 套件就会说"清单与磁盘不一致",而真相是"summary.py 根本没跑起来"。
|
||||
* (我第一版就把这条放在 `if (m)` 之前,自检⑤当场红 —— 顺序错了。
|
||||
* 自检在这里又替我挡了一次。)
|
||||
*/
|
||||
if (m) {
|
||||
return {
|
||||
line: ` ${m[1]}` +
|
||||
'(**注意:清单与磁盘不一致** —— 上面的数字**不代表磁盘上现在有多少个变异体**)',
|
||||
whyLines,
|
||||
};
|
||||
}
|
||||
}
|
||||
if (m) {
|
||||
/* status 非 0 但没被上面两条抓住(例如未知退出码)⇒ 也要说,不许静默 */
|
||||
return {
|
||||
line: ` ${m[1]}` + (status !== 0 ? `(注意:summary.py status=${status})` : ''),
|
||||
whyLines: status !== 0 ? whyLines : [],
|
||||
};
|
||||
}
|
||||
return {
|
||||
line: ` mutants=(summary.py 没打出 RESULT:status=${status} ` +
|
||||
`${(stderr || '').trim().split('\n').slice(-1)[0] || ''})`,
|
||||
@ -1529,6 +1583,110 @@ function summarizeMutants(stdout, status, stderr) {
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
/*
|
||||
* 变异体播报自检(`--mutants-line-selftest`):钉住"**先看退出码,再看正则**"。
|
||||
*
|
||||
* 为什么需要:这条决策**在正常路径上走不到** —— 套件以 root 跑,而盲读只在
|
||||
* **非 root + `jobs/` 不可读**时才发生。只在我手跑 `runuser -u nobody` 时才经过的分支
|
||||
* 等于**没有判据守着**;哪天顺序被调回"先看正则",没有任何东西会响,
|
||||
* 而它是"读数器说自己没读数"的**唯一通道**。
|
||||
* (我实测过:盲读/部分可读时 `summary.py` 照样打得出一行**看着正常**的
|
||||
* `RESULT mutants=<数字>`,所以这个顺序不是风格问题。)
|
||||
*/
|
||||
if (process.argv.includes('--mutants-line-selftest')) {
|
||||
const blind = 'RESULT mutants=NO-READING ran=NO-READING skipped=NO-READING on_new_criteria=NO-READING diag=counts-unusable(**没读数**)\n'
|
||||
+ ' ✗✗ **读不到 jobs/ 目录**(/x/jobs)—— 上面的数字**全部无效**:\n';
|
||||
const partial = 'RESULT mutants=47 ran=0 skipped=47 on_new_criteria=0 diag=counts-unusable(…原始条目 73…)\n'
|
||||
+ ' ★ 清单与磁盘不一致 —— 上面的数字**不代表"磁盘上现在有多少个变异体"**:\n'
|
||||
+ ' 清单里有、**在但读不到**:jobs-one.json(权限问题,不是缺失)\n';
|
||||
const normal = 'RESULT mutants=48 ran=47 skipped=1 on_new_criteria=35 diag=none(口径A=只挂新判据 35 …) baseline=7/7✓\n';
|
||||
/*
|
||||
* 第三例(pi 2026-09-18):`unlisted`/`ghosts` 走 **status=1** ——
|
||||
* 数字仍然打出来、正则也匹配,但**它不是全集**(磁盘上还有没登记的文件)。
|
||||
*/
|
||||
const unlisted = 'RESULT mutants=48 ran=47 skipped=1 on_new_criteria=35 diag=manifest-mismatch(…原始条目 73…)\n'
|
||||
+ ' ★ 清单与磁盘不一致 —— 上面的数字**不代表"磁盘上现在有多少个变异体"**:\n'
|
||||
+ ' 未列入清单:jobs-UNLISTED-probe.json(新加的 job 文件必须显式加进 jobs.manifest.json)\n'
|
||||
+ ' 清单里有、磁盘上没有:jobs-ghost.json\n';
|
||||
/*
|
||||
* `baseline-stale`:底本过期(与 HEAD 逐字节相同)⇒ **退 0**(不是缺陷),
|
||||
* 但那一格必须能被读者看见。注意它在 `RESULT` 行里有 `baseline=` 段落。
|
||||
*/
|
||||
const stale = 'RESULT mutants=48 ran=47 skipped=1 on_new_criteria=35 diag=baseline-stale'
|
||||
+ '(口径A=只挂新判据 35 …) baseline=6/7⚠**底本过期**(1 个文件与 HEAD 逐字节相同 ⇒ 是正常提交改过、不是变异残留)\n';
|
||||
const cases = [
|
||||
// ① 正常:原样播报、不转印任何东西
|
||||
['正常(status 0)', normal, 0, { line: ' RESULT mutants=48 ran=47 skipped=1 on_new_criteria=35', why: 0 }],
|
||||
// ② 盲读:**必须**说"没读数",且把 ✗✗ 转印出来
|
||||
['盲读(status 2,正则不匹配)', blind, 2, { line: '没读数', why: 1 }],
|
||||
/*
|
||||
* ③ 部分可读:status=2 但 stdout 里是一行**看着完全正常**、**且匹配正则**的
|
||||
* `mutants=47 …`。这一条是整条自检的**要害** —— 它证明"先判 status"不是多余:
|
||||
* 只按正则走就会把 47 播报成权威数字,而真相是"有一个 job 文件没读到"。
|
||||
*/
|
||||
['部分可读(status 2,正则**匹配**)', partial, 2, { line: '没读数', why: 2 }],
|
||||
/*
|
||||
* ③(pi 2026-09-18 的第三例)清单与磁盘不一致:status=**1**、正则**匹配**。
|
||||
* `mutants=48 …` 按清单算是**对的**,但磁盘上还有没登记的文件 ⇒ 读的人必须知道
|
||||
* "这不是全集"。原来它们退 **0** ⇒ `whyLines` 被 `status !== 0 ? … : []` 丢掉、
|
||||
* 套件输出里 grep "未列入清单" = 0 次(我端到端复现过)。
|
||||
* ⇒ 这里要求:**数字照播**(不改它,它没错)+ 挂上"不是全集" + 把两行原因都转印。
|
||||
*/
|
||||
['清单与磁盘不一致(status 1,正则匹配)', unlisted, 1,
|
||||
{ line: '不代表磁盘上现在有多少个变异体', why: 3 }],
|
||||
// ④ 异常退出码但正则匹配(既不是 0/1/2)⇒ 也要留痕,不许静默
|
||||
['未知退出码', normal, 3, { line: 'status=3', why: 0 }],
|
||||
// ⑤ 完全没打出 RESULT(如脚本不存在)⇒ 报 status 与 stderr 末行
|
||||
['没打出 RESULT', '', 1, { line: '没打出 RESULT', why: 0 }],
|
||||
/*
|
||||
* ⑥★ **`baseline-stale`:不许红,但必须看得见**(pi 2026-09-18 本封的要害)。
|
||||
*
|
||||
* 它是"底本过期"(文件与 HEAD 逐字节相同 ⇒ 正常提交改过),**不是缺陷** ⇒ 退 **0**;
|
||||
* 但"这一格说的什么"必须到达读者。⇒ 这一条把**转印与 status 解耦**钉住:
|
||||
* `status === 0` 且 `diag=baseline-stale` ⇒ **仍然播报那一格**(走 `diag` + `DIAG_NOTE`)。
|
||||
* (旧实现是 `status !== 0 ? whyLines : []` ⇒ 这一格当年就是被它丢掉的。)
|
||||
*
|
||||
* ⚠️ 转印条数是 **0**:`baseline=…` 那一段**长在 `RESULT` 行上**,而 `RESULT` 行
|
||||
* 由 `diag` + `DIAG_NOTE` 传达,`whyLines` **显式排除它**(否则同一句话印两次)。
|
||||
* ⇒ 判据看 `line` 里有 `diag=baseline-stale` 与那句说明,而不是看转印。
|
||||
*/
|
||||
['baseline-stale(status 0 也要播报)', stale, 0, { line: 'diag=baseline-stale', why: 0 }],
|
||||
/*
|
||||
* ⑦★ **诊断码表是闭合的**:上游加了新码、下游不认识 ⇒ **报警**,不许静静当成"没问题"。
|
||||
* 这是"按字面量裁射程"的解药:不靠关键词列表,靠**枚举**。
|
||||
*/
|
||||
['不认识的诊断码 ⇒ 报警', 'RESULT mutants=1 ran=0 skipped=1 on_new_criteria=0 diag=brand-new-state\n',
|
||||
0, { line: '不认识诊断码', why: 0 }],
|
||||
];
|
||||
let bad = 0;
|
||||
for (const [what, stdout, status, want] of cases) {
|
||||
const got = summarizeMutants(stdout, status, 'boom');
|
||||
const okLine = got.line.includes(want.line);
|
||||
/*
|
||||
* ★ 期望值用**案例里声明的** `want.why`,不在这里另算一遍 ——
|
||||
* 我第一版既声明了 `want.why`、又用一条正则从 stdout 现算 `wantWhy`,
|
||||
* 于是同一个事实**两份实现**(正是本仓反复消的形状):`want.why` 写了却**从没被读**,
|
||||
* 而现算那条一旦与 `whyLines` 的过滤器不同步,自检就会**自证自恰**地绿。
|
||||
*/
|
||||
const okWhy = got.whyLines.length === want.why;
|
||||
const ok = okLine && okWhy;
|
||||
console.log(`${ok ? 'ok ' : 'RED '} ${what}:line="${got.line.trim().slice(0, 60)}" ` +
|
||||
`转印 ${got.whyLines.length} 行${ok ? '' : `(期望含 "${want.line}"、转印 ${want.why} 行)`}`);
|
||||
if (!ok) bad++;
|
||||
}
|
||||
// ⑥ 反面对照:把顺序调回"先看正则"会怎样 —— 直接验证那个错误实现确实会被骗
|
||||
{
|
||||
const m = /(RESULT mutants=\d+ ran=\d+ skipped=\d+ on_new_criteria=\d+)/.exec(partial);
|
||||
const wrong = m ? ` ${m[1]}` : '(else)';
|
||||
const ok = wrong.includes('mutants=47');
|
||||
console.log(`${ok ? 'ok ' : 'RED '} 反面对照:先看正则的实现会把部分可读播报成 mutants=47` +
|
||||
`(=它确实会被骗,这正是本自检要守的)`);
|
||||
if (!ok) bad++;
|
||||
}
|
||||
process.exit(bad ? 1 : 0);
|
||||
}
|
||||
|
||||
let mutantsLine = '';
|
||||
try {
|
||||
// ★ 路径要 join(HERE, …):`HERE` 是 `test/`(不是 `client/electron/`)——
|
||||
|
||||
Reference in New Issue
Block a user