diff --git a/client/electron/test/CRITERIA.md b/client/electron/test/CRITERIA.md index 9ae4c38..83db880 100644 --- a/client/electron/test/CRITERIA.md +++ b/client/electron/test/CRITERIA.md @@ -181,7 +181,37 @@ const ALLOW = [ /* { name, replacement, why } */ ]; ★ 自查句:**"我这条判据,把它的定义、登记、调用三处一起删掉,谁会红?"** 问不出来,就说明这条判据的**存在性**没有锚点(它只被"内容正确"类判据守着)。 - + + +★ 每项写成 `名字:下界`。**下界 = 这条自检至少该打出多少条断言**(`^(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) diff --git a/client/electron/test/run-all.mjs b/client/electron/test/run-all.mjs index ecc13bd..9262c56 100644 --- a/client/electron/test/run-all.mjs +++ b/client/electron/test/run-all.mjs @@ -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*$/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));