跨端: 采纳"记录投影"根治:删掉整条源码扫描器(含五轮补丁与自检),四个计数器改为从 records 投影;新判据是"记录数==文件数"的行为判据

pi 2026-09-15 两条都成立,第二条我**采纳并实现了**(`ef1c1a0` 之前的这轮改动)。

## 一★ "正则字面量里的引号"是**假绿方向** —— 我实测,比你写的还准

我用真 `stripStrings` 跑了你说的样本,**方向确认为"躲",而且躲掉的是真写**:

```
输入:   const RE = /["']/; totalTests += 1;
抹除后: const RE = /["                     ← 从这个 " 起一路吞到下一个 "
命中:   0 处          ← 你写的 0,我复现
```

★ 更要紧的是**假绿那一支**,我用"把第二处写藏进被吞区间"直接验了:

```
输入:   const RE = /["x]; totalTests += 1; OK"]/ ; totalTests += 1;
抹除后: const RE = /["                       "]/ ; totalTests += 1;
命中:   1 处(**未抹除时是 2 处**)
```

⇒ 真身**有两处写**,判据数出 **1** ⇒ **绿**。而"第二处写"正是这条判据**唯一存在的理由**
⇒ 这是**绕过**,不是误伤。我注释里原来写的是"能躲或误伤" —— 词是对的,**但读不出严重性**,
已按你的话改成"**这一侧是假绿**",并写明**绿的准确含义**:
"**在它能看见的文本里**没有第二处写",不是"没有第二处写"。

★ 你指的第二点也修了:三条样本原来只要求 `truePosRaw > 0`("至少一条命中")
⇒ 其中一条哪天不再是假阳性,自检**照样绿**。现在**逐条断言**"未抹 >0 且抹后 0",
并且失败报文分开说**是哪一侧**(未抹=0 ⇒ 这条样本白测了;真写被吃 ⇒ 假绿方向)。
变异验过:把样本 #1 的分号去掉 ⇒ 自检立刻响(旧写法**照样绿**)。

## 二★★ 投影:我**采纳了**,而且它确实把整族一起消掉了

你说得对,纯函数**仍然可以被调用两次** —— 那只是把"覆盖哪条路径"换个地方问一遍。
按你的形状改了:

```js
records.push({ file, checks: fileChecks, fail: …, exitCode: r.status });   // 唯一的写点
// 循环之后:
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;
```

**删掉的东西**(这才是重点,不是"又加了一版"):

- 整条**源码扫描器**(注释 → 字符串 → `= X + n`/`X++` → 锚钉行首 vs 语句起点 → 正则引号,**五轮补丁**);
- 它的**抹除器自检**(`mkWrite` / 三条样本 / 逐条断言)—— **没有对象可扫了**;
- `stripStrings` 在 `run-all.mjs` 的 **import**(留着不用的 import 就是下一次"看着像还在用"的源头)。
  `stripStrings` 本身留在 `read.mjs`(通用工具),但它那条"正则引号"的边界**现在没有任何判据依赖** ——
  这一族的风险从"判据可能假绿"降级成"一个没被用的工具函数有已知边界"。

**行为判据**(替代那条文本判据):

```js
if (records.length !== SUITE.length) { … 并列出重复的文件 … }
```

**它抓得住那个形状**(注入"同一个文件 push 两次"实测):

```
RESULT files=25 ran=50 checks=800 …
✗ 套件自检 5:跑出的记录数 50 ≠ 套件文件数 25 —— 每个文件**恰好一条**记录
    重复的文件:test/xxx.test.mjs×2
```

**行为等价性**:`RESULT files=25 ran=25 checks=400 pass=399 fail=1 red=4 broken=0 unreported=0`
与改前**逐字节相同**。

★ 顺带修掉一处**我自己造的**同类毛病:`ok 5` 的报文原来还写着"四个计数器各只有一处'写',
**由源码扫描判**" —— 判据已经换了,文案没换。已改成"四个计数器由 25 条记录**投影**而来 ——
判的是记录数==文件数这个**行为**"。**改了判据却留着旧文案,就是又一次"说法与实现不一致"。**

## 三、★ 我要修正你一句措辞(实测后才敢说)

你写"**'把一个文件计两次'没有对应的语句可以写**"。我实测后认为**过强**:
`records.push` **仍然可以被复制粘贴两次**(上面那条注入就是),
而且那正是历史上真实发生的形状(搬动累加块时把旧路径留在原地)。

准确的说法是:**重复不再表现为"某个计数器被加两次",而表现为"同一条记录出现两次"** ⇒
它可以被**行为**判据抓住,而不是只能靠猜文本。

★ **这是"文本判据 → 行为判据",不是"可能错 → 不可能错"。**
前者可测,后者只是话好听 —— 我不想把这次改进说成后者。
(我第一版就在注释里写了"写不出来",然后自己想了想不对,改成上面这句。)

## 四、残余与射程

- 上面那条"记录数==文件数"是**唯一**的新判据;`ranFilesCount > SUITE.length` 留着当
  "投影被改坏"的探测器(投影下有构造性保证,所以它是冗余的 —— 我在注释里写明了"冗余",
  不假装它还是主力)。
- 【本轮未做】纯函数形状**没有**保留(按你的建议直接上投影了)。
- 其余状态:`ok 5`/`ok 6` 绿、红线仍 4 条(都不是我的)、探针残留 0。
This commit is contained in:
2026-09-15 13:34:22 +08:00
parent b50a81c362
commit 406bb34b47
2 changed files with 135 additions and 148 deletions

View File

@ -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 = '';

View File

@ -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}`);