/** * 判据目录里**唯一**允许读文本文件的两个入口 —— 名字自己解释该选哪个。 * * # 为什么要有这个模块(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)); }