Files
MailUI4Agents/client/electron/test/commit-hygiene.test.mjs
JianFeeeee 6df3c356d6 test(判据): ★★★ 套件从 10-02 10:46 起一条都没跑过 —— 补接线 + 结算登记数
**零读数 24 小时,仓库里没有任何记录**(DEBTS.json 58 条里 grep「没接进」0 命中)。

漏接线的三个(都来自 aeb1f41 / 2f17f62,都比 run-all.mjs 最后一次改动晚):
    test/inbox-fallback-poll.test.mjs   ← SSE 兜底轮询(探测 total 变化)
    test/sse-credentials.test.mjs      ← SSE 订阅跟账号凭证走
    test/web-comment-only.test.mjs

**为什么严重(三层放大)**:
1. 自检 2「每个 *.test.mjs 都要在清单里」在跑任何判据**之前** exit(1)
   ⇒ 实测 RESULT 行数 = **0**
2. npm test = run-all && vitest && typecheck ⇒ vitest(270 格)与
   typecheck **一起不跑** —— 而两者单独跑都是绿的
3. 失败信息只有一行中文 stderr,**不含"红"字样**,看起来像"环境问题"
⇒ 下一个人据上一份报告继续推断"判据在把守" ⇒ **报告的证据等级被系统性高估**。
守卫本身**不删**(漏接线绝不静默是真价值),代价靠「新增即接线」这条义务兜。

**顺带结算的登记数漂移**(都在 clean HEAD 上就红,非本次引入):
· harmony-window 9→10、appearance-defaults 7→8、harmony-2in1 23→24
  ("自报条数 > 登记条数"是显式的编辑义务:只判下界时多出来的条数删掉不红)
· harmony-appearance 28→29 —— 配合工作树里别人新增的那条设备判据
· static-criteria 5→4 —— appearance-defaults 已上设备并移出 STATIC_ONLY,
  **移出名单时忘了回头改这笔登记**,由 commit-hygiene 的机器镜像抓住
· debt-visibility REGISTERED['harmony-appearance'] 4→6 —— 两处都是
  **散文**(一处引用文件既有句子、一处在报错文案里),按该文件既有先例登记
  并注明;⚠️ 写那段说明时不能引用词表里的词,否则本文件自己数超(实测 12→14 即红)

**commit-hygiene 基线推进**:7d081095 与 477479a37 两条 `fix(harmony):` 改了
"鸿蒙源码 + 另一侧判据",按本仓口径该标 `跨端:`。二者**已推送到 origin 与
origin-https**(merge-base --is-ancestor 实测为真)⇒ 不能 amend;也不往 MARKERS
放行 `fix(harmony):`(那等于永久允许"说单端、实际改两端")。唯一正确处置是
推进基线 + 具名记下。已验证:COMMIT_HYGIENE_BASELINE 覆盖后 pass=4 fail=0。

**CRITERIA.md 新增 §6.0「怎么读判据的数」** —— 原有 17 节全在讲「怎么写」,
缺的就是这一半,而今天两起独立事件都出在它:
· 6.0.1 接线守卫的失效形状是「全停」不是「那一条不跑」;ran 必须 == SUITE 条数
· 6.0.2 退出码只能来自不接管道的运行(`cmd >file 2>&1; echo $?`)。
  **本会话我连踩 5 次** `cmd | tail -N` ⇒ 报的是 tail 的码。实例:
  npm test|tail-80(真实:0 条判据跑过)、npm test|tail-30(真实:vitest 根本没跑)、
  tsc --noEmit|tail-20(**碰巧**也是 0 —— 事实为真但**当时无根据**,仍须重取证)
· 6.0.3 && 链里「全绿」要问**真跑到那一环了吗**(red 之后的东西根本没跑,
  而日志里「有 RESULT 行」与「无下游输出」可以同时出现)
· 6.0.4 判据变红先问「判据用的工具本身可信吗」(读取器的缺陷是**静默**的)
· 6.0.5 **当你就是改工具的人**:第一假设是「我弄坏了它」不是「代码回归」——
  「判据过期了」这个反应本身就错,它默认了「我改的是正确的东西」。
  附本次三次改错的下游依赖表,以及"下游依赖是**行为依赖**,
  codegraph 那类符号图看不见"。

★ 顺带记一条取证教训:本机每条命令都吐一行 libpcre 的
  `no version information` 噪声 ⇒ 某次 grep 的输出被它吞掉,
  我把"命令返回空"当成了"没有匹配"。**空输出要连退出码一起看**,
  这与 6.0.2 是同一族,只是这次发生在我自己的取证上。
2026-10-03 10:56:47 +08:00

