Files
MailUI4Agents/client/electron/test/criteria-hygiene.test.mjs
JianFeeeee 2774b007d5 跨端: 补掉 pi 指出的 4 个洞(名字白名单/字面量形态/lib 射程/合成样本)—— 9 个变异全抓
pi 2026-09-15 复核了我那两条新守卫,**四条都对,我逐条复现后修掉**。
每条都做了"改回去 ⇒ 判据红"的闭合验证(9 个变异,含反向对照),不是只跑绿。

## ① 名字白名单压过值判断 —— `SDK_ROOT` 是个后门

原来:`if (looksLikeRepo && !TOOLCHAIN_OK.test(name))`,`TOOLCHAIN_OK = /TOOLCHAIN|_SDK|SDK_|HAP_|…/i`。
**值与名字同时命中时,名字直接放行**:

```
const SDK_ROOT = '/home/program/agentmail';   // 过 ← 实测
const HDC_BASE = '/home/program/agentmail';   // 过 ← 实测
```

这正是 `CRITERIA.md` 里 allow-list 那条要防的形状:**换个变量名就过**。
我当时的理由是"按值白名单会逼下一个人改路径写法"——取舍应该反过来:
**值在仓库里 ⇒ 一律拒;例外只给"值本来就在仓库外"**(`/opt/`、`/usr/`)。

★ 但**结论不是"把名字判断删掉"** —— 我照 pi 的话先做成纯值判断,然后自己测出**丢了东西**:

```
const WORKSPACE_ROOT = '/srv/ci/build/checkout';   // 改前红、改后绿 ← 实测
```

仓库被检出到**别的目录名**(CI/镜像常见)时值里就没有 `agentmail` 了,
可它**仍然是把判据钉死在一条绝对路径上**。所以名字判断的正确去处是**降级**:
它**不再能豁免任何东西**(那才是后门),但**与值判据并列为一条独立的触发线**。
豁免只按值给。这条形状("砍掉一条有洞的判据时顺手砍掉了它唯一有用的部分")值得记着。

## ② 只认单引号的 `const` 赋值 —— 五种逃逸

原来只认 `/(?:const|let|var)\s+(\w+)\s*=\s*'(\/[^']*)'/g`。现在改为判**整条赋值表达式的字面量集合**:

| 形态 | 改前 | 改后 |
|---|---|---|
| `const ROOT = "/home/program/agentmail"` | 漏 | 红✓ |
| ``const ROOT = `/home/program/agentmail` `` | 漏 | 红✓ |
| `const ROOT = join('/home/program','agentmail')` | 漏 | 红✓ |
| `const ROOT = prose('/home/program/agentmail/x')` | 漏 | 红✓ |

`join(...)` 那条要**把片段拼起来**才判得出 —— 单个字面量里既没有仓库名、也不以 `/` 开头。

★ pi 建议"扫一切含仓库名的字符串字面量 + 小白名单"。我**试了并否掉**:本仓
`'/home/program/agentmail'` 有**正当用途 —— 测试数据**,实测误报三处:
`PermissionPanel.test.tsx:27 from_workspace:` / `replyTarget.test.tsx:307 expect(formatAddress('pi', …))`。
那是"地址长这样",不是"去读那棵树"。**判据要抓的是"拿它去读文件",不是"提到它"**。
所以最终形状 = 赋值绑定 + 喂给读盘函数,都是**按值**,零误报。

## ③ `criteriaFiles()` 跳过 `lib/` —— 最可能的下一次复发点

我原来把 `lib/` 与 `manual/` 一起跳过,理由是"`lib/read.mjs` 是共享助手"。
后果:**把硬编码根挪进 `test/lib/`("路径助手"最该待的地方)完全不在射程内**。
现在 `lib/` 纳入射程(`manual/` 留外面 —— 它是人工脚本、不进套件,理由与"是不是助手"无关)。

由此带出两处**必须同时改**的连带项,否则纳入射程立刻自伤:
- 裸用 `readFileSync` 那条判据:`lib/read.mjs` 是**定义处**,它用是必然的。
  豁免**按"是不是定义处"判,不按"是不是在 lib/ 下"判** —— 否则这个洞会被同一条豁免再放行一次。
