Files
MailUI4Agents/client/electron/test/criteria-hygiene.test.mjs
JianFeeeee c4279a95ce feat(criteria): 文件名不得是"句子里的一段" —— 我为**自己制造的**那个化石加守
## 我制造了什么(本会话 `34bcd3e9` 那轮,实测复现)

```
我在 bash 里写了一句带嵌套未转义双引号 + 裸 `>` 的说明:
   echo "  ⇒ 所以"mtime > 动作时刻是**必要但不充分**,且会被**后人无关的写**覆盖""
bash 把 `>` 读成**重定向** ⇒ **在仓库根建了一个文件**:
   文件名 = `动作时刻是**必要但不充分**,且会被**后人无关的写**覆盖`
   内容   = `  ⇒ 所以mtime`(18 字节)
⇒ 我在检查工作树时看到 `?? "动作时刻是…"` 才发现的。
  用 /tmp 里的最小复现验证到**同名同内容** ⇒ 确认是我的 shell 引号事故,不是别人的。
```

## 为什么值得一条判据(它会被提交)

```
· 未跟踪文件**不会**被普通 `git add <path>` 带上 ⇒ 平时看不见、不会被门禁提醒;
· 但本仓**明文记录过 `git add -A` 事故**(`docs/DEV-TOOLING.md`: 含把约 7000 行重排扫进功能提交那次)
  ⇒ 下一次 `-A` 就会把这个"句子文件"带进历史,而**进了历史就永远删不干净**;
· 它的**名字本身就说明它是无意的** —— 没有人的文件名会是"…且会被**后人无关的写**覆盖"。
```

## 判据(零先例实测 ⇒ 不误伤)

```
仓库内(排除 node_modules/.git/build/release/dist/.hvigor/.codegraph/oh_modules)
  不得有名字含**中文标点**(,。、!?;:()「」“”《》【】)、**markdown 粗体 `**`**、或**换行**的文件
★ 判**名字的形态**,不判目录 —— 按目录裁豁免正是本仓记过的"给逃逸指路"。
实测先例: 加这条之前全仓命中 **0**(根目录 0、全仓 0)⇒ 不误伤任何现存文件。
```

## 双向变异

```
① 重建那个句子文件名 ⇒ **not ok 10**,逐个点名 ✓(上面就是它抓到的输出)
② 删掉 ⇒ 10/10 绿 ✓
```

## 顺带

```
run-all.mjs 注册条数 9 → **10**(新增 test 必须登记精确条数,本仓约定)
```

★ 这条与我这轮前两条(`fc54a81` 两处排除、`b16c38a` "提到≠发现路径")是**同一族**:
  **"看起来像在表达一件事"的东西被当成了"它就是那件事"** ——
  前两条在判据的**匹配口径**里,这条在**文件名**上(一段散文被当成了路径)。

验证: criteria-hygiene 10/10;drift 自检 64/0;两文件 `node --check` 通过;
现场: 探针已删(`git status` 只剩本提交的两个文件)。
2026-09-25 07:58:36 +08:00

927 lines
58 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.

