修复: 条数**可以伪造** —— 打满 13 条空 ok 就绕过了层 6 的下界(层 7:锚点不能落在自述上)

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

## 一、缺陷:下界判据的输入是"自我报告"

层 6 我加的是"比条数下界",而条数是从**输出**里数的 ——
**数的是它"打了几条",不是它"验了几条"**。于是把 `exitcodeSelfTest` 的体换成
"循环打 13 条空 `ok`、`return 0`"(不打任何真断言):

```
基线:      checks=459 pass=455 fail=4 skip=0 red=9 broken=0 unreported=0 verdict=red
假 ok×13:  checks=459 pass=455 fail=4 skip=0 red=9 broken=0 unreported=0 verdict=red   ← 逐字相同
下界相关 RED: 0 条(13 ≥ 下界 12 ⇒ 相等,无从对比)
```

**最尖形式**:假 ok×13 **+** `UPSTREAM_RC['baseline-residue']=99`(= 它本该抓的那个)
⇒ 0 条 RED、0 次提及 UPSTREAM。
**对照**(不掏空、只改值)⇒ `red=9→10`,真报"与真跑出来的 rc=1 不符"(⇒ 掏空是唯一原因)。

★ 根因:**下界判据的输入是"自述"** —— 判据自己说它做了多少,而它可以撒谎。
**锚点不能落在"被守对象的自述"上,必须落在它管不着的东西上。**

## 二、三种"看起来更硬"的替代我都否掉了(理由:仍在自述这一侧)

· **黄金输出**(逐字节比对期望输出)= 又一份**手写数据**(本仓刚证过那族可被同步改);
· **扫源码找关键词**= "**提到 vs 就是**"(本仓反复证过它脆);
· **毒化环境变量**(该模式下每条自检必须报红)= 把"必须红"的断言写在**自检函数体内**
  ⇒ **假体不打它**。★ 我实测过:模拟毒化后假体 `red=9`、下界红 0 —— 与层 7 同一问题。

## 三、修法:**因果锚点** —— 改坏被守的生产符号,那条自检必须红

```
· 假体(空 `ok`)在符号被改坏时仍然打 ok ⇒ 它不红 ⇒ **红**(判据抓它);
· 真自检读了那个符号 ⇒ 符号坏 ⇒ 它报 RED ⇒ 绿。
⇒ 这是"自检真的读了那个符号"的**行为**证据,不是它的自述。
```

实现:
· 快入口 `--only-selftest=<名>`(**实测 52~696ms/条**,对比 `--X-selftest` 要跑整套 suite 的 4.3~4.9s);
· 隔离用 `mkdtempSync` + `cpSync(join(HERE), …)`(**只拷 `test/`,1.6MB / 6ms**);
· `CAUSAL` 表:`probe-selftest`→`probeIsDue`、`skip-selftest`→`parseSkips` 的正则、
  `verdict-selftest`→`verdictOf` 的返回式、`exitcode-selftest`→`UPSTREAM_RC['baseline-residue']`。

★ **关系表本身也被真跑锚住**(这是它与"下界数字"的关键区别):
表里每一对都必须实测"改坏了它真会红";把符号写错(写成它不读的)⇒
那条自检改坏后**不红** ⇒ **红**。⇒ **表自己也被因果判据守着。**

## 四、我实现时踩的两个坑(都记在注释里)

① **必须拷整个 `test/`**:run-all 的**自检 1/2** 要 `readdirSync(test)` 与 `SUITE` 对齐,
   缺文件会让它在跑自检**之前**就 `exit(1)` ⇒ **读到的是假红**。
   我第一版少拷东西时就这么读错过:`rc=1` 看着像"改坏生效",其实是清单自检停了整套。
   这与本仓"隔离要用**最小**夹具"不冲突 —— 最小**自洽**的夹具,不是最小字节数。
