Files
MailUI4Agents/client/electron/test/criteria-hygiene.test.mjs
JianFeeeee cb54c53937 跨端: 补上 --check 的反向洞:CHECK_GATE_RC 真正 exit 出去(红门禁不再退 0);干跑不再重写 dist(BUILD_INFO_OUT 改道临时目录,build-stamp 的红不会被干跑刷掉)
pi 2026-09-15 两条都成立,而且第一条是我**自己上一个 commit 的注释在骗人**。

## 一★★ `CHECK_GATE_RC` 只被打印、**从来没被 `exit` 用** —— 我把它从末尾挪走时丢了退出

```
$ grep -n "exit.*CHECK_GATE_RC" deploy/install.sh
(无)
$ 干跑分支终点: exit 0        ← 无条件 0
```

而我上一版注释写的是"**最后用记下的码退出(见文件末尾的 `CHECK_GATE_RC`)**" ——
**文件末尾根本没有那一行**(那里是正式安装路径的 systemctl/curl,干跑走不到)。
我上一轮把那段从"文件末尾"挪到 `go vet` 之前时,**挪了打印、丢了 `exit`**。

⇒ 后果是我上一个 commit **把病修反了**:

| | 第一道红的行为 | 命令退出码 |
|---|---|---|
| 修之前 | 中止一切,诊断全丢 | **失败**(正确的那半) |
| 我那一版 | 诊断全跑完 | **0**(错的那半) |

而丢掉的这一半**正好是会被引用的那一半** —— 这条链上引用过 `install.sh --check` 的 `exit 0`
当绿读数。**"注释说的事和代码做的事不一致"这是我第三次犯**(前两次:`unreportedFiles` 说修了没修、
`ok 5` 报文认证了没人判的界)。

**修法**:干跑分支终点 `exit "${CHECK_GATE_RC:-0}"`。

## 二★★ "干跑"并不只读:它重写 `client/electron/dist`,能**静默刷掉 `build-stamp` 的红**

同一个脚本在别处很小心地不写 `node_modules`("干跑不写依赖"),却**照跑 `npm run build`**。
两个后果(你指出的):

1. **干跑改共享状态** —— 而它自己那句"干跑没有执行"的清单只列了**工作区外**的路径,`dist` 悄悄在射程内;
2. **它能把 `build-stamp` 从红刷成绿** —— 那条比的是 `dist/BUILD_INFO.json` 的 `gitRev`/`srcHash`
   与当前源码,而它现在的红**恰恰就是"产物是在旧提交上构建的"**(实测 `gitRev=7b16fec` vs `HEAD=406bb34`)。
   跑一次干跑 ⇒ 重构建 ⇒ 不匹配消失 ⇒ **一条红没了,而没有任何人决定过"重构建"**。

**修法**:干跑改成 `gen:bg` + **`vite build --outDir <临时目录>`** + `build-info.mjs`(新增 `BUILD_INFO_OUT`)。
**真装的 `npm run build` 一字不改**(仍写真正的 `dist`)。

**实测**(`dist` 内容指纹 sha256):

```
跑之前  ff97258fbcc1de49…
跑之后  ff97258fbcc1de49…   ✓ 未动
BUILD_INFO.json: gitRev=7b16fec vs HEAD=406bb34 ⇒ **红还在**(没被干跑刷掉)
退出码=1(红门禁 ⇒ 非 0)
```

**退出码两侧都验了**(不是只看"非 0"就收):
- 真跑一次全脚本 ⇒ `bash -x` 追到 `+ exit 1`(**真的走到了我那一行**);
- 抽出那一行(逐字节)配三种前置状态 ⇒ `CHECK_GATE_RC=1` → 1;空 → 0;未设 → 0;
- 构造全绿情形(中和两处真门禁)⇒ **退出码 0**,`FAIL` 行数 0。

★ 顺带一条**我自己踩到的坑**:第一次构造"全绿"副本时我把脚本拷到 `/tmp` 跑,
而 `REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"` ⇒ `REPO=/tmp` ⇒
`env-defaults.sh` 找不到 ⇒ 退出 1,**看着像"绿路径也退 1"**。放到仓内(临时文件、跑完即删)才是对的。
**又一次"我的验证环境和生产不同"** —— 这次差异是脚本位置。

## 三、你指出的"同一现象两种政策":加了互相指向,并写明**为什么不能统一**

- `install.sh` 的 origin WARN 旁边写明:AGC 闹钟对同一现象判**红**,**有意如此**;
- `criteria-hygiene` 第 6 条的报错里加上镜像的一句。

理由(写进两处):那边问"**本机配置能不能用**" ⇒ 离线不是配置错 ⇒ WARN;
本条问"**凭证有没有进过远端历史**" ⇒ **查不了就答不出** ⇒ 红。
并且写明统一到任一边的代价:统一到 WARN ⇒ **泄漏告警在离线时静默变绿**;
统一到红 ⇒ 离线机器上一次干跑就报假红。

## 四、残余(照实写)

- `gen:bg` 会**原地重写** `src/background-takeover.generated.css`(已跟踪)。实测内容幂等
  (`git status` 干净),所以**不改内容**;但"写文件这个动作"仍在
  ⇒ "干跑只读"的准确说法是"**不改任何被跟踪文件的内容、不碰 dist**",不是"一个字节都不写"。
  我把这句写进了注释 —— **没验到的部分不写成保证**。
- `go vet` 那条红是 `/root/.cache/go-build` **权限**问题(环境),不是代码;维持既有记录。
- 你上封提的"记录投影"已在 `406bb34` 做完;与本文无关。
- 红线仍 4 条(`narrow-layout`/`nav-merge`/`harmony-presets`/`build-stamp`),**都不是我的**。
2026-09-15 13:41:50 +08:00

569 lines
35 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 } 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;
const src = prose(f);
if (src.includes("from './lib/read.mjs'") || src.includes("from '../lib/read.mjs'")) {
const m = /import \{([^}]*)\} from '\.\.?\/lib\/read\.mjs'/.exec(src);
const bound = new Set((m ? m[1] : '').split(',').map(x => x.trim().split(/\s+as\s+/).pop()).filter(Boolean));
for (const name of EXPORTS) {
if (new RegExp(`\\b${name}\\(`).test(src) && !bound.has(name)) {
problems.push(`${relative(RELECTRON, f)} 用了 ${name}(…) 但没 import(已 import:${[...bound].join('、') || '无'})`);
}
}
} else {
for (const name of EXPORTS) {
if (new RegExp(`\\b${name}\\(`).test(src)) {
problems.push(`${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 ')}`);
});