diff --git a/client/electron/test/lib/read.mjs b/client/electron/test/lib/read.mjs index dcbc258..b39d3ea 100644 --- a/client/electron/test/lib/read.mjs +++ b/client/electron/test/lib/read.mjs @@ -53,11 +53,26 @@ export function code(path) { * ★ **行号必须不变**(与 `stripComments` 同一条硬要求):字符串里的每个换行 * 换成等价数量的空行。而且**保留引号本身**,这样"这里原本有个字符串"仍然看得见。 * - * ⚠️ 已知限制,写清楚免得下一个人以为它比实际更强: - * 逐字符扫描,**不区分正则字面量** —— `/a"b/` 里的 `"` 会被当成字符串开始 - * (随后一直吃到下一个 `"`)。本仓判据里没有把真计数器名写进正则字面量的情形, - * 但这是**这个实现的边界**,不是"已经解决"。 - * (真正根治是"判行为而不是判文本":把被扫的逻辑抽成纯函数,拿合成输入喂它。) + * ⚠️ 已知限制,**方向是"假绿",不是"误伤"** —— 这一点我第一版写错了,pi 2026-09-15 测出真方向: + * 逐字符扫描,**不区分正则字面量**。单个**不成对**的引号就够把后面一大段当字符串吞掉: + * + * 输入: const RE = /["']/; totalTests += 1; + * 抹除后: const RE = /[" ← 从那个 " 起,一路吞到**下一个 "** + * 命中: 0 处 + * + * 两种后果,**要防的是第二种**: + * ① 常见的是**假红**(唯一那处写被吞 ⇒ 报"出现 0 处",而文案还指不到真因); + * ② **假绿**:被吞的区间里正好藏着**第二处写**,而第一处写在区间之外 ⇒ 计数仍是 1 + * ⇒ **"重复"就在那里,判据却绿**。 + * 实测:`const RE = /["x]; totalTests += 1; OK"]/ ; totalTests += 1;` + * ⇒ 真身 **2** 处,抹除后只数出 **1** 处。 + * + * ★ 而"第二处写"正是这条判据**唯一存在的理由** ⇒ ② 是**绕过**。 + * 所以这条判据的绿只能保证:"**在它能看见的文本里**没有第二处写" —— 不是"没有第二处写"。 + * **把绿当成证明,就会在这里栽。** + * + * (修它需要词法状态机 —— 不划算,因为下一版要改成"记录投影", + * 那时"计两次"根本没有对应的语句可写,这一族连扫描对象都不存在。) */ export function stripStrings(src) { let out = ''; diff --git a/client/electron/test/run-all.mjs b/client/electron/test/run-all.mjs index 3862f81..24747a6 100644 --- a/client/electron/test/run-all.mjs +++ b/client/electron/test/run-all.mjs @@ -21,7 +21,7 @@ * * 写判据之前先读 `test/CRITERIA.md`(判结构与行为,不判字面与邻接)。 */ -import { prose, stripComments, stripStrings } from './lib/read.mjs'; +import { prose, stripComments } from './lib/read.mjs'; import { spawnSync } from 'node:child_process'; import { existsSync, readdirSync } from 'node:fs'; import { dirname, join } from 'node:path'; @@ -188,29 +188,23 @@ const reds = []; * 绕出去的 glob 反而把它们跑绿了)。⇒ 权威来源必须由**套件自己**给出: * 它只累加**它自己实际跑过并解析到自报条数**的那些文件,不猜、不扫目录。 */ -let totalTests = 0; +const records = []; /** - * 逐文件自报的**失败**条数之和(`fail=` 那一格)。 + * 各计数器**不在这里累加** —— 它们由下面的 `records` **投影**出来(见 `records` 的说明)。 * - * ⚠️ 别再把这行写成"`pass + fail == checks` 是恒等式"就算完了 —— - * 汇总行里的 `pass` 是 `totalTests - totalFail` **派生**出来的,所以那个等式 - * **按构造恒成立、永远抓不到任何东西**(上一轮"两边一起虚高时它照样成立"就是同一个病: - * **恒等式两边同源就没有检验力**)。 - * - * 现在**两边各自独立测得**:`checks` 取文件的**总条数** - * (自定义文件 `pass+fail`、node:test 取 `# tests`),`fail` 取文件的失败条数。 - * 所以 `pass + fail == checks` 从"定义式"变成了**真不变量** —— - * 单位取错(例如用 `# pass` 当总条数)现在会**直接破坏它**,而不是被它掩盖。 + * 旧形状是这四个 `let` 各自 `+=` 一处,靠"源码里每个计数器只许一处写"的**文本扫描**保证。 + * 那个形状长出了一整族扫描器毛病(注释/字符串/等价写法/锚/正则引号), + * 而**改成投影后这一族连扫描对象都不存在** ⇒ 四个计数器与那条扫描自检一起删掉了。 + * 读它们的地方一个字都没改(`RESULT` 行、自检 5、汇总),只是它们现在是**派生值**。 */ -let totalFail = 0; -/** - * **实际跑了但没能自报条数**的文件数 —— 这个数必须显式打出来。 - * 没有它,`checks=` 会**静默少算**(跑过的文件里有多少条没被计入,读者看不出来)。 - * 我第一次量就撞上了:`checks=379` 而逐文件手算合计 386,差的 7 条正是这些文件里的。 - */ -let unreportedFiles = 0; -/** 实际跑过(并解析到自报条数)的文件数 —— 与 `SUITE.length` 分开报,差额看得见 */ -let ranFilesCount = 0; +// 下面这四行是**投影**(`totalTests`/`totalFail`/`ranFilesCount`/`unreportedFiles` 的旧名保留, +// 让读的地方不用改;它们的值全部来自 `records`,没有任何一步是"就地累加")。 +/* eslint-disable no-unused-vars */ +let totalTests; +let totalFail; +let unreportedFiles; +let ranFilesCount; +/* eslint-enable no-unused-vars */ /* * ─── 相位(pi 2026-09-14 裁定)─── * @@ -319,15 +313,31 @@ for (const [file, flags, expected] of SUITE) { const nodePass = /^# pass (\d+)/m.exec(out); const nodeTotal = nodeTests ? Number(nodeTests[1]) : (nodePass ? Number(nodePass[1]) + (nodeFail ? Number(nodeFail[1]) : 0) : null); - const ran = marker ? Number(marker[1]) + Number(marker[2]) : nodeTotal; - const failed = marker ? Number(marker[2]) : (nodeFail ? Number(nodeFail[1]) : 0); - if (ran !== null) { - totalTests += ran; - ranFilesCount += 1; - totalFail += failed; - } else { - unreportedFiles += 1; // ★ 唯一的增量处(另一份在绿色 else 里,已删,见下) - } + const fileChecks = marker ? Number(marker[1]) + Number(marker[2]) : nodeTotal; + const fileFail = marker ? Number(marker[2]) : (nodeFail ? Number(nodeFail[1]) : 0); + + /* + * ★★ 唯一的信息来源:**这个文件只 push 一条记录**(pi 2026-09-15 的建议,我采纳)。 + * + * 旧写法是四个模块级 `let` 各自 `+=`,于是"每个计数器只许一处写"只能**靠扫描源码文本**去盯 + * —— 而那条扫描器自己就长出了一整族毛病:注释、字符串、`= X + n`、`X++`、锚钉在行首 + * vs 语句起点、正则字面量里的引号(**最后这条还是假绿方向:会吞掉真写**)。 + * 每一轮修好其中一个实例,族还在。 + * + * ⇒ 换成投影后:**"把一个文件计两次"没有对应的语句可以写** —— + * 不是"判据盯着别写",而是**写不出来**(要计两次得先把同一条记录 push 两次, + * 而那件事有**行为**判据盯着,见汇总处的 `records.length === SUITE.length`)。 + * 那一整族(注释/字符串/拼法/锚/正则引号)**连扫描对象都不存在了**。 + * + * 注意这里刻意**不做任何累加** —— `ran === null` 的含义("跑了但没自报条数") + * 被原样记进记录,怎么解读交给下面那个投影,不在这里就地分类。 + */ + records.push({ + file, + checks: fileChecks, // null = 跑了但没能自报条数 + fail: fileChecks === null ? null : fileFail, + exitCode: r.status, + }); // 不 break:后面每条都要跑出来,否则"红了几条"这个信息本身是假的 if (crashed || empty) { const firstErr = (out.match(/^.*(Error|error:).*$/m) || [''])[0].trim().slice(0, 160); @@ -339,12 +349,13 @@ for (const [file, flags, expected] of SUITE) { } else if (r.status !== 0) reds.push(`${file}(退出码 ${r.status})`); else { /* - * ★ 这里**只保留"没自报条数"的那条红**;累加**不能**在这里做。 + * ★ 这里**只保留"没自报条数"的那条红**;**任何累加都不在这里做** —— + * 投影之后累加根本不作为语句存在,上面那条 push 出来的记录是唯一的信息来源。 * * 这段曾经是累加块(`totalTests += ran` / `ranFilesCount += 1` / `totalFail += …`)**唯一的** * 位置,而它在 `else` 里 ⇒ **红文件一条都不计入**,`checks` 静默少算 * (实测:`checks=379` 而逐文件手算 387,差的正是红文件里的)。 - * 后来把累加搬到了红绿**分支之外**(上面 `if (ran !== null) { … }`), + * 后来把累加搬到红绿**分支之外**, * **但没删掉这一份** —— 于是变成: * * `totalTests`/`ranFilesCount`/`totalFail` 对**每个 exit 0 的文件计两遍**, @@ -371,7 +382,7 @@ for (const [file, flags, expected] of SUITE) { * ⇒ 这一行现在删掉了,并且由自检 5 的 `unreportedFiles ≤ files` 钉着 * (它今天不可见:`unreported=0`,没有文件触发 —— **看不见正是它需要结构性上界的理由**)。 */ - if (ran === null) { + if (fileChecks === null) { /* * 报错**自带修法**(pi 2026-09-14):这条契约的受众不只是读过规范的人 —— * 并发写 WebUI 的 agent 新加判据时不会打开 CRITERIA.md,看到红的第一反应 @@ -389,22 +400,45 @@ for (const [file, flags, expected] of SUITE) { console.log(\`RESULT pass=\${pass} fail=\${fail}\`); 样板:test/theme.test.mjs、test/background.test.mjs (用 node:test 写的判据不用管:runner 认 \`# pass N\`。)`); - } else if (expected > 0 && ran !== expected) { + } else if (expected > 0 && fileChecks !== expected) { // **相等**,不是"不少于"(pi 邮件 `9fed4386`):只判下界时,**多加的条数不受保护** —— // 下一个人删掉那条"多出来的"判据不会有任何东西变红,于是"被删会红"这个性质只覆盖前缀。 // 两个方向的成因与处置都不同,所以分开写。 - const why = ran < expected - ? `自报 ${ran} 条 < 清单里登记的 ${expected} 条。常见成因:判据被删/被跳过(写在 process.exit() 之后、 + const why = fileChecks < expected + ? `自报 ${fileChecks} 条 < 清单里登记的 ${expected} 条。常见成因:判据被删/被跳过(写在 process.exit() 之后、 条件里提前 return)、check() 的实现被改坏(合并冲突)、marker 打在了汇总之前但计数没接上。 确认确实该减少条数时,把清单里那个数字一起改掉(那是一次显式、可复核的编辑)。` - : `自报 ${ran} 条 > 清单里登记的 ${expected} 条 —— 新加的那几条**不在"被删会红"的保护内**: - 只判下界时,删掉多出来的部分不会有任何东西变红。请把清单里这个数字改成 ${ran}(一次显式编辑), + : `自报 ${fileChecks} 条 > 清单里登记的 ${expected} 条 —— 新加的那几条**不在"被删会红"的保护内**: + 只判下界时,删掉多出来的部分不会有任何东西变红。请把清单里这个数字改成 ${fileChecks}(一次显式编辑), 新判据才真正被登记保护。`; reds.push(`${file}\n ↳ ${why}`); } } } +/* + * ─── 投影:把 `records` 算成那四个计数器(**这里是它们唯一的"来处"**)─── + * + * ★ 为什么是这个形状(pi 2026-09-15 建议,我采纳并实测): + * "每个文件只该计一次"原文是靠**扫源码文本**保证的,而那条扫描器自己长出了一整族毛病 + * (注释 → 字符串 → `= X + n`/`X++` → 锚钉行首 vs 语句起点 → 正则字面量里的引号; + * 最后这条还是**假绿方向**:会吞掉真写、把"其实有两处写"数成 1)。 + * 换成投影后,"计两次"不再有对应的语句 —— 那一族**连扫描对象都不存在**。 + * + * ★ 但我要**修正 pi 的一句措辞**(实测过才敢说):他写"计两次**写不出来**"。 + * `records.push` 本身**仍然可以被写两次**(复制粘贴就会),所以"写不出来"过强。 + * 准确的说法:**重复不再表现为"某个计数器被加两次",而表现为"同一条记录出现两次"** ⇒ + * 它可以被**行为**判据抓住(见下面的 `records.length === SUITE.length`), + * 而不是只能靠"文本里 `+=` 有几处"去猜。 + * ★ **这是"文本判据 → 行为判据",不是"可能错 → 不可能错"** —— + * 前者可测,后者只是话好听。我不想把改进说成保证。 + */ +const reportedRecords = records.filter(r => r.checks !== null); +totalTests = reportedRecords.reduce((n, r) => n + r.checks, 0); +totalFail = reportedRecords.reduce((n, r) => n + r.fail, 0); +ranFilesCount = reportedRecords.length; +unreportedFiles = records.length - reportedRecords.length; + /* * ─── 静态判据的**欠账**与到期机制(pi 2026-09-14 提议) ─── * @@ -689,113 +723,48 @@ try { probs.push(`checks=${totalTests} < fail=${totalFail} —— fail 是 checks 的子集,这不可能成立`); } /* - * ★ **"每个文件计一次"必须查"增量语句有几处",不能查数值上界** - * (pi 2026-09-15 指出我漏了 `unreportedFiles` 之后,**我自己测出来的更正**): + * ★★ 这里原来是一条**源码文本扫描**("四个计数器各只许一处写"),连同它的自检一起删掉了。 * - * 我原打算给 `unreportedFiles` 补一条 `unreported ≤ files` 的上界 —— **它是空的**: - * 一次运行里一个文件只被处理一次,而 bug 的效果是**同一次处理里加两次** ⇒ - * 只有 1 个这种文件时 `unreported=2`,而 `2 > 25` 为假 ⇒ **永不触发**。 - * 要让它触发得需要 >25 个"绿且不自报"的文件,而套件只有 25 个文件。 - * ⇒ **数学上成立、实际上永远抓不到任何东西的判据** —— - * 这正是我们这一路在消的形状,而我是**在加它的时候**又造了一个。 + * 为什么删(pi 2026-09-15 建议,我采纳):那条扫描器自己长出了**五轮**毛病 —— + * 注释 → 字符串 → `= X + n`/`X++` → 锚钉行首 vs 语句起点 → + * 正则字面量里的引号(**最后一轮是假绿方向**:会吞掉真写,把"其实有两处写"数成 1, + * 而"第二处写"正是它唯一存在的理由)。每轮我都修好了那个实例,**族还在**。 * - * ★ **但真原因比"同一次加两次"更一般,而且我上面对 `ran > files` 的"更正"是错的** - * (pi 2026-09-15 把我的更正驳回来了,算式我实测复现过)。设 - * N=文件数、P=自报成功的、U=N−P=没自报的、Gp=绿且自报的、Gu=绿且没自报的,有 bug 时: + * ⇒ 改成**投影**后(见上面 `reportedRecords`),那四个计数器**没有"写"可言**, + * 扫描器**没有对象可扫** —— 整段删掉,`stripStrings`/`stripComments` 的引入也一并去掉。 + * (`stripStrings` 本身留在 `read.mjs`:它是通用工具,只是**这条判据**不再用它 —— + * 所以这里连 import 也去掉了。**留着不用的 import 就是下一次"看着像还在用"的源头。**) * - * ranFilesCount = P + Gp unreportedFiles = U + Gu - * ran > N ⟺ Gp > U - * unreported > N ⟺ Gu > P ← 两者是**镜像** - * - * 正常情况下 U ≈ 0(每个文件都自报)⇒ **`ran > files` 只要 Gp ≥ 1 就响 —— - * 任何一个被双计的绿文件就够**。我先前写的"只在几乎所有文件都双计时才响"**不成立**。 - * 实测:只让**一个**绿文件多计一次 ⇒ `ran=26 > files=25` **当场响**。 - * 而同一组数下 `unreported = U + Gu` 几乎恒为 0 —— 那才是它**永不触发**的原因。 - * - * ⇒ **可复用的规则**:`counter ≤ files` 这类结构性上界的**区分力 = 计数器典型值与上界之间的 - * 余量(slack)**。 - * · `ran` 典型值 ≈ files(余量 0)⇒ **极尖**,+1 就响 ⇒ **这条可以信**; - * · `unreported` 典型值 0(余量 25)⇒ 要 `Gu > P` 才响 ⇒ 永不触发。 - * **"结构性上界"本身不分好坏,要看余量。** 我是先拿"结构性的就是好的"当结论, - * 于是把一条很尖的判据误判成侥幸 —— **同一把尺子量两个余量截然不同的计数器**。 - * - * 所以源码处数扫描仍然要(它管的是另一件事:**拼法**),`ran > files` 也留着。 - * 诚实说清性质:这是**形态判据**(读自己的源码),不是行为判据 —— - * 它能抓"搬动/新增时留了旧路径"(实际发生的那三次), - * 抓不到"在某条路径上被条件性地加两次"。 - * 后者要真跑,得把累加抽成一个纯函数(入参 = 解析结果,出参 = 增量),再拿合成输入喂它。 - * **那才是能真跑的形状,本轮不做,但记在这里**。 - * - * ⚠️ 计数必须**锚在语句位置**,否则**我这条报错文案自己**里出现的写法会被数进去 —— - * 第一次跑就是 `totalTests += 出现 2 处`,而真身只有一处 - * (**扫描器咬到自己的文档**,与 `@ohos`/`toISOString` 那两次同族)。 - * `stripComments` 只去注释、**不去字符串**,所以这里不能只靠它。 - * - * ⚠️ 而且**只认 `+=` 是个假绿方向的洞**(pi 2026-09-15 抓到,我实测复现): - * `totalTests = totalTests + ran;` 这样的等价写法**躲得过扫描而仍然双计**。 - * ⇒ 所以判的是**对这四个计数器的"写"**,不是 `+=` 的出现: - * `+=` / `= X + …` / `= X - …` / `++` / `--` 都算。 - * - * ⚠️⚠️ 锚**不能是行首** —— 我第一次写成 `^\s*X`,然后实测发现**注入的第二处照样漏**: - * 语句可以从**行中间**开始,`if (ran === null) { ranFilesCount += 0; }` 就是一行两条语句, - * 而"第二处"长得**正是这个样子**。⇒ 锚必须是**语句起点**:`(?:^|[;{])`。 - * (**这是我第二次栽在同一件事上**:扫描器的锚要按**语言结构**定,不是按**版面**定 —— - * 上一次是"扫描器咬到自己的文档",这次是"锚钉在行首而语句不在行首"。) - * 实测四种等价写法(`+=` / `= X + n` / `X++` / 行中间)**全部命中**后才收。 - * - * ⚠️⚠️ **只去注释不够,还得去字符串**(pi 2026-09-15 第二次抓到这个族,我复现): - * `stripComments` **不去字符串**(我在 `read.mjs` 里早就写明过这条限制), - * 于是下面这种**假阳性**: - * probs.push(`oops; 真计数器名 += 1`); ← 被 MATCHED - * 锚是 `(?:^|[;{])`,而 `;` / `{` 在**字符串里**照样是字符 ⇒ 数到 2 ⇒ 报红, - * **而代码是对的** ⇒ **判据开始消费散文**,下一个人会去改自己的**文案**来哄判据。 - * - * ★ 而且**这个文件当时是"靠标点侥幸"绿的**:那句修法文案里写的是 - * `` `totalTests +=` `` —— `totalTests` 前面是**反引号**,不是 `;`/`{`,所以躲过了。 - * **不是设计让它躲过去的。** 换个写法(`;` 后紧跟真名、或写成 `{ totalTests +=`) - * 它立刻假红。(同理:我把"真名"写进**本段注释**里也不行 —— 注释被去掉了, - * 但**字符串没有**,所以这里只用占位符 `真计数器名`。) - * - * ⇒ 所以扫之前先 `stripStrings`(`read.mjs` 里新增,**保留行号与引号**), - * 并且**自检这个抹除本身**:用它复现那个假阳性,证明它真的被消掉了 —— - * **不带自检的"我加了过滤"= 又一次"读数器没先被证明是好的"**。 - * - * 仍不是根治(`x ||= y`、`x = 1 + x` 反序、`}` 之后的写、正则字面量里的引号都能躲 - * 或误伤)—— 根治是上面那个纯函数:**判行为,不判文本**。 + * ⚠️ 但**别把这次改动说成"不可能再计两次"**(pi 的原话是"写不出来",我实测后认为过强): + * `records.push` 仍然可以被复制粘贴两次。准确的说法是: + * 重复不再表现为"某个计数器被加两次",而表现为"**同一条记录出现两次**" ⇒ + * 于是它**能被行为判据抓住**,而不是只能靠猜文本。 + * **这是"文本判据 → 行为判据",不是"可能错 → 不可能错"。** */ - const ownRaw = prose(fileURLToPath(import.meta.url)); - const ownSrc = stripStrings(stripComments(ownRaw)); - - /* - * 自检抹除器:合成一个**只含假阳性**的样本,它必须数出 0; - * 再合成一个**真写**,必须数出 1。两个都过才允许用扫描结果判真身。 - */ - const mkWrite = (c) => new RegExp( - `(?:^|[;{])\\s*${c.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*(?:\\+=[^=]|=[^=]\\s*${c.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*[-+]|[+-]{2})`, 'gm'); - const CN = 'totalTests'; - const falsePos = ['probs.push(`oops; ' + CN + ' += 1`);', - 'const s = "x; ' + CN + ' += 1";', - 'console.log(`{ ' + CN + ' += 1 }`);'].join('\n'); - const truePos = [' ' + CN + ' += ran;'].join('\n'); - const nFalse = (stripStrings(falsePos).match(mkWrite(CN)) || []).length; - const nTrue = (stripStrings(truePos).match(mkWrite(CN)) || []).length; - const truePosRaw = (falsePos.match(mkWrite(CN)) || []).length; // 未抹除时应 > 0 - if (nFalse !== 0 || nTrue !== 1 || truePosRaw === 0) { - probs.push(`扫描器的**抹除自检**失败:字符串样本数出 ${nFalse}(应 0)、` - + `真写样本数出 ${nTrue}(应 1)、未抹除时字符串样本数出 ${truePosRaw}(应 >0,` - + `否则说明这个自检没有真的复现出那个假阳性)。\n` - + ` ★ 抹除器坏了就别用扫描结果 —— **"看不到"与"没有"必须分得开**。`); - } else { - for (const counter of ['totalTests', 'ranFilesCount', 'totalFail', 'unreportedFiles']) { - const n = (ownSrc.match(mkWrite(counter)) || []).length; - if (n !== 1) { - probs.push(`对 \`${counter}\` 的**写**(语句位置)出现 **${n}** 处(只许 1 处)—— ` + - `"每个文件只该计一次"是对**整族**计数器都成立的性质,不是一个一个判的;\n` + - ` 这个形状已经发生过三次(搬动/新增时留了旧路径),而**两处都跑得通、都报得出数**。\n` + - ` 注意判的是**写**:\`X += n\`、\`X = X + n\`、\`X++\` 都算(只认 \`+=\` 会漏)。`); - } - } + if (records.length !== SUITE.length) { + const seen = new Map(); + for (const r of records) seen.set(r.file, (seen.get(r.file) || 0) + 1); + const dup = [...seen].filter(([, n]) => n > 1); + probs.push(`跑出的记录数 ${records.length} ≠ 套件文件数 ${SUITE.length} —— ` + + `每个文件**恰好一条**记录(这是"每个文件只该计一次"的**行为**版本;` + + `原来那条是扫源码文本的,已删,理由见上)。\n` + + (dup.length > 0 + ? ` 重复的文件:${dup.map(([f, n]) => `${f}×${n}`).join('、')}\n` + : ` 没有重复文件 ⇒ 是**漏了**或**多跑了**,不是 push 两次:检查 SUITE 与循环。\n`) + + ` 修法:records.push 每个文件只该执行一次(它在循环体里、红绿分支**之外**)。` + + `注意"搬动累加块"这个动作历史上做过三次,都把旧路径留在了原地。`); } + if (ranFilesCount > SUITE.length) { + probs.push(`ran=${ranFilesCount} > files=${SUITE.length} —— 自报条数的文件比文件总数还多,` + + `不可能成立。\n` + + ` ★ 这条现在**冗余**于上面那条(投影下 ranFilesCount 就是"checks 非空的记录数",` + + ` 有 ≤ 记录数 ≤ 文件数 的构造性保证)。**留着当"投影被改坏"的探测器**:` + + ` 哪天有人把它换回就地累加,这条会立刻响。`); + } + if (totalTests < totalFail) { + probs.push(`checks=${totalTests} < fail=${totalFail} —— fail 是 checks 的子集,这不可能成立`); + } + if (probs.length) { console.error('✗ 套件自检 5(总数行的自洽性):'); for (const x of probs) console.error(` - ${x}`); @@ -810,11 +779,14 @@ try { * 形状:**"读数器替一件事作证"落在自检自己身上** —— 而这条自检存在的理由, * 正是"`RESULT` 那行自作证"。**读数器也要被读数。** * - * `unreported` 现在由下面那句源码处数判(判的是"写有几处",不是值上界)—— - * 所以报文里只提它由谁判,不再写一个没人算的不等式。 + * ★ 本条的判据形状**在 pi 2026-09-15 的建议后换过**:原来是"扫自己的源码, + * 数四个计数器各有几处写"(文本判据,长出了五轮毛病); + * 现在是"**记录数 == 文件数**"(行为判据,见上面 `reportedRecords`)。 + * 报文如实说它现在由谁判 —— 改了判据却留着旧文案,就是又一次"说法与实现不一致"。 */ console.log(`ok 5 - 总数行自洽(ran=${ranFilesCount} ≤ files=${SUITE.length},` + - `fail 是 checks 的子集;四个计数器各只有一处"写",由源码扫描判)`); + `fail 是 checks 的子集;四个计数器由 ${records.length} 条记录**投影**而来 —— ` + + `判的是"记录数 == 文件数"这个**行为**,不是"源码里 \`+=\` 有几处")`); } } catch (e) { console.error(`✗ 套件自检 5 跑不了:${e.message || e}`);