② **锚文本的唯一性必须在除去 `CAUSAL` 表本身**的源码里数:
   我第一版没排除 ⇒ `probe`/`verdict` 立刻报"锚文本不唯一",而真因是**表里那一行字面量**,
   不是生产里有两处。★ 又是那句:**"提到"与"就是"在文本上长得一样。**

## 五、变异验证(都已还原)

| 变异 | 结果 |
|---|---|
| **层 7** 假 ok×13(exitcodeSelfTest) | **红**:`把 \`exitcode-selftest\` 守的生产符号改坏之后,它**仍然全绿** ⇒ 它没在读那个符号(或它是个空壳)`,`red=9→11` |
| **层 7 最尖**:假 ok×13 + `UPSTREAM_RC`=99 | **红**(同上,修复前 0 条) |
| 回归 **层6** 掏空 | 1 条(`只打出 0 条断言`) |
| 回归 **层5** 删整条 `skipSelfTest` | 1 条(契约要求而 `SELFTESTS` 缺) |
| 回归 **M43 / M45 / M47 / M48 / 名字级未接线** | 各 1 条 |
| 干净树假红检查 | `^RED` **0 条**、因果红 **0 条** |
| `CAUSAL` 表 4 对逐条直验 | 改坏后每条 `--only-selftest` rc=1(未改坏 rc=0) |

## 六、⚠️ 残留(如实登记)

· **跨文件同步删 `CAUSAL` 表项 + 对应生产符号**仍可绕过(与层 5/6 同形:我把成本从
  一次编辑抬到两次,性质没变)⇒ **不声称层 7 封死**。
· 另外:`CRITERIA.md` 里那三名"下界"仍可被改小(层 6 的残留),
  但**现在它不再单独构成绕过** —— 改小下界后仍要过因果判据(假体不读符号 ⇒ 红)。

## 七、验证与状态

· 五个自检单独跑全 rc=0;默认跑 `checks=459 pass=454 fail=5 skip=0 red=10 broken=0 unreported=0 verdict=red`、
  `mutants=48 ran=47 skipped=1 on_new_criteria=35 diag=none baseline=7/7✓`;正常态 `^RED ` **0 条**。
· ⚠️ `fail=4→5`、`red=9→10` **不是**本提交造成的:新增的是 `test/harmony-nav.test.mjs` 的
  设备占用红(连续 **32** 轮被别的会话占着前台,`K=3` 上限后按设计报红)。
  **我用 stash 证实过**:在**不含本改动**的干净 HEAD 上同样是 `red=10` + 该文件 `# fail 1`。
· 本提交含 `client/electron/test/run-all.mjs`(快入口 + `CAUSAL` 因果判据)与
  `client/electron/test/CRITERIA.md`(新增 §6.1.1)。
· 提交前 `HEAD=3175ee7`。
This commit is contained in:
2026-09-18 08:31:01 +08:00
parent 3175ee7267
commit 72f35ff9cd
2 changed files with 146 additions and 0 deletions

View File