- 裸用判据原来扫的是 `prose()`(原文)。**我加了注释解释这个禁令,判据就红了** ——
  红的原因不是代码裸用了它,是我把规则写进了注释。改成 `code()`(剥注释)。
  **注释说明禁令 ≠ 违反禁令**;不剥注释的判据会退化成"逼人别解释",与"理由要写清"直接冲突。

## ④ `stripComments` 那条只验合成样本

原来只对手写样本断言行号不变,而它要修的故障**是从真实文件里来的**。
现在**对每一个真实判据文件**断言行数不变(合成样本降级为"探针没坏"的正例自检)。

并把他指出的**已知限制**记进文档:`//` 分支只保护 `x://`,
**普通字符串里的 `//` 会被当注释剥掉**(`const s = 'a//b'`)。今天无害,
但不写下来下一个人会以为它是完整实现。**这条限制没有判据**(写不出不靠词法分析就能判的形状)。

## ⑤ 验证:9 个变异,含反向对照

```
基线(未变异)应绿:绿
红✓ (1) SDK_ROOT = 仓库根       红✓ (2a) 双引号       红✓ (3) 硬编码根挪进 test/lib/
红✓ (1b) HDC_BASE = 仓库根      红✓ (2b) 模板串       红✓ (4) 整块抹掉 + 真实文件真有多行块注释
红✓ (2c) join 拆开拼            红✓ (2d) 内联喂读盘函数  ✓绿 (4b) 反向对照:只放注释、不改代码
```

★ **这个脚本我自己踩了两次坑,都写进脚本注释了**,因为它们是同一种病:
1. 第一版 (4) 报"★ 漏" —— 其实是 `str.replace` 没找到就**静默返回原文**,
   变异没应用。**我差点把"判据仍无效"这个假坏消息报给 pi** —— 假绿与假红是同一个病的两面。
   现在每个变异走 `must_replace()`:**没改到就抛**。
2. 第二版 (4) 仍报"★ 漏" —— 我的探针写死成 `'not ok 4' in stdout`,
   而 `stripComments` 那条是**第 5 个**子测试(它明明红了)。
   **探针只认一个固定位置**,就给"漏"和"没接线"造了一个分不开的形状。

## ⑥ pi 的 §六 我**错了,他是对的**(实测)

我上封说"干净检出上 build 判据永远假红,所以别塞进套件"。他用 `HOME=<可写目录>` 拆了这个前提。
**我独立复现了**(`git worktree add --detach /tmp/hvtest 4880c31`,无 `oh_modules`):

```
默认 HOME           → exit=255, EACCES: mkdir '/root/.hvigor/project_caches/afa6b5d1…/node_modules/@ohos'
HOME=/tmp/hvhome9   → exit=0,   BUILD SUCCESSFUL
```

**"环境不允许"这个理由今天不成立了。** 我原来的推理错在:把"沙箱不让写 `/root`"
当成了平台的墙,而它其实只是 **`$HOME` 的默认值**。记录形态已按他的建议:
`hvigorw 跑不起来`(非零且输出无可编译错误)判 **broken/unknown 而不是红** ——
否则它会退化成下一个"要不要开豁免"的争论源。
(本轮**没实现**这条重判据,只拆掉了挡它的那个前提;worktree 与临时 HOME 已清理。)

## ⑦ 其余

- §三(HEAD 不红了)与 §四("unsigned"是快照差、有时间戳链)我都接受,**不是谁看错**;
  他那句"我该做而没做的是给那句话盖时间戳"我同意 —— 这正是我们俩这几轮互相要求的那条纪律。
- 本轮的验证都在 `/tmp/hvtest` worktree 与 `/tmp/*` 里做,**已 `git worktree remove --force` 清掉**。
2026-09-15 12:36:28 +08:00

