修复: 套件读数不再取决于"你怎么调用它"—— 子进程钉 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` 正在被改等)。
This commit is contained in:
2026-09-17 19:00:11 +08:00
parent 2e53871a22
commit a47b42b36e
3 changed files with 117 additions and 12 deletions

View File

@ -23,8 +23,9 @@
* 4. 挂载即播那档(.rise-in)必须被 prefers-reduced-motion 显式关掉。
*/
import { readdirSync } from 'node:fs';
import { join } from 'node:path';
import { check, finish } from './lib/checks.mjs';
import { code } from './lib/read.mjs';
import { code, PKG } from './lib/read.mjs';
// 判"规则/代码里有没有这个东西"一律走剥注释版(code):解释性注释里会原样引用被禁的写法,
// 读原文会把它当成"还在用"(criteria-hygiene 就是这么抓到本文件第一版裸用 readFileSync 的)。
@ -40,7 +41,10 @@ function componentsSrc() {
else if (/\.tsx?$/.test(e.name) && !/\.test\./.test(e.name)) out.push(code(p));
}
};
walk('src');
/* ★ 基准用 PKG(= client/electron),**不是**相对 cwd:
* `readdirSync` 不经过 read.mjs,所以这里必须显式给基准。
* 原来传 'src' ⇒ 从仓库根直接跑这个文件时 `ENOENT: scandir 'src'`(pi 2026-09-17)。 */
walk(join(PKG, 'src'));
return out.join('\n');
}
const src = componentsSrc();

View File

@ -17,6 +17,58 @@
* "解释性注释与它解释的标识符同名"的地方)。
*/
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;
/**
* 剥掉注释:只用于"代码里有什么"。
@ -37,7 +89,7 @@ export function stripComments(src) {
/** 读文件并**剥掉注释** —— 判"代码里有什么"用这个 */
export function code(path) {
return stripComments(readFileSync(path, 'utf8'));
return stripComments(readFileSync(abspath(path), 'utf8'));
}
/**
@ -95,10 +147,10 @@ export function stripStrings(src) {
/** 读文件**原文** —— 判"注释/文档里写了什么"用这个 */
export function prose(path) {
return readFileSync(path, 'utf8');
return readFileSync(abspath(path), 'utf8');
}
/** 读**二进制**(安装包、图片等)—— 需要 Buffer 时用它,别在判据里裸用 readFileSync */
export function bytes(path) {
return readFileSync(path);
return readFileSync(abspath(path));
}

View File

@ -301,8 +301,31 @@ for (const [file, flags, expected] of SUITE) {
console.log(`\n========== ${file} ==========`);
const shape = shapeOf(file);
const all = shape.usesNodeTest ? [...flags, '--test'] : flags;
// 收集输出再自己打回去:观感不变(stdio:'inherit' 的等价物),但能顺手做"跑了吗"的检查
const r = spawnSync(process.execPath, [...all, join(ROOT, file)], { encoding: 'utf8' });
/*
* 收集输出再自己打回去:观感不变(stdio:'inherit' 的等价物),但能顺手做"跑了吗"的检查
*
* ★★ `cwd: ROOT` 这一格是**读数的一部分**,不是顺手加的(pi 2026-09-17 实测)。
*
* 原来这里不传 `cwd` ⇒ 子进程**继承调用者的 cwd**,于是**同一份代码、两种调用法、两个结论**:
*
* cd client/electron && node test/run-all.mjs ⇒ 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。真因:有两条判据按**包内相对路径**读文件
* (用 read.mjs 那个剥注释的入口、传包内相对路径那种写法),它们本来就假定
* `cwd = client/electron`,
* 只是没人把那个假定钉住 ⇒ 从仓库根跑时 `ENOENT` 崩在 import 期。
*
* ⇒ **要让运行器去满足判据的既有假定,不是改判据去迁就调用者**:
* `ROOT` 已经是判据文件的绝对路径,这里再钉 `cwd` 就是让"包内相对路径"这个
* 既有约定**真的成立**。一行覆盖现有的与将来的所有判据。
* (判据自己按 `HERE` 解析路径是**第二件事**、第二道保险,不替代这一行。)
*
* ★ 这条比"套件整个死掉"隐蔽得多:死掉时输出只剩一行、谁都看得出来;
* 而这里是**看起来很正常的汇总行**,只有把两种调用法的数字并排放才看得出。
* **"同一份代码两种调用法两个结论"本身就是读数不可信** —— 所以这不是"调用者姿势不对"。
*/
const r = spawnSync(process.execPath, [...all, join(ROOT, file)], { encoding: 'utf8', cwd: ROOT });
const out = (r.stdout || '') + (r.stderr || '');
process.stdout.write(r.stdout || '');
process.stderr.write(r.stderr || '');
@ -334,8 +357,7 @@ for (const [file, flags, expected] of SUITE) {
const onlyFileLevel = notOkNames.length > 0 && !assertionFailed;
const CRASH_SIGNS = /(SyntaxError|ReferenceError|TypeError|Cannot find module|ERR_MODULE_NOT_FOUND|is not defined|is not a function|CompileError|build failed|Unexpected identifier|missing ',' in argument list)/;
const crashed = r.status !== 0 && (onlyFileLevel || (notOkNames.length === 0 && CRASH_SIGNS.test(out)));
const empty = out.trim().length === 0;
/*
const empty = out.trim().length === 0; /*
* ── 条数累计:**必须在红/绿分支之外** ──
*
* 原来它写在下面 `else { … }`(只在**绿**时进入)里,于是**红文件一条都不计入**,
@ -394,13 +416,40 @@ for (const [file, flags, expected] of SUITE) {
fail: fileChecks === null ? null : fileFail,
exitCode: r.status,
});
/*
* ★★ 结构判据补一格:**退出码非零 且 一条条数都没自报** ⇒ broken(不是 red)。
*
* 这是 pi 2026-09-17 报的第二个洞,而它比 cwd 那条更毒 —— 它是**分类错了**:
* `narrow-layout` 在 import 期 `ENOENT` 崩掉(**一条 `not ok` 都没有**),
* 于是走 `CRASH_SIGNS` 那条兜底;而 ENOENT **不在**那个关键字表里
* ⇒ `crashed=false` ⇒ 落进 `reds`:`- test/narrow-layout.test.mjs(退出码 1)`。
* 读者看到的是"**这条判据不成立**",真相是"**这个文件没读完就死了,它有多少条根本没测**"。
* 同一次运行里真红的样子是带 `↳ 自报 N 条 > 登记 M 条 …` 自述的 ——
* **同一种红,两种含义**,而 `red=10` 这个总数把两者混在一起报。
*
* ★ 为什么不照 pi 说的往 `CRASH_SIGNS` 里加 `ENOENT|EACCES|no such file`:
* 上面第 329 行那条注释早就把方向定死了 —— **"我不再往里加更多错误关键字:
* 那是往文本解析里加补丁,方向是错的"**,而且那个表**必然漏**(今天漏 ENOENT,
* 明天漏 ENOTDIR/ELOOP/ERR_DLOPEN_FAILED…)。照加上去就是**第六次**修同一个形状。
*
* ⇒ 改用**结构**事实:这条判据是不是**跑完过**?不是就看它有没有留下**任何**条数记录。
* 一个跑完的文件必然自报条数(`RESULT pass=` 或 `# tests`);**跑了但没自报**是
* 另一条已知的、单独记账的情形(`unreported`,见下面 `else` 分支)。
* 两者**不会同时成立** ⇒ 这条规则只吃掉"崩了且毫无自报"那一格,不碰任何真红:
* 断言失败的文件 `exited != 0` 但**有**自报条数 ⇒ 仍然走 red。
* (★ 这个区分才是要害:`exitCode !== 0` 本身**不足以**判 broken ——
* 不然真红会被一起吞掉,那是**假绿方向的错**,比误报成红更坏。)
*/
const diedWithoutReporting = r.status !== 0 && fileChecks === null;
// 不 break:后面每条都要跑出来,否则"红了几条"这个信息本身是假的
if (crashed || empty) {
if (crashed || empty || diedWithoutReporting) {
const firstErr = (out.match(/^.*(Error|error:).*$/m) || [''])[0].trim().slice(0, 160);
brokens.push(`${file}(${empty ? '跑完没有任何输出' : `退出码 ${r.status},但没有一句断言失败`})` +
brokens.push(`${file}(${empty ? '跑完没有任何输出'
: diedWithoutReporting && !crashed ? `退出码 ${r.status},且一条条数都没自报` : `退出码 ${r.status},但没有一句断言失败`})` +
(firstErr ? `\n ↳ ${firstErr}` : '') +
'\n ↳ 这是 **broken(跑不起来)**,不是 red:它证明不了任何判据成立或不成立。' +
'\n 常见成因:语法/标识符错(`X is not defined`)、import 写错、编译不过。' +
'\n 常见成因:语法/标识符错(`X is not defined`)、import 写错、编译不过、' +
'\n **按相对路径读文件但 cwd 不对**(`ENOENT: … open \'src/index.css\'`)。' +
'\n 用于变体验证时:broken **不算这次变异有效**,要重做。');
} else if (r.status !== 0) reds.push(`${file}(退出码 ${r.status})`);
else {