diff --git a/client/electron/test/mutants/summary.py b/client/electron/test/mutants/summary.py index c7b3b7a..577abec 100644 --- a/client/electron/test/mutants/summary.py +++ b/client/electron/test/mutants/summary.py @@ -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 diff --git a/client/electron/test/run-all.mjs b/client/electron/test/run-all.mjs index f3a3b93..2ccf341 100644 --- a/client/electron/test/run-all.mjs +++ b/client/electron/test/run-all.mjs @@ -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/`)——