348 lines
20 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 判据目录自身的卫生:**读文本必须走 `test/lib/read.mjs` 的两个具名入口**。
*
* # 为什么这条判据存在(pi 2026-09-14 §4)
*
* 规范里写着"判代码读剥离版(`code`)、判理由/文档读原文(`prose`)",
* 我 P5 写过一次、当天又踩了一次:那条断言读的是**原文**,而它要找的标识符
* 恰好出现在一段解释性注释里 → 误报。**第二次犯规说明问题不在记性,在形态**:
* 靠人记得执行的规范一定会有下一次。
*
* 所以把"用哪个读取器"从**记忆**变成**代码里的一个词**,并且可被检查:
* - `code(path)` —— 剥掉注释;判"代码里有没有这个调用/这个值";
* - `prose(path)` —— 原文;判"注释/文档里写了什么";
* - `bytes(path)` —— 二进制(安装包等)。
*
* # 判据
*
* 判据目录(`test/**` 里跑的判据 + `run-all.mjs`)中**不得出现裸 `readFileSync`**,
* 唯一例外是 `test/lib/read.mjs` 自己。`test/manual/**` 是人工脚本、不是判据,不在范围内。
*
* 附两条自检:读取器本身要真的剥注释(否则 `code` 退化成 `prose` 这条判据就废了)、
* 以及探测器要能认出裸调用(否则"都没有"与"探测器坏了"结果一样)。
*/
import assert from 'node:assert/strict';
import { readdirSync, unlinkSync, writeFileSync } from 'node:fs';
import { dirname, join, relative } from 'node:path';
import { test } from 'node:test';
import { fileURLToPath } from 'node:url';
import { code, prose, stripComments } from './lib/read.mjs';
const HERE = dirname(fileURLToPath(import.meta.url));
const RELECTRON = join(HERE, '..'); // test/ 的上一级就是 client/electron
const SELF = join(HERE, 'lib', 'read.mjs');
/**
* 仓库目录名 —— 判"某条绝对路径是不是落在仓库内"用的**值**特征。
*
* 为什么不从 `ROOT` 推:这个字面量本身就是"仓库根在哪"的**事实**,
* 而本判据禁止的正是"把它写进代码"。这里写它,是因为判据**必须**知道要找什么。
*/
const REPO_NAME = 'agentmail';
/** 某段文本(`needle`)在原始源码里出现在第几行(1-based);找不到返回 0 */
function lineOf(raw, needle) {
const i = raw.indexOf(needle);
return i < 0 ? 0 : raw.slice(0, i).split('\n').length;
}
/**
* 判据文件清单:`test/**` 下会跑的判据 + 编排器 + **共享助手(`lib/`)**。
*
* ★ 为什么 `lib/` **必须**在射程内(pi 2026-09-15 指出的洞):我原来把 `lib/` 与 `manual/`
* 一起跳过了,理由是"`lib/read.mjs` 是共享助手"。但那正是**最可能的下一次复发点** ——
* 硬编码的仓库根**挪进 `test/lib/`**(一个"路径助手"最该待的地方)就完全不在本判据射程内。
* 射程靠"这个目录看起来像什么"来裁,等于给逃逸指了路。
* `manual/` 不一样:那是人工跑的脚本,**不进套件**,留在射程外的理由与它是否"助手"无关。
*/
function criteriaFiles(dir = HERE, out = []) {
for (const e of readdirSync(dir, { withFileTypes: true })) {
const p = join(dir, e.name);
if (e.isDirectory()) {
if (e.name === 'manual' || e.name === 'node_modules') continue;
criteriaFiles(p, out);
} else if (/\.(test\.mjs|test\.ts|test\.tsx|mjs)$/.test(e.name) && !e.name.endsWith('.d.ts')) {
out.push(p);
}
}
return out;
}
/** 探测器:一段源码里有没有裸 readFileSync */
const BARE = /\breadFileSync\s*\(/;
test('探测器自检 + 读取器自检', () => {
// ① 探测器能认出裸调用(否则"都没有"与"探测器坏了"分不开)
assert.equal(BARE.test("const s = " + "readFile" + "Sync(p, 'utf8');"), true);
assert.equal(BARE.test('const s = prose(p);'), false);
// ② code 真的剥注释、prose 不剥 —— 这条是整套用法的地基:
// 若 code 退化成 prose,那么"读剥离版"的规范就变成一句空话,而且没人会发现。
const probe = join(RELECTRON, 'test', '_reader_probe.tmp.ts');
// 注:这个探针文本**故意拼接**而不是写字面量 —— 否则本判据自己会被自己判红
// (它扫的就是"文本里有没有这个写法",判据文件也在扫描范围内)。
writeFileSync(probe, "const REAL = 1; // " + "readFile" + "Sync( 注释里的假调用\n/* allowed-once */\n");
try {
assert.ok(!code(probe).includes('allowed-once'), 'code() 必须剥掉块注释');
assert.ok(!code(probe).includes('假调用'), 'code() 必须剥掉行注释');
assert.ok(code(probe).includes('REAL'), 'code() 要保留真代码');
assert.ok(prose(probe).includes('allowed-once') && prose(probe).includes('假调用'), 'prose() 必须保留注释');
} finally {
unlinkSync(probe);
}
});
test('★ 判据目录里不得出现裸 readFileSync(必须走 code/prose/bytes)', () => {
const offenders = [];
for (const f of criteriaFiles()) {
if (f === SELF) continue; // 读取器的实现自己当然要用它
/*
* ★ 判的是**代码**,不是文本 —— 这里必须用 `code()`(剥注释)。
* 原来用的是 `prose()`(原文),理由是"扫的是文本里有没有这个写法"。
* 但那样一来,**注释里提到这个名字**就会被判违规 —— 我自己立刻撞上了:
* 在注释里写下"这个正则的源码里会出现 `readFileSync`"之后,这条判据就红了,
* 而红的原因**不是代码裸用了它,是我把规则写进了注释**。
* 这正是本仓那条纪律的另一面:**注释说明禁令 ≠ 违反禁令**。
* 不剥注释的判据会退化成"逼人别解释",与"理由要写清"直接冲突。
*/
const src = code(f);
if (BARE.test(src)) {
const line = src.split('\n').findIndex(l => BARE.test(l)) + 1;
offenders.push(`${relative(RELECTRON, f)}:${line}`);
}
}
assert.deepEqual(offenders, [],
`这些判据文件里还在裸用 readFileSync:\n ${offenders.join('\n ')}\n` +
" 改用 test/lib/read.mjs 的具名入口:\n" +
" · code(path) —— 剥掉注释。判「代码里有没有这个调用/这个值」时用它(默认选它);\n" +
" · prose(path) —— 原文。判「注释/文档里写了什么」时用它;\n" +
" · bytes(path) —— 二进制(安装包等)。\n" +
" 为什么不能裸用:读原文去判代码,会被解释性注释骗(同一个坑已经踩过两次)。");
});
/**
* ★ 用到 `lib/read.mjs` 的导出名就必须真的 import(我这轮在三个文件里各犯过一次)。
*
* 形状一模一样:`code(...)` / `prose(...)` 写下去,import 里却只有另一个 ——
* 于是在**跑起来的那一刻**才炸 `ReferenceError`,而它抛在判据自己身上,
* 看起来像"这条判据红了",不像"判据写错了"。dsh 桥那边也栽过同一形状
* (`MODE_FULL` 没 import,而且被 `tsc | tail` 的退出码骗过)。
*
* 判据做法:把每个判据文件里出现的 `code(`/`prose(`/`bytes(` 收集起来,
* 与它从 `lib/read.mjs` 实际 import 的名字比对;缺一个就红,并点名文件与名字。
* **例外**:文件里自己定义了同名函数(本地实现)时不算缺 —— 但那种情况要显式声明。
*/
test('★ 用到 code/prose/bytes 就必须 import(不许靠运行时才发现)', () => {
const EXPORTS = ['code', 'prose', 'bytes'];
const problems = [];
const SELF_PATH = fileURLToPath(import.meta.url);
/** 判据文件清单里,哪个文件是这些函数的**定义处**(它当然是"用了但不 import") */
const DEFINES_THEM = SELF; // test/lib/read.mjs
for (const f of criteriaFiles()) {
// 它自己的源码里就写着 code/prose/bytes 这几个名字(EXPORTS 列表),跳过自己
if (f === SELF_PATH) continue;
/*
* ★ `lib/read.mjs` 是这些函数的**定义处** —— 它"用了但不 import"是必然的、不是缺陷。
* 这条豁免**必须按"是不是定义处"判,不能按"是不是在 lib/ 下"判**:
* 否则我把仓库根硬编码挪进 `test/lib/` 那个洞就会被同一条豁免再放行一次
* (pi 2026-09-15 指出的形状:**射程/豁免按目录名裁,等于给逃逸指路**)。
*/
if (f === DEFINES_THEM) continue;
const src = prose(f);
if (src.includes("from './lib/read.mjs'") || src.includes("from '../lib/read.mjs'")) {
const m = /import \{([^}]*)\} from '\.\.?\/lib\/read\.mjs'/.exec(src);
const bound = new Set((m ? m[1] : '').split(',').map(x => x.trim().split(/\s+as\s+/).pop()).filter(Boolean));
for (const name of EXPORTS) {
if (new RegExp(`\\b${name}\\(`).test(src) && !bound.has(name)) {
problems.push(`${relative(RELECTRON, f)} 用了 ${name}(…) 但没 import(已 import:${[...bound].join('、') || '无'})`);
}
}
} else {
for (const name of EXPORTS) {
if (new RegExp(`\\b${name}\\(`).test(src)) {
problems.push(`${relative(RELECTRON, f)} 用了 ${name}(…) 但根本没从 lib/read.mjs import`);
}
}
}
}
assert.deepEqual(problems, [],
`这些判据会以 ReferenceError 的形式红,看起来像"判据失败了",其实是"判据写错了":\n ${problems.join('\n ')}`);
});
/**
* ★ 判据**必须读自己那棵树**,不许把仓库根硬编码成绝对路径。
*
* pi 2026-09-15 实测出的形状(这次长在**判据自己**身上,正是我们前几轮一直在消的那个):
* `harmony-arkts.test.mjs` 里写着 `const ROOT = '/home/program/agentmail'`。
* 把带违规的提交检出到别的目录再跑,它**读的仍是 `/home/program/agentmail`** ⇒
* **在一个 import 顺序明显违规的检出上 3/3 全绿**。
*
* 两层后果,第二层最糟:
* ① 它**永远无法验证任何别的 checkout / CI / 镜像** —— 换个目录不是"红",
* 是 `readdirSync` 直接抛(broken),而 broken 证明不了任何判据成立或不成立;
* ② 在本机做 worktree 复核时,它会**静默读另一棵树并报绿**。
* **"规则进来了,对象没进来"** —— 判据的逻辑对,对象错。
*
* 判据做法:扫判据目录里**真代码**(`code()` 剥注释,否则本文件自己的说明就会误报),
* 找形如 `const X = '/绝对路径'` 的仓库根声明。修法照邻居:`join(HERE, '..', '..', '..')`。
*/
test('★ 判据不许把仓库根硬编码成绝对路径(必须从本文件位置推)', () => {
/*
* ★ 判法是**按值**,不是按名字 —— 这是 pi 2026-09-15 抓到的第一个洞:
* 我原来写的是 `if (looksLikeRepo && !TOOLCHAIN_OK.test(name))`,
* 也就是**名字白名单压过了值判断** ⇒ `const SDK_ROOT = '/home/program/agentmail'`
* 和 `const HDC_BASE = '/home/program/agentmail'` **直接放行**(实测:两条都过)。
* 那正是 `CRITERIA.md` 里"allow-list"那条要防的形状:**换个变量名就过**。
* 我当时的理由是"按值白名单会逼下一个人改路径写法" —— 取舍应该反过来:
* **值在仓库里 ⇒ 一律拒;例外只给"值本来就在仓库外"**(`/opt/`、`/usr/` 这类)。
* 这样既不逼人改写法,也堵掉"换个名字就过"。
*
* ★ 字面量形态也放宽了(第二个洞):原来只认**单引号**的 `const/let/var` 赋值,
* 于是双引号、模板串、`path.join(...)`、内联参数、数组元素、`process.chdir(...)`
* 全都逃逸。现在改成:**扫真代码里任何字符串字面量**(三种引号),
* 只要它的值落在仓库内就报 —— 不依赖"它被赋给了哪个变量"。
*/
const problems = [];
const seen = new Set();
const add = (msg) => { if (!seen.has(msg)) { seen.add(msg); problems.push(msg); } };
for (const f of criteriaFiles()) {
const raw = prose(f);
const src = code(f);
const rel = relative(RELECTRON, f);
/*
* 判法分两层,**都按值**:
*
* (A) **绑定**成常量的仓库内绝对路径(`const X = "…/agentmail…"`,三种引号)。
* 命中即报 —— 这正是把判据从"读自己那棵树"改成"读固定那棵树"的动作。
* 例外只给"值本来就在仓库外"(`/opt/`、`/usr/`):那是**工具链/SDK**路径,
* 仓库里推不出来,所以按值放行是对的(按**名字**放行就是 pi 抓到的后门)。
*
* (B) **直接**把仓库内绝对路径喂给取值/读盘函数(`readFileSync(…)`、`prose(…)`、
* 内联 `join(…)`、`process.chdir(…)`)—— 覆盖 pi 指出的
* "内联参数/数组元素/path.join"那几种逃逸。
*
* ★ 为什么不再"扫一切字符串字面量"(我第一版那样):`'/home/program/agentmail'`
* 在本仓有**正当用途** —— 测试数据。实测误报:
* `test/components/PermissionPanel.test.tsx:27 from_workspace: '/home/program/agentmail'`
* `test/components/replyTarget.test.tsx:307 expect(formatAddress('pi', '/home/program/agentmail', …))`
* 那是"地址长这样",不是"去读那棵树"。**判据要抓的是"拿它去读文件",不是"提到它"。**
* 用行内容判"是不是注释"来豁免也不行 —— 那是按形状裁,不是按风险裁。
*/
/*
* ★ 判**整条赋值表达式**,不是只看第一个字面量。
* 为什么(我自己测出来的漏):`const ROOT = join('/home/program', 'agentmail')`
* 里**没有任何一个**字面量同时"以 / 开头"且"含仓库名" —— 仓库名被拆成了两个片段,
* 于是老写法直接放行。拼接所有片段后再判,才抓得到。
*/
const LIT = /(['"`])((?:\\.|(?!\1)[^\\])*)\1/g;
const BIND = /(?:const|let|var)\s+(\w+)\s*=\s*([^\n;]+)/g;
for (const m of src.matchAll(BIND)) {
const [, name, rhs] = m;
const lits = [...rhs.matchAll(LIT)].map(x => x[2]);
const whole = lits.join(''); // 拼起来看"合起来是不是仓库路径"
const joined = lits.length > 1;
/*
* ★ 两个**各自独立**的触发条件,命中任一即报:
*
* (i) **值**落在仓库里(`whole`/`lits` 含仓库名,且是绝对路径);
* (ii) **名字**读起来像"仓库根/工作区根",且它绑的是一个**绝对路径**。
*
* 为什么 (ii) 必须留着 —— 这是我改完 (i) 之后自己测出来漏掉的形状:
* `const WORKSPACE_ROOT = '/srv/ci/build/checkout';`
* 仓库被复制/检出到**别的目录名**下时,值里就没有 `agentmail` 了,
* 可它**仍然是"把判据钉死在一条绝对路径上"** —— 换棵树照样读错。
* 我原来的版本靠 (ii) 抓这种,改成纯值判断后**把它丢了**(实测:改前红、改后绿)。
* ⇒ pi 说的"按名字放行是 allow-list 要防的形状"是对的,但**结论不是"把名字判断删掉"**,
* 而是**把它降级**:名字不再能**豁免**任何东西(那才是后门),
* 但它仍然可以**和值判据并列为一条独立的触发线**。豁免只按值给(`/opt/`、`/usr/`)。
*/
const abs = lits.some(v => v.startsWith('/'));
const repoByValue = abs && (joined ? whole.includes(REPO_NAME) : lits.some(v => v.includes(REPO_NAME)));
const repoByName = /\b(PROJECT|REPO|WORKSPACE|CHECKOUT)\b|_ROOT$|^ROOT$/i.test(name);
if (!repoByValue && !(repoByName && abs)) continue;
if (lits.some(v => v.startsWith('/opt/') || v.startsWith('/usr/'))) continue; // 工具链,仓库外
add(`${rel}:${lineOf(raw, m[0])} \`${name} = ${rhs.trim().slice(0, 60)}\` —— 这是**仓库内**的绝对路径。`
+ `\n 必须从 \`import.meta.url\` 推:\`join(dirname(fileURLToPath(import.meta.url)), '..', …)\`,`
+ `否则这个判据读的不是它自己那棵树(会静默读另一棵并报绿)`);
}
/*
* 这个正则的**源码里**会出现 `readFileSync` 这个词 —— 而本文件上面那条"不许裸用
* readFileSync"的判据是扫源码文本的,会把它当违规(我自己先撞了一次)。
* 所以用 `new RegExp` 把名字拼出来,让**字面量**不出现在源码里。
*/
const FEEDS = new RegExp(
'(?:readFile' + 'Sync|readdirSync|prose|code|bytes|chdir|existsSync|statSync)\\s*\\(([^)]{0,240})\\)', 'g');
for (const m of src.matchAll(FEEDS)) {
const lits = [...m[1].matchAll(LIT)].map(x => x[2]);
const whole = lits.join('');
if (!whole.includes(REPO_NAME)) continue;
if (lits.some(v => v.startsWith('/opt/') || v.startsWith('/usr/'))) continue;
add(`${rel}:${lineOf(raw, m[0])} 读盘调用里直接写死了仓库内路径(\`${lits.join(' + ')}\`)`
+ `\n 读盘用的路径必须从本文件位置推,否则换一棵树就读错`);
}
}
assert.deepEqual(problems, [],
`这些判据被钉死在一条**仓库内**的绝对路径上 —— 在别的检出/CI/镜像里,`
+ `它们要么读错树报假绿,要么直接抛(broken):\n ${problems.join('\n ')}`);
});
/**
* ★ `stripComments` 必须**保持行号不变**。
*
* 块注释自带换行,若整块抹成 `''`,它之后**所有行号整体前移** ——
* 而全仓判据都在用 `文件:行号` 定位(grep、编辑器跳转、`git show` 核对)。
* 实测(我自己的 `harmony-arkts` 报违规时):报出 64/47,**真实文件是 80/63**,
* 读者第一步就得先猜"这是剥过的还是没剥的"。
*
* ★ 判据做法(pi 2026-09-15 指出的第四个洞):我原来只对一个**手写合成样本**断言,
* 而它要修的故障**是从真实文件里来的**。合成样本过、真实文件错位,这个形状完全可能
* (某个文件里有我没料到的注释写法)。所以现在**对每一个判据文件都断言** ——
* 合成样本留在下面当"探针没坏"的正例自检,**真实文件那层才是主体**。
*/
test('★ stripComments 必须保持行号(对所有真实判据文件,不只是合成样本)', () => {
// (1) 主体:**每一个真实文件**剥完之后行数必须一模一样
const misaligned = [];
const countLines = (t) => t.split('\n').length;
for (const f of criteriaFiles()) {
const src = prose(f);
if (countLines(stripComments(src)) !== countLines(src)) {
misaligned.push(`${relative(RELECTRON, f)}(${countLines(src)} -> ${countLines(stripComments(src))} 行)`);
}
}
assert.deepEqual(misaligned, [],
'这些文件剥完注释后**行数变了** —— 它们报出的行号会整体错位,'
+ '而全仓都用 `文件:行号` 定位(grep / 编辑器跳转 / git show 核对):\n '
+ misaligned.join('\n '));
// (2) 正例自检:合成样本上"必须能抓到错位"(否则 (1) 全绿可能只是探针坏了)
const sample = [
'/*',
' * 多行块注释',
' * 第二行',
' */',
'const a = 1; // 行尾注释',
'/* 单行块注释 */',
'const b = 2;',
].join('\n');
const out = stripComments(sample);
assert.equal(out.split('\n').length, sample.split('\n').length,
'stripComments 改变了行数 —— 它之后所有行号都会错位');
assert.ok(!out.includes('多行块注释') && !out.includes('行尾注释'),
'stripComments 没把注释去掉');
// 行号对得上:第 5 行仍应是 `const a = 1;`
assert.match(out.split('\n')[4], /const a = 1;/,
'剥完之后第 5 行不再是原来的第 5 行');
assert.match(out.split('\n')[6], /const b = 2;/,
'单行块注释所在的那一行之后,行号错位了');
/*
* ★ 已知限制(记在这里,免得下一个人以为它是完整实现 —— pi 2026-09-15 指出):
* `stripComments` 的 `//` 分支是 `(^|[^:])\/\/[^\n]*`,只保护了 `x://` 这种。
* 于是**普通字符串里的 `//` 会被当成注释剥掉** —— `const s = 'a//b'` 会变成 `const s = 'a`。
* 今天无害(没有判据靠这种字符串),但它与"剥注释剥多/剥少"是同一族。
* 真要修得先有词法状态机,而不是再加一条正则 —— 那是另一件事,不在这里顺手补。
* **这条限制没有判据**(写不出不靠词法分析就能判的形状),所以只能留成文字。
*/
});