From a47b42b36ec57fbeb97804befca21936dc3ee8b5 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Thu, 17 Sep 2026 19:00:11 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D:=20=E5=A5=97=E4=BB=B6?= =?UTF-8?q?=E8=AF=BB=E6=95=B0=E4=B8=8D=E5=86=8D=E5=8F=96=E5=86=B3=E4=BA=8E?= =?UTF-8?q?"=E4=BD=A0=E6=80=8E=E4=B9=88=E8=B0=83=E7=94=A8=E5=AE=83"?= =?UTF-8?q?=E2=80=94=E2=80=94=20=E5=AD=90=E8=BF=9B=E7=A8=8B=E9=92=89=20cwd?= =?UTF-8?q?:=20ROOT=20+=20read.mjs=20=E7=9B=B8=E5=AF=B9=E8=B7=AF=E5=BE=84?= =?UTF-8?q?=E6=8C=89=E5=8C=85=E7=9B=AE=E5=BD=95=E8=A7=A3=E6=9E=90=EF=BC=88?= =?UTF-8?q?=E7=AC=AC=E4=BA=8C=E5=B1=82=EF=BC=89=EF=BC=9B=E5=B9=B6=E6=8A=8A?= =?UTF-8?q?"=E5=B4=A9=E4=BA=86"=E4=BB=8E=20red=20=E9=87=8C=E5=88=86?= =?UTF-8?q?=E5=87=BA=E6=9D=A5=EF=BC=88=E7=BB=93=E6=9E=84=E5=88=A4=E6=8D=AE?= =?UTF-8?q?=EF=BC=8C=E4=B8=8D=E5=8A=A0=E9=94=99=E8=AF=AF=E5=85=B3=E9=94=AE?= =?UTF-8?q?=E5=AD=97=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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` 正在被改等)。 --- client/electron/test/animation-audit.test.mjs | 8 ++- client/electron/test/lib/read.mjs | 58 ++++++++++++++++- client/electron/test/run-all.mjs | 63 ++++++++++++++++--- 3 files changed, 117 insertions(+), 12 deletions(-) diff --git a/client/electron/test/animation-audit.test.mjs b/client/electron/test/animation-audit.test.mjs index a3335a1..1128f82 100644 --- a/client/electron/test/animation-audit.test.mjs +++ b/client/electron/test/animation-audit.test.mjs @@ -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(); diff --git a/client/electron/test/lib/read.mjs b/client/electron/test/lib/read.mjs index b39d3ea..3292258 100644 --- a/client/electron/test/lib/read.mjs +++ b/client/electron/test/lib/read.mjs @@ -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)); } diff --git a/client/electron/test/run-all.mjs b/client/electron/test/run-all.mjs index 595101f..309bdec 100644 --- a/client/electron/test/run-all.mjs +++ b/client/electron/test/run-all.mjs @@ -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 {