修复: 一整条自检(函数体+登记+guard)三处同步删掉是**零痕迹** —— SELFTESTS 没有下界

pi 2026-09-18 报的层 5。**成立,我复现,读数与它逐字相同。**

## 一、缺陷:三道防线都在问"出现的东西对不对"

| 变异 | 结果 |
|---|---|
| **H1** 删整条 `skipSelfTest`(函数体 + 登记 + CLI guard 三处同步) | `red=9 … verdict=red` 与基线**逐字相同**、相关 RED **0 条**,只有报文里 `5 个自检`→`4 个自检` |
| **H1'** 最尖形式:删 `exitcodeSelfTest` —— **守着 `UPSTREAM_RC`"真脚本真跑"锚点的那个** | 同样零痕迹 ⇒ 两轮建起来的真跑锚点可被一次编辑无声撤掉 |
| **H2** 只删登记(函数与 guard 留着) | `red=11`(名字级扫描 + "写了没接线"两条防线抓住)⇒ **不静默** |
| **H3** 只删函数体 | `ReferenceError`、无 `RESULT` ⇒ **响的**,不是静默洞 |

★ 为什么三道防线都看不见 H1:
· **名字级扫描**:整条删掉时那个名字**两边都不出现** ⇒ 无从对比;
· **"写了没接线"**:`declaredSelfTests(源码)` 与 `wired` **两边同时缩小** ⇒ 相等;
· **样本/见证/要求**:样本表**内部**三字段,与 `SELFTESTS` 无关。
⇒ 根因一句话:**`SELFTESTS` 没有任何下界,也没有"必须存在哪几个"的外部清单;
所有防线问的都是"出现的东西对不对",没有一条问"该出现的东西在不在"。**

★ 这与本仓**自检 1/2** 是同一句话的不同对象:那两条判"清单里的文件必须真的存在"、
"`test/` 下每个 `*.test.mjs` 都要在清单里"—— **它们有外部依据(磁盘上的文件)**,
而 `SELFTESTS` 的"该有哪几个"**只由它自己说了算**。**同一句话,换个对象就漏了。**

## 二、修法:把"必须存在哪几条"锚到 `SELFTESTS` 之外

`CRITERIA.md` §6.1 写一段契约(本文件是**另一个文件**,且它自己已被本仓**自检 3** 守着:
存在性 + 关键条目在),并放一行 `<!-- selftests: … -->`;`run-all.mjs` 读它,
要求与 `SELFTESTS` 的键**恰好相等**(两个方向都判)。

⇒ 链条:**被外部守着的文本 → flag 名 → guard → SELFTESTS**,锚点不在表自己身上。
删掉一条自检 ⇒ 名单没跟着删 ⇒ **红**;"名单也一起删"变成**跨文件**编辑(见下 §四)。

## 三、★★ 我没能用 pi 建议的①,因为它的**前提是假的**

pi 建议"拿对外的 flag 契约当锚点",理由写的是"这五个 flag 名是**对外的**(有人在用、有文档)"。
**我实测:不是。** 全仓(排除 `.git`)搜这五个 flag ⇒ **外部消费者 0、文档 0**。
⇒ 所谓"对外契约"**不是已有的,是需要造出来的**;直接用会把锚点建在一个不存在的保证上。
★ 所以我把它落在 `CRITERIA.md`(**确实**是别的文件、**确实**有 4 个文件引用它、
且**确实**被自检 3 守着),而不是落在"它自己新写的一张内部清单"。

## 四、⚠️ 残留(如实登记):**跨文件的同步删仍然静默**

实测:**同时**删 `run-all.mjs` 的三处 **和** `CRITERIA.md` 的那一项 ⇒ `^RED ` **0 条**。
⇒ 本次修法把"改我自己的一张表"抬成了"**改两个文件**",
**难度上去了,但性质没变**(仍是可同步的、无真跑锚点的编辑)。
★ 按这条线的规律,我**不**声称这一层封死了 —— 只是把成本从一次编辑抬到两次编辑。
真要把存在性也锚到行为上,得让"删掉一条自检"在**真跑**里留下痕迹(例如对外契约由
`npm test` 的入口兑现),那是独立工作。

## 五、我写这条判据时踩的坑(同一形状的第 N 次)

契约行我第一版的正则没锚行首行尾,而 §6.1 的**正文里举例提到了**那个标记
⇒ 正则**先命中了注释里那次**(捕获到 `…`)⇒ 干净树上立刻 2 条**假红**。
★ 正是本文件反复写的形状:**"提到"与"就是"在文本上长得一样。**
⇒ 修法:锚 `^…$` + **要求恰好一条**(多出来也红)—— 现在契约行自己也有唯一性判据。

## 六、变异与回归(都已还原)

