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` 正在被改等)。
120 lines
5.6 KiB
JavaScript
120 lines
5.6 KiB
JavaScript
/**
|
||
* 动画全量盘点 —— 把"整体动画"从"每次靠人眼看一遍"变成一条常驻判据。
|
||
*
|
||
* # 为什么需要它(2026-09-15 用户:「全面检查整体的动画」)
|
||
*
|
||
* 一次盘点查出两处**死动画**与一处**过宽的动画**,三种形态都极难靠肉眼发现:
|
||
* ① `@keyframes pane-in` 挂在 `html.view-switch .pane-enter` 上,而 **`.pane-enter`
|
||
* 没有任何组件在穿** —— 规则看着像"页面有入场动画",实际一次都不会播;
|
||
* ② `.animate-menu-in`(菜单入场)写好了、keyframes 也有,**同样没人穿**
|
||
* ⇒ 所有下拉/候选菜单其实都是"啪"地出现;
|
||
* ③ `html.view-switch .glass-control` 让**所有**控件档元素在每次切视图时一起动
|
||
* (几十个按钮/输入框同时淡入位移),是"闪"和卡顿的现成来源。
|
||
*
|
||
* 这三种都不是"动画不够好看",而是**接线断了**:类与使用者脱钩、作用域开得过大。
|
||
* 所以判据钉的是"接线",不是时长与曲线(那两样由人看着定)。
|
||
*
|
||
* # 判据
|
||
*
|
||
* 1. 每个 @keyframes 都必须有人穿 —— 从规则里取出穿它的类名,回源码里找;
|
||
* 找不到就是死动画(① 就是这么被抓住的)。
|
||
* 2. 弹层(.popup-surface 的菜单)必须带上菜单入场类(② 的接线)。
|
||
* 3. 不得再出现"整档控件一起动"的 view-switch 规则(③)。
|
||
* 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, PKG } from './lib/read.mjs';
|
||
|
||
// 判"规则/代码里有没有这个东西"一律走剥注释版(code):解释性注释里会原样引用被禁的写法,
|
||
// 读原文会把它当成"还在用"(criteria-hygiene 就是这么抓到本文件第一版裸用 readFileSync 的)。
|
||
const css = code('src/index.css');
|
||
|
||
/** 组件源码全文:判"这个类有没有人穿"必须看代码,不是看我们的记忆 */
|
||
function componentsSrc() {
|
||
const out = [];
|
||
const walk = dir => {
|
||
for (const e of readdirSync(dir, { withFileTypes: true })) {
|
||
const p = `${dir}/${e.name}`;
|
||
if (e.isDirectory()) walk(p);
|
||
else if (/\.tsx?$/.test(e.name) && !/\.test\./.test(e.name)) out.push(code(p));
|
||
}
|
||
};
|
||
/* ★ 基准用 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();
|
||
|
||
/** 取出所有 @keyframes 名 + 每条规则的 selector 文本 */
|
||
const keyframes = [...css.matchAll(/@keyframes\s+([a-zA-Z0-9_-]+)/g)].map(m => m[1]);
|
||
const rules = [...css.matchAll(/(^|\n)([^{}\n][^{}]*)\{([^{}]*)\}/g)].map(m => ({
|
||
sel: m[2].trim().replace(/\s+/g, ' '),
|
||
body: m[3]
|
||
}));
|
||
|
||
/**
|
||
* 一条 selector 在**最终**要不要靠某个类名才能命中?
|
||
* 只取"最后一个复合选择器"上的类(`html.view-switch .pane-enter` → `pane-enter`),
|
||
* 祖先里的类(html.view-switch、.app-shell)是状态开关,不算穿的人。
|
||
*/
|
||
function wearerClasses(sel) {
|
||
return sel
|
||
.split(',')
|
||
.map(part => part.trim().split(/\s+/).pop() || '')
|
||
.filter(last => last.startsWith('.'))
|
||
.map(last => last.split(/[\s.:[>]/)[0].replace(/^\./, ''))
|
||
.filter(Boolean);
|
||
}
|
||
|
||
const dead = [];
|
||
const unwrapped = [];
|
||
for (const k of keyframes) {
|
||
const users = rules.filter(r => new RegExp(`animation:\\s*${k}\\b`).test(r.body));
|
||
if (users.length === 0) {
|
||
dead.push(`${k}(没有任何规则用它)`);
|
||
continue;
|
||
}
|
||
// 这条 keyframes 的每一个使用者都必须"有人穿" —— 否则它还是不会播
|
||
const worn = users.some(r => {
|
||
const cls = wearerClasses(r.sel);
|
||
if (cls.length === 0) return true; // 元素选择器/通配:不算死
|
||
return cls.some(c => new RegExp(`["'\`\\s]${c}(?=["'\`\\s]|$)`).test(src) || src.includes(c));
|
||
});
|
||
if (!worn) unwrapped.push(`${k} ← ${users.map(u => u.sel).join(' | ')}`);
|
||
}
|
||
|
||
check(
|
||
'每个 @keyframes 都有人穿(没有死动画)',
|
||
dead.length === 0 && unwrapped.length === 0,
|
||
`只定义了没人穿的动画(看着有、永远不播):${[...dead, ...unwrapped].join(';')}`
|
||
);
|
||
|
||
check(
|
||
'弹层菜单带着菜单入场类',
|
||
/animate-menu-in/.test(rules.map(r => r.sel).join(',')) &&
|
||
/popup-surface[^`]*animate-menu-in|animate-menu-in[^`]*popup-surface/.test(src),
|
||
'菜单入场类存在但没人穿 —— 下拉/候选菜单是"啪"地出现(2026-09-15 盘点抓到的第二处死动画)'
|
||
);
|
||
|
||
// 只在**顶层规则**里查:reduced-motion 块里那句 `html.view-switch .glass-control` 是
|
||
// "把它关掉"的名单,不是"让它动"的规则 —— 第一版没区分,判据自己假红。
|
||
const topLevel = css.replace(/@media[^{]*\{(?:[^{}]|\{[^{}]*\})*\}/g, '');
|
||
check(
|
||
'没有"整档控件一起动"的切视图规则',
|
||
!/html\.view-switch\s+\.glass-control/.test(topLevel),
|
||
'view-switch 又把 .glass-control 整档带上了 —— 每次切视图几十个按钮/输入框一起动(闪与卡顿的现成来源)'
|
||
);
|
||
|
||
const reducedBlocks = css.match(/@media \(prefers-reduced-motion: reduce\)\s*\{[\s\S]*?\n\}/g) || [];
|
||
check(
|
||
'挂载即播那档被 reduced-motion 显式关掉',
|
||
reducedBlocks.some(b => /(^|\n)\s*\.rise-in\s*,/.test(b)),
|
||
'prefers-reduced-motion 覆盖不到 .rise-in —— 关掉动画的人照样会看到它'
|
||
);
|
||
|
||
finish('动画盘点');
|