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;`(`}` 之后的写)都躲得过。
但**搬动/复制粘贴会保留 `+=`** —— 也就是我声明的射程(实际发生的那三次)覆盖得住。
- **根治形状**(已写进注释):把累加抽成**纯函数**,拿合成输入喂它 ——
那时判的是行为,不是文本,这一整族(注释/字符串/拼法/锚)一起消失。**本轮不做。**
90 lines
4.6 KiB
JavaScript
90 lines
4.6 KiB
JavaScript
/**
|
||
* 判据目录里**唯一**允许读文本文件的两个入口 —— 名字自己解释该选哪个。
|
||
*
|
||
* # 为什么要有这个模块(pi 2026-09-14 §4:同一处坑我踩了两次)
|
||
*
|
||
* 规范里写着"判代码读剥离版、判理由读原文",我 P5 写过一次、当天又踩了一次
|
||
* (`'rejected'` 那段**注释**里正好写着 `allowed-once`,被当成"这里会放行"误报)。
|
||
* **第二次犯规说明问题不在记性,在形态**:靠人记得执行的规范一定会有下一次。
|
||
* 所以把"用哪个读取器"从**记忆**变成**代码里的一个词**,并且可以被判据检查。
|
||
*
|
||
* - `code(path)`:**剥掉注释**。判"代码里有没有这个调用/这个值"时必须用它 ——
|
||
* 否则解释性注释("这里写 'rejected' 而不是 'denied',因为只认 allowed-once")
|
||
* 会被当代码读,产生假红/假绿。
|
||
* - `prose(path)`:**原文**。判"理由写清了没/文档里有没有这句话"时用它。
|
||
*
|
||
* 选错的典型症状:断言里的标识符恰好在同文件的注释里出现过(这类误报几乎都集中在
|
||
* "解释性注释与它解释的标识符同名"的地方)。
|
||
*/
|
||
import { readFileSync } from 'node:fs';
|
||
|
||
/**
|
||
* 剥掉注释:只用于"代码里有什么"。
|
||
*
|
||
* ★ **行号必须保持不变** —— 这一条是硬要求,不是风格问题。
|
||
* 原来块注释是用 `''` 直接抹掉的,而块注释**自带换行**,抹掉它就把后面所有行的行号
|
||
* 整体前移。后果实测(我自己的 `harmony-arkts` 判据报违规时):
|
||
* 报出"最后一个 import 在第 64 行、第 47 行已是语句",而**真实文件里是第 80 / 63 行** ——
|
||
* 全仓的判据都在用 `文件:行号` 定位(`grep -n`、编辑器跳转),**报出来的行号必须能直接用**,
|
||
* 否则读者第一步就得先猜"这是剥过的还是没剥的"。
|
||
* 修法:块注释里的每个换行都**换成等价数量的空行**(而不是整块删掉)。
|
||
*/
|
||
export function stripComments(src) {
|
||
return src
|
||
.replace(/\/\*[\s\S]*?\*\//g, (m) => '\n'.repeat((m.match(/\n/g) || []).length))
|
||
.replace(/(^|[^:])\/\/[^\n]*/g, '$1'); // 行注释(避开 https:// 这类;它不含换行,行号天然不变)
|
||
}
|
||
|
||
/** 读文件并**剥掉注释** —— 判"代码里有什么"用这个 */
|
||
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');
|
||
}
|
||
|
||
/** 读**二进制**(安装包、图片等)—— 需要 Buffer 时用它,别在判据里裸用 readFileSync */
|
||
export function bytes(path) {
|
||
return readFileSync(path);
|
||
}
|