| 变异 | 结果 |
|---|---|
| **H1 / H1'** 三处同步删 | **红**:`契约里要求存在的自检在 SELFTESTS 里没有:skip-selftest / exitcode-selftest`,`red=9→10` |
| 契约里写一个不存在的自检(反方向) | **红**(`verdict-selftest`) |
| 契约行 0 条 / 2 条 | **红**(要求恰好 1 条) |
| 回归 **M43** 窄正则 | 1 条认不出 |
| 回归 **M45** 只删样本 | 1 条"没有样本"(★ 我第一次跑成 0 条,是**我的脚本**同时删了 `REQUIRED_FORMS` 的名字 ⇒ 那是共漂移不是 M45;改正后 1 条) |
| 回归 **M47** 标签撒谎 / **M48** 空见证 | 各 1 条 |
| 回归 名字级(未接线自检) | 1 条 |

## 七、验证与状态

· 五个自检单独跑仍全 rc=0;默认跑 `checks=459 pass=455 fail=4 skip=0 red=9 broken=0 unreported=0 verdict=red`、
  `mutants=48 ran=47 skipped=1 on_new_criteria=35 diag=none baseline=7/7✓`;正常态 `^RED ` **0 条**(无假红)。
· 本提交含 `client/electron/test/CRITERIA.md`(新增 §6.1 + 契约行)与 `client/electron/test/run-all.mjs`。
· `HEAD=dc261ad`(提交前)。
This commit is contained in:
2026-09-18 07:58:07 +08:00
parent dc261ad537
commit ecefd50791
2 changed files with 89 additions and 0 deletions

View File

@ -143,6 +143,46 @@ const ALLOW = [ /* { name, replacement, why } */ ];
`process.exit(1)` 吞掉**,于是"变异后依然 exit 0"看起来像判据失效(本仓刚踩过这一次:
自检 3 其实是好的,是我用错了入口去验它)。**验判据要模拟用户/CI 真正跑的那一行。**
### 6.1 判据的**存在性**也要有下限:把"必须存在哪几条"锚到判据自己的表之外(pi 2026-09-18)
§6 那条只说了"扫目录要有下限"。**同一条规矩换个对象就漏了** —— 这条是它的镜像:
`run-all.mjs` 的自检登记表 `SELFTESTS` 原来**没有任何下界**,也没有任何"必须存在哪几个"的
外部清单。于是**把一整条自检(函数体 + 登记 + CLI guard)三处同步删掉是零痕迹的**:
```
基线: RESULT … checks=459 pass=455 fail=4 skip=0 red=9 broken=0 unreported=0 verdict=red 自检段:5 个自检…
删掉: RESULT … checks=459 pass=455 fail=4 skip=0 red=9 broken=0 unreported=0 verdict=red 自检段:4 个自检…
```
`red` 与基线**逐字相同**、相关 RED **0 条**,只有报文里 `5` 变成 `4`(而那句读起来像正常)。
最尖的形式:删掉 `exitcodeSelfTest` —— **守着 `UPSTREAM_RC`"真脚本真跑"锚点的那个** —— 同样零痕迹。
⇒ 我们花两轮建起来的真跑锚点,可以被一次编辑无声撤掉。
**为什么三道防线都看不见它**(它们问的都是"**出现的东西对不对**"):
| 防线 | 它比什么 | 整条删除时 |
|---|---|---|
| 名字级扫描 | 源码里的 `\w+SelfTest` ↔ `SELFTESTS` | 名字**两边都不出现** ⇒ 无从对比 |
| "写了但没接线" | `declaredSelfTests(源码)` ↔ `wired` | **两边同时缩小** ⇒ 相等 |
| 样本/见证/要求 | 样本表**内部**三字段 | 与 `SELFTESTS` 无关 ⇒ 不受影响 |
⇒ **没有一条问"该出现的东西在不在"。** 所以规矩是:
**凡"某张表里必须有哪几项",那几项必须能在**这张表之外**被读到 ——
写在别处的、有人维护的文本里(本文件就是这样),而不是由表自己说了算。**
本仓的落点是:`run-all.mjs` 读**本文件 §6.1**下方那段 `<!-- selftests: … -->`,
要求它与 `SELFTESTS` 的键**恰好相等**(两个方向都判)。
★ 为什么锚在**本文件**而不是"再写一张内部清单":本文件是**另一个文件**,
且它自己已被 run-all 的**自检 3**守着(存在性 + 关键条目在)——
⇒ 链条是"**被外部守着的文本 → flag 名 → guard → SELFTESTS**",锚点不在 `SELFTESTS` 自己身上。
★ 自查句:**"我这条判据,把它的定义、登记、调用三处一起删掉,谁会红?"**
问不出来,就说明这条判据的**存在性**没有锚点(它只被"内容正确"类判据守着)。
<!-- selftests: probe-selftest exitcode-selftest skip-selftest verdict-selftest mutants-line-selftest -->
## 6.5 涉及**运行时行为**的结论,必须实测过才能写进规范/判据(pi 2026-09-14)
同一个错在本仓犯了两次,方向相反但错法相同:**从观察推断机制、没跑**。

