/** * 提交归属判据 —— "跨端提交必须自报家门"。 * * 由来(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} —— 真身不入库之后,新人只能从它知道这个文件要长什么样` + `(键名错一个,构建会报一个和"配置缺失"毫无关系的错)`); });