Files
MailUI4Agents/client/electron/test/commit-hygiene.test.mjs
JianFeeeee 6cf431ee11 跨端: AGC 客户端配置不入库(gitignore + rm --cached + example)+ 一条判据代替"靠记得"
pi 2026-09-15 的裁定:**gitignore + `git rm --cached` + example,不轮换**。
我照办了,并且把**决定性事实**更正过来 —— 我上一封说"已经进了公开历史",**那句是错的**。

## 一、暴露窗口:我原先的假设**方向反了**

我上一封写的是"它**已经进过**公开仓历史,gitignore 撤不回,要认真考虑轮换"。
**实测不成立**(pi 查的,我逐条复核):

```
$ git cat-file -e origin/main:…/rawfile/agconnect-services.json
fatal: path '…' exists on disk, but not in 'origin/main'      ← 远端没有这个文件
$ git branch -a --contains b806a05                            → 只有本地 main
$ git rev-list --count origin/main..HEAD                      → 119(本地领先,落后 0)
```

**整段鸿蒙工作一次都没推上去。** 所以窗口是**"直到下一次 push"**,不是"已经泄露"。
这把修法从**止血**变成**赶在 push 之前做完就行** —— 顺序因此是判据的一部分:
**先入库 ignore + `rm --cached`,再 push**。哪次先推了,就立刻变成"必须轮换"。

**不轮换我同意**,两条理由第二条更硬:① 文件本来就要打进 HAP,HAP 到谁手里它就到谁手里;
② `server/internal/push/config.go:51-55` 的 `AppSecret` 走 `AppSecretFile`(`resolveSecret`,
`hms.go:95` 读它,例 `/etc/agentmail/hms.secret`),**能替你发推送的凭证不在这个文件里**
⇒ AGC 客户端配置泄露**升级不成"能发推送"**。理由已写进提交信息,免得将来有人"按惯例轮换一次"
(那会白白换掉两个客户端版本的一致性判据)。

## 二、`git rm --cached`:本地那份**必须留着**

它**必须在本地存在才能构建**(`hvigorw` 打包时要读)。所以:

```
git rm --cached <文件>     ← 只动索引,磁盘上那份不动
```

撤完实测:`ls` 仍在(2656 B)、`git check-ignore -v` 命中 `client/harmony/.gitignore:26`、
**`hvigorw assembleHap --no-daemon` 仍然 `BUILD SUCCESSFUL`、0 error**。
(这条我特意重编了一次 —— "撤出索引"与"构建还能用"是两件事,不能靠推理。)

## 三、★ 判据才是机制(pi 说的这条比 gitignore 重要,我同意)

`gitignore` 单独挡不住:这个文件**必须在本地存在**,任何人一次 `git add -A` 就把它加回来了,
而**那次 add 不会有任何东西变红**。所以加了
`commit-hygiene.test.mjs` 的「★ 版本库里不许跟踪 AGC 配置真身」:

- 扫**所有 tracked 文件**(不只 rawfile),找"AGC 配置的形状" —— 同时出现
  `"client_secret": "[!` / `"code1": "<32+ 位十六进制>"` / `"api_key": "[!`;命中即红并点名;
- 按**内容**判,不按文件名豁免(`example` 是**故意**带这些键名的 —— 结构留、值全打掉,
  所以它靠"值都是 `<!…>`"自然通过,而不是靠一个文件名白名单);
- 另一半:**`agconnect-services.example.json` 必须存在** —— 否则新人不知道这文件要长什么样,
  只能问人或猜,而**键名猜错会报一个和"配置缺失"毫无关系的构建错**。

**变异验证**(不是只跑绿):`git add -f` 把真身加回来 ⇒ **判据红并点名**;
`git rm --cached` 还原 ⇒ **绿**。

