From 4d8c99b829724c9a9e3221091be30f05726d72e3 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Tue, 15 Sep 2026 13:14:52 +0800 Subject: [PATCH] =?UTF-8?q?=E8=B7=A8=E7=AB=AF:=20=E8=87=AA=E6=A3=80?= =?UTF-8?q?=E6=89=AB=E6=8F=8F=E7=9A=84=E5=81=87=E9=98=B3=E6=80=A7=EF=BC=9A?= =?UTF-8?q?=E6=B3=A8=E9=87=8A=E9=82=A3=E6=94=AF=E5=A0=B5=E4=BA=86=E3=80=81?= =?UTF-8?q?**=E5=AD=97=E7=AC=A6=E4=B8=B2=E6=B2=A1=E5=A0=B5**=EF=BC=88?= =?UTF-8?q?=E4=BB=8A=E5=A4=A9=E9=9D=A0=E5=8F=8D=E5=BC=95=E5=8F=B7=E4=BE=A5?= =?UTF-8?q?=E5=B9=B8=E7=BB=BF=EF=BC=89+=20=E6=8A=B9=E9=99=A4=E5=99=A8?= =?UTF-8?q?=E8=87=AA=E5=B8=A6=E8=87=AA=E6=A3=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pi 2026-09-15 第二次抓到这个族,**成立**,我照他的办法复现了。 ## 一、扫描仍会**咬字符串里的文字** `stripComments` **只去注释、不去字符串**(这条限制我在 `read.mjs` 里早就写明过), 而扫描的锚是 `(?:^|[;{])` —— `;` 与 `{` 在**字符串里**照样是字符: ```js probs.push(`oops; totalTests += 1`); // 被 MATCHED(假阳性) const s = "x; totalTests += 1"; // 被 MATCHED console.log(`{ totalTests += 1 }`); // 被 MATCHED ``` ⇒ 数到 2 ⇒ 报红,**而代码是对的**。后果不是"多一条红",而是 **判据开始消费散文**:下一个人会跑去改自己的**文案**来哄判据。 ★ 而且**这个文件当时是"靠标点侥幸"绿的**:那句修法文案写的是 `` `totalTests +=` `` —— `totalTests` 前面是**反引号**,不是 `;`/`{`,所以躲过了。 **不是设计让它躲过去的**:换成 `{ totalTests +=` 或中文分号紧跟真名,立刻假红。 ## 二、修法:抹字符串的"体",并且**自检这个抹除** - `read.mjs` 新增 `stripStrings(src)`:逐字符把 `'`/`"`/`` ` `` 的**体**抹成空白, **保留引号与换行**(行号不变 —— 与 `stripComments` 同一条硬要求)。 - 扫描改成 `stripStrings(stripComments(raw))`。 - ★ **抹除器自带自检**:合成"只含假阳性"的样本(必须数出 **0**)、合成"真写"(必须数出 **1**), 并且断言**未抹除时假阳性样本确实 >0** —— **否则这个自检没有真的复现出那个假阳性,等于没验。** 自检不过就不采信扫描结果(**"看不到"与"没有"必须分得开**)。 ## 三、变异验证 5/5 | 注入 | 期望 | 实测 | |---|---|---| | 第二处写(`+=`) | 红 | 红 ✓ | | 第二处用 `X = X + n` | 红 | 红 ✓ | | 第二处用 `X++` | 红 | 红 ✓ | | 字符串里 `; totalTests += 1` | **不响** | 不响 ✓ | | 字符串里 `{ totalTests += 1 }` | **不响** | 不响 ✓ | ★ 这个验证脚本我改了**四版**才收,每版的毛病都是"读数器没先被证明是好的": ① 注入没命中(缩进写成 6 空格、文件里是 4)⇒ 报假"★ 漏"; ② 锚 `const probs = [];` 在文件里出现 **2** 次 ⇒ `replace` 打到错的地方; ③ 我把**代码**换成 `+ ran` 去验"字符串"那一支 —— 那仍是**一处**写,当然不红,预期本身就写错了。 ⇒ 定稿:每次注入 `assert count==1`、锚选唯一的、**每个 case 的预期先想清楚再写**。 ## 四、残余(照实写,不当成已解决) - **假阳性方向**:正则字面量里的引号(`/a"b/`)会被 `stripStrings` 当成字符串开始。 本仓判据没有把真计数器名写进正则字面量,但这是**实现的边界**,已在 `stripStrings` 的 注释里写明,免得下一个人以为它比实际更强。 - **假阴性方向**(pi 记的,我认):`totalTests = 1 + totalTests;`(反序)与 `if (a) { b(); } totalTests += 1;`(`}` 之后的写)都躲得过。 但**搬动/复制粘贴会保留 `+=`** —— 也就是我声明的射程(实际发生的那三次)覆盖得住。 - **根治形状**(已写进注释):把累加抽成**纯函数**,拿合成输入喂它 —— 那时判的是行为,不是文本,这一整族(注释/字符串/拼法/锚)一起消失。**本轮不做。** --- client/electron/test/lib/read.mjs | 38 ++++++++++++++++++ client/electron/test/run-all.mjs | 66 ++++++++++++++++++++++++------- 2 files changed, 90 insertions(+), 14 deletions(-) diff --git a/client/electron/test/lib/read.mjs b/client/electron/test/lib/read.mjs index 4e547f3..dcbc258 100644 --- a/client/electron/test/lib/read.mjs +++ b/client/electron/test/lib/read.mjs @@ -40,6 +40,44 @@ export function code(path) { return stripComments(readFileSync(path, 'utf8')); } +/** + * 把**字符串/模板串的"体"**抹成空白(保留引号与换行)。 + * + * ★ 为什么需要它:`stripComments` 只去注释,**不去字符串**。于是"在源码里找某个写法" + * 的那类判据会**咬到字符串里的文字** —— 这个族本仓已经踩过三次 + * (`@ohos`、`toISOString`、以及"自检的报错文案把自己数进去")。 + * 实测(pi 2026-09-15 用它自己的正则复现,我也复现): + * probs.push(`oops; totalTests += 1`); ← 被 MATCHED(假阳性) + * 而代码是对的 ⇒ **判据开始消费散文**,下一个人会去改**文案**来哄判据。 + * + * ★ **行号必须不变**(与 `stripComments` 同一条硬要求):字符串里的每个换行 + * 换成等价数量的空行。而且**保留引号本身**,这样"这里原本有个字符串"仍然看得见。 + * + * ⚠️ 已知限制,写清楚免得下一个人以为它比实际更强: + * 逐字符扫描,**不区分正则字面量** —— `/a"b/` 里的 `"` 会被当成字符串开始 + * (随后一直吃到下一个 `"`)。本仓判据里没有把真计数器名写进正则字面量的情形, + * 但这是**这个实现的边界**,不是"已经解决"。 + * (真正根治是"判行为而不是判文本":把被扫的逻辑抽成纯函数,拿合成输入喂它。) + */ +export function stripStrings(src) { + let out = ''; + let i = 0; + while (i < src.length) { + const ch = src[i]; + if (ch !== '"' && ch !== "'" && ch !== '`') { out += ch; i += 1; continue; } + const quote = ch; + out += ch; i += 1; + while (i < src.length) { + const d = src[i]; + if (d === '\\') { out += ' '; i += 2; continue; } // 转义:两个字符都抹掉(等长) + if (d === '\n') { out += '\n'; i += 1; continue; } // 换行**保留**(行号不变) + if (d === quote) { out += d; i += 1; break; } + out += ' '; i += 1; + } + } + return out; +} + /** 读文件**原文** —— 判"注释/文档里写了什么"用这个 */ export function prose(path) { return readFileSync(path, 'utf8'); diff --git a/client/electron/test/run-all.mjs b/client/electron/test/run-all.mjs index efc598c..3862f81 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 } from './lib/read.mjs'; +import { prose, stripComments, stripStrings } from './lib/read.mjs'; import { spawnSync } from 'node:child_process'; import { existsSync, readdirSync } from 'node:fs'; import { dirname, join } from 'node:path'; @@ -742,20 +742,58 @@ try { * (**这是我第二次栽在同一件事上**:扫描器的锚要按**语言结构**定,不是按**版面**定 —— * 上一次是"扫描器咬到自己的文档",这次是"锚钉在行首而语句不在行首"。) * 实测四种等价写法(`+=` / `= X + n` / `X++` / 行中间)**全部命中**后才收。 - * 仍不是根治(`x ||= y`、`x = (x) + 1` 括号式、反射赋值都能躲)—— 根治就是上面那个纯函数。 + * + * ⚠️⚠️ **只去注释不够,还得去字符串**(pi 2026-09-15 第二次抓到这个族,我复现): + * `stripComments` **不去字符串**(我在 `read.mjs` 里早就写明过这条限制), + * 于是下面这种**假阳性**: + * probs.push(`oops; 真计数器名 += 1`); ← 被 MATCHED + * 锚是 `(?:^|[;{])`,而 `;` / `{` 在**字符串里**照样是字符 ⇒ 数到 2 ⇒ 报红, + * **而代码是对的** ⇒ **判据开始消费散文**,下一个人会去改自己的**文案**来哄判据。 + * + * ★ 而且**这个文件当时是"靠标点侥幸"绿的**:那句修法文案里写的是 + * `` `totalTests +=` `` —— `totalTests` 前面是**反引号**,不是 `;`/`{`,所以躲过了。 + * **不是设计让它躲过去的。** 换个写法(`;` 后紧跟真名、或写成 `{ totalTests +=`) + * 它立刻假红。(同理:我把"真名"写进**本段注释**里也不行 —— 注释被去掉了, + * 但**字符串没有**,所以这里只用占位符 `真计数器名`。) + * + * ⇒ 所以扫之前先 `stripStrings`(`read.mjs` 里新增,**保留行号与引号**), + * 并且**自检这个抹除本身**:用它复现那个假阳性,证明它真的被消掉了 —— + * **不带自检的"我加了过滤"= 又一次"读数器没先被证明是好的"**。 + * + * 仍不是根治(`x ||= y`、`x = 1 + x` 反序、`}` 之后的写、正则字面量里的引号都能躲 + * 或误伤)—— 根治是上面那个纯函数:**判行为,不判文本**。 */ - const ownSrc = stripComments(prose(fileURLToPath(import.meta.url))); - for (const counter of ['totalTests', 'ranFilesCount', 'totalFail', 'unreportedFiles']) { - const c = counter.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - // 语句起点 = 行首、或上一条语句的 `;` / 块开始的 `{`(**不是**行首 —— 见上) - const WRITE = new RegExp( - `(?:^|[;{])\\s*${c}\\s*(?:\\+=[^=]|=[^=]\\s*${c}\\s*[-+]|[+-]{2})`, 'gm'); - const n = (ownSrc.match(WRITE) || []).length; - if (n !== 1) { - probs.push(`对 \`${counter}\` 的**写**(语句位置)出现 **${n}** 处(只许 1 处)—— ` + - `"每个文件只该计一次"是对**整族**计数器都成立的性质,不是一个一个判的;\n` + - ` 这个形状已经发生过三次(搬动/新增时留了旧路径),而**两处都跑得通、都报得出数**。\n` + - ` 注意判的是**写**:\`X += n\`、\`X = X + n\`、\`X++\` 都算(只认 \`+=\` 会漏)。`); + 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 (probs.length) {