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 记的方向一致。
This commit is contained in:
2026-10-03 10:43:11 +08:00
parent 5ad75fb229
commit 93697c4061
5 changed files with 184 additions and 21 deletions

View File

@ -80,11 +80,134 @@ export const pkgPath = abspath;
* 全仓的判据都在用 `文件:行号` 定位(`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) {
return src
.replace(/\/\*[\s\S]*?\*\//g, (m) => '\n'.repeat((m.match(/\n/g) || []).length))
.replace(/(^|[^:])\/\/[^\n]*/g, '$1'); // 行注释(避开 https:// 这类;它不含换行,行号天然不变)
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;
}
/** 读文件并**剥掉注释** —— 判"代码里有什么"用这个 */