290 lines
16 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` 可覆盖(用于验证判据真的会红)。
*
* ── ★★ 2026-10-03 基线推进(**与下面 2026-09-24 同形状的第二次**:两条 `fix(harmony):` 改了双端)──
*
* 实测两条,都在**基线之前**、且**已推到 origin 与 origin-https** ⇒ **不改历史**:
* 7d081095 fix(harmony): ★★ 顶部文案用 windowDecor 判 2in1(真机实测)
* → client/electron/test/harmony-2in1.test.mjs + client/harmony/.../MainPage.ets
* 477479a37 fix(harmony): ★★ 三页 AppHeader 顶栏避让硬编码 0(真机实测)
* → client/electron/test/harmony-window.test.mjs + 鸿蒙三个 .ets
*
* ★ 为什么是**同一族**疏漏:它们都**改了鸿蒙源码 + 同步改另一侧的判据**,
* 而"鸿蒙侧的每次修复都要同步改另一侧的判据"正是本仓的跨端纪律
* (与 2026-09-24 那四条的成因逐字相同),只是 subject 只写了 `fix(harmony):`。
* ★ 为什么**不能** `git commit --amend` 补标:两条都已推送
* (实测 `merge-base --is-ancestor` 对 origin 与 origin-https 均为真)⇒ 改写会分叉远端。
* ★ 也**不能**往 `MARKERS` 里放行 `fix(harmony):` —— 那等于**永久**允许
* "说单端、实际改两端",判据从此失灵(与 2026-09-24 那次的结论一致)。
* ⇒ 唯一正确处置:**推进基线**(编辑本文件即推进),并把这两条具名记在这里。
*
* ── ★★ 2026-09-24 基线推进(本条注释的作用就是推进它)──
*
* 机制:基线 = **最后修改本文件的提交**(上面那行 `git log -1 -- …`)。
* 所以下面这五条已经落到基线之前、不再被计了:
* 146ebab 鸿蒙|修发件箱点不开(SentRow 缺 onClick)
* 50ff2d2 鸿蒙|日历翻月滑动动画修好
* f4490b1 鸿蒙|图标 fill 型分族 + 页签条阴影根因修复
* e981e24 鸿蒙|动画对齐 all_five 落地
* f08e5ed 跨端对齐:授权栏 navigator_only + 组件按页拆分 ← 这条本来就标了词
*
* ⇒ **这是我的疏漏,记在这里而不是抹掉**:那四条用了 `鸿蒙|` 前缀,
* 但它们同时改了 `client/electron/test/`(鸿蒙侧的每次修复都要同步
* 改另一侧的判据 —— 那是本仓的跨端纪律),按本判据的口径**应该标 `跨端:`**。
* 前缀本来是在说"这件事动了哪些端",标成单端就与事实不符了。
*
* ★ 为什么用"推进基线"而不是往 `MARKERS` 里加 `鸿蒙|`:
* 加了就等于**永久**放行"说单端、实际改两端",判据从此失灵;
* 而推进基线只豁免**已经发生、不可重写**的那五条,往后的照旧要标。
* 判据本意(“同时改两端就要自报家门”)一点没动。
*/
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 = [];
/*
* ★ **一个读不到的文件不许把整条判据吃掉**(我自己撞上的):
* 原来这里是裸 `prose(join(ROOT, f))`。只要**任何一个** tracked 文件此刻读不到
* (并发写入者正在写、刚 `git add` 还没落盘、被 `rm` 了),`prose()` 就抛 `ENOENT`,
* 整条判据**崩掉** ⇒ 报出来的是"跑不起来",而**真正该报的"谁夹带了真值"一条都没报**。
* 实测:`server/internal/repo/suggest_paths_test.go` 被并发会话删掉的那一瞬间,
* 这条判据以 `ENOENT` 红,而它本该报的是 AGC 形状。
* ⇒ **一道判据自己变成它要抓的东西的噪声**。所以逐个文件 try/catch,读不到的单独记账,
* 并且**读不到就明说**(不许静默跳过 —— 静默跳过等于给"把真值放进一个读不到的文件"让路)。
*/
const unreadable = [];
for (const f of files) {
let src;
try {
src = prose(join(ROOT, f));
} catch (e) {
unreadable.push(`${f}(${(e && e.code) || 'ERR'})`);
continue;
}
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(unreadable, [],
'这些 **tracked** 文件此刻读不到 ——\n' +
' 这条判据要**逐个读 tracked 文件**才能判"谁夹带了 AGC 真值",读不到就判不了。\n' +
' ★ 不静默跳过:静默跳过等于给"把真值放进一个读不到的文件"让路。\n' +
` 常见成因:并发会话正在写/删(本仓是共享工作树)。重跑一次通常就好。\n ${unreadable.join('\n ')}`);
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} —— 真身不入库之后,新人只能从它知道这个文件要长什么样` +
`(键名错一个,构建会报一个和"配置缺失"毫无关系的错)`);
});