Files
MailUI4Agents/client/electron/test/lib/read.mjs
JianFeeeee 93697c4061 test(工具链): ★★★ stripComments 两趟正则把真代码当注释吃掉 137 行 —— 改单趟扫描
**现象**:harmony-admin 那条「服务端要注册 GET /auth/me」报红,而
server/cmd/server/main.go:194 **明明写着** r.Get("/auth/me", handler.Me)。

**真因**(不是服务端写错,是读取器错了):
    main.go:78   // 与 /api/v1/agent/* 完全同一份代码
                                          ↑ 这个 /* 在 // 里面
stripComments 原来是**两趟正则**(先块 {/\*[\s\S]*?\*\//g}、后行),
两趟**互相看不见对方** ⇒ 块注释那趟在**还没删行注释**的文本上看到那个 /*,
当块注释开头,一路找下一个 */(在 :214)⇒ **137 行 / 37 条路由注册**
被当注释抹掉,含它正在断言的 r.Get("/auth/me", …)。
全仓另有 12 处同样写法(/me/*、/assets/*、plugins/*…)。

⇒ 失效形状是「**读取器静默少给一段真代码**」(不抛错、不警告),
  症状却出现在**被测对象**上 —— 看起来像"服务端把路由删了"。

**修法**:单趟字符扫描,且状态只用源码(注释内部不参与字符串状态)。
并**补上正则字面量**这一条 —— 漏认的方向是**假绿**:
harmony-device.mjs:59 的 /"bundleName"\s*:\s*"([^"]+)"/ 有 4 个引号(奇数),
打开的"字符串"永不闭合 ⇒ 后面所有注释被当字符串跳过。

五条性质逐条实测:① 行号不变 ② 'http://…' 字符串不被腰斩
③ 注释里的引号不污染状态 ④ 正则字面量被当正则 ⑤ 块注释连文本一起删。
变异测试:把旧实现放回去 ⇒ harmony-admin 确实变红(确认真修好了,
而不是"改的东西恰好没人用")。

**顺带修的三处判据自身缺陷**(都不是源码问题):
· inbox-fallback-poll:原断言钉的是**行尾注释里的字**
  (删掉注释照样绿、塞进 await fetchInbox() 也照样绿)⇒ 改为取
  if (lastTotal === null) { … } 整个分支做结构断言
· inbox-fallback-poll / sse-credentials:裸 readFileSync ⇒ 具名 code()
  (换成更严格的读取后变红,暴露的是判据本来就在判错的对象)
· appearance-defaults:写死包名 ⇒ ourBundle()(deviceprobe 那条在盯这个)
· criteria-hygiene:自检样本「以 // 开头的字面量」被判成写死路径 ⇒
  按**形状**排除,**不按文件/变量名豁免**(该文件自己的注释已写过
  「豁免按名字或目录裁 = 给逃逸指路」)。变异验证:改成真路径仍红。

边界 / 未做:本函数**不区分模板串里的 ${…} 与字符类里的 /**,
失效方向是假绿(少剥注释),与 stripStrings 记的方向一致。
2026-10-03 10:43:11 +08:00

280 lines
16 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.

/**
* 判据目录里**唯一**允许读文本文件的两个入口 —— 名字自己解释该选哪个。
*
* # 为什么要有这个模块(pi 2026-09-14 §4:同一处坑我踩了两次)
*
* 规范里写着"判代码读剥离版、判理由读原文",我 P5 写过一次、当天又踩了一次
* (`'rejected'` 那段**注释**里正好写着 `allowed-once`,被当成"这里会放行"误报)。
* **第二次犯规说明问题不在记性,在形态**:靠人记得执行的规范一定会有下一次。
* 所以把"用哪个读取器"从**记忆**变成**代码里的一个词**,并且可以被判据检查。
*
* - `code(path)`:**剥掉注释**。判"代码里有没有这个调用/这个值"时必须用它 ——
* 否则解释性注释("这里写 'rejected' 而不是 'denied',因为只认 allowed-once")
* 会被当代码读,产生假红/假绿。
* - `prose(path)`:**原文**。判"理由写清了没/文档里有没有这句话"时用它。
*
* 选错的典型症状:断言里的标识符恰好在同文件的注释里出现过(这类误报几乎都集中在
* "解释性注释与它解释的标识符同名"的地方)。
*/
import { readFileSync } from 'node:fs';
import { dirname, isAbsolute, join } from 'node:path';
import { fileURLToPath } from 'node:url';
/*
* ★★ **相对路径一律相对 `client/electron` 解析**,与调用者的 cwd 无关
* (pi 2026-09-17 实测报的洞;这是**第二道**保险)。
*
* 为什么要有这一层:本目录里有 12 处形如 `code('src/index.css')` 的调用 ——
* 它们**假定包目录是基准**,只是没人把那个假定钉住。于是
* `node test/narrow-layout.test.mjs`(cwd=仓库根)⇒ `ENOENT: open 'src/index.css'`
* 崩在 import 期。
*
* ★ 运行器那层已经钉了 `cwd: ROOT`(`run-all.mjs` 的 `spawnSync`),但**它盖不住
* "直接跑单个判据文件"这个调用法** —— 而 pi 报的复现步骤正是直接跑单文件。
* 两层各管一段,不是重复:
* · 运行器那层:覆盖现有的与将来的**所有**判据(含不走 `read.mjs` 的读取);
* · 这一层:让**本入口**在**任何 cwd** 下语义一致(直接跑单文件也成立)。
*
* ⚠️ 基准取**本文件的上一级**(`test/lib` → `test` → `client/electron`),
* 而不是 `process.cwd()`:判据目录与包目录的相对位置是**代码里的结构事实**,
* cwd 是**调用者的临时状态**。用后者正是上面那个 bug 的成因。
* 绝对路径原样透传(不碰),于是仓库根/绝对路径的调用不受影响。
*/
const PKG_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
/*
* ★ 必须**同时接受 `URL` 对象**(2026-09-17;这是我第一版改坏的地方)。
*
* 我用 `isAbsolute(p)` 判"是不是绝对路径",而 `isAbsolute` **只吃字符串** ——
* 传 `URL` 就抛 `ERR_INVALID_ARG_TYPE`。`narrow-layout.test.mjs:8` 正是这么调用的:
* const read = p => prose(new URL(p, import.meta.url));
* 于是我把一个**本来正确、且与 cwd 无关**的调用改崩了。
*
* ⇒ 判据:`URL` 对象**原样透传**。它已经是一个**解析过的绝对位置**,
* 语义上比字符串更明确,没有任何理由去动它 —— 转换反而是引入 bug 的那一步。
* (这条也是本仓的老形状:**改一个共用入口时,要先把调用方的所有形态列全**,
* 否则"修 A 破 B",而 B 那处看起来完全无关。)
*/
const abspath = (p) => {
if (p instanceof URL) return p; // 已解析的位置,原样透传
return isAbsolute(p) ? p : join(PKG_ROOT, p);
};
/*
* 把基准**导出去**:判据里凡是要 `readdirSync`/`existsSync`/`import()` 的地方
* (这些不经过本文件)必须用**同一个**基准。
* 否则"有些路径相对包目录、有些相对 cwd"这个洞会以别的形式回来 ——
* 而每处各自拼一次 `new URL(…, import.meta.url)` 就是"同一个事实多份实现",
* 那正是本仓反复消的形状(`stripStrings` 曾有两份兄弟副本、`blurStyleFor` 一份死的)。
*/
export const PKG = PKG_ROOT;
/** 把包内相对路径解析成绝对路径(与 code/prose/bytes 用的**同一个**函数) */
export const pkgPath = abspath;
/**
* 剥掉注释:只用于"代码里有什么"。
*
* ★ **行号必须保持不变** —— 这一条是硬要求,不是风格问题。
* 原来块注释是用 `''` 直接抹掉的,而块注释**自带换行**,抹掉它就把后面所有行的行号
* 整体前移。后果实测(我自己的 `harmony-arkts` 判据报违规时):
* 报出"最后一个 import 在第 64 行、第 47 行已是语句",而**真实文件里是第 80 / 63 行** ——
* 全仓的判据都在用 `文件:行号` 定位(`grep -n`、编辑器跳转),**报出来的行号必须能直接用**,
* 否则读者第一步就得先猜"这是剥过的还是没剥的"。
* 修法:块注释里的每个换行都**换成等价数量的空行**(而不是整块删掉)。
*
* ★★ 2026-10-03 换成**单遍字符扫描**。原来是两趟正则(先块后行),
* 而那两趟**互相看不见对方**,于是有一类输入会把**真代码当成注释吃掉**:
*
* `server/cmd/server/main.go:78`
* // 与 /api/v1/agent/* 完全同一份代码 —— 不存在第二套收窄或配额逻辑。
* ↑ 这个 `/*` 在 `//` 里面
* 块注释正则在**还没删行注释**的文本上跑,看到这个 `/*` 就当成块注释开头,
* 一路找下一个 `*` 加 `/`(在 `:214`),**把中间 137 行、37 条路由注册全当成注释抹掉** ——
* 包括 `r.Get("/auth/me", handler.Me)`。
* ⇒ `harmony-admin` 那条「服务端要注册 GET /auth/me」就此**假红**,
* 而它保护的是「管理入口对所有人永不显示」这个**线上真实存在过**的 bug。
* ⚠️ 全仓另有 **12 处**同样的 `// … /*…` 写法(`/me/*`、`/assets/*`、`plugins/*`…),
* 只要它们出现在某个块注释的终点之前就会触发同一形状。
*
* 所以必须**一趟**走:遇到行注释标记就吃到行尾(此时那个块注释标记本来就不存在),
* 遇到块注释标记就吃到它的终点。一趟之后就不存在"先删了行注释、块注释正则没看到"这种时序差。
*
* ⇒ 刻意的已知限制(方向是**假绿**,与 `stripStrings` 同性质):
* 本函数**不认字符串字面量**。`const s = "/*";` 里的块注释标记仍会被当成开头。
* 真要修需要完整的词法状态机(字符串 + 模板串 + 正则字面量三套规则),
* 代价远大于收益 —— 而 `stripStrings` 已经单独覆盖了字符串这一层,
* 需要两者时按 `code()` → `stripStrings()` 的顺序组合即可(那个顺序是安全的:
* 先把字符串抹成空白,后面就不会再有字符串里的块注释标记)。
*/
export function stripComments(src) {
let out = '';
let i = 0;
/*
* ★★ 单趟扫描(2026-10-03 重写)。这一版之前有**两版都错的**实现,
* 而**两次错的都是我、判据两次都没错** —— 记在这里是因为教训比代码值钱:
*
* 【为什么必须单趟】旧实现是两趟正则(先块后行),两趟**互相看不见对方**:
* `server/cmd/server/main.go:78` 那行 `// 与 /api/v1/agent/* 完全同一份代码`
* 里的块注释标记,被块注释正则在**还没删行注释**的文本上当成开头,
* 一路找到 `:214` 的终点 ⇒ **把中间 137 行、37 条路由注册全当注释抹掉**,
* 包括 `r.Get("/auth/me", handler.Me)`。
* ⇒ `harmony-admin`「服务端要注册 GET /auth/me」就此**假红**,
* 而它保护的是「管理入口对所有人永不显示」这个**线上真实存在过**的 bug。
* 全仓另有 **12 处**同样的写法(`/me/*`、`/assets/*`、`plugins/*`…)。
*
* 【我犯的第一版错:把"删多少"改窄了】改成"用空格填充等长" ⇒
* `harmony-logic` / `harmony-nav` 立刻变红:`harmony-logic.test.mjs:967`
* 那个 `[\s\S]{0,300}?` 窗口是**按"删掉"的尺度**标定的,填空格把窗口撑爆。
*
* 【我犯的第二版错:状态算在错误的文本上】为了躲第一版,改成"回看已输出的 `out`
* 来判断在不在字符串里",并把块注释改成"保留换行、其余也删" ⇒
* `criteria-hygiene` 报 `harmony-device.mjs`「用了 code(…) 但没 import」——**假红**:
* `harmony-device.mjs:566` 那个**块注释根本没被剥掉**(`:569` 的 `` `code()` `` 还在),
* 因为 `out` 里注释已被抹过,**拿它重算出来的上下文与真实源码不对应**。
*
* 【这一版为什么对】状态只用**源码**、且只在**注释之外**翻转:
* - 注释里的撇号/引号**不参与**字符串状态 ⇒ 不会造出幻影字符串;
* - 真字符串里的 `//` 与块注释标记**不被当注释** ⇒ `'http://…'` 不会被腰斩;
* - 单趟 ⇒ 不存在"两趟各看一半"的时序差。
*
* ⚠️ 刻意的已知限制(方向是**假绿**,与 `stripStrings` 同性质):
* **不区分正则字面量**。`const RE = /\/\//;` 里的 `//` 会被当成行注释
* ⇒ 该行**之后**的注释不再被剥。方向是"少剥"(判据看不见),不是"误伤"。
* 真要严谨就在上层先 `stripStrings()` —— 那个顺序是安全的
* (先把字符串抹成空白,注释里的标记也就无从起作用)。
*/
let inStr = null; // 所在字符串的引号;null = 不在字符串里
while (i < src.length) {
const c = src[i];
const d = src[i + 1];
if (inStr !== null) { // 在字符串里:只找收尾引号,其余原样输出
out += c;
i += 1;
if (c === '\\') { if (i < src.length) { out += src[i]; i += 1; } continue; }
if (c === inStr) inStr = null;
continue;
}
/*
* ★ 正则字面量:**必须单独认**(第三版补上;前两版都漏了它,方向是**假绿**)。
* 实测漏它的后果(`test/lib/harmony-device.mjs:59`):
* `const m = /"bundleName"\s*:\s*"([^"]+)"/.exec(app);`
* 里面有 4 个引号、整行**奇数**个 `"` ⇒ 从这里打开的"字符串"**永远不闭合**
* ⇒ 后面 `:566` 那个**真正的块注释**被当成字符串内容整段跳过
* ⇒ `code()` 留着 `:569` 的文字 ⇒ `criteria-hygiene` 假红。
* 判据:除号之前只能是**"这一段里出现过运算符"**的那些 `/`。
* —— `a = /re/` 里 `=` 是运算符;`(x) / 2` 里 `x` 不是。
* 方向说明:认错成正则(把除法当正则)只会**提前结束**正则,
* 之后回到正常扫描;而漏认(把正则当除法)就是上面这个"永不闭合"。
* ★ 这仍不是完整词法分析(模板串里的 `${…}`、字符类里的 `/` 等边角未覆盖),
* 但覆盖了本仓真实存在的写法,且失效方向是**假绿**而不是误伤。
*/
if (c === '/') {
let k = i - 1;
while (k >= 0 && /\s/.test(src[k])) k -= 1;
const prev = k >= 0 ? src[k] : '';
const regexStart = prev === '' || '=(,:[!&|?{};+-*%~^<>'.includes(prev);
if (d !== '/' && d !== '*' && regexStart) {
let j = i + 1, inClass = false, closed = false;
while (j < src.length) {
const e = src[j];
if (e === '\\') { j += 2; continue; }
if (e === '\n') break;
if (e === '[') inClass = true;
else if (e === ']') inClass = false;
else if (e === '/' && !inClass) { closed = true; break; }
j += 1;
}
if (closed) {
while (i <= j) { out += src[i]; i += 1; }
while (i < src.length && /[a-z]/.test(src[i])) { out += src[i]; i += 1; } // 标志位
continue;
}
}
}
if (c === '"' || c === "'" || c === '`') { inStr = c; out += c; i += 1; continue; }
if (c === '/' && d === '/') { // 行注释:吃到换行,抹成空白(换行天然保留 ⇒ 行号不变)
while (i < src.length && src[i] !== '\n') { out += ' '; i += 1; }
continue;
}
if (c === '/' && d === '*') { // 块注释:**删掉全部字符,只保留换行**(旧行为,故意保持)
i += 2;
while (i < src.length && !(src[i] === '*' && src[i + 1] === '/')) {
if (src[i] === '\n') out += '\n';
i += 1;
}
if (i < src.length) i += 2;
continue;
}
out += c;
i += 1;
}
return out;
}
/** 读文件并**剥掉注释** —— 判"代码里有什么"用这个 */
export function code(path) {
return stripComments(readFileSync(abspath(path), 'utf8'));
}
/**
* 把**字符串/模板串的"体"**抹成空白(保留引号与换行)。
*
* ★ 为什么需要它:`stripComments` 只去注释,**不去字符串**。于是"在源码里找某个写法"
* 的那类判据会**咬到字符串里的文字** —— 这个族本仓已经踩过三次
* (`@ohos`、`toISOString`、以及"自检的报错文案把自己数进去")。
* 实测(pi 2026-09-15 用它自己的正则复现,我也复现):
* probs.push(`oops; totalTests += 1`); ← 被 MATCHED(假阳性)
* 而代码是对的 ⇒ **判据开始消费散文**,下一个人会去改**文案**来哄判据。
*
* ★ **行号必须不变**(与 `stripComments` 同一条硬要求):字符串里的每个换行
* 换成等价数量的空行。而且**保留引号本身**,这样"这里原本有个字符串"仍然看得见。
*
* ⚠️ 已知限制,**方向是"假绿",不是"误伤"** —— 这一点我第一版写错了,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 = '';
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(abspath(path), 'utf8');
}
/** 读**二进制**(安装包、图片等)—— 需要 Buffer 时用它,别在判据里裸用 readFileSync */
export function bytes(path) {
return readFileSync(abspath(path));
}