修复: **函数体可以被掏空** —— 名字/登记/guard/契约行全在,里面不检查任何东西(层 6)
pi 2026-09-18 报的层 6。**成立,我复现,读数与它逐字相同。**
## 一、缺陷:掏空是零痕迹的
把 `exitcodeSelfTest` 的**函数体**(14616 字节)换成 `{ return 0; }`
(名字、`SELFTESTS` 登记、CLI guard、`CRITERIA.md` 契约行**一个都不动**):
```
基线: checks=459 pass=455 fail=4 skip=0 red=9 broken=0 unreported=0 verdict=red
掏空: checks=459 pass=455 fail=4 skip=0 red=9 broken=0 unreported=0 verdict=red ← 逐字相同
^RED: 0 条
```
**最尖的形式**:掏空 **+ 把 `UPSTREAM_RC['baseline-residue']` 改成 99**(= 它本该抓的那个)
⇒ `red=9`、**0 行提到 UPSTREAM**。
**对照(证明掏空是唯一原因)**:不掏空、只把值改成 99 ⇒ **`red=10`**,且真报出
`UPSTREAM_RC[baseline-residue]=99 与真跑出来的 rc=1 不符`。
⇒ 我们花三轮把 `UPSTREAM_RC` 锚到"真脚本真跑"上,**这个锚点可以被一次"清空函数体"无声撤掉**。
★ 根因:**层 5 及之前所有防线问的都是"它**在不在**",没有一条问"它**做了没有**"。**
层 5 修的是存在性,而存在性有**两种**失去方式:**名字没了**,和 **名字在、里面是空的**。
(★ 层 5 与层 6 是**两根轴**:层 5 问"还在吗",层 6 问"做了吗"。
它们共同的教训:**每根轴的"底"看起来都像整体的底** —— "到底了"只对当前那根轴成立。)
## 二、修法:给"判据在工作"一个**行为**下界
每跑完一条自检,记下它**真跑出来**的断言条数(`^(ok|RED) ` 行数),
与 `CRITERIA.md` 契约行里声明的**下界**比 ⇒ 掏空 ⇒ 条数掉到 0 ⇒ **红**。
契约项写成 `名字:下界`(棘轮语义:只增不减,掉下来才红)。
**实测条数(确定性:连测 3 次 + 两个相位都相同)**:probe=3 exitcode=13 skip=5 verdict=13 mutants-line=9。
**下界取略低于实测的余量**:`3 / 12 / 5 / 12 / 9`。
★ 下界写在 **`CRITERIA.md`**(外部文件、被 4 个文件引用、自己已被自检 3 守着),
不在 `SELFTESTS` 自己身上 —— 与 §6.1 同一条理由。
★ **为什么不用"恰好等于实测"**:相等的下界会让**任何**一条断言的小改动都变成
"必须同步改数字"(噪音),而留余量只拦"掉到明显不对"的那种(掏空 ⇒ 0、删一半 ⇒ 腰斩)。
诚实说:**这也意味着"改小下界"本身就是一条绕过路径**(见 §四)。
## 三、⚠️ 一个我实测出来的真实约束(不做区分就会在别的机器上假红)
判据**只在"该自检自称绿"时**才比下界。理由:`exitcodeSelfTest` 在**拿不到降权工具**
(`runuser`/`setpriv`)的机器上会 `continue` 掉两个案例、多打一条说明 ——
那是它**故意的**行为(前提构造不出来就报红,不许静默跳过),那种机器上条数本来就不同。
⇒ 若不管"红不红"都比下界,就会在无降权工具的机器上**假红**。
(掏空仍必被抓:掏空后 `bad=0` ⇒ 自称绿 ⇒ 条数 0 ⇒ 红。)
## 四、⚠️ 残留(如实登记):**"把下界改小"是一条绕过路径**
我实测:把五条下界都改成 `1`,再把 `exitcodeSelfTest` 掏空成只打一条假 `ok`
⇒ **`^RED ` 0 条**(下界判据不响)。
⇒ 这是"**数字可以被改小**"那个老形状在**新落点**上的复现 —— 我把下界放到表外,
但**下界本身仍是一个可编辑的数字**。
★ 它比原来**贵一点**(要同时改 `CRITERIA.md` 的数字 **和** 掏空函数体,是跨文件的两处编辑),
但**性质没变**。所以**我不声称层 6 封死了**,与层 5 一样。
真要把下界也锚到行为上,得让"该打多少条"由**真跑对照**决定(例如拿一份已知坏输入要求它红),
那是独立工作。★ pi 也提过"要求每条自检必须能被某个坏输入弄红"(更硬),
我没选它,因为它要为 5 条自检各造一份坏输入,而**"坏输入"自己又成了手写数据**(刚被证过的那族)。
## 五、我写这条判据时自己踩的坑(第 N 次同一形状)
第一版我把 `entries` 声明在 `if (contractMs.length === 1) { … }` 块**内部**,
然后在块外用 `typeof entries === 'undefined' ? [] : entries` 兜 ——
那**永远取到 `[]`** ⇒ 下界判据**静默不跑**。
★ **一个"以防万一"的兜底写法,把判据本身变成了空判据** ——
比不写更坏,因为它**看起来在**。已改成同作用域声明 + 不留兜底。
⇒ 教训与 §6.1 那句"存在性锚点"合起来是同一句:**判据不跑时,谁来喊?**
## 六、变异验证(都已还原)
| 变异 | 结果 |
|---|---|
| **掏空 `exitcodeSelfTest`** | **红**:`自检 --exitcode-selftest **自称绿,却只打出 0 条断言**(契约下界 12)⇒ 它可能被**掏空**了`,`red=9→10` |
| **掏空 + `UPSTREAM_RC` 改 99** | **红**(同上;修复前 0 条) |
| 契约行整体删掉 | **红**(`契约行有 0 条,要求恰好 1 条`) |
| 下界写成非数字 `x` | **红**(`没写下界(或下界不是 ≥1 的整数)`) |
| **下界改 1 + 掏空成假 ok** | **不红** ⇒ §四 的残留,如实登记 |
| 回归 **层5** 删整条 `skipSelfTest` | 1 条(契约要求而 `SELFTESTS` 缺) |
| 回归 **M43** 窄正则 / **M45** 只删样本 / **M47** 标签撒谎 / **M48** 空见证 / 名字级未接线 | 各 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 补层 6 说明 + 契约行加下界)与 `client/electron/test/run-all.mjs`。
· 提交前 `HEAD=ecefd50`。
This commit is contained in:
@ -181,7 +181,37 @@ const ALLOW = [ /* { name, replacement, why } */ ];
|
||||
★ 自查句:**"我这条判据,把它的定义、登记、调用三处一起删掉,谁会红?"**
|
||||
问不出来,就说明这条判据的**存在性**没有锚点(它只被"内容正确"类判据守着)。
|
||||
|
||||
<!-- selftests: probe-selftest exitcode-selftest skip-selftest verdict-selftest mutants-line-selftest -->
|
||||
<!-- selftests: probe-selftest:3 exitcode-selftest:12 skip-selftest:5 verdict-selftest:12 mutants-line-selftest:9 -->
|
||||
|
||||
★ 每项写成 `名字:下界`。**下界 = 这条自检至少该打出多少条断言**(`^(ok|RED) ` 行数)。
|
||||
语义是 §6.6 那种**棘轮**:**只增不减** —— 加断言不用改数字,**掉下来才红**。
|
||||
下界是**行为**读数(自检真跑出来的行数),不是"源码里那句 `console.log` 在不在"。
|
||||
|
||||
★★ 为什么必须有它(**层 6**,pi 2026-09-18 报):上面的契约行只管**存在性**,
|
||||
而存在性有**两种**失去方式 —— **名字没了**,和 **名字在、里面是空的**。后者零痕迹:
|
||||
|
||||
```
|
||||
把 `exitcodeSelfTest` 的函数体(14616 字节)换成 `{ return 0; }`
|
||||
(名字、登记、CLI guard、契约行一个都不动)
|
||||
⇒ red 与基线逐字相同、^RED 0 条
|
||||
最尖形式:掏空 + 把 `UPSTREAM_RC['baseline-residue']` 改成 99 ⇒ 0 行提到 UPSTREAM
|
||||
对照(只改值、不掏空)⇒ red=10,真报「UPSTREAM_RC[baseline-residue]=99 与真跑出来的 rc=1 不符」
|
||||
```
|
||||
|
||||
⇒ **所有防线问的都是"它在不在",没有一条问"它做了没有"。**
|
||||
★ 所以下界必须写在**这张表之外**(就是本文件),不能由 `SELFTESTS` 自己说了算 ——
|
||||
与 §6.1 同一条理由。**数字掉下来 = 那条自检要么被掏空、要么被改得不再检查东西。**
|
||||
|
||||
⚠️ 下界取的是**略低于实测值**的余量(实测 3/13/5/13/9),不是"恰好等于实测":
|
||||
相等的下界会让**任何**一条断言的小改动都变成"必须同步改数字",而留余量只拦
|
||||
"掉到明显不对"的那种(掏空 ⇒ 0、判据被删掉一半 ⇒ 腰斩)。
|
||||
★ 判据只在**该自检自称绿**时才比下界:`exitcodeSelfTest` 在**拿不到降权工具**的机器上
|
||||
会 `continue` 掉两个案例(那是它**故意的**行为 —— 前提构造不出来就报红,不许静默跳过),
|
||||
那种机器上条数本来就不同 ⇒ 不做这个区分就会在**别的机器上假红**。
|
||||
|
||||
★ 层 5 与层 6 是**两根轴**:层 5 问"**它还在吗**",层 6 问"**它做了吗**"。
|
||||
★★ 而它们共同的教训是:**每根轴的"底"看起来都像整体的底。**
|
||||
"到底了"永远只对**当前那一根轴**成立 —— 这条我等了三轮才学会,写在这里给下一个人。
|
||||
|
||||
## 6.5 涉及**运行时行为**的结论,必须实测过才能写进规范/判据(pi 2026-09-14)
|
||||
|
||||
|
||||
@ -2308,6 +2308,7 @@ let selfCheckRuns = 0;
|
||||
* 不许被我盖住(本仓"错误信息被吞掉"那条老教训)。
|
||||
*/
|
||||
const swallow = console.log;
|
||||
const selfCheckEmitted = new Map();
|
||||
for (const [name, fn] of SELFTESTS) {
|
||||
const seen = [];
|
||||
console.log = (...a) => seen.push(a.join(' '));
|
||||
@ -2321,6 +2322,13 @@ let selfCheckRuns = 0;
|
||||
} finally {
|
||||
console.log = swallow;
|
||||
}
|
||||
/*
|
||||
* ★★ **"它做了没有"**(pi 2026-09-18 报的层 6)。
|
||||
*
|
||||
* 上面收的 `seen` 是这条自检**真跑出来**的断言行 —— 把它记下来,
|
||||
* 等一下与 `CRITERIA.md` 里声明的**下界**比。理由见下面那段长注释。
|
||||
*/
|
||||
selfCheckEmitted.set(name, seen.filter(l => /^(ok|RED) /.test(l)).length);
|
||||
if (failed) {
|
||||
const why = seen.filter(l => /^RED /.test(l)).map(l => l.replace(/^RED\s+/, ''));
|
||||
reds.push(`(自检)\`--${name}\` 红了:${why.length ? why.join(';') : '(它没打出 RED 行,真因见上)'}`
|
||||
@ -2520,16 +2528,42 @@ let selfCheckRuns = 0;
|
||||
*/
|
||||
const SELFTEST_CONTRACT_RE = /^<!--\s*selftests:\s*([^>]*?)\s*-->\s*$/gm;
|
||||
const contractMs = [...criteriaDoc.matchAll(SELFTEST_CONTRACT_RE)];
|
||||
/*
|
||||
* ⚠️ `contractEntries` 必须与 `contractMs` **同作用域**声明(不是嵌在下面那个 `if` 里)。
|
||||
* 我第一版把它放在 `if (contractMs.length === 1) { … }` 内部,然后在块外用
|
||||
* `typeof entries === 'undefined' ? [] : entries` 兜 —— 那**永远取到 `[]`**,
|
||||
* 于是下界判据**静默不跑**。★ 这正是本文件反复写的形状:
|
||||
* **一个"以防万一"的兜底写法,把判据本身变成了空判据**(比不写更坏:它看起来在)。
|
||||
* ⇒ 改成同作用域声明;缺项由下面两条"没有/多余"的红覆盖,不靠兜底。
|
||||
*/
|
||||
const contractEntries = contractMs.length === 1
|
||||
? contractMs[0][1].split(/\s+/).filter(Boolean).map(t => {
|
||||
const i = t.lastIndexOf(':');
|
||||
return i < 0 ? { name: t, floor: null } : { name: t.slice(0, i), floor: t.slice(i + 1) };
|
||||
})
|
||||
: [];
|
||||
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();
|
||||
/*
|
||||
* 契约项写成 `名字:下界` —— 下界是这条自检**至少该打出多少条断言**。
|
||||
* ★ 语义照本仓 §6.6 的棘轮:**只增不减**(加断言不用改数字,掉下来才红)。
|
||||
*/
|
||||
const entries = contractEntries;
|
||||
const contract = entries.map(e => e.name).sort();
|
||||
const keys = SELFTESTS.map(([n]) => n).sort();
|
||||
const missing = contract.filter(n => !keys.includes(n));
|
||||
const undeclared = keys.filter(n => !contract.includes(n));
|
||||
const badFloor = entries.filter(e => !/^\d+$/.test(e.floor || '') || Number(e.floor) < 1)
|
||||
.map(e => e.name);
|
||||
if (badFloor.length) {
|
||||
console.log(`RED 契约里这些自检**没写下界**(或下界不是 ≥1 的整数):${badFloor.join('、')}`
|
||||
+ ' ⇒ 没有下界就等于"它做了没有"没人问');
|
||||
reds.push(`(自检)契约里缺/坏下界:${badFloor.join('、')}`);
|
||||
}
|
||||
if (missing.length) {
|
||||
console.log(`RED 契约里要求存在的自检**在 \`SELFTESTS\` 里没有**:${missing.join('、')}`
|
||||
+ ' ⇒ 那条判据被删掉了(它的存在性有外部锚点,躲不掉)');
|
||||
@ -2541,6 +2575,54 @@ let selfCheckRuns = 0;
|
||||
reds.push(`(自检)\`SELFTESTS\` 有而契约没登记:${undeclared.join('、')}`);
|
||||
}
|
||||
}
|
||||
/*
|
||||
* ★★★ **"它做了没有"** —— 给判据的工作量一个**行为**下界(pi 2026-09-18 报的层 6)。
|
||||
*
|
||||
* 层 5 修的是"整条判据被删"(存在性)。存在性有**两种**失去方式:
|
||||
* **名字没了**,和 **名字在、里面是空的**。后者零痕迹 —— 我实测:
|
||||
*
|
||||
* 把 `exitcodeSelfTest` 的**函数体**(14616 字节)换成 `{ return 0; }`
|
||||
* (名字、`SELFTESTS` 登记、CLI guard、`CRITERIA.md` 契约行**一个都不动**)
|
||||
* ⇒ `checks=459 pass=455 fail=4 skip=0 red=9 broken=0 unreported=0 verdict=red`
|
||||
* 与基线**逐字相同**、`^RED ` **0 条**。
|
||||
*
|
||||
* ★ 最尖的形式(我认为这条最能说明问题):**掏空 + 把 `UPSTREAM_RC['baseline-residue']` 改成 99**
|
||||
* (= 它本该抓的那个)⇒ `red=9`、**0 行提到 UPSTREAM**。
|
||||
* **对照**(只改值、不掏空)⇒ `red=10` 且真报出
|
||||
* `UPSTREAM_RC[baseline-residue]=99 与真跑出来的 rc=1 不符`。
|
||||
* ⇒ **我们花三轮把 `UPSTREAM_RC` 锚到"真脚本真跑"上,这个锚点可以被一次"清空函数体"无声撤掉**,
|
||||
* 而那条自检的**所有外部特征一个都不缺**。
|
||||
*
|
||||
* ⇒ 根因一句话:**上面所有防线问的都是"它**在不在**",没有一条问"它**做了没有**"。**
|
||||
*
|
||||
* ⇒ 修法:每跑完一条自检,记下它**真跑出来**的断言条数(`^(ok|RED) ` 行数),
|
||||
* 与 `CRITERIA.md` 契约行里声明的**下界**比 —— 掏空 ⇒ 条数掉到 0 ⇒ **红**。
|
||||
* ★ 下界在 `CRITERIA.md`(**外部文件**、被 4 个文件引用、自己已被自检 3 守着),
|
||||
* 不在表自己身上、也不在函数体里 —— 照 §6.1 那条"锚点必须在表之外"。
|
||||
*
|
||||
* ⚠️ **下界只在"该自检自称没红"时判**,理由是我实测出来的一个真实约束:
|
||||
* `exitcodeSelfTest` 在**拿不到降权工具**的机器上会 `continue` 掉两个案例、
|
||||
* 多打一条说明(它那条是**故意的**:前提构造不出来就报红,不许静默跳过)。
|
||||
* 那种机器上条数**本来就不同** ⇒ 若不管"红不红"都比下界,会在**别的机器上假红**。
|
||||
* ⇒ 所以判据是:**自称绿的,工作量必须够**;自称红的,由上面那条"自检自己不该红"管。
|
||||
* (这样"掏空"必被抓:掏空后 `bad=0` ⇒ 自称绿 ⇒ 但条数 0 ⇒ 红。)
|
||||
* ★ 我没有把下界设成"恰好等于实测值":那是**又一张可以同步改小的手写表**。
|
||||
* 取的是 `min(实测, 契约)` 的下界语义,且故意留出余量(见下)。
|
||||
*/
|
||||
for (const e of contractEntries) {
|
||||
if (e.floor === null || !/^\d+$/.test(e.floor)) continue;
|
||||
const emitted = selfCheckEmitted.get(e.name);
|
||||
if (emitted === undefined) continue; // 缺项已由上面那条红覆盖
|
||||
const failedThis = reds.some(r => r.includes(`\`--${e.name}\``));
|
||||
if (failedThis) continue; // 自称红 ⇒ 由"自检自己不该红"那条管
|
||||
if (emitted < Number(e.floor)) {
|
||||
console.log(`RED 自检 \`--${e.name}\` **自称绿,却只打出 ${emitted} 条断言**`
|
||||
+ `(契约下界 ${e.floor})⇒ 它可能被**掏空**了:名字、登记、guard、契约行都在,`
|
||||
+ '但里面不检查任何东西');
|
||||
reds.push(`(自检)\`--${e.name}\` 的工作量低于契约下界:${emitted} < ${e.floor}`
|
||||
+ ' —— **存在性不等于在做事情**');
|
||||
}
|
||||
}
|
||||
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));
|
||||
|
||||
Reference in New Issue
Block a user