@ -213,6 +213,54 @@ const ALLOW = [ /* { name, replacement, why } */ ];
★★ 而它们共同的教训是:**每根轴的"底"看起来都像整体的底。**
"到底了"永远只对**当前那一根轴**成立 —— 这条我等了三轮才学会,写在这里给下一个人。
### 6.1.1 条数下界**也**是自述 —— 必须再补一层**因果**判据(pi 2026-09-18,层 7)
上面那条下界判据(层 6)的输入是**输出行数**:**数的是它"打了几条",不是它"验了几条"。**
所以把函数体换成"循环打满 13 条空 `ok`、`return 0`"就**恰好绕过**它:
```
基线: red=9 … verdict=red
假 ok×13: red=9 … verdict=red ← 逐字相同、下界相关 RED 0 条(13 ≥ 下界 12)
假 ok×13 + UPSTREAM_RC['baseline-residue']=99 ⇒ 0 条 RED、0 次提及 UPSTREAM
对照(只改值、不掏空)⇒ red=9→10,真报「与真跑出来的 rc=1 不符」
```
⇒ 根因一句话:**下界判据的输入是"自我报告"** —— 判据自己说它做了多少,而它可以撒谎。
**锚点不能落在"被守对象的自述"上,必须落在它管不着的东西上。**
★ 三种"看起来更硬"的替代都被否掉了,理由都是它们仍在自述这一侧:
· **黄金输出**(逐字节比对期望输出)= 又一份**手写数据**(本文件刚证过那族可被同步改);
· **扫源码找关键词**("源码里有那个断言")= "**提到 vs 就是**"(本文件反复证过它脆);
· **毒化环境变量**(该模式下每条自检必须报红)= 把"必须红"的断言写在**自检函数体内**
⇒ **假体不打它**。实测:模拟毒化后假体 `red=9`、下界红 0 —— 与层 7 同一问题。
★★ **本仓采用的锚点是"外部因果":改坏被守的生产符号 → 那条自检必须红。**
```
· 假体(空 `ok`)在符号被改坏时仍然打 ok ⇒ 它不红 ⇒ **红**(判据抓它);
· 真自检读了那个符号 ⇒ 符号坏 ⇒ 它报 RED ⇒ 绿。
⇒ 这是"自检真的读了那个符号"的**行为**证据,不是它的自述。
```
实现要点(`run-all.mjs` 的 `CAUSAL` 表 + 快入口):
- 快入口 `--only-selftest=<名>`:实测 **52~696ms/条**
(对比 `--X-selftest` 要跑整套 suite 的 4.3~4.9s);
- 隔离用 `mkdtempSync` + `cpSync(join(HERE), …)`(**只拷 `test/`,1.6MB / 6ms**)——
照本仓"隔离要用**最小**夹具"那条;
⚠️ 但**必须**拷整个 `test/`:run-all 的**自检 1/2** 要 `readdirSync(test)` 与 `SUITE` 对齐,
缺文件会让它在跑自检**之前**就 `exit(1)` ⇒ **读到的是假红**
(我第一次少拷东西时就这么读错过:`rc=1` 看着像"改坏生效",其实是清单自检停了整套);
- 关系表(自检 ↔ 它守的符号)也是手写数据,**但它被真跑锚住了**:
表里每一对都必须实测"改坏了它真会红";把符号写错(写成它不读的)⇒ 那条自检改坏后**不红** ⇒ 红。
⇒ 表自己也被因果判据守着,这是它与"下界数字"的**关键区别**。
- ⚠️ 锚文本的唯一性必须在**除去 `CAUSAL` 表本身**的源码里数,
否则会**匹配到表里那一行字面量**(我第一版就踩了:probe/verdict 立刻报"不唯一",
真因是表自己,不是生产里有两处)。★ 又是那句:"提到"与"就是"在文本上长得一样。
★ 层级表(**每层都是一根轴**):写法(4) → 存在性(5,"它还在吗") → 行为(6,"它做了吗")
→ **自述(7,"它说的话可信吗")**。
## 6.5 涉及**运行时行为**的结论,必须实测过才能写进规范/判据(pi 2026-09-14)
同一个错在本仓犯了两次,方向相反但错法相同:**从观察推断机制、没跑**。

View File

