Files
MailUI4Agents/client/electron/test/commit-hygiene.test.mjs
JianFeeeee b16e2d24fd fix(欠账): RESULT 行同时打"登记 vs 实测"(副本漂移可见 + 不等即红);共享树 stash 纪律与两条推广入册
pi 2026-09-14 三条。

1. **可见的那个数字是副本**:`docs/DEBTS.json` 手可编辑,而它唯一的守卫是 Go 那条断言
   —— 相位耦合(打印它的相位里 Go 断言未必跑过)+ 副本会漂移(我自己 §12 那句话)。
   现在 RESULT 行同时给出:

   `debts=9(registered) measured_static=5==登记 ✓(…;本相位只有 static 有权威实测,其余由 go test 侧判据比对)`

   即:**本相位能实测的(static,权威来源就是代码)当场比对并标注 ✓/✗**;
   **测不了的明说由谁比对**,不冒充已实测。并新增判据:**static 的登记值 ≠ 实测值即红**。

2. **共享树上不要用 `git stash` / `--autostash`**(pi:同一台机器,只是自动化了)——
   它把**别人**未提交的改动也收走,冲突/中断时可能留在 stash 里,而 ta 只看到"我的改动不见了"。
   入册 CRITERIA §8.1:优先 `git worktree add`;必须原地操作就先约定窗口;事后核对别人的文件还在原处。

3. **两条推广入册**(各只出现过一次,但形状会复发):
   · §15 **判据必须自足**:不许通过共享可变状态在判据之间传递结论 —— 顺序依赖的显形方式
     永远是**假绿**(我踩过:余额由 SKIP 那支写入、登记判据去读 ⇒ 同包按文件顺序跑 ⇒ 读到 0);
   · §16 **"要提醒人的"输出必须走默认路径**(`TestMain` 收尾打印在常态 `go test` 里不存在),
     配套动作是 pi 那句:**写完"可打印的余额"之后,去看一眼默认路径实际打出来的样子**。
2026-09-14 17:21:26 +08:00

184 lines
8.8 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 { 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 { readFileSync } = await import('node:fs');
const { join } = await import('node:path');
const src = readFileSync(join(HERE, 'run-all.mjs'), 'utf8');
const registered = JSON.parse(readFileSync(join(HERE, '..', '..', '..', 'docs', 'DEBTS.json'), 'utf8'))
.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} —— 可见的那个数字是副本,` +
`漂移了就必须两边一起改(这类"登记类"判据别改数字了事:先看是哪笔欠账还清了)`);
});