/** * 提交归属判据 —— "跨端提交必须自报家门"。 * * 由来(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); });