Files
MailUI4Agents/client/electron/test/criteria-hygiene.test.mjs
JianFeeeee 4d78fc1580 跨端: 钩子删 ref 的**误红**(全零 sha ≠ 空串)+ 闹钟改读**远端真值**(本地 ref 可被改坏)
pi 2026-09-15 复核时找到两条**一行级**的错,两条都对,都已修。

## 一、`.githooks/pre-push`:删除 ref 被误判成"无法解析范围"

根因一行(原 `:33`):

```bash
[ -z "${lsha:-}" ] && continue        # 判的是**空**,而 git 表示"删除"用的是**全零 sha**
```

于是删除时走 `else`,拼出 `旧sha..0000…`,`git log` 解析不了 ⇒ `exit 1` 中止。实测复现(真 bare 仓):

```
$ git push /tmp/bare-del --delete tmp
pre-push: 无法解析范围 '6702cc2f…..0000…' —— 中止推送(宁可推不出去,也不要盲推)
error: failed to push some refs
```

**为什么这不是小事**:**删除不发布任何东西** ⇒ 这是**纯误红**;
而它**恰恰因为"宁可推不出去"这条正确的方向而不可见** —— 看起来就像设计好的安全行为。
同族形状:**一道闸消费了不属于它管辖的东西**("到期闸把汇总一起吃掉"也是这个)。
附带效果:`install.sh --check` 会一直说"钩子已接",而删分支/删 tag 全被挡。

★ **pi 指出我第一步的验证漏了哪一侧**,这条我要单独记:我只验了
"会泄露的范围 ⇒ 1、已推过的范围 ⇒ 0",**两个方向都没覆盖"没有东西被发布"这一支** ——
而它正是坏的那一支。**和"只判下界"是同一条:把边界少判了一侧。**

修后三支全验(真 bare 仓):

| 情形 | 修前 | 修后 |
|---|---|---|
| 干净推入 | 0 | **0** |
| **删除分支** | **1(误红)** | **0** |
| 含 AGC 的范围 | 1 | **1**(仍拦) |

并且验了远端**真的没收到** main(`git --git-dir=… rev-parse --verify refs/heads/main` 不存在)。

## 二、闹钟读的 `origin/main` 是**本地可改**的那条 —— 我自己演示过

pi 的推论很硬:我上一轮判"从未发布 ⇒ 不轮换",用的就是 `git log origin/main -- <路径>`;
而**这条 ref 我一度指到自己伪造的提交上**。
⇒ 只读它的话,"**ref 被改坏了**"与"**它其实被推过**"是**同一个盲区**。
(他并且**对着远端真值重核了那条裁定**,结论仍成立 —— 但那是他补的,不是那条判据自己保证的。)

改法:先问**远端真值**,并且**必须能证明本地 ref == 远端 tip**,本地历史才可信。三值语义:

| 状态 | 处置 |
|---|---|
| 一致 | 判本地历史 |
| **不一致** | **红** —— 本地 ref 陈旧或被改过,此时"绿"毫无意义 |
| **问不到** | **红并明说"这是不知道,不是安全"** |

★ 第三支是**特意**做成红的:一条专门用来抓"绕过"的闹钟,如果因为"不 fetch / 网络不通"
就报绿,那它**自己就能被绕过**。仓库里已有这个三值形状(`PROBES` 的 `unknown`),照它做。
(保留 `localTip.status` 检查:`rev-parse` 失败时报"没有 ≠ 干净"。)

## 三、验证:四态 + 三支,都在**独立副本/临时 bare 仓**里做

```
闹钟  基线(本地有该路径、远端没有)  → ok 6(绿)
      状态2 本地 ref ≠ 远端真值        → not ok(红)
      状态3 问不到远端                 → not ok(红,且理由是"不知道")
      真泄露 远端 main 含该路径        → not ok(红,点名"按已泄露处理/轮换")
钩子  干净推 0 / 删分支 0 / 含 AGC 1
```

**共享仓库一个字节都没动**:`origin/main = 6702cc2` 与远端一致,
refs 只有 `refs/heads/main` 与 `refs/remotes/origin/main`(无漂移)。

★ 这个验证脚本我改了**三版**,每一版的毛病都同族(**读数器没先被证明是好的**),
已全部写进脚本头注释:① `cp -a src/.git dst/.git` 在 dst 不存在时失败 ⇒ 在**没有 .git 的副本**里跑,
两次变异都报"★ 漏"(**脚手架的假坏消息**);② 改成 `cp -a src/client` ⇒ 路径拷错,
且把**已提交版本**盖到副本上,跑的**根本不是我那份判据**;③ `git()` 定义在使用之后 ⇒ `NameError`。
定稿只用 `git clone`、只同步**改过的单个文件**,并**断言副本 md5 == 源树 md5** 才往下走。

## 四、pi 那条去重我确认:`RESULT` 口径已查完

他在 `42631181` 里已经把它查成真 bug(累加块两份),我已在 `d23ef69` 修掉并加了自检 5。
本轮不重复处理。
2026-09-15 12:48:51 +08:00

443 lines
27 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 陈旧或被改过 —— 这种时候"绿"毫无意义)
* 问不到 ⇒ **红并明说**"这是不知道,不是安全"
*/
const rb = /refs\/remotes\/origin\/(\S+)/.exec(git(['symbolic-ref', '-q', 'refs/remotes/origin/HEAD']).stdout.trim());
const branch = rb ? rb[1] : 'main';
const lsr = git(['ls-remote', 'origin', `refs/heads/${branch}`]);
assert.equal(lsr.status, 0,
`问不到远端(\`git ls-remote origin refs/heads/${branch}\` 失败)——\n`
+ ' ★ 这是**不知道**,不是**安全**。一条专门用来抓"绕过"的闹钟,\n'
+ ' 如果因为"不 fetch / 问不到"就报绿,那它自己就能被绕过。\n'
+ ' 修法:确认远端可达、`origin` 名字对,再跑这条。');
const remoteTip = (lsr.stdout.trim().split('\n')[0] || '').split('\t')[0];
assert.ok(/^[0-9a-f]{40}$/.test(remoteTip),
`远端没有 refs/heads/${branch}(或输出形状不认识)—— 同样按"不知道"处理,不当绿。`
+ `\n 原始输出:${JSON.stringify(lsr.stdout.slice(0, 200))}`);
const localTip = git(['rev-parse', `refs/remotes/origin/${branch}`]);
assert.equal(localTip.status, 0,
`本地没有 refs/remotes/origin/${branch} —— 无从证明本地历史等于远端,别把"没有"读成"干净"`);
const localSha = localTip.stdout.trim();
assert.equal(localSha, remoteTip,
'本地 `origin/' + branch + '` 与**远端真值不一致** ——\n'
+ ` 本地:${localSha}\n 远端:${remoteTip}\n`
+ ' ⇒ 本地那条历史**此刻不可信**,而这条判据要读的正是它。\n'
+ ' ★ 这种时候报"绿"是没有意义的(**"ref 被改坏了"与"它其实被推过"是同一个盲区** ——\n'
+ ' 我在做变体验证时就亲手把这条 ref 指到过伪造的提交上)。\n'
+ ' 修法:`git fetch origin` 把它拉回真值,再跑。');
const published = git(['log', `origin/${branch}`, '--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 ')}`);
});