跨端: 预设色板两套(pi:这不是"观感未验"而是机制上确定不同)+ 手写色清册跨文件 + TMPDIR 按会话分家 + 提交归属可判

pi 读完 `model/Wallpaper.ts` 后指出四处,全部处理。这一提交同时改了
`client/harmony/` 与 `client/electron/`(跨端改动),所以 subject 按新约定自报家门。

## 1 预设色板不随主题 —— **类别判错了:不是"未验",是机制上确定不同**

我上一版把"深色档预设"记成"观感未验"。pi 指出:WebUI 的 `.bg-preset-*` 写的是
`rgb(var(--c-blue-100))`,而 `--c-*` 在 `.dark` 里整体换了一套(blue-100 → `30 43 67`),
所以 **WebUI 的预设自动随主题变**;这边只有浅色那套 = 深色主题下"浅色渐变垫在深色系统表面之下",
正是这一整轮在治的病。**它不需要真机就能判**(机制写在代码里)—— 我把可判的东西
记成了"未验",这跟上一轮把"没做"写成"没验"是同一类错。

选 pi 倾向的那条(跟随主题,与 WebUI 一致):

- 色板两套:`LIGHT_*` 取 CSS `:root`、`DARK_*` 取 CSS `.dark`;`paletteFor(dark)` 选一套,
  `layersFor(id, dark)` 按主题出层;
- `isDarkMode(theme, systemColorMode)` 放在纯逻辑里:选了 dark/light 就照办,
  `system` 看系统当时的 `colorMode`(锚到 SDK:`COLOR_MODE_DARK = 0` / `COLOR_MODE_LIGHT = 1`;
  读不到按浅色,与 WebUI 的 `:root` 默认一致);
- 系统深浅从 `resourceManager.getConfigurationSync().colorMode` 读
  (`Context` 基类没有 `config`;`UIAbilityContext.config` 要转型;两个枚举取值一致,都核过 SDK);
- 判据:两套值与 `:root`/`.dark` **逐个相等**;每个预设的深浅两套**必须真的不同**
  (否则"两套"是抄了两遍);网格线色也要换;`isDarkMode` 五种输入。
- **未做**:运行期间改系统深浅色不会自动重算(要重进页面)——系统侧正确做法是订阅
  `applicationContext.on('environment', …)`,记在 §7.17b。

变异:`DARK_BLUE_100` 偏一位 → 红;`paletteFor` 永远返回浅色(= 我原来那个状态)→ 红;
`isDarkMode` 把系统深浅记反 → 红;页面把深浅写死成 false → 红。

## 2 手写色清册**跨文件按类扫**(原 A2 只保护 `Theme.ets`)

`Wallpaper.ts` 也有手写色。若对照是"按名字枚举"的,第 15 个色就会逃掉 ——
与 A2 要防的是同一件事,只是换了文件。现在一份清册按类扫:全 `ets/` 树里每个
`X: string = '#RRGGBB'` 都必须登记(Theme 的品牌/业务语义色,或预设色板 ——
后者常量名必须带 `LIGHT_`/`DARK_` 前缀,值由 CSS 两段比对负责)。反向也判清册过期。

变异:`Theme.ets` 加未登记色 → 红;`Wallpaper.ts` 加未登记色 → 红;
加一个"看着合规"的 `DARK_EXTRA` → 红。

## 3 `TMPDIR` 互踩(pi 提出)

这个 worktree 可能同时有多个 agent 跑构建,而 fpm 会把 291MB 的 `linux-unpacked`
**整份复制**进 `TMPDIR` —— 撞车就是随机的产物损坏。`whoami` 区分不开(大家都是 root),
所以按**会话**分家:`TMPDIR=$PWD/.tmp/${DSH_SESSION_ID:-$(id -un)-$$}`
(进了 `npm run build:linux` 与 BUILD.md 的手敲命令;普通终端退化成"用户+PID")。

## 4 提交归属变成**跑判据就看得出来**(pi 给的形状)

新的 `test/commit-hygiene.test.mjs`:扫最近 40 条提交,**同时改两侧目录**的提交
必须在 subject 里自报家门(`跨端:`)。两条防腐:基线 = 该判据文件自己的引入提交
(**历史不改**,规则管从今往后);分类逻辑拿合成输入自检
(未标注的混合提交必须判红、标注过的不许红)——否则"解析没跑起来"时它会全绿。
变异:`COMMIT_HYGIENE_BASELINE` 指到老提交 → 历史里那两个被卷进去的提交立刻判红。

## 验证

`hvigorw assembleHap` BUILD SUCCESSFUL;`npm test` 退出码 0
(12 个判据文件全绿 + vitest 258/258;`commit-hygiene` 在本提交落地后基线生效)。
This commit is contained in:
2026-09-14 14:57:18 +08:00
parent 0ce8b29546
commit 22c9be7181
10 changed files with 530 additions and 44 deletions

View File

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