example 我做了泄漏核对:真文件里所有 ≥12 字符的值逐个比对,**只剩 3 处 `package_name`**
(`com.jianf.agentmail`,它本来就写在 `AppConfig` 里、必须是这个值,打掉了反而误导)。
其余保留原值的是 **AGC 各区域网关域名**(`connect-drcn.dbankcloud.cn` 之类)——
那是华为的公共基础设施域名、不是本项目凭证,打掉只会让模板不能用。

## 四、`blurStyleFor`:删除后生产代码里 5 处注释在说一个**不存在的函数**

函数已按 pi 的裁定删除(`9a10ab2`,并发会话落的)。但删除后 `Wallpaper.ts`(4 处)与
`MainPage.ets`(1 处)还在用**现在时**提它 —— 这比之前更危险:下一个人会去找一个
**已经被有意删掉**的函数,找不到就会**重新实现它**,而"为什么不该回来"正是那次删除唯一值钱的东西。
全部改成过去时 + 已删除,并在 `Appearance.ts` 原处留碑文。
`:251` 那处尤其要改:原文"`blurStyleFor` 也写了、就是没有任何调用点"会被读成
**还差一个调用点没补**,而事实是**连函数都不该有**。

## 五、`debt-visibility` 那条红(pi 数出我漏的那条)

`harmony-deviceprobe.test.mjs`(2 处)**按次数登记、不整文件放行** ——
整文件放行的话,将来在这个文件里写一句真实的「这里没判」就**不会红**。
那 2 处也不是"这块没验",而是对**词表本身**的断言。
另在 `docs/DEBTS.json` 补一笔 `deviceprobe-fixture-timing`(到期前提:两份 fixture
从"人工存文件"变成"当场采集")。

⚠️ **Go 侧未本机验证**:`go test ./internal/repo/` 在本机报
`module cache not found: neither GOMODCACHE nor GOPATH is set`。我读了
`TestDebtLedgerMatchesMeasurement`,它只校验"每笔都有 due/where"+"三笔必须同处登记",
**没有"所有 id 必须在 Go 侧列出"的断言** ⇒ 新增一笔不需要改 Go。
但这是**读代码得出的结论,不是跑出来的**,如实标未验。

## 六、我自己记错的两个数(pi 更正)

- **`STATIC_ONLY` 是 7 不是 8** —— 我上封写 8,`RESULT static=7` 与闸门打的 7 个文件
  都是 7。我记串了。
- `PROBE_DEVICE=none` 下**是 7 条红**,我只列了 6 条,漏了 `debt-visibility`(本笔已修)。

现况:**红 7 → 4**,剩的 4 条**都不是我的**(`narrow-layout` 88>64、`nav-merge` 9>8、
`harmony-presets` 6>5 是别的会话新加判据没更新登记数;`build-stamp` 是 `dist` 没重构建)。
2026-09-15 12:25:25 +08:00

232 lines
12 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.

