Files
MailUI4Agents/client/electron/test/lib/read.mjs
JianFeeeee a47b42b36e 修复: 套件读数不再取决于"你怎么调用它"—— 子进程钉 cwd: ROOT + read.mjs 相对路径按包目录解析(第二层);并把"崩了"从 red 里分出来(结构判据,不加错误关键字)
pi 2026-09-17 实测报的:**同一份代码、两种调用法、两个结论**。我复现并修了两层。

## 一、复现(同 HEAD,只换 cwd)

| 调用法 | 修前 RESULT |
|---|---|
| `cd client/electron && node test/run-all.mjs`(`npm test` 入口)| `ran=29 checks=440 red=8 unreported=0` |
| `node client/electron/test/run-all.mjs`(仓库根)| `ran=27 checks=348 red=10 unreported=2` |

差 **92 条**(= 88 + 4),红 8 → 10。两次都稳定复现。

**根因**:`run-all.mjs` 的 `spawnSync` **不传 `cwd`** ⇒ 子进程继承调用者的 cwd。
而全仓有 **12 处**判据按**包内相对路径**读文件(`code('src/index.css')` 这种)
—— 它们本来就假定 `cwd = client/electron`,只是没人把那个假定钉住。
从仓库根跑 ⇒ `ENOENT` **崩在 import 期**。

## 二、修(两层,各管一段)

1. **运行器**:`spawnSync(..., { cwd: ROOT })` —— 一行覆盖**现有的与将来的所有**判据。
   方向是"让运行器去满足判据的既有假定",不是改判据去迁就调用者。
2. **`read.mjs`**(第二层):`code/prose/bytes` 里的相对路径一律按**包目录**解析
   (基准取自 `import.meta.url`,**不是** `process.cwd()` —— 前者是代码里的结构事实,
   后者正是这个 bug 的成因)。pi 报的复现步骤是**直接跑单个判据文件**,
   那条路**不经过运行器**,所以这层不是重复。
   基准 `PKG` 也**导出**了:`readdirSync`/`existsSync` 不经过 `read.mjs`,
   各处自己拼一次就是"同一个事实多份实现"(本仓反复消的形状)。

**验证(三种 cwd × 两个相位,逐字节一致)**:

```
build   /home/program/agentmail          ran=29 checks=440 red=8 unreported=0
build   /home/program/agentmail/client/electron  同上
build   /                                同上
install /home/program/agentmail          ran=27 checks=428 red=7 unreported=0
install /home/program/agentmail/client/electron  同上
```
(三种 cwd 一致、且与 `npm test` 入口一致 ⇒ 不存在"另一种调用法给别的数"。)

## 三、★ 第二处:那两条不是"红了",是"没跑完"——而它被报成了 red

`narrow-layout` 从仓库根跑:**先打完 43 条"通过",然后第 44 条 ENOENT 崩掉**,
而 `CRASH_SIGNS`(关键字表)**不认 ENOENT** ⇒ `crashed=false` ⇒ 落进 `reds`:
`- test/narrow-layout.test.mjs(退出码 1)`,**一条 `↳` 自述都没有**。
读者看到的是"这条判据不成立",真相是"**它有多少条根本没测**"。
同一次运行里真红是带 `↳ 自报 88 > 登记 64` 自述的 —— **同一种红,两种含义**,
而 `red=10` 把两者混着报。

**★ 我没有照 pi 说的往 `CRASH_SIGNS` 里加 `ENOENT|EACCES|no such file`**:
第 329 行那条注释早把方向定死了 —— **"我不再往里加更多错误关键字:那是往文本解析里加补丁"**,
而且那个表**必然漏**(今天漏 ENOENT,明天漏 ENOTDIR/ELOOP…),加上去就是**第六次**修同一形状。

**改用结构事实**:`exitCode !== 0 && checks === null` ⇒ broken。
一个跑完的文件**必然**自报条数;"跑了但没自报"是另一条**单独记账**的情形(`unreported`)。
两者不会同时成立 ⇒ 只吃掉"崩了且毫无自报"那一格。
**要害是这个区分**:`exitCode !== 0` 本身**不足以**判 broken ——
不然真红会被一起吞掉,那是**假绿方向的错**,比误报成红更坏。
(实测 `build-stamp` 是真红且有 `# tests 7` ⇒ `checks=7≠null` ⇒ **不吃它** ✓。)

**变异验证**(撤掉 `cwd: ROOT`,模拟 pi 报的原状):
`files=29 ran=27 checks=348 red=8 broken=2 unreported=2` ⇒
那两条正确归 **broken**(`(退出码 1,且一条条数都没自报)`),而 **`red` 仍是 8 —— 没吃掉任何真红**。
还原逐字节一致(sha256 相同)。

## 四、★ 我自己在实现里踩的两个坑(都实测抓到)

1. **`abspath()` 用 `isAbsolute(p)` 判绝对路径 ⇒ 传 `URL` 对象抛 `ERR_INVALID_ARG_TYPE`。**
   `narrow-layout.test.mjs:8` 正是 `prose(new URL(p, import.meta.url))` ——
   一个**本来正确且与 cwd 无关**的调用,被我改崩了(两个 cwd 都崩,所以**看起来像"我修好了"**)。
   ⇒ 判据改成 `URL` 对象**原样透传**:它已是解析过的位置,转换才是引入 bug 的那一步。
   教训:**改一个共用入口时,要先把调用方的所有形态列全**,否则"修 A 破 B",而 B 看起来无关。
2. **`join` 忘了 import** ⇒ `ReferenceError`。补上。
   (正是 `run-all.mjs` 里那条 `CRASH_SIGNS` 注释讲的老故事。)

另外:我在 `run-all.mjs` 新注释里写了带括号的入口名,**被 `criteria-hygiene` 第 3 条抓到**
(它不剥注释)—— 这是我上一轮刚记下的坑的**第二次**,已改掉。

## 五、测量

| 相位 | RESULT(三种 cwd 一致)|
|---|---|
| build | `files=29 ran=29 checks=440 pass=438 fail=2 red=8 broken=0 unreported=0` |
| install | `files=29 ran=27 checks=428 pass=427 fail=1 red=7 broken=0 unreported=0` |

重新接线后 `narrow-layout` 自报 **88** 条(登记 64)⇒ 它那条"登记数没跟上"的红变成**有自述**的了。
剩下 8 条红都不是本次改动(清一色并发会话的工作:`harmony-nav` 正在被改等)。
2026-09-17 19:00:11 +08:00

157 lines
8.7 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`、编辑器跳转),**报出来的行号必须能直接用**,
* 否则读者第一步就得先猜"这是剥过的还是没剥的"。
* 修法:块注释里的每个换行都**换成等价数量的空行**(而不是整块删掉)。
*/
export function stripComments(src) {
return src
.replace(/\/\*[\s\S]*?\*\//g, (m) => '\n'.repeat((m.match(/\n/g) || []).length))
.replace(/(^|[^:])\/\/[^\n]*/g, '$1'); // 行注释(避开 https:// 这类;它不含换行,行号天然不变)
}
/** 读文件并**剥掉注释** —— 判"代码里有什么"用这个 */
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));
}