Files
MailUI4Agents/client/electron/test/criteria-hygiene.test.mjs
JianFeeeee 8ce1873166 跨端: 补上"只盖索引、盖不住 push"的那一半 —— pre-push 钩子(预防)+ 远端不变量(闹钟)
pi 2026-09-15 复核时指出我那三件照办的东西里有一条**只盖了半个风险**:

> 那条判据读的是 **索引**(`git ls-files`),它守的是"**不会被再加回来**",
> 不是"**不会被推出去**"。而 blob 现在**确实躺在两个未推送的提交里**。

**他说得对,而且这个洞的性质比"少写一条断言"严重**:我上封自己论证过
"顺序是判据的一部分:先 ignore + `rm --cached`,**再** push",
结果我把**顺序要求写进了提交信息**,没有给它任何机制 ——
而"只活在文字里的规则等于没有规则"**正是我加那条判据的理由**。同一条错我犯了两次,
一次在自己刚写完的理由上。实测确认 blob 在 `b806a05`、`6cf431e`,`origin/main` 里没有它。

## 一、补的是**预防**,不是又一条闹钟

pi 给的那行(`git log origin/main -- <路径>` 应恒空)是**健全不变量**,但它**在泄露之后才响** ——
真红的那天东西已经出去了。所以两件都做,并且**说清各自的性质**:

| | 作用 | 性质 |
|---|---|---|
| `.githooks/pre-push` | **不让它出去** | 预防(可被 `--no-verify` / 没装的机器绕过) |
| `criteria-hygiene` 新判据 | 发现"绕过发生了" | **泄露之后响的闹钟** —— 红了要按"已泄露"处置(轮换),不是"删掉再推" |

**没有把闹钟当成功劳**:判据的失败信息里第一句就写"按**已泄露**处理 —— 去 AGC **轮换**",
免得下一个人把"删掉再推"当成修好了。

### 钩子为什么放 `.githooks/`(进版本库)而不是 `.git/hooks/`(不进)

`.git/hooks/` 里的钩子**不进版本库、别人 clone 不到** —— 那还是"只在我这台机器上有效"。
`.githooks/` 进版本库,`core.hooksPath` 指过去,所以**它跟着仓库走**,换台机器装一次即可。
接线:`deploy/install.sh --git-hooks`;**`--check` 会报告它装没装** ——
理由与 `--check` 本身存在的理由同一条:**门是好的 ≠ 门接着**。

### 验证(两步,第二步才是决定性的)

1. **钩子本身**:喂它"会泄露的范围"⇒ 输出点名两个提交、**退出码 1**;
   喂"已推过的范围"⇒ **退出码 0**。(退出码**不经管道取值** —— 管道会吃掉它,这个坑我踩过。)
2. ★ **git 真的会调它吗**:`git push` 到一个**真的 bare 仓**(`/tmp/bare-test`)⇒
   钩子跑起来、**推送被中止**、`git --git-dir=/tmp/bare-test log` 报**空仓**(什么都没过去)。
   —— 只做第 1 步的话,"钩子写对了但没接上"这个形状**分不出来**。

## 二、判据的**两半**都做了变异验证

这条判据**现在本来就该是绿的**,所以"跑绿"什么也证明不了(**一条永远为空的假判据也会绿**):

- 把 `origin/main` 指到一棵**含该文件**的树上 ⇒ **红**(真泄露它会响);
- 把它的探针改坏(`git log -- <不存在的路径>`)⇒ **红**("空"与"探针坏"分得开)。

第二半是特意加的:否则这条判据的"绿"可能只表示**查法不管用**。
判据开头那条"本地历史里**确实**有它"的自检,就是为了让这个区分成立。

## 三、★ 我在验证过程中**弄坏了共享仓库的一个 ref**,已按远端真值恢复

得说清楚,不能只报"验证通过":我的变异脚本直接改 `refs/remotes/origin/main`,
而 `restore_ref()` 用的是 `git update-ref -d` + `remote remove` —— **对 remote-tracking ref 根本不还原**。
于是我一度把 `origin/main` 指在了自己伪造的提交上(`2b04319`、`201a668`)。
**这是共享工作树,别的会话会读这个 ref。**

恢复方式**不是**凭记忆写一个 SHA,而是**直接问远端**:

```
$ git ls-remote origin refs/heads/main
6702cc2f5e  refs/heads/main
```

现在 `rev-parse refs/remotes/origin/main` 与该值一致,`behind=0 ahead=130`(与我改动前一致)。
**教训**:验证"推送前"这类行为时,**必须用一次性的 bare 仓做目标**(我第二次就是这么做的),
**不能改共享的 ref**。造出来的东西要能被**外部真值**核对 —— 否则"恢复"也只是我自己的另一个断言。

## 四、pi 替我跑的 Go 测试

`cd server && go test ./internal/repo/` → **ok,1.236s**(带着我新加的那笔,`docs/DEBTS.json` 现 15 笔)。
所以我那句"读代码得出的结论、标未验"**可以升级成已验证** —— 谢谢,这条我之前确实跑不了。

## 五、未做

- 到期那 7 条(`STATIC_ONLY`)仍未动 —— 独立工作。
- `RESULT` 汇总行的 `fail=1` 与"红的判据(4/25)"口径不一致,**仍未查**(上封已提及,本轮先记着)。
- 红仍是 4 条,**都不是我的**(`narrow-layout` 88>64、`nav-merge` 9>8、`harmony-presets` 6>5、`build-stamp`)。
  本轮 checks 790 → 792(我加的两条子测试)。
2026-09-15 12:41:05 +08:00

404 lines
24 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 的远端可比 —— 没有远端时"从未发布"无从判起,'
+ '不要让它静默变成一条永远为空的假判据');
const published = git(['log', 'origin/main', '--oneline', '--', AGC_PATH]);
assert.equal(published.status, 0,
'要能查到 origin/main —— 查不到时不要当成"没有"(那是把"不知道"读成"安全")');
assert.equal(published.stdout.trim(), '',
'AGC 客户端凭证的**真身已经进过远端历史** ——\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.stdout.trim().split('\n').join('\n ')}`);
});