/**
* 提交归属判据 —— "跨端提交必须自报家门"。
*
* 由来(pi 2026-09-14):我这轮的鸿蒙改动被**并发写入者**的 `git add -A`
* 整片扫进了两个 WebUI 提交(提交信息写的是"联系人项玻璃卡""手势与横向滚动分家"),
* 代码是对的、归属是错的。当时的处置是补一条文档留痕(§7.17a)——
* 但"按路径 add"这种做法**靠记性**,而记性不是判据。
*
* 可判的形状(pi 给的):同时改了 `client/harmony/` 与 `client/electron/` 的提交,
* **必须在 subject 里显式标注**(`跨端:`)。我们本来就有"跨端判据要两侧一起改"的约定,
* 那种提交是合法的 —— 只是必须自报家门。不报的,就是被 `git add -A` 卷进去的。
*
* 两条防腐:
* 1. **历史不改**(pi 明确说不用改)。基线 = 本判据文件自己的引入提交,
* 比它更老的提交一律豁免 —— 规则管"从今往后"。
* 2. 分类逻辑拿**合成输入**自检(混合提交无标注 → 必须判红;有 `跨端:` → 不许红)。
* 否则这条判据可能在"解析没跑起来"的情况下全绿。
*/
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import { dirname, join } from 'node:path';
import { prose } from './lib/read.mjs';
import { fileURLToPath } from 'node:url';
const HERE = dirname(fileURLToPath(import.meta.url));
const ROOT = join(HERE, '..', '..', '..');
/** 跨端提交的"自报家门"标记:subject 里出现任一个即可 */
const MARKERS = ['跨端:', 'cross-client:', '[跨端]'];
/** 判据扫多少条提交 */
const SCAN = 40;
function git(args) {
return spawnSync('git', args, { cwd: ROOT, encoding: 'utf8' });
}
/**
* 解析 `git log --format=%H%x09%s --name-only` 的输出 → [{hash, subject, files}]。
*
* 纯函数,便于拿合成输入自检(见文件末尾)。
*/
export function parseLog(out) {
const commits = [];
let cur = null;
for (const line of out.split('\n')) {
if (line.startsWith('\t')) continue;
const m = /^([0-9a-f]{40})\t(.*)$/.exec(line);
if (m) {
cur = { hash: m[1], subject: m[2], files: [] };
commits.push(cur);
continue;
}
const f = line.trim();
if (cur !== null && f.length > 0) cur.files.push(f);
}
return commits;
}
/** 一趟提交该不该被要求"自报家门":两侧目录都碰了才算跨端 */
export function isMixed(commit) {
const harmony = commit.files.some(f => f.startsWith('client/harmony/'));
const electron = commit.files.some(f => f.startsWith('client/electron/'));
return harmony && electron;
}
/** 有没有自报家门 */
export function isDeclared(commit) {
return MARKERS.some(m => commit.subject.includes(m));
}
/** 分类:返回该提交的判定结果(供判据与自检共用) */
export function classify(commits, baseline) {
const idx = baseline === null ? -1 : commits.findIndex(c => c.hash === baseline);
// 基线之后的提交才判(基线找不到时:全判,宁可红也不要静默放过)
const inScope = idx >= 0 ? commits.slice(0, idx) : commits;
return inScope
.filter(isMixed)
.map(c => ({ hash: c.hash.slice(0, 8), subject: c.subject, declared: isDeclared(c) }));
}
test('跨端提交必须自报家门(同时改 harmony 与 electron 的提交要标 `跨端:`)', () => {
const available = git(['--version']);
assert.equal(available.status, 0, '这条判据要能跑 git');
const log = git(['log', `-n${SCAN}`, '--format=%H%x09%s', '--name-only']);
assert.equal(log.status, 0, 'git log 要能跑(否则这条判据无从判起)');
const commits = parseLog(log.stdout);
assert.ok(commits.length >= 5, `要能解析到提交(实际 ${commits.length} 条)—— 解析退化了就会全绿`);
/*
* 基线:本判据文件自己的**引入提交**。比它更老的提交(含被卷进 WebUI 提交的那两个)
* 一律豁免 —— pi 明确说历史不用改,规则管"从今往后"。
* `COMMIT_HYGIENE_BASELINE` 可覆盖(用于验证判据真的会红)。
*/
const envBaseline = process.env.COMMIT_HYGIENE_BASELINE;
const own = git(['log', '-1', '--format=%H', '--', 'client/electron/test/commit-hygiene.test.mjs']);
const baselineHash = envBaseline !== undefined
? (envBaseline === '' ? null : envBaseline)
: (own.status === 0 && own.stdout.trim().length === 40 ? own.stdout.trim() : null);
const verdicts = classify(commits, baselineHash);
const undeclared = verdicts.filter(v => !v.declared);
assert.deepEqual(undeclared.map(v => `${v.hash} ${v.subject}`), [],
'这些提交同时改了 client/harmony/ 与 client/electron/ 却没说自己是跨端提交 —— ' +
'要么在 subject 里写明(`跨端: …`),要么别用 `git add -A` 把别人的改动卷进来(按路径 add)');
});
test('自检:分类逻辑对合成输入有分辨力(否则这条判据可能在"解析没跑起来"时全绿)', () => {
const mixed = {
hash: 'a'.repeat(40),
subject: 'fix(webui): 联系人项改为玻璃卡',
files: ['client/electron/src/x.tsx', 'client/harmony/entry/src/main/ets/pages/MainPage.ets']
};
const declared = {
hash: 'b'.repeat(40),
subject: '跨端: 通信页签两边一起改',
files: ['client/electron/src/x.tsx', 'client/harmony/entry/src/main/ets/pages/MainPage.ets']
};
const single = {
hash: 'c'.repeat(40),
subject: 'fix(webui): 只改 WebUI',
files: ['client/electron/src/x.tsx']
};
assert.equal(isMixed(mixed), true, '两侧都改 = 跨端');
assert.equal(isMixed(declared), true);
assert.equal(isMixed(single), false, '只改一侧不算跨端(否则这条会天天误报)');
assert.equal(isDeclared(mixed), false);
assert.equal(isDeclared(declared), true, '`跨端:` 要认出来');
// 基线之后:混合未标注 → 判红;标注过 → 不判
const v = classify([mixed, declared, single], null);
assert.deepEqual(v.map(x => `${x.hash.slice(0, 2)}:${x.declared}`), ['aa:false', 'bb:true']);
/*
* 基线语义:**比基线新的**才判(git log 是新的在前)。
* 所以数组里 mixed 在前(比 declared 新)→ 它要判;declared 自己与更老的 single 豁免。
*/
const scoped = classify([mixed, declared, single], declared.hash);
assert.deepEqual(scoped.map(x => x.hash.slice(0, 2)), ['aa'], '基线之前的提交要豁免(历史不改)');
// 反向:基线就是最新那条时,没有任何提交在范围内(= 刚引入这条判据时的正常状态)
assert.deepEqual(classify([declared, mixed, single], declared.hash).filter(x => x.hash.startsWith('bb')), []);
// 解析器:真实的 git log 形状要能解开
const parsed = parseLog([
`${'d'.repeat(40)}\t跨端: 某次跨端改动`,
'',
'client/electron/src/a.ts',
'client/harmony/entry/src/main/ets/a.ets',
`${'e'.repeat(40)}\tfix(webui): 只改 WebUI`,
'',
'client/electron/src/b.ts',
''
].join('\n'));
assert.equal(parsed.length, 2);
assert.equal(parsed[0].files.length, 2, '文件清单要解析到(否则 isMixed 永远 false)');
assert.equal(parsed[1].files.length, 1);
});
/**
* ★ 欠账余额的**可见副本**必须与实测一致(pi 2026-09-14 裁定 §1)。
*
* `docs/DEBTS.json` 是手可编辑的副本;只打登记值的话,可见数字可以相对现实过期而全绿
* —— 这正是"门挂错了相位"的又一格。本判据管得住的那一笔是 `static-criteria`:
* 它的权威来源就是代码本身(`STATIC_ONLY.length`,在 `run-all.mjs` 里)。
* (Go 侧那几笔由 `go test` 的 `TestDebtLedgerMatchesMeasurement` 比对;本相位测不了它,
* 所以 **不**在这里假装比对 —— 只把"能测的那笔"钉住。)
*/
export async function debtLedgerStaticMatches() {
const src = prose(join(HERE, 'run-all.mjs'));
const registered = JSON.parse(prose(join(HERE, '..', '..', '..', 'docs', 'DEBTS.json')))
.debts.find(d => d.id === 'static-criteria').count;
// 实测:STATIC_ONLY 的条目数(数 `'test/xxx.test.mjs', '...', '...'` 形状的行)
const block = /const STATIC_ONLY = \[([\s\S]*?)\n\];/.exec(src);
assert.ok(block, '要能取到 STATIC_ONLY 清单');
const measured = (block[1].match(/^\s*\['test\//gm) || []).length;
return { registered, measured };
}
test('★ 欠账登记里的 static-criteria 必须等于实测(可见副本不许漂移)', async () => {
const { registered, measured } = await debtLedgerStaticMatches();
assert.equal(measured, registered,
`欠账登记说 static-criteria=${registered},实测 ${measured} —— 可见的那个数字是副本,` +
`漂移了就必须两边一起改(这类"登记类"判据别改数字了事:先看是哪笔欠账还清了)`);
});
/**
* ★ 版本库里不许有 **AGC 客户端配置的真身**(pi 2026-09-15 裁定)。
*
* 为什么单靠 `.gitignore` 不够 —— 这是这条判据存在的全部理由:
* `agconnect-services.json` **必须在本地存在才能构建**(`hvigorw` 打包时要读它),
* 所以它天然会被 `git add -A` 顺手加回来;而**任何一次这样的 add 都不会有东西变红**。
* **ignore 是方便,判据才是机制。**
*
* 判据形状:扫**所有 tracked 文件**(不只 rawfile),找"AGC 配置的形状" ——
* 同时出现 `client_secret` 与 `code1` 这种键。命中即红,并点名文件。
* 另一半:`agconnect-services.example.json` **必须存在** —— 否则新人拿到仓库
* 不知道这个文件要长什么样,只能去问人或者猜(然后可能猜错键名,构建报一个看不懂的错)。
*/
test('★ 版本库里不许跟踪 AGC 配置真身(gitignore 挡不住 `git add -A`,判据才挡得住)', () => {
const ls = git(['ls-files']);
assert.equal(ls.status, 0, '要能列出 tracked 文件');
const files = ls.stdout.split('\n').map(s => s.trim()).filter(Boolean);
assert.ok(files.length > 50, `tracked 文件数异常(${files.length})—— 列不出来时这条会假绿`);
const SHAPE = [
{ what: 'AGC 信封密钥', re: /"client_secret"\s*:\s*"\[!/ },
{ what: 'AGC 校验码', re: /"code1"\s*:\s*"[0-9A-F]{16,}"/ },
{ what: 'AGC api_key 信封', re: /"api_key"\s*:\s*"\[!/ }
];
const offenders = [];
for (const f of files) {
// example 是**故意**带这些键名的(结构留、值全打掉)——按内容判,不按文件名豁免
const src = prose(join(ROOT, f));
const hit = SHAPE.filter(s => s.re.test(src)).map(s => s.what);
// 只声明了键名但没有真值(值全是 `<…>`)的不算违规
const onlyPlaceholders = !/\[!00/.test(src) && !/"[0-9A-F]{32,}"/.test(src);
if (hit.length > 0 && !onlyPlaceholders) offenders.push(`${f}(${hit.join('、')})`);
}
assert.deepEqual(offenders, [],
'这些 tracked 文件里有 **AGC 配置的真身**(不是 example)——\n' +
' 它是客户端凭证,而本仓的镜像是**匿名可 clone 的公开项目**(docs/GITCODE-MIRROR.md):\n' +
' 一旦随 push 上去就撤不回。处理:\n' +
' 1) `git rm --cached <文件>`(**加 --cached**,别把本地那份删了 —— 构建要用它)\n' +
' 2) 确认 `client/harmony/.gitignore` 里那条规则在\n' +
' 3) 检查它有没有**已经**进过某次 push:`git cat-file -e origin/main:<路径>`\n' +
' 有 ⇒ 已经泄露,要按"已泄露"处理(轮换);没有 ⇒ 只是本地,赶在下一次 push 前做完就行\n' +
` 命中:\n ${offenders.join('\n ')}`);
const examplePath = 'client/harmony/entry/src/main/resources/rawfile/agconnect-services.example.json';
assert.ok(files.includes(examplePath),
`缺少 ${examplePath} —— 真身不入库之后,新人只能从它知道这个文件要长什么样` +
`(键名错一个,构建会报一个和"配置缺失"毫无关系的错)`);
});