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

@ -428,7 +428,16 @@ test('★ 设备:外观缓存**真的**按账号落了盘(不是只看着像
* `/data/app/el2/100/base/<bundle>/haps/entry/preferences/` 都见过),
* 而 `find` 一次就拿到真实位置。猜路径的那版在真机上扑空过。
*/
const BUNDLE = 'com.jianf.agentmail';
/*
* ★ 2026-10-03 包名改走 `ourBundle()`(读 AppScope/app.json5),不再写死。
* 起因:`harmony-deviceprobe` 那条「包名不许在判据里四处漂移」对此报红 ——
* 它防的是**一次真实事故**(包名改过一次,几处判据各写各的,有一处没跟)。
* ⇒ 这里正是它要拦的形状,不是误报。
* 下游 `${BUNDLE}` 用于拼 find 路径,所以取不到时必须当场说清(不能静默拼出
* `/base/null/…` —— 那会变成"设备上找不到文件"这种看不出原因的红)。
*/
const BUNDLE = D.ourBundle();
assert.ok(BUNDLE, 'ourBundle() 读不到 AppScope/app.json5 里的 bundleName(判据形状不对)');
const found = D.shellOn(hdc, `find /data/app/el2/100/base/${BUNDLE} -name 'agentmail_appearance' -type f 2>/dev/null | head -1`);
const path = (found.stdout || '').trim().split('\n').pop();
if (!path) {

View File

@ -371,6 +371,25 @@ test('★ 判据不许把仓库根硬编码成绝对路径(必须从本文件
* 但它仍然可以**和值判据并列为一条独立的触发线**。豁免只按值给(`/opt/`、`/usr/`)。
*/
const abs = lits.some(v => v.startsWith('/'));
/*
* ★★ 2026-10-03 排除「注释形状的样本」这一类(自己撞的假红,判据没错)。
*
* 实测:`harmony-deviceprobe.test.mjs` 那条判据**自检自己**(它验证
* “注释里的包名不该误红”),于是它**故意造了两段含注释标记的样本**:
* const sample = `if (fg !== 'com.agentmail.harmony') { t.skip(); }`;
* const commented = `// 历史说明:原来是 'com.agentmail.harmony'`;
* 第二段那个字面量**以 `//` 开头** ⇒ `abs` 为真;又因为旧包名
* `com.agentmail.harmony` 里含仓库名 `agentmail` ⇒ `repoByValue` 也为真
* ⇒ 被当成“把判据钉死在仓库内绝对路径上”。
*
* ★ 为什么按**形状**排除而不是按文件/变量名开豁免:
* 本文件上面那段注释已记过教训 —— **豁免/射程按名字或目录裁,等于给逃逸指路**
* (pi 2026-09-15)。而这里要排除的是**一类输入**(“字面量内容本身就是注释”),
* 不是某个人写的某段代码,所以按形状判是收窄而非开洞。
* ⚠️ 注意这只排除了 (A) 分支;真正的“写死路径”若是注释形状,仍会被别的判据抓到。
*/
const looksLikeCommentSample = lits.some(v => /^\s*(\/\/|\/\*)/.test(v));
if (looksLikeCommentSample) continue;
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;

View File

@ -17,13 +17,10 @@
*/
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { code } from './lib/read.mjs';
const HERE = dirname(fileURLToPath(import.meta.url));
const pollSrc = readFileSync(join(HERE, '..', 'src', 'lib', 'inboxFallbackPoll.ts'), 'utf8');
const appSrc = readFileSync(join(HERE, '..', 'src', 'App.tsx'), 'utf8');
const pollSrc = code('src/lib/inboxFallbackPoll.ts');
const appSrc = code('src/App.tsx');
test('★ 探针只取计数(limit=1),变了才重拉', () => {
// 全量重拉会让接口与渲染无谓抖动;探针要廉价
@ -36,9 +33,25 @@ test('★ 探针只取计数(limit=1),变了才重拉', () => {
});
test('★ 首轮只建基线不触发刷新(否则刚打开页面就白拉一次)', () => {
/*
* ★ 2026-10-03 改写:原来这一格断言的是 `return false; // 首轮建立基线` ——
* **它钉的是行尾注释里的字**,所以 (a) 任何人改个措辞就假红,
* (b) 把注释删掉反而**照样通过**(`return false;` 还在,只是没了后缀)。
* `CRITERIA.md` §1「判结构与行为,不判字面与邻接」钉的就是这一族。
* 顺带发现:本判据原来用裸 `readFileSync` 读全文,注释与代码混在一起判 ——
* 换成具名的 `code()`(剥注释)之后,这一格才**暴露**出来它本来就判错了对象。
*
* 现在判的是**结构**:`lastTotal === null` 的分支里只有赋值 + `return false`,
* **不得**出现 `fetchInbox`(出现了就意味着首轮会白拉一次列表)。
*/
const branch = pollSrc.match(/if \(lastTotal === null\) \{([\s\S]*?)\n {4}\}/);
assert.ok(branch, '未找到「首轮建立基线」那个分支(`if (lastTotal === null)`)');
assert.match(branch[1], /lastTotal = total;/, '首轮分支必须先把基线记下来');
assert.match(branch[1], /return false;/, '首轮分支必须返回 false(不触发刷新)');
assert.ok(
/lastTotal === null/.test(pollSrc) && /return false; \/\/ 首轮建立基线/.test(pollSrc),
'首轮必须只记录基线,不触发刷新');
!/fetchInbox/.test(branch[1]),
'★ 首轮分支里出现 fetchInbox ⇒ 刚打开页面就白拉一次列表 —— ' +
'这正是这条判据要拦的行为(原来只判了行尾注释,删掉注释也照样绿)');
});
test('★ 探针失败不得重置基线', () => {

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;
}
/** 读文件并**剥掉注释** —— 判"代码里有什么"用这个 */

View File

@ -1,19 +1,18 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { readFileSync as read } from 'node:fs';
import { code } from './lib/read.mjs';
const HERE = dirname(fileURLToPath(import.meta.url));
const SRC = join(HERE, '..', 'src');
const sse = readFileSync(join(SRC, 'api', 'sse.ts'), 'utf8');
const app = readFileSync(join(SRC, 'App.tsx'), 'utf8');
// ★ 服务端在**仓库根**(`server/`),而 `code()` 的相对基准是**包目录**
// (`test/lib/read.mjs` 的 `PKG_ROOT`)⇒ 跨到包外必须给绝对路径。
// 绝对路径由 `code()` 原样透传(见该文件 `abspath` 的注释),所以这样是安全的。
const REPO_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..', '..');
const sse = code('src/api/sse.ts');
const app = code('src/App.tsx');
// 服务端在另一个仓(../../../../server),读它是为了钉「两侧同步」这条 ——
// 只改客户端不改服务端时,重放永远不生效,而**没有任何东西会红**。
const sseMgr = read(
join(SRC, '..', '..', '..', 'server', 'internal', 'sse', 'manager.go'),
'utf8');
const sseMgr = code(join(REPO_ROOT, 'server/internal/sse/manager.go'));
/**
* WebUI 实时性:SSE 订阅必须**跟着账号凭证**走 + 断线重连要能补回漏掉的事件。