@ -304,7 +304,9 @@ const brokens = [];
const phaseWillRun = SUITE.map(([f]) => f)
.filter(f => !(PHASE === 'install' && sideOf(f) === SIDE.SOURCE));
const skippedByPhase = [];
const ONLY_SELFTEST = (process.argv.find(a => a.startsWith('--only-selftest=')) || '').split('=')[1] || '';
for (const [file, flags, expected] of SUITE) {
if (ONLY_SELFTEST) { skippedByPhase.push(file); continue; }
if (PHASE === 'install' && sideOf(file) === SIDE.SOURCE) {
skippedByPhase.push(file);
continue;
@ -2292,6 +2294,14 @@ if (process.argv.includes('--mutants-line-selftest')) process.exit(mutantsLineSe
* ⇒ 任何"调用自检"的代码都必须排在**所有** `const` 表与自检函数定义之后。
* (所以这里紧贴生产区段入口:`let mutantsLine` 之前。)
*/
if (ONLY_SELFTEST) {
const T = { 'probe-selftest': probeSelfTest, 'exitcode-selftest': exitcodeSelfTest,
'skip-selftest': skipSelfTest, 'verdict-selftest': verdictSelfTest,
'mutants-line-selftest': mutantsLineSelfTest };
const fn = T[ONLY_SELFTEST];
if (!fn) { console.error(`✗ 未知自检 ${ONLY_SELFTEST}`); process.exit(2); }
process.exit(fn() > 0 ? 1 : 0);
}
let selfCheckRuns = 0;
{
const SELFTESTS = [
@ -2623,6 +2633,94 @@ let selfCheckRuns = 0;
+ ' —— **存在性不等于在做事情**');
}
}
/*
* ★★★ **因果锚点**:把被守的**生产符号**真的改坏,那条自检**必须红**
* (pi 2026-09-18 报的层 7)。
*
* 层 6 我给的是"比条数下界",而条数是从**输出**里数的 ——
* **数的是它"打了几条",不是它"验了几条"**。实测:
*
* 把 `exitcodeSelfTest` 的体换成"循环打 13 条空 `ok`、`return 0`"(不打任何真断言)
* ⇒ `checks=459 pass=455 fail=4 skip=0 red=9 broken=0 unreported=0 verdict=red`
* 与基线**逐字相同**、下界相关 RED **0 条**(条数 13 ≥ 下界 12 ⇒ 相等,无从对比)。
* 最尖形式(假 ok×13 + `UPSTREAM_RC['baseline-residue']=99`)⇒ 0 条 RED、0 次提及 UPSTREAM;
* **对照**(不掏空、只改值)⇒ `red=9→10` 且真报"与真跑出来的 rc=1 不符"。
*
* ⇒ 根因一句话:**下界判据的输入是"自我报告"** —— 判据自己说它做了多少,而它可以撒谎。
* 这与这条线是同一句:**锚点不能落在"被守对象的自述"上,必须落在它管不着的东西上。**
*
* ★ 我**没有**采用 pi 提的三种替代(黄金输出 / 扫源码关键词 / 毒化环境变量):
* · 黄金输出 = 又一份手写数据(刚被证过那族);
* · 扫源码关键词 = "提到 vs 就是"(本文件反复证过它脆);
* · 毒化模式 = 把"必须报红"的断言写在**自检函数体内** ⇒ **假体不打它**。
* 我实测过:模拟毒化后假体 `red=9`、下界红 0 —— 与层 7 同一问题(仍是自述)。
* ⇒ 三种都仍在"被守对象自己说了算"这一侧。所以锚点改成**外部因果**:
*
* **改坏生产符号 → 那条自检必须红。**
* · 假体(空 `ok`)在符号被改坏时**仍然打 ok** ⇒ 它不红 ⇒ **红**(判据抓它);
* · 真自检读了那个符号 ⇒ 符号坏 ⇒ 它报 RED ⇒ 绿。
* ⇒ 这是"**自检真的读了那个符号**"的**行为**证据,不是它的自述。
*
* ★ 成本:实测单条自检的**快入口** `--only-selftest=<名>` 是 **52~696ms**
* (对比 `--X-selftest` 要跑整套 suite 的 4.3~4.9s),`cp -a test` 是 6ms。
* 隔离用 `mkdtempSync` + `cpSync`(**只拷 `test/`,1.6MB**)——
* 照本仓那条"隔离要用最小夹具"(整棵复制会换掉判据的输入)。
* ⚠️ 但这里**必须**拷 `test/` 整份:run-all.mjs 的**自检 1/2** 要 `readdirSync(test)`
* 与 `SUITE` 对齐,缺文件会让它在跑自检**之前**就 exit(1) ⇒ 读到的是假红。
* (我第一次少拷东西时就这么读错过:rc=1 看着像"改坏生效",其实是清单自检停了整套。)
*
* ★ 关系表(自检 ↔ 它守的生产符号)也是手写数据 —— **但它现在被真跑锚住了**:
* 表里每一对都必须实测"改坏了它真会红";写错了(比如把符号写成它不读的)
* ⇒ 那一条自检在改坏后**不会红** ⇒ **红**。⇒ 表自己也被因果判据守着。
*/
const CAUSAL = [
['probe-selftest', "const probeIsDue = v => v === true || v === 'unknown';",
'const probeIsDue = v => v === true;'],
['skip-selftest', 'return [...(out || \'\').matchAll(/^(?:ok|not ok) \\d+ - (.+?) # SKIP(.*)$/gm)]',
'return [...(out || \'\').matchAll(/^(?:ok|not ok) \\d+ - (.+?) # SKIPPED(.*)$/gm)]'],
['verdict-selftest', "? 'green' : 'red';", "? 'red' : 'red';"],
/*
* ★ 这一条是 pi 点名的**最尖形式**:`UPSTREAM_RC` 是"真脚本真跑"那个锚点的表。
* 把 `baseline-residue` 的值改坏 ⇒ `--exitcode-selftest` **必须红**
* (它真跑 `summary.py` 拿 rc 来比)。假体不读表 ⇒ 仍然全绿 ⇒ 被这条抓住。
*/
['exitcode-selftest', " 'baseline-residue': 1,", " 'baseline-residue': 99,"],
];
for (const [name, from, to] of CAUSAL) {
const tmp = mkdtempSync(join(tmpdir(), 'am-causal-'));
try {
cpSync(join(HERE), join(tmp, 'test'), { recursive: true });
const target = join(tmp, 'test', 'run-all.mjs');
const src = prose(target);
/*
* ⚠️ 必须在**除去这张因果表本身**的源码里数唯一性 ——
* 否则锚文本会**匹配到它自己那一行**(我第一版就踩了:probe/verdict 两条
* 立刻报"锚文本不唯一",而真因是**表里的字面量**,不是生产里有两处)。
* ★ 又是本文件那句:"提到"与"就是"在文本上长得一样。
*/
const CAUSAL_BLOCK_RE = /const CAUSAL = \[[\s\S]*?\n \];/;
const searchable = src.replace(CAUSAL_BLOCK_RE, (m) => m.replace(/[^\n]/g, ' '));
if (searchable.split(from).length - 1 !== 1) {
console.log(`RED 因果判据的锚文本在源码里不唯一(${name})⇒ 这条判据没在测它以为的东西`);
reds.push(`(自检)因果锚文本不唯一:${name}`);
continue;
}
writeFileSync(target, src.replace(from, to));
const got = spawnSync(process.execPath, [target, `--only-selftest=${name}`],
{ encoding: 'utf8', cwd: ROOT });
if (got.status === 0) {
console.log(`RED 把 \`${name}\` 守的生产符号改坏之后,它**仍然全绿**`
+ ' ⇒ 它没在读那个符号(或它是个空壳)');
reds.push(`(自检)**因果判据**:改坏生产符号后 \`${name}\` 不红 —— 它没在读它`);
} else if (got.status !== 1) {
console.log(`RED 因果判据跑 \`${name}\` 时退出码是 ${got.status}(不是 1)`
+ ' ⇒ 它不是"报红",是崩了/环境不对,这个读数无效');
reds.push(`(自检)因果判据里 \`${name}\` 退出码异常:${got.status}`);
}
} finally {
rmSync(tmp, { recursive: true, force: true });
}
}
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));