diff --git a/client/electron/test/appearance-defaults.test.mjs b/client/electron/test/appearance-defaults.test.mjs index c6b03be..9c23871 100644 --- a/client/electron/test/appearance-defaults.test.mjs +++ b/client/electron/test/appearance-defaults.test.mjs @@ -428,7 +428,16 @@ test('★ 设备:外观缓存**真的**按账号落了盘(不是只看着像 * `/data/app/el2/100/base//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) { diff --git a/client/electron/test/criteria-hygiene.test.mjs b/client/electron/test/criteria-hygiene.test.mjs index a0e0536..d0bb21f 100644 --- a/client/electron/test/criteria-hygiene.test.mjs +++ b/client/electron/test/criteria-hygiene.test.mjs @@ -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; diff --git a/client/electron/test/inbox-fallback-poll.test.mjs b/client/electron/test/inbox-fallback-poll.test.mjs index ae08878..761601b 100644 --- a/client/electron/test/inbox-fallback-poll.test.mjs +++ b/client/electron/test/inbox-fallback-poll.test.mjs @@ -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('★ 探针失败不得重置基线', () => { diff --git a/client/electron/test/lib/read.mjs b/client/electron/test/lib/read.mjs index 3292258..6d44157 100644 --- a/client/electron/test/lib/read.mjs +++ b/client/electron/test/lib/read.mjs @@ -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; } /** 读文件并**剥掉注释** —— 判"代码里有什么"用这个 */ diff --git a/client/electron/test/sse-credentials.test.mjs b/client/electron/test/sse-credentials.test.mjs index ecbd124..94df8b2 100644 --- a/client/electron/test/sse-credentials.test.mjs +++ b/client/electron/test/sse-credentials.test.mjs @@ -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 订阅必须**跟着账号凭证**走 + 断线重连要能补回漏掉的事件。