/**
* 判据目录自身的卫生:**读文本必须走 `test/lib/read.mjs` 的两个具名入口**。
*
* # 为什么这条判据存在(pi 2026-09-14 §4)
*
* 规范里写着"判代码读剥离版(`code`)、判理由/文档读原文(`prose`)",
* 我 P5 写过一次、当天又踩了一次:那条断言读的是**原文**,而它要找的标识符
* 恰好出现在一段解释性注释里 → 误报。**第二次犯规说明问题不在记性,在形态**:
* 靠人记得执行的规范一定会有下一次。
*
* 所以把"用哪个读取器"从**记忆**变成**代码里的一个词**,并且可被检查:
* - `code(path)` —— 剥掉注释;判"代码里有没有这个调用/这个值";
* - `prose(path)` —— 原文;判"注释/文档里写了什么";
* - `bytes(path)` —— 二进制(安装包等)。
*
* # 判据
*
* 判据目录(`test/**` 里跑的判据 + `run-all.mjs`)中**不得出现裸 `readFileSync`**,
* 唯一例外是 `test/lib/read.mjs` 自己。`test/manual/**` 是人工脚本、不是判据,不在范围内。
*
* 附两条自检:读取器本身要真的剥注释(否则 `code` 退化成 `prose` 这条判据就废了)、
* 以及探测器要能认出裸调用(否则"都没有"与"探测器坏了"结果一样)。
*/
import assert from 'node:assert/strict';
import { readdirSync, unlinkSync, writeFileSync } from 'node:fs';
import { spawnSync } from 'node:child_process';
import { dirname, join, relative, resolve } from 'node:path';
import { test } from 'node:test';
import { fileURLToPath } from 'node:url';
import { code, prose, stripComments } from './lib/read.mjs';
const HERE = dirname(fileURLToPath(import.meta.url));
const RELECTRON = join(HERE, '..'); // test/ 的上一级就是 client/electron
const SELF = join(HERE, 'lib', 'read.mjs');
/** 仓库根 —— 从**本文件位置**推(这不是硬编码,是本判据要求的正确写法) */
const REPO_ROOT = join(HERE, '..', '..', '..');
/** 跑一条 git 命令(在仓库根,拿字符串回来)。与 `commit-hygiene` 同形。 */
function git(args) {
return spawnSync('git', args, { cwd: REPO_ROOT, encoding: 'utf8' });
}
/**
* 仓库目录名 —— 判"某条绝对路径是不是落在仓库内"用的**值**特征。
*
* 为什么不从 `ROOT` 推:这个字面量本身就是"仓库根在哪"的**事实**,
* 而本判据禁止的正是"把它写进代码"。这里写它,是因为判据**必须**知道要找什么。
*/
const REPO_NAME = 'agentmail';
/** 某段文本(`needle`)在原始源码里出现在第几行(1-based);找不到返回 0 */
function lineOf(raw, needle) {
const i = raw.indexOf(needle);
return i < 0 ? 0 : raw.slice(0, i).split('\n').length;
}
/**
* 判据文件清单:`test/**` 下会跑的判据 + 编排器 + **共享助手(`lib/`)**。
*
* ★ 为什么 `lib/` **必须**在射程内(pi 2026-09-15 指出的洞):我原来把 `lib/` 与 `manual/`
* 一起跳过了,理由是"`lib/read.mjs` 是共享助手"。但那正是**最可能的下一次复发点** ——
* 硬编码的仓库根**挪进 `test/lib/`**(一个"路径助手"最该待的地方)就完全不在本判据射程内。
* 射程靠"这个目录看起来像什么"来裁,等于给逃逸指了路。
* `manual/` 不一样:那是人工跑的脚本,**不进套件**,留在射程外的理由与它是否"助手"无关。
*/
function criteriaFiles(dir = HERE, out = []) {
for (const e of readdirSync(dir, { withFileTypes: true })) {
const p = join(dir, e.name);
if (e.isDirectory()) {
if (e.name === 'manual' || e.name === 'node_modules') continue;
criteriaFiles(p, out);
} else if (/\.(test\.mjs|test\.ts|test\.tsx|mjs)$/.test(e.name) && !e.name.endsWith('.d.ts')) {
out.push(p);
}
}
return out;
}
/** 探测器:一段源码里有没有裸 readFileSync */
const BARE = /\breadFileSync\s*\(/;
test('探测器自检 + 读取器自检', () => {
// ① 探测器能认出裸调用(否则"都没有"与"探测器坏了"分不开)
assert.equal(BARE.test("const s = " + "readFile" + "Sync(p, 'utf8');"), true);
assert.equal(BARE.test('const s = prose(p);'), false);
// ② code 真的剥注释、prose 不剥 —— 这条是整套用法的地基:
// 若 code 退化成 prose,那么"读剥离版"的规范就变成一句空话,而且没人会发现。
const probe = join(RELECTRON, 'test', '_reader_probe.tmp.ts');
// 注:这个探针文本**故意拼接**而不是写字面量 —— 否则本判据自己会被自己判红
// (它扫的就是"文本里有没有这个写法",判据文件也在扫描范围内)。
writeFileSync(probe, "const REAL = 1; // " + "readFile" + "Sync( 注释里的假调用\n/* allowed-once */\n");
try {
assert.ok(!code(probe).includes('allowed-once'), 'code() 必须剥掉块注释');
assert.ok(!code(probe).includes('假调用'), 'code() 必须剥掉行注释');
assert.ok(code(probe).includes('REAL'), 'code() 要保留真代码');
assert.ok(prose(probe).includes('allowed-once') && prose(probe).includes('假调用'), 'prose() 必须保留注释');
} finally {
unlinkSync(probe);
}
});
test('★ 判据目录里不得出现裸 readFileSync(必须走 code/prose/bytes)', () => {
const offenders = [];
for (const f of criteriaFiles()) {
if (f === SELF) continue; // 读取器的实现自己当然要用它
/*
* ★ 判的是**代码**,不是文本 —— 这里必须用 `code()`(剥注释)。
* 原来用的是 `prose()`(原文),理由是"扫的是文本里有没有这个写法"。
* 但那样一来,**注释里提到这个名字**就会被判违规 —— 我自己立刻撞上了:
* 在注释里写下"这个正则的源码里会出现 `readFileSync`"之后,这条判据就红了,
* 而红的原因**不是代码裸用了它,是我把规则写进了注释**。
* 这正是本仓那条纪律的另一面:**注释说明禁令 ≠ 违反禁令**。
* 不剥注释的判据会退化成"逼人别解释",与"理由要写清"直接冲突。
*/
const src = code(f);
if (BARE.test(src)) {
const line = src.split('\n').findIndex(l => BARE.test(l)) + 1;
offenders.push(`${relative(RELECTRON, f)}:${line}`);
}
}
assert.deepEqual(offenders, [],
`这些判据文件里还在裸用 readFileSync:\n ${offenders.join('\n ')}\n` +
" 改用 test/lib/read.mjs 的具名入口:\n" +
" · code(path) —— 剥掉注释。判「代码里有没有这个调用/这个值」时用它(默认选它);\n" +
" · prose(path) —— 原文。判「注释/文档里写了什么」时用它;\n" +
" · bytes(path) —— 二进制(安装包等)。\n" +
" 为什么不能裸用:读原文去判代码,会被解释性注释骗(同一个坑已经踩过两次)。");
});
/**
* ★ 用到 `lib/read.mjs` 的导出名就必须真的 import(我这轮在三个文件里各犯过一次)。
*
* 形状一模一样:`code(...)` / `prose(...)` 写下去,import 里却只有另一个 ——
* 于是在**跑起来的那一刻**才炸 `ReferenceError`,而它抛在判据自己身上,
* 看起来像"这条判据红了",不像"判据写错了"。dsh 桥那边也栽过同一形状
* (`MODE_FULL` 没 import,而且被 `tsc | tail` 的退出码骗过)。
*
* 判据做法:把每个判据文件里出现的 `code(`/`prose(`/`bytes(` 收集起来,
* 与它从 `lib/read.mjs` 实际 import 的名字比对;缺一个就红,并点名文件与名字。
* **例外**:文件里自己定义了同名函数(本地实现)时不算缺 —— 但那种情况要显式声明。
*/
test('★ 用到 code/prose/bytes 就必须 import(不许靠运行时才发现)', () => {
const EXPORTS = ['code', 'prose', 'bytes'];
const problems = [];
const SELF_PATH = fileURLToPath(import.meta.url);
/** 判据文件清单里,哪个文件是这些函数的**定义处**(它当然是"用了但不 import") */
const DEFINES_THEM = SELF; // test/lib/read.mjs
for (const f of criteriaFiles()) {
// 它自己的源码里就写着 code/prose/bytes 这几个名字(EXPORTS 列表),跳过自己
if (f === SELF_PATH) continue;
/*
* ★ `lib/read.mjs` 是这些函数的**定义处** —— 它"用了但不 import"是必然的、不是缺陷。
* 这条豁免**必须按"是不是定义处"判,不能按"是不是在 lib/ 下"判**:
* 否则我把仓库根硬编码挪进 `test/lib/` 那个洞就会被同一条豁免再放行一次
* (pi 2026-09-15 指出的形状:**射程/豁免按目录名裁,等于给逃逸指路**)。
*/
if (f === DEFINES_THEM) continue;
/*
* ★ 判"有没有 import"要**解析说明符**,不能按写法硬匹配(2026-09-18 实测,我自己撞的)。
* 原来只认 `'./lib/read.mjs'` / `'../lib/read.mjs'` 两个字面量 ——
* 而 `test/lib/harmony-device.mjs` **就在 `lib/` 里**,它按同目录惯例写 `'./read.mjs'`,
* 两种都不匹配 ⇒ 假红"**根本没从 lib/read.mjs import**"(其实 import 了,而且
* 运行时完全正常)。本仓已经用"解析后比较"解决过同一族(`abspath` 之于写死的相对路径、
* `join(HERE,…)` 之于硬编码仓库根),这是又一次 ⇒ 同样改成
* "把说明符解析成绝对路径,与 `SELF` 比"。**豁免/射程按字面量裁,等于给逃逸指路。**
*
* ★ 判"用没用"也必须剥注释(`code()`):这里原来是 `prose(f)`(原文),
* 于是**注释里**写下 `prose(…)` 就算"用了"。实测:`harmony-device.mjs` 的注释里
* 提到 `prose()`,正则命中 **3 处,其中只有 1 处是真调用**。
* 这与上面那条"不许裸用 readFileSync"踩过的是同一个坑 ——
* 我在那条上用了 `code()` 并写了理由,**这条漏了**。
* 纪律的另一面:**注释说明禁令 ≠ 违反禁令**。
*/
const src = code(f);
const dir = dirname(f);
const bound = new Set();
let importsRead = false;
for (const m of src.matchAll(/import\s*\{([^}]*)\}\s*from\s*['"]([^'"]+)['"]/g)) {
const spec = m[2];
if (!spec.startsWith('.')) continue; // 只认相对说明符(裸包名与这里无关)
if (resolve(dir, spec) !== SELF) continue; // ← 解析后比,而不是比写法
importsRead = true;
for (const n of m[1].split(',')
.map(x => x.trim().split(/\s+as\s+/).pop()).filter(Boolean)) bound.add(n);
}
for (const name of EXPORTS) {
if (new RegExp(`\\b${name}\\(`).test(src) && !bound.has(name)) {
problems.push(importsRead
? `${relative(RELECTRON, f)} 用了 ${name}(…) 但没 import(已 import:${[...bound].join('、') || '无'})`
: `${relative(RELECTRON, f)} 用了 ${name}(…) 但根本没从 lib/read.mjs import`);
}
}
}
assert.deepEqual(problems, [],
`这些判据会以 ReferenceError 的形式红,看起来像"判据失败了",其实是"判据写错了":\n ${problems.join('\n ')}`);
});
/**
* ★ 判据**必须读自己那棵树**,不许把仓库根硬编码成绝对路径。
*
* pi 2026-09-15 实测出的形状(这次长在**判据自己**身上,正是我们前几轮一直在消的那个):
* `harmony-arkts.test.mjs` 里写着 `const ROOT = '/home/program/agentmail'`。
* 把带违规的提交检出到别的目录再跑,它**读的仍是 `/home/program/agentmail`** ⇒
* **在一个 import 顺序明显违规的检出上 3/3 全绿**。
*
* 两层后果,第二层最糟:
* ① 它**永远无法验证任何别的 checkout / CI / 镜像** —— 换个目录不是"红",
* 是 `readdirSync` 直接抛(broken),而 broken 证明不了任何判据成立或不成立;
* ② 在本机做 worktree 复核时,它会**静默读另一棵树并报绿**。
* **"规则进来了,对象没进来"** —— 判据的逻辑对,对象错。
*
* 判据做法:扫判据目录里**真代码**(`code()` 剥注释,否则本文件自己的说明就会误报),
* 找形如 `const X = '/绝对路径'` 的仓库根声明。修法照邻居:`join(HERE, '..', '..', '..')`。
*/
test('★ 判据不许把仓库根硬编码成绝对路径(必须从本文件位置推)', () => {
/*
* ★ 判法是**按值**,不是按名字 —— 这是 pi 2026-09-15 抓到的第一个洞:
* 我原来写的是 `if (looksLikeRepo && !TOOLCHAIN_OK.test(name))`,
* 也就是**名字白名单压过了值判断** ⇒ `const SDK_ROOT = '/home/program/agentmail'`
* 和 `const HDC_BASE = '/home/program/agentmail'` **直接放行**(实测:两条都过)。
* 那正是 `CRITERIA.md` 里"allow-list"那条要防的形状:**换个变量名就过**。
* 我当时的理由是"按值白名单会逼下一个人改路径写法" —— 取舍应该反过来:
* **值在仓库里 ⇒ 一律拒;例外只给"值本来就在仓库外"**(`/opt/`、`/usr/` 这类)。
* 这样既不逼人改写法,也堵掉"换个名字就过"。
*
* ★ 字面量形态也放宽了(第二个洞):原来只认**单引号**的 `const/let/var` 赋值,
* 于是双引号、模板串、`path.join(...)`、内联参数、数组元素、`process.chdir(...)`
* 全都逃逸。现在改成:**扫真代码里任何字符串字面量**(三种引号),
* 只要它的值落在仓库内就报 —— 不依赖"它被赋给了哪个变量"。
*/
const problems = [];
const seen = new Set();
const add = (msg) => { if (!seen.has(msg)) { seen.add(msg); problems.push(msg); } };
for (const f of criteriaFiles()) {
const raw = prose(f);
const src = code(f);
const rel = relative(RELECTRON, f);
/*
* 判法分两层,**都按值**:
*
* (A) **绑定**成常量的仓库内绝对路径(`const X = "…/agentmail…"`,三种引号)。
* 命中即报 —— 这正是把判据从"读自己那棵树"改成"读固定那棵树"的动作。
* 例外只给"值本来就在仓库外"(`/opt/`、`/usr/`):那是**工具链/SDK**路径,
* 仓库里推不出来,所以按值放行是对的(按**名字**放行就是 pi 抓到的后门)。
*
* (B) **直接**把仓库内绝对路径喂给取值/读盘函数(`readFileSync(…)`、`prose(…)`、
* 内联 `join(…)`、`process.chdir(…)`)—— 覆盖 pi 指出的
* "内联参数/数组元素/path.join"那几种逃逸。
*
* ★ 为什么不再"扫一切字符串字面量"(我第一版那样):`'/home/program/agentmail'`
* 在本仓有**正当用途** —— 测试数据。实测误报:
* `test/components/PermissionPanel.test.tsx:27 from_workspace: '/home/program/agentmail'`
* `test/components/replyTarget.test.tsx:307 expect(formatAddress('pi', '/home/program/agentmail', …))`
* 那是"地址长这样",不是"去读那棵树"。**判据要抓的是"拿它去读文件",不是"提到它"。**
* 用行内容判"是不是注释"来豁免也不行 —— 那是按形状裁,不是按风险裁。
*/
/*
* ★ 判**整条赋值表达式**,不是只看第一个字面量。
* 为什么(我自己测出来的漏):`const ROOT = join('/home/program', 'agentmail')`
* 里**没有任何一个**字面量同时"以 / 开头"且"含仓库名" —— 仓库名被拆成了两个片段,
* 于是老写法直接放行。拼接所有片段后再判,才抓得到。
*/
const LIT = /(['"`])((?:\\.|(?!\1)[^\\])*)\1/g;
const BIND = /(?:const|let|var)\s+(\w+)\s*=\s*([^\n;]+)/g;
for (const m of src.matchAll(BIND)) {
const [, name, rhs] = m;
const lits = [...rhs.matchAll(LIT)].map(x => x[2]);
const whole = lits.join(''); // 拼起来看"合起来是不是仓库路径"
const joined = lits.length > 1;
/*
* ★ 两个**各自独立**的触发条件,命中任一即报:
*
* (i) **值**落在仓库里(`whole`/`lits` 含仓库名,且是绝对路径);
* (ii) **名字**读起来像"仓库根/工作区根",且它绑的是一个**绝对路径**。
*
* 为什么 (ii) 必须留着 —— 这是我改完 (i) 之后自己测出来漏掉的形状:
* `const WORKSPACE_ROOT = '/srv/ci/build/checkout';`
* 仓库被复制/检出到**别的目录名**下时,值里就没有 `agentmail` 了,
* 可它**仍然是"把判据钉死在一条绝对路径上"** —— 换棵树照样读错。
* 我原来的版本靠 (ii) 抓这种,改成纯值判断后**把它丢了**(实测:改前红、改后绿)。
* ⇒ pi 说的"按名字放行是 allow-list 要防的形状"是对的,但**结论不是"把名字判断删掉"**,
* 而是**把它降级**:名字不再能**豁免**任何东西(那才是后门),
* 但它仍然可以**和值判据并列为一条独立的触发线**。豁免只按值给(`/opt/`、`/usr/`)。
*/
const abs = lits.some(v => v.startsWith('/'));
const repoByValue = abs && (joined ? whole.includes(REPO_NAME) : lits.some(v => v.includes(REPO_NAME)));
const repoByName = /\b(PROJECT|REPO|WORKSPACE|CHECKOUT)\b|_ROOT$|^ROOT$/i.test(name);
if (!repoByValue && !(repoByName && abs)) continue;
if (lits.some(v => v.startsWith('/opt/') || v.startsWith('/usr/'))) continue; // 工具链,仓库外
add(`${rel}:${lineOf(raw, m[0])} \`${name} = ${rhs.trim().slice(0, 60)}\` —— 这是**仓库内**的绝对路径。`
+ `\n 必须从 \`import.meta.url\` 推:\`join(dirname(fileURLToPath(import.meta.url)), '..', …)\`,`
+ `否则这个判据读的不是它自己那棵树(会静默读另一棵并报绿)`);
}
/*
* 这个正则的**源码里**会出现 `readFileSync` 这个词 —— 而本文件上面那条"不许裸用
* readFileSync"的判据是扫源码文本的,会把它当违规(我自己先撞了一次)。
* 所以用 `new RegExp` 把名字拼出来,让**字面量**不出现在源码里。
*/
const FEEDS = new RegExp(
'(?:readFile' + 'Sync|readdirSync|prose|code|bytes|chdir|existsSync|statSync)\\s*\\(([^)]{0,240})\\)', 'g');
for (const m of src.matchAll(FEEDS)) {
const lits = [...m[1].matchAll(LIT)].map(x => x[2]);
const whole = lits.join('');
if (!whole.includes(REPO_NAME)) continue;
if (lits.some(v => v.startsWith('/opt/') || v.startsWith('/usr/'))) continue;
add(`${rel}:${lineOf(raw, m[0])} 读盘调用里直接写死了仓库内路径(\`${lits.join(' + ')}\`)`
+ `\n 读盘用的路径必须从本文件位置推,否则换一棵树就读错`);
}
}
assert.deepEqual(problems, [],
`这些判据被钉死在一条**仓库内**的绝对路径上 —— 在别的检出/CI/镜像里,`
+ `它们要么读错树报假绿,要么直接抛(broken):\n ${problems.join('\n ')}`);
});
/**
* ★ `stripComments` 必须**保持行号不变**。
*
* 块注释自带换行,若整块抹成 `''`,它之后**所有行号整体前移** ——
* 而全仓判据都在用 `文件:行号` 定位(grep、编辑器跳转、`git show` 核对)。
* 实测(我自己的 `harmony-arkts` 报违规时):报出 64/47,**真实文件是 80/63**,
* 读者第一步就得先猜"这是剥过的还是没剥的"。
*
* ★ 判据做法(pi 2026-09-15 指出的第四个洞):我原来只对一个**手写合成样本**断言,
* 而它要修的故障**是从真实文件里来的**。合成样本过、真实文件错位,这个形状完全可能
* (某个文件里有我没料到的注释写法)。所以现在**对每一个判据文件都断言** ——
* 合成样本留在下面当"探针没坏"的正例自检,**真实文件那层才是主体**。
*/
test('★ stripComments 必须保持行号(对所有真实判据文件,不只是合成样本)', () => {
// (1) 主体:**每一个真实文件**剥完之后行数必须一模一样
const misaligned = [];
const countLines = (t) => t.split('\n').length;
for (const f of criteriaFiles()) {
const src = prose(f);
if (countLines(stripComments(src)) !== countLines(src)) {
misaligned.push(`${relative(RELECTRON, f)}(${countLines(src)} -> ${countLines(stripComments(src))} 行)`);
}
}
assert.deepEqual(misaligned, [],
'这些文件剥完注释后**行数变了** —— 它们报出的行号会整体错位,'
+ '而全仓都用 `文件:行号` 定位(grep / 编辑器跳转 / git show 核对):\n '
+ misaligned.join('\n '));
// (2) 正例自检:合成样本上"必须能抓到错位"(否则 (1) 全绿可能只是探针坏了)
const sample = [
'/*',
' * 多行块注释',
' * 第二行',
' */',
'const a = 1; // 行尾注释',
'/* 单行块注释 */',
'const b = 2;',
].join('\n');
const out = stripComments(sample);
assert.equal(out.split('\n').length, sample.split('\n').length,
'stripComments 改变了行数 —— 它之后所有行号都会错位');
assert.ok(!out.includes('多行块注释') && !out.includes('行尾注释'),
'stripComments 没把注释去掉');
// 行号对得上:第 5 行仍应是 `const a = 1;`
assert.match(out.split('\n')[4], /const a = 1;/,
'剥完之后第 5 行不再是原来的第 5 行');
assert.match(out.split('\n')[6], /const b = 2;/,
'单行块注释所在的那一行之后,行号错位了');
/*
* ★ 已知限制(记在这里,免得下一个人以为它是完整实现 —— pi 2026-09-15 指出):
* `stripComments` 的 `//` 分支是 `(^|[^:])\/\/[^\n]*`,只保护了 `x://` 这种。
* 于是**普通字符串里的 `//` 会被当成注释剥掉** —— `const s = 'a//b'` 会变成 `const s = 'a`。
* 今天无害(没有判据靠这种字符串),但它与"剥注释剥多/剥少"是同一族。
* 真要修得先有词法状态机,而不是再加一条正则 —— 那是另一件事,不在这里顺手补。
* **这条限制没有判据**(写不出不靠词法分析就能判的形状),所以只能留成文字。
*/
});
/**
* ★ AGC 真身**从未进过远端**(健全不变量:这个路径永远不该出现在 `origin/main` 历史里)。
*
* 为什么在 `commit-hygiene` 那条之外**还要**这一条 —— pi 2026-09-15 指出的洞:
* 那条判据读的是 **index**(`git ls-files`),它守的是"**不会再被加回来**",
* **不是**"**不会被推出去**"。两者的差别在 `git rm --cached` 之后立刻可见:
* 文件从 index 消失了(那条判据绿),可 **blob 还躺在未推送的提交里**(`7647c24`、`320c93f`),
* 下一次 `git push` 会连它一起发出去,**而没有任何东西会红**。
*
* ★ 诚实说清它的性质(不夸大成"预防"):
* **它是在泄露之后响的闹钟。** 真到它红的那一天,东西已经出去了,
* 处置方式必须是"按已泄露处理"(去 AGC 轮换),而不是"把它删掉再推"。
* 真正的**预防**是 `.githooks/pre-push`(推送前拦下)—— 那条我已经做了,
* 并且由 `deploy/install.sh --git-hooks` 接线。
* 两条都要:钩子会被 `--no-verify` / 没装的机器绕过,**闹钟负责发现绕过**。
*/
test('★ AGC 真身从未进过远端历史(泄露之后响的闹钟,不是预防)', () => {
const AGC_PATH = 'client/harmony/entry/src/main/resources/rawfile/agconnect-services.json';
/*
* 先用本地可达历史自检**探针本身**:如果连本地历史都查不出这条路径,
* 说明 `git log -- <路径>` 这个查法在这棵树上根本不管用,那么下面的"远端为空"
* 就毫无意义(**空与"探针坏了"必须分得开**)。本地历史里**确实**有它。
*/
const local = git(['log', '--all', '--oneline', '--', AGC_PATH]);
assert.equal(local.status, 0, '要能跑 git log(否则这条判据无从判起)');
assert.ok(local.stdout.trim().length > 0,
'探针自检失败:**本地**历史里都查不到这条路径 —— 那么"远端为空"只是因为查法不管用,'
+ '不是因为它没被推过。先修探针(`git log -- <路径>`),别把坏探针的空输出读成"安全"');
const rem = git(['remote', 'get-url', 'origin']);
assert.equal(rem.status, 0,
'这条判据要有一个名为 origin 的远端可比 —— 没有远端时"从未发布"无从判起,'
+ '不要让它静默变成一条永远为空的假判据');
/*
* ★ **不许只读本地 `origin/main`**(pi 2026-09-15 指出,而且是我自己演示出来的):
* 它是**本地可改**的 —— 我在做变体验证时亲手把它指到了自己伪造的提交上。
* 而 pi 判"从未发布 ⇒ 不轮换"用的**正是这条 ref**。
* ⇒ 只读它的话,"**ref 被改坏了**"与"**它其实被推过**"是**同一个盲区**。
*
* 所以:先问**远端真值**(`git ls-remote`),并且必须能证明
* **本地 ref == 远端 tip**,本地那条历史才可信。证不出来就**不当绿**。
*
* 三值语义(仓库里已有这个形状:`PROBES` 的 unknown):
* 一致 ⇒ 本地历史可信,判它
* 不一致 ⇒ **红**(本地 ref 陈旧或被改过 —— 这种时候"绿"毫无意义)
* 问不到 ⇒ **红并明说**"这是不知道,不是安全"
*
* ★★ 而**覆盖面**也要按同一句话判(pi 2026-09-15 抓到的第三个洞):
* 我原来只问 `refs/heads/<当前分支>` **一条 ref**,而这条判据的标题说的是
* "从未进过**远端**" —— 于是**一次把受污染历史推到旁支、或推一个指向它的 tag**,
* 凭证就出去了,而这条判据**照样绿**。
* ⇒ 这就是"**把'不知道'读成'安全'**"的**同一句话换一根轴**:
* 可达性那一侧我立了"问不到 ⇒ 红",**覆盖面**这一侧却把
* "我没枚举到的 ref"**静默当成干净** —— **"看不到 ⇒ 绿"**。
*
* 修法(pi 给的):`ls-remote` 问**全部 ref**,对**每一条**的 tip 都查该路径是否在其
* 可达历史里。查明"远端有哪些 ref"是 `ls-remote` 的免费信息,没有理由只问一条。
*
* ⚠️ 我**没有**把"远端 ref 集合恰好等于 HEAD + refs/heads/main"写成不变量 ——
* 那会在加第一个 tag / 第一条正常旁支时误红(**为了抓泄露而给日常操作设卡**,
* 与 `pre-push` 删 ref 那次同族:**一道闸消费了不属于它管辖的东西**)。
* 真正要判的性质是"**有没有哪条 ref 的可达历史里有那个 blob**",
* 它对**任意** ref 集合都成立。
*
* ★★ 但我当时写的**理由**是错的,pi 2026-09-15 驳倒了它,我照他的办法重测也复现了:
* 我写过"`git log <sha> -- <路径>` 查的是从该 sha 可达的全部历史,**所以未 fetch 的
* 对象也在其列**(这一点我实测过)"。**不成立**:
* `git log <sha>` **必须先有这个对象**才能走可达历史;本地没有 ⇒
* `fatal: bad object <sha>`(退出码 128)。
* 实测(`/tmp` 一次性仓库,clone 之后才把新提交推到新 ref):
* $ git cat-file -e <sha> → 没有
* $ git log --oneline <sha> -- agc.json
* fatal: bad object 953c6138…
* 我那次"实测过"大概是测到了**对象恰好在本地**的情形(那一轮我推的 tag 指向的提交
* 同时也在 main 上,clone 时就跟着下来了)——
* **又是"读数器没先被证明是好的",而且这次我把一次假读数写成了"实测过"。**
*
* ⇒ 于是**代码实现的规则和那段理由相反**:未 fetch 的 ref 会落进 `unresolved` ⇒ 红。
* 也就是说**同事在远端新建一条完全良性的 tag,只要这个 clone 没 fetch 到,这条判据就红**。
* 我不反对这个方向("查不了 ≠ 干净",方向安全),但**理由必须改成这个说法**:
* 真正的不变量是"**这个 clone 必须拿到远端每一条 ref 的对象,否则本条红**"。
* 否则读那段理由的人会以为良性 tag 是"无事发生",第一次撞红时会当成误报去消掉它 ——
* 那正是这条判据最可能被消掉的路径。
*
* ★★ 而且**照最自然的做法 fetch 也修不好它**(pi 实测,我也复现):
* $ git fetch origin → 分支的对象有了;**tag-only 的还是没有**
* $ git fetch --tags origin → 这才有
* (tag 跟随只跟随"指向本地已有对象的 tag",所以不在任何分支上的 tag 普通 fetch 拉不下来。)
*
* ⇒ 所以这条判据**自己把缺的对象拿回来**(做法见下):精确抓**那一条** ref 到
* `refs/agentmail-probe/*` 命名空间 —— **不碰用户的 ref、不拉全仓、不动工作树**,
* 拉完再判。拿不到才报红,并且**报错自带修法**(本仓规矩)。
*/
const lsr = git(['ls-remote', 'origin']);
assert.equal(lsr.status, 0,
'问不到远端(`git ls-remote origin` 失败)——\n'
+ ' ★ 这是**不知道**,不是**安全**。一条专门用来抓"绕过"的闹钟,\n'
+ ' 如果因为"不 fetch / 问不到"就报绿,那它自己就能被绕过。\n'
+ ' 修法:确认远端可达、`origin` 名字对,再跑这条。');
/*
* 解析成 {ref, sha}。跳过 `HEAD`(symbolic,与某条分支同 sha,查它是重复劳动);
* 形状不认识的直接跳过(下面 `remoteRefs.length > 0` 会兜住"全都没认出来")。
*/
const remoteRefs = [];
for (const line of lsr.stdout.trim().split('\n')) {
const [sha, ref] = line.split('\t');
if (!sha || !ref || ref === 'HEAD') continue;
if (!/^[0-9a-f]{40}$/.test(sha)) continue;
remoteRefs.push({ ref, sha });
}
assert.ok(remoteRefs.length > 0,
'远端一条 ref 都没解析出来(或输出形状不认识)—— 按"不知道"处理,不当绿。'
+ `\n 原始输出:${JSON.stringify(lsr.stdout.slice(0, 200))}`);
/*
* 对**每一条**远端 ref 的 tip 查该路径(`git log <sha> -- <路径>` = 从该 sha 可达的历史)。
* **本地没有那个对象就抓那一条**(精确,见上),抓完再查。抓不到 ⇒ unknown(红),不当绿。
*
* ★★ 这是一条**会写仓库的判据**,所以写操作必须按仓库已有的那套纪律来
* (pi 2026-09-15 抓到两处,部署锁 / `$RM` 随机名都有先例):
*
* ① **探针 ref 名要唯一** —— 本工作树**有多个会话**并发跑套件。原来的名字
* `refs/agentmail-probe/<ref>` 是确定的 ⇒ 两个并发进程抓进**同一个** ref,
* 然后各自跑那圈**对全部远端 ref 的** `update-ref -d` ⇒
* **一边能把另一边正在用的探针 ref 删掉**。
* (后果我核过,**不是错判**:判定用的是 sha,对象抓进来不会因为 ref 被删而消失,
* `git log <sha>` 照样成立 —— 是**垃圾/卫生**问题,不是"会报错绿"的问题。
* 但这仍然是"我的判据去动别人的东西",不该留。)
* ⇒ 加 `-${process.pid}`。
*
* ② **清理必须在 `finally` 里** —— `fetch` 是**网络 I/O**,最可能卡住/被超时杀掉;
* 而**任何在"抓到了"与"清理了"之间发生的打断**(Ctrl-C、runner 超时、kill)
* 都会把 `refs/agentmail-probe/*` **永久留在共享仓**(吊住对象、出现在
* `for-each-ref` 类检查里)。原来清理在循环**之后**,不在 `finally` ⇒ 打断就留垃圾。
*
* ③ 清理要按**试过的每一条**来,不能按"抓成功的那几条" ——
* `fetch` 可能**部分成功后再失败**(ref 建了、对象没齐),那一支也要删。
*/
const PROBE_TAG = `-${process.pid}`;
const probeRef = ref =>
`refs/agentmail-probe/${ref.replace(/^refs\//, '').replace(/\//g, '-')}${PROBE_TAG}`;
const published = [];
const unresolved = [];
const fetched = [];
const triedProbes = new Set();
try {
for (const { ref, sha } of remoteRefs) {
let lg = git(['log', '--oneline', sha, '--', AGC_PATH]);
if (lg.status !== 0) {
// 本地缺这个对象 ⇒ 精确抓这一条(--no-tags 免得顺带拉别的 tag)
const dst = probeRef(ref);
triedProbes.add(dst);
const f = git(['fetch', '--no-tags', 'origin', `+${ref}:${dst}`]);
lg = git(['log', '--oneline', sha, '--', AGC_PATH]);
if (f.status !== 0 || lg.status !== 0) {
unresolved.push(`${ref}(${sha.slice(0, 8)})`
+ (f.status !== 0 ? `\n fetch 也失败:${(f.stderr || '').trim().split('\n')[0].slice(0, 90)}` : ''));
continue;
}
fetched.push(`${ref}(抓到 ${dst},本次判定后删除)`);
}
if (lg.stdout.trim() !== '') {
published.push(`[${ref}] ${sha.slice(0, 8)}\n`
+ lg.stdout.trim().split('\n').map(l => ' ' + l).join('\n'));
}
}
} finally {
// 探针 ref 只为本条判据存在 —— **无论怎么离开(含被打断前的正常异常路径)都删**
for (const dst of triedProbes) git(['update-ref', '-d', dst]);
}
/*
* ★★ 披露必须在**断言之前**打(我自己撞到的):原来这段在两条 `assert` **之后**,
* 于是**判红时它不执行** —— 而"判红"恰恰是读者最需要知道"这条判据刚才动过仓库"的时刻。
* ⇒ **披露只在平安无事时发生**,等于没披露。和"读数器替一件事作证"同族:
* 这次是"**平安路径专有的披露**"。
* 顺带更正我上一封的说法:我说过"抓过对象时必须说出来" ——
* 代码实际做到的是"**没出事的时候**说出来"。**说法与实现不一致,以实现为准。**
*/
if (fetched.length > 0) {
console.log(` (本条本次临时抓了 ${fetched.length} 条远端 ref 的对象:`
+ fetched.map(s => s.split('(')[0]).join('、') + ' —— 这就是"这条判据会写仓库"的样子)');
}
assert.deepEqual(unresolved, [],
'这几条远端 ref 的 tip **拿不到、也抓不回来** ——\n'
+ ' ★ 按"不知道"处理,**不当绿**:**查不了 ≠ 干净**。\n'
+ ' ★★ 这里对"远端不可达"判**红**,而 `deploy/install.sh --check` 的 origin 可达性检查\n'
+ ' 对同一现象判 **WARN** —— **两处政策相反是有意的**,别以"一致性"为名统一掉:\n'
+ ' · 那边问"本机配置能不能用" ⇒ 离线不是配置错 ⇒ WARN;\n'
+ ' · 本条问"凭证有没有进过远端历史" ⇒ **查不了就答不出** ⇒ 红。\n'
+ ' (统一到 WARN ⇒ 泄漏告警在离线时静默变绿;统一到红 ⇒ 离线机器上一次干跑就报假红。)\n'
+ ' ★ 真因不是"远端不可达"(sha 是从 `ls-remote` 拿的,**远端当然可达它**),\n'
+ ' 而是"**本地没有这个对象**",且 `git log <sha>` 必须先有对象。\n'
+ ' ★ 修法(**只 `git fetch` 不够** —— 它默认只抓 `refs/heads/*`,\n'
+ ' 不在任何分支上的 tag 抓不下来):\n'
+ ' git fetch --tags origin\n'
+ ' 本条本来会自己精确抓那一条,这次没成 —— 所以是远端/网络/权限的问题,\n'
+ ' 不是"少 fetch 了一下"。确认远端可达再跑。\n'
+ ` ${unresolved.join('\n ')}`);
assert.deepEqual(published, [],
`AGC 客户端凭证的**真身已经进过远端历史**(在 ${published.length} 条 ref 上查到)——\n`
+ ' 它含 `client_secret`/`api_key` 信封与明文 `client_id`/`app_id`,\n'
+ ' 而本仓镜像是**匿名可 clone 的公开项目**(docs/GITCODE-MIRROR.md)。\n'
+ ' ★ 处置**不是**"删掉再推"(历史里的 blob 撤不回):按**已泄露**处理 ——\n'
+ ' 1) 去 AGC 控制台**轮换** client_secret / api_key;\n'
+ ' 2) 历史清理(filter-repo)+ 强推,并与所有 clone 过的人对齐;\n'
+ ' 3) 查清是哪次 push 绕过了 `.githooks/pre-push`(`--no-verify`?没装的机器?)。\n'
+ ` 命中:\n ${published.join('\n ')}`);
});
/*
* ★★ 政策门禁不许"写了但没人跑"(dsh 2026-09-19)。
*
* 形状(这是本仓第 5 次同一个形状,前 4 次见 `CRITERIA.md §16`):
* `deploy/check-file-modes.sh` 是**源文件权限政策的唯一判据**,
* 逻辑写得对(`b7dc9e9` 还专门修过它自己那份 `[ -x ]` 恒真的 root 陷阱),
* **但全仓没有任何地方执行它** —— 唯一的非注释提及是 `mutants/summary.py`
* 里一句 `print(...)` 的**文案**。而它当时**正红着**(2 个文件是 600、
* `jobs/` 缺属主 x 位)。判据在,但走不到。
*
* 为什么这一类**只能靠判据守**、不能指望"顺手发现":
* `git ls-files -s` 只记 **100644 / 100755**(只含可执行位),
* **组/其他读位不进版本库** ⇒ 实测把受跟踪文件 `chmod 600 ↔ 644`,
* `git status` 两次都空、`git diff` 也不动。没有任何"顺带"的通道。
*
* 判什么:**宣称是"政策/门禁"的 `deploy/check-*.sh`,必须至少被一个入口脚本
* (`install.sh` / `redeploy-*.sh`)在**可执行位置**调用**。
*
* ★ 射程如实标出(别让它冒充比它更宽的东西):
* · 只管 `deploy/check-*.sh`(**政策门禁那一族**),**不管** `check-deploy-drift.mjs`
* 这类**按需手动工具** —— 后者自带 `--self-check`、文档里明确是"事后自查",
* 要求它也进入口是**错的**(会把工具当门禁,然后逼人把它挂上去)。
* · 判的是"**有没有**接线",**不判"接得对不对"**(挂在 `--check` 累积通道里
* 而不是直接调,是另一条纪律,由 `install.sh` 自己的注释与 141-157 行守着)。
* · 数的是**可执行位置**的出现,所以**注释里提到不算**(本仓纪律:
* "注释说明禁令 ≠ 违反禁令")—— 用 `code()` 剥注释,与上一条同形。
*/
test('★ 政策门禁(deploy/check-*.sh)必须被入口脚本真正调用,不许"判据在但走不到"', () => {
const DEPLOY = join(REPO_ROOT, 'deploy');
const ENTRIES = ['install.sh', 'redeploy-gateway.sh', 'redeploy-plugin.sh'];
/*
* ★★ 为什么**不能**直接用 `code()`(本判据第一版就是这么写的,**假绿了**):
*
* `lib/read.mjs` 的 `stripComments()` 只认 `//` 与 `/* *\/` —— 那是 **JS** 的注释。
* `install.sh` 是 **shell**,它的注释是 `#`。于是 `code()` 对 shell 文件**原样返回**,
* 包括我写在它上面那段解释里的一句
* `# \`check-file-modes.sh\` 红时 \`exit 1\`,而本脚本是 \`set -e\``
* ⇒ 变异验证(把接线整段删掉)**判据仍然绿** —— 它读到的是**我自己的解释文案**。
* 实测:
* 变异①(接线拆掉)⇒ 期望红,实际 **rc=0(绿)**
* `code(install.sh).includes('check-file-modes.sh')` ⇒ **true**,命中的是 `#` 开头那行
* 这正是本仓那条纪律的又一次现身("注释说明禁令 ≠ 违反禁令"),
* 也是 `stripComments` 那段注释自己警告过的形状:"判据开始消费散文"。
*
* ⇒ 自己剥 shell 注释。**已知限制,方向如实标出**:
* · 只处理"行首(可带空白)的 `#`"与"空白后的 ` #`";
* `${VAR#pat}` 这种**没有空格**的写法因此**不会**被误当注释(正是想要的)。
* · 引号内的 `#`(`echo "a # b"`)会被当成注释起点 ⇒ 该行后半被截掉。
* 对本判据**方向是安全的**:截掉只会**漏掉**接线(假红),
* 不会**凭空造出**接线(假绿)——而假红是会被人当场看见的那一侧。
*/
const stripShellComments = (src) => src.split('\n').map((line) => {
const t = line.trimStart();
if (t.startsWith('#')) return '';
const m = line.match(/(^|\s)#/); // 行首或空白后的 #(避开 ${VAR#pat})
return m ? line.slice(0, m.index) : line;
}).join('\n');
/*
* ★ 还要排掉"散文行":真接线长这样(命令位置)
* "$REPO/deploy/check-shared-libs.sh"
* bash "$REPO/deploy/check-sandbox.sh" ...
* 而散文长这样
* echo "药方:跑 deploy/check-file-modes.sh"
* ⇒ 抹掉 `echo`/`printf` 开头的行。**不做字符串剥离** ——
* 真接线里文件名**就在引号内**(`"$REPO/deploy/x.sh"`),
* 剥字符串会把真接线一起剥掉(那是把判据弄瞎,不是弄准)。
*/
const entryCode = ENTRIES
.map((e) => ({ e, src: stripShellComments(code(join(DEPLOY, e))) }))
.filter(({ src }) => src.length > 0)
.map(({ e, src }) => ({
e,
src: src.split('\n')
.filter((l) => !/^\s*(echo|printf)\b/.test(l))
.join('\n'),
}));
/*
* ★ 反空真护栏:本判据的结论是"每个门禁都有人调" ——
* 如果 `deploy/check-*.sh` 一个都没列出来(路径写错、目录挪了),
* `∀x∈∅` 会让它**看起来和真判据一样绿**。本仓纪律:"空真看起来和真判据一样绿"。
*/
const gates = readdirSync(DEPLOY)
.filter((n) => /^check-.*\.sh$/.test(n))
.sort();
assert.ok(gates.length >= 3,
`只找到 ${gates.length} 个 deploy/check-*.sh(期望 ≥3)—— 大概率是路径/目录变了,`
+ '而不是"门禁真的变少了"。空真必须报红,不许静默通过。');
assert.ok(entryCode.length >= 2,
`只读到 ${entryCode.length} 个入口脚本(期望 ≥2)—— 同上,反空真。`);
const unwired = [];
for (const g of gates) {
// 在**入口脚本的代码里**找这个文件名(含 `$REPO/deploy/` 前缀那种写法)
const called = entryCode.some(({ src }) => src.includes(g));
if (!called) unwired.push(g);
}
assert.deepEqual(unwired, [],
`这些政策门禁**写了却没有任何入口脚本调用**(判据在,但走不到):\n`
+ ` ${unwired.join('\n ')}\n`
+ ' 为什么这条要紧(实测形状):`deploy/check-file-modes.sh` 就这样空转了 ——\n'
+ ' 它的权限政策判得**完全正确**,而它当时**正红着**(2 个文件 600 + `jobs/` 缺 x),\n'
+ ' 全仓唯一的非注释提及是另一个文件里一句 `print(...)` 的文案。\n'
+ ' ⇒ 药方:在 `deploy/install.sh` 里调用它。\n'
+ ' ⚠️ 挂在 `--check` 下要**走累积通道**(照 `npm_rc`/`CHECK_GATE_RC` 的既有做法),\n'
+ ' **不能直接调** —— 门禁红时它 `exit 1`,而 `install.sh` 是 `set -e`,\n'
+ ' 会在那里中止、后面所有诊断一行都不打(`install.sh` 141-157 行刚修过同一个毛病)。');
});
/*
* ★★ 非门禁工具的**发现路径**(pi `a6dd501c` 2026-09-25 指出,我复现)。
*
* 上一条判据管住了 `check-*.sh`(门禁族)—— 它靠**命名约定**被强制接线。
* 而那条约定有个代价:**为了躲开它而改名之后,就没有任何判据管"改名后还找不找得到"**。
* 实测:
* · `recount-relay-counts.sh` —— 新名(为躲 `check-*` 约定而改)非自身引用 = **1**
* (只有 `docs/DEBTS.json`),`install.sh` / `.githooks` / `docs/DEV-TOOLING.md` 全 0
* · `archive-stale-sessions.sh` —— 非自身引用 = **0**(全仓只命中它自己)
* 对照:同族的 `prune-deploy-artifacts.sh` 在 `docs/DEV-TOOLING.md:71` 有**专节** ——
* 那才是它会被找到的原因(发现路径存在,不靠人记得)。
*
* ⇒ 判据: **每个非门禁 `deploy/*.sh` 至少要有一个"非自身"的引用**。
* 与上一条**同族但机制不同**:上一条防"判据在但**走不到**"(执行路径),
* 这一条防"工具在但**没人知道它存在**"(发现路径)。
* ⚠️ 为什么上一条盖不住它: `check-*` 靠 `readdirSync` 强制,
* 而**改名正好绕开那条约定** —— 于是"改名"这个合规动作把工具从"强制被调用"
* 直接降级成"零引用",**而且没有任何东西会红**。
*/
test('★ 非门禁工具(deploy/*.sh)必须有发现路径(至少一处非自身引用)', () => {
const DEPLOY = join(REPO_ROOT, 'deploy');
// 排除门禁族(上一条已管)与三个入口脚本自身(它们是入口,不是"被发现的对象")
const ENTRY_ITSELF = new Set(['install.sh', 'redeploy-gateway.sh', 'redeploy-plugin.sh']);
const tools = readdirSync(DEPLOY)
.filter((n) => n.endsWith('.sh') && !/^check-.*\.sh$/.test(n) && !ENTRY_ITSELF.has(n))
.sort();
// 反空真:工具一个都没列出来 ⇒ 大概率是路径/目录变了,不是"工具真的没了"
assert.ok(tools.length >= 2,
`只找到 ${tools.length} 个非门禁 deploy/*.sh(期望 ≥2)—— 大概率是路径变了。空真必须报红。`);
/*
* 扫描范围:可能承载"发现路径"的文本。用 `prose()`(原文)——
* 判的是"文档/注释里有没有提到它",那**正是散文**,不是代码。
*/
const SCAN_DIRS = ['docs', 'deploy', 'client/electron/test', '.githooks', 'scripts'];
const SCAN = SCAN_DIRS.flatMap((d) => {
const base = join(REPO_ROOT, d);
try {
return readdirSync(base, { recursive: true })
.map((f) => join(base, String(f)))
.filter((p) => /\.(md|mjs|sh|json)$/.test(p) && !p.includes('node_modules'));
} catch { return []; }
}).filter((p) => resolve(p) !== resolve(fileURLToPath(import.meta.url)));
/*
* ★★★ **必须排除本判据自己** —— 这一条我第一版漏了,当场假绿。
*
* 那条注释里写着「`archive-stale-sessions.sh` —— 非自身引用 = **0**」,
* 而判据扫的是"文件里有没有出现这个名字" ⇒ **它自己那句描述**就被数成了 1 个引用
* ⇒ 一个零引用的孤儿**因为被描述成孤儿而看起来有引用** ⇒ `refs=1` ⇒ 判绿。
*
* 实测:排除 SELF 之前 8/8 全绿(假绿);排除之后立刻报出 `archive-stale-sessions.sh`。
* 这与本仓已记录的那族**完全同形**:"判据开始消费散文" ——
* 判据红/绿的原因变成了**它自己怎么写这段说明**,而不是被测对象的状态。
* ⚠️ 泛化:**任何"扫全仓找引用"的判据都必须排除观察者本身**,
* 否则"描述缺陷"与"存在引用"不可区分(这条与 `stripComments` 那条同源)。
*
* ★★★ 还有**第二个**排除,它常被误认成同一个(pi `661416f3` 就认错了,我实测反驳):
* 这里有两处 `continue`,**职责不同**:
* · `SCAN` 那一层(上面 `.filter(resolve(p) !== …import.meta.url)`)排除的是**观察者**
* —— 本判据自己,理由见上。
* · 这一层(`resolve(join(DEPLOY, t))`)排除的是**被测工具自己的文件** ——
* 每个工具的头注释都写自己的名字(实测 5/5 各 1 处),若不排除,
* **"自名"就会被算成"发现路径"** ⇒ 每个孤儿自己证明自己可达 ⇒ 永久假绿。
* ⇒ ★ 所以这一处**不能**改成"排除观察者",那是**删掉**本排除(实测: 真孤儿立刻漏报)。
* ★ 但"按名字(basename)"确实不够 —— 那是 pi 那封里**唯一成立**的部分:
* 若别处出现与工具同 basename 的文件(如 `docs/prune-demo.sh`),
* `basename(p) === t` 会**把它也跳过** ⇒ 那处引用白算 ⇒ **假红**(实测: 唯一引用在
* 同名他文件里时,按名字的版本误报孤儿;按身份的版本判绿 ✓)。
* ⇒ 结论(比 pi 的建议严一格,且不丢本排除): **按身份比对被测工具自己的路径**。
*
* ★★★ 而**第三个**洞是我自己实测出来的(pi `cf5d9b18` 的 ⑯″ 正好指向它,我复现):
* 原判据只问「这个名字出现过吗」(`prose(p).includes(t)`)⇒
* **"提到"就算"发现路径"** ⇒ 于是**我自己的分析散文**(本仓 `docs/API.md`,5420 行逐轮记录)
* 里随口提一句,就能让一个真孤儿判绿。
* 实测(构造):
* 造 `deploy/zz-prose-only.sh`(无任何文档专节)+ 只在 `docs/API.md` 追加一行
* "分析随笔:zz-prose-only.sh 这次只是个例子" ⇒ 判据 **9/9 全绿**(孤儿没被报出)★ 假绿
* ⇒ 根因: 判据**分不清**「有人会照着这条信息找到它」(发现路径)与
* 「我在讨论里提过它」(提到)。而**分析日志恰恰全是后者**。
* ★ 修法: 要求引用是**可定位的** —— 必须带路径 `deploy/<t>`,而不只是裸名。
* 理由: 「发现路径」的本义是"读者能**照着走到**那个工具";裸名不告诉他在哪。
* 实测本仓 5/5 个非门禁工具的现有发现路径**本来就都是路径限定的**
* (`docs/DEV-TOOLING.md` 的专节标题与表格、`deploy/redeploy-gateway.sh` 注释、
* `deploy/prune-deploy-artifacts.sh` 注释、`docs/DEBTS.json` 的 where)
* ⇒ 所以这条收紧**不误伤任何现存工具**,而恰好挡住"散文提到"。
* ⚠️ 注意它**不是**"排除 `docs/API.md` 这个文件"—— 那是按文件名裁豁免(给逃逸指路)。
* 判据落在**引用的形态**上,任何文件里的路径限定引用都算数。
*/
const SELF_FILE = resolve(fileURLToPath(import.meta.url));
/** 反空真: 两处排除必须真的指向不同对象,合并了就会退化成"删掉一个"。 */
assert.notEqual(
resolve(join(DEPLOY, tools[0])), SELF_FILE,
'两处排除指向了同一个文件 ⇒ 本判据的排除逻辑坏了(观察者 == 被测工具?)。',
);
/*
* ★ 判据自身的判定器 + **它的自检**(照本仓"每条判据都要能被判据守"的惯例)。
* 为什么必须自检: 这条判据的**判据**就是"算不算发现路径",它一旦退回裸名匹配,
* 症状是**假绿**(孤儿不报)—— 而那正是它存在的理由,所以不能只靠注释声明。
*/
const isDiscoveryRef = (text, tool) => text.includes(`deploy/${tool}`);
assert.equal(isDiscoveryRef('见 deploy/archive-stale-sessions.sh', 'archive-stale-sessions.sh'), true,
'路径限定引用必须算作发现路径');
assert.equal(isDiscoveryRef('见 archive-stale-sessions.sh', 'archive-stale-sessions.sh'), false,
'裸名(散文提到)**不得**算作发现路径 —— 它不告诉读者那个工具在哪');
const orphans = [];
for (const t of tools) {
let refs = 0;
for (const p of SCAN) {
// 排除**工具自己的文件**(按身份,不按名字 —— 按名字会漏掉同名他文件里的引用)
if (resolve(p) === resolve(join(DEPLOY, t))) continue;
if (isDiscoveryRef(prose(p), t)) refs++;
}
if (refs === 0) orphans.push(t);
}
assert.deepEqual(orphans, [],
`这些工具**没有任何发现路径**(存在,但没人会知道它存在):\n`
+ ` ${orphans.join('\n ')}\n`
+ ' ⇒ 药方:在 `docs/DEV-TOOLING.md` 加一节(照 `prune-deploy-artifacts.sh` 的先例),\n'
+ ' 或在相关入口/工具里引用它。\n'
+ ' ⚠️ 为什么要紧(实测形状):为了躲开 `check-*` 命名约定而**改名**,\n'
+ ' 会让工具从"强制被调用"掉到"零引用",而**上一条判据正好不再覆盖它** ——\n'
+ ' 改名这个合规动作本身制造了一个无判据的盲区。');
});
/*
* ★★ 同一断言在两处各有一份副本时,**两份都要有守**(pi `7ec0044a` 指出,我验证)。
*
* 背景:⑤b("已装二进制 = 当前 HEAD")这句话出现在**两个文件**里 ——
* · `deploy/check-deploy-drift.mjs` 的 `negative` 常量(+ 它的自检格)
* · `deploy/redeploy-gateway.sh` 的注释与 `ok` 文案(§7)
* 而**只有前者有自检**。实测:
* grep '已装二进制 = 当前 HEAD' 全仓 ⇒ **只命中 redeploy-gateway.sh 那一行**
* redeploy-gateway.sh 的 `--self-check` 出现次数 = **0**
* ⇒ 同一个动作、同一句"不覆盖"的声明,**一处有守、一处没有**。
*
* ⇒ 判据: **两份负向清单必须同时存在,且都点名同一对失败类(脏树 / 手工替换)**。
* 为什么不能只查一份: 删掉 shell 那一份、或它漂成只写一项,**没有任何东西会红** ——
* 而读者看到的 `ok` 行正是 shell 那份(那是部署时**打在屏幕上**的那份)。
* ⚠️ 这与上一条(发现路径)同族: 都防"**声明的副本**没有守卫"。
* 区别: 上一条防"没人知道工具存在",这一条防"**声明漂了没人知道**"。
*/
test('★ ⑤b 的负向清单在 JS 与 shell 两处都有副本,两份都必须点名同一对失败类', () => {
const JS = join(REPO_ROOT, 'deploy', 'check-deploy-drift.mjs');
const SH = join(REPO_ROOT, 'deploy', 'redeploy-gateway.sh');
// 每一份都必须同时点出这两类(正是它们**不被**该判据覆盖)
const CLASSES = ['脏树', '手工替换'];
const missing = [];
for (const [name, p] of [['check-deploy-drift.mjs', JS], ['redeploy-gateway.sh', SH]]) {
const src = prose(p);
for (const c of CLASSES) {
if (!src.includes(c)) missing.push(`${name} 未点名「${c}」`);
}
}
assert.deepEqual(missing, [],
`⑤b 的负向清单有副本没写全(**副本漂了,而它恰好是屏幕上看的那份**):\n`
+ ` ${missing.join('\n ')}\n`
+ ' ⇒ 这两类的**唯一**呈现处就是这两份文案;漏掉一类 ⇒ 读者会把绿读成"没问题":\n'
+ ' · 脏树构建(含未提交代码,只披露不判红)\n'
+ ' · 部署后被手工替换/修改(只看内嵌 revision)\n'
+ ' ⚠️ 为什么两处都要守: `check-deploy-drift.mjs` 有 `--self-check`,\n'
+ ' 而 `redeploy-gateway.sh` **没有** ⇒ 只守前者的话,删掉/写漂 shell 那份不会红,\n'
+ ' 而 shell 那份是**部署当时打印 `ok` 行**所用的文本。');
});
/*
* ★★ 仓库里不得有"**句子里的一段**"当文件名(shell 引号事故的化石)。
*
* ## 为什么存在(我 `34bcd3e9` 那轮亲手制造了一个)
*
* 我在 bash 里写了一句带**嵌套未转义双引号 + 裸 `>`** 的说明文本,形状是:
* echo " ⇒ 所以"mtime > 动作时刻是**必要但不充分**,且会被…覆盖""
* bash 把 `>` 读成**重定向** ⇒ **在仓库根建了一个文件**,文件名是那句话的后半截
* (`动作时刻是**必要但不充分**,且会被**后人无关的写**覆盖`),内容是我 echo 的前半截。
* 实测复现: 同名同内容(`⇒ 所以mtime`,18 字节)。
*
* ## 为什么值得一条判据(不是我手滑,是它会**被提交**)
*
* · 未跟踪文件**不会**被普通 `git add <path>` 带上 ⇒ 平时看不见;
* · 但本仓**明文记录过三次 `git add -A` 事故**(`docs/DEV-TOOLING.md`,含把 7000 行
* 重排扫进功能提交那次)⇒ 下一次 `-A` 就会把这个"句子文件"带进历史,
* 而它**永远删不干净**(进了历史就得改写)。
* · 它的**名字本身就说明它是无意的** —— 人的文件名不会是"…且会被**后人无关的写**覆盖"。
*
* ## 判据(零先例实测 ⇒ 不误伤)
*
* 仓库内(排除忽略目录)**不得有**名字含中文标点、markdown 粗体标记、或换行的文件。
* 实测: 加上这条之前,全仓命中 **0**(根目录 0、全仓 0)⇒ 收紧不误伤任何现存文件。
* ★ 判的是**名字**(形态),不判"哪个目录" —— 按目录裁豁免正是本仓记过的"给逃逸指路"。
*/
test('★ 文件名不得是"句子里的一段"(shell 引号事故的化石;含中文标点/粗体标记)', () => {
// 忽略目录:构建产物与依赖,它们的名字不受本判据约束
const SKIP = new Set(['node_modules', '.git', 'build', 'release', 'dist', '.hvigor', '.codegraph', 'oh_modules']);
const PUNCT = ',。、!?;:()「」“”《》【】';
const bad = [];
const walk = (dir) => {
let entries;
try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return; }
for (const e of entries) {
if (SKIP.has(e.name)) continue;
const full = join(dir, e.name);
if ([...PUNCT].some((c) => e.name.includes(c)) || e.name.includes('**') || e.name.includes('\n')) {
bad.push(relative(REPO_ROOT, full));
}
if (e.isDirectory()) walk(full);
}
};
walk(REPO_ROOT);
assert.deepEqual(bad, [],
`这些**文件名是"句子里的一段"** —— 几乎必然是一次 shell 引号事故留下的化石:\n`
+ ` ${bad.join('\n ')}\n`
+ ' ⇒ 常见成因: `echo "…"某词 > 后半句"` —— 嵌套未转义双引号让 `>` 变成**重定向**,\n'
+ ' bash 于是在该目录**新建一个文件**,名字是那句话的后半截。\n'
+ ' ⇒ 处置: 删掉它;并把说明文本改用 `printf \'%s\\n\'` 或单引号包裹(避免 `>` 被解析)。\n'
+ ' ⚠️ 为什么必须现在删: 未跟踪文件普通 `git add <path>` 带不上,**但它会被 `git add -A` 带上** ——\n'
+ ' 而本仓已记录过三次 `-A` 事故;一旦进了历史,这个名字就永远在那儿了。');
});