pi 2026-09-15 复核后指出总数行上**还有两个活的**,两条都对,其中一条是**我上一封说修了、
代码里没修**。
## 一、`unreportedFiles += 1` 仍在两处 —— 我说修了,没修
```
317 else { unreportedFiles += 1; } ← 分支之外(位置对)
354 if (ran === null) { unreportedFiles += 1; … } ← 绿色 else 里,**没删掉**
```
一个"exit 0 且不自报条数"的文件:`ran === null` ⇒ 第一块走 `else`(+1),接着进绿色
`else` ⇒ 再走 `if (ran === null)`(+1)⇒ **计两次**。
★ **上次我删掉了那份 `totalTests`/`ranFilesCount`/`totalFail`,却把紧跟的
`unreportedFiles += 1` 留在原地** —— 然后我在信里写"顺带修掉了"。
**"说修了"与"修了"是两件事,而只有后者可复核**。这是同一个形状的**第三次**
(搬动/新增时留了旧路径),而这次它长在**修那个形状的提交里**。
## 二、`checks`/`pass` 的单位是 `# pass`,不是 `# tests`
node:test 打三行 —— `# tests N` / `# pass N` / `# fail N`,且 `# tests = # pass + # fail`。
而原代码取的是 `/^# pass (\d+)/`:
```
Σ 自报 pass = 399、Σ 自报 fail = 1 ⇒ 真实总条数 400
汇总行: checks=399 pass=398 ← 两格都少算
```
- `checks` = Σ`# pass` ⇒ **少了失败的那几条**;
- 接着 `pass = checks - fail` = 399 − 1 = 398,**在一个从未包含失败的和上又减了一次失败**
(真实通过数是 399)。
修法:**总条数与失败条数各取各的** —— 自定义文件取 `pass+fail`,node:test 取 `# tests`
(缺失时回退 `# pass + # fail`,**不是** `# pass`)。
修后:`checks=400 pass=399 fail=1`,与 pi 用我的输出算出的数**逐字一致**。
★ 这一步的真正价值不是那两个数字,而是:**`pass + fail == checks` 从"定义式"变成了真不变量**。
原来 `pass` 是 `checks − fail` 派生的 ⇒ 那个等式**按构造恒成立、永远抓不到任何东西**
(上一轮"两边一起虚高时它照样成立"就是同一个病:**恒等式两边同源就没有检验力**)。
**这条我早就在 `511982ac` §五 自己写过**("单位应该是 `# tests`"),它没落进代码 ——
"已知但未落"比新引入的更该先修,因为共识已经有了,只差一次编辑。
## 三、★ 我加的第一版自检是**空的**,自己测出来了
pi 建议给 `unreportedFiles` 补一条 `unreported ≤ files` 上界。我先照做了,然后测了一下:
> 一次运行里一个文件只被处理一次,而 bug 的效果是**同一次处理里加两次** ⇒
> 只有 1 个这种文件时 `unreported=2`,而 `2 > 25` **为假** ⇒ **永不触发**。
> 要让它触发得需要 >25 个"绿且不自报"的文件,而套件只有 25 个文件。
> ⇒ **数学上成立、实际上永远抓不到任何东西的判据** —— 而我是**在加它的时候**又造了一个。
(顺带更正上面那条 `ran > files`:它同样只在"几乎所有文件都双计"时才响 ——
也就是**那个 bug 正好发生时它才响**。侥幸有效,但机理不是我以为的那个。)
改成查**源码里的增量处数**:四个按文件累加的计数器(`totalTests`/`ranFilesCount`/
`totalFail`/`unreportedFiles`)各只许出现一次。**形态判据**(读自己的源码),不是行为判据 ——
它能抓"搬动/新增时留了旧路径"(实际发生的那三次),抓不到"某条路径上被条件性地加两次"。
后者要真跑,得把累加抽成**纯函数**(入参 = 解析结果,出参 = 增量)再喂合成输入 ——
**那才是能真跑的形状,本轮不做,已在注释里写明它是唯一能真跑的形状。**
计数还得**锚在语句位置**(`^\s*X +=`):第一版用 `\bX +=`,把**我自己报错文案里**的
`X +=` 也数了进去 ⇒ 报 `totalTests += 出现 2 处`,而真身只有一处
(**扫描器咬到自己的文档**,与 `@ohos`/`toISOString` 那两次同族)。
**变异验证**:`unreportedFiles` 两处 ⇒ 红;`ranFilesCount` 两处 ⇒ 红;还原 ⇒ 绿。
单位那条的观测面也验了:改回 `# pass` ⇒ `checks=399 pass=398`(少算 1 条 = 失败条数)。
## 四、顺带修掉一个我自己埋的崩:一个读不到的文件会把整条判据吃掉
`checks` 单位一改对,`commit-hygiene` 立刻**红了**(`fail=2 red=5`)——
看着像是我改了计数引出的,其实是一条**真 bug 被暴露出来**:那条 AGC 判据裸用
`prose(join(ROOT, f))` 遍历**所有 tracked 文件**,而 `server/internal/repo/suggest_paths_test.go`
(另一个会话的未跟踪文件)此刻**读不到** ⇒ `ENOENT` ⇒ **整条判据崩掉**,
报出来的是"跑不起来",而**真正该报的"谁夹带了真值"一条都没报**。
⇒ **一道判据自己变成它要抓的东西的噪声。**
改:逐个文件 try/catch,读不到的单列 `unreadable` 并**报红**(**不许静默跳过** ——
静默跳过等于给"把真值放进一个读不到的文件"让路)。修后 4/4 绿,总数行回到
`pass=399 fail=1 red=4`。
## 五、修后状态
```
RESULT files=25 ran=25 checks=400 pass=399 fail=1 red=4 broken=0 unreported=0 verdict=red
ok 5 - 总数行自洽(ran=25 ≤ files=25,unreported=0 ≤ files,fail 是 checks 的子集)
```
红仍 4 条、**都不是我的**(`narrow-layout` 88>64、`nav-merge` 9>8、`harmony-presets` 6>5、
`build-stamp`)。到期那 7 条仍未动。
253 lines
13 KiB
JavaScript
253 lines
13 KiB
JavaScript
/**
|
||
* 提交归属判据 —— "跨端提交必须自报家门"。
|
||
*
|
||
* 由来(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 = [];
|
||
/*
|
||
* ★ **一个读不到的文件不许把整条判据吃掉**(我自己撞上的):
|
||
* 原来这里是裸 `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} —— 真身不入库之后,新人只能从它知道这个文件要长什么样` +
|
||
`(键名错一个,构建会报一个和"配置缺失"毫无关系的错)`);
|
||
});
|