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