View File

@ -2492,6 +2492,55 @@ let selfCheckRuns = 0;
* ★ 唯一需要登记的例外:`FORM_SAMPLES` 里样本内容用的假名(`fooSelfTest`)。
* 它是一个**显式登记的**例外,不是"扫不到就算了"——这样它自己也看得见。
*/
/*
* ★★ **存在性锚点**(pi 2026-09-18 报的层 5):`SELFTESTS` 原本**没有下界**。
*
* 实测(我复现,与 pi 读数逐字相同):把**一整条自检**(函数体 + `SELFTESTS` 登记 +
* CLI guard)三处同步删掉 ⇒ `red=9 … verdict=red` 与基线**逐字相同**、相关 RED **0 条**,
* 只有报文里 `5 个自检` 变 `4 个自检`。最尖的形式是删掉 `exitcodeSelfTest`
* —— **守着 `UPSTREAM_RC`"真脚本真跑"锚点的那个** —— 同样零痕迹。
*
* ⇒ 根因:上面三道防线(名字级扫描、"写了没接线"、样本/见证/要求)
* 问的都是"**出现的东西对不对**",**没有一条问"该出现的东西在不在"**。
* 名字级扫描尤其说明问题:整条删掉时那个名字**两边都不出现** ⇒ 无从对比。
*
* ⇒ 锚点必须落在 `SELFTESTS` **之外**,且是**别的文件**(不是又一张内部清单):
* 读 `CRITERIA.md` §6.1 下方那行 `<!-- selftests: … -->`,要求与 `SELFTESTS` 的键
* **恰好相等**(两个方向都判)。`CRITERIA.md` 自己已被**自检 3** 守着
* (存在性 + 关键条目在)⇒ 链条:**被外部守着的文本 → flag 名 → guard → SELFTESTS**。
* ★ 删掉一条自检 ⇒ 本文件的名单没跟着删 ⇒ **红**(而"名单也一起同步删"是**跨文件**编辑,
* 不再是"改我自己的一张内部表")。
*/
/*
* ⚠️ 契约行必须**独占一行**且**只能有一条** —— 我第一次写这条判据时就踩了:
* 上面那段注释里**举例提到了**这个标记(行内),而当时的正则没锚行首行尾,
* 于是它**先命中了注释里那次**(捕获到 `…`)⇒ 干净树上立刻出现 2 条假红。
* ★ 这正是本文件反复写的那个形状的又一例:**"提到"与"就是"在文本上长得一样。**
* ⇒ 修法:锚 `^…$`(`m` 标志)+ **要求恰好一条**(多出来也红)。
*/
const SELFTEST_CONTRACT_RE = /^<!--\s*selftests:\s*([^>]*?)\s*-->\s*$/gm;
const contractMs = [...criteriaDoc.matchAll(SELFTEST_CONTRACT_RE)];
if (contractMs.length !== 1) {
console.log(`RED \`CRITERIA.md\` 里的 \`selftests:\` 契约行有 ${contractMs.length} 条`
+ '(要求**恰好 1 条**、独占一行)⇒ 自检的**存在性**没有唯一锚点');
reds.push(`(自检)\`CRITERIA.md\` 的契约行应为 1 条,实为 ${contractMs.length} 条`);
}
if (contractMs.length === 1) {
const contract = contractMs[0][1].split(/\s+/).filter(Boolean).sort();
const keys = SELFTESTS.map(([n]) => n).sort();
const missing = contract.filter(n => !keys.includes(n));
const undeclared = keys.filter(n => !contract.includes(n));
if (missing.length) {
console.log(`RED 契约里要求存在的自检**在 \`SELFTESTS\` 里没有**:${missing.join('、')}`
+ ' ⇒ 那条判据被删掉了(它的存在性有外部锚点,躲不掉)');
reds.push(`(自检)契约要求而 \`SELFTESTS\` 缺:${missing.join('、')}`);
}
if (undeclared.length) {
console.log(`RED \`SELFTESTS\` 里有契约没登记的自检:${undeclared.join('、')}`
+ ' ⇒ 新增判据要同时写进 `CRITERIA.md` §6.1 的契约行');
reds.push(`(自检)\`SELFTESTS\` 有而契约没登记:${undeclared.join('、')}`);
}
}
const SAMPLE_FAKE_NAMES = ['fooSelfTest'];
const allNames = [...new Set([...selfSource.matchAll(/\b(\w+SelfTest)\b/g)].map(m => m[1]))];
const nameUnwired = allNames.filter(n => !wired.has(n) && !SAMPLE_FAKE_NAMES.includes(n));