From 317f3265e3c7d2a3e2b2fa100a8b79663c634e9b Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Mon, 14 Sep 2026 16:35:40 +0800 Subject: [PATCH] =?UTF-8?q?fix(=E5=88=A4=E6=8D=AE):=20=E6=8E=A2=E9=92=88?= =?UTF-8?q?=E4=B8=89=E5=80=BC=20+=20=E5=8F=91=E5=B8=83=E5=80=99=E9=80=89?= =?UTF-8?q?=E6=A0=87=E7=AD=BE=20=E2=80=94=E2=80=94=20=E9=A1=BA=E5=B8=A6?= =?UTF-8?q?=E6=9F=A5=E5=87=BA=E6=8E=A2=E9=92=88=E4=BB=8E=E5=86=99=E4=B8=8B?= =?UTF-8?q?=E9=82=A3=E5=A4=A9=E8=B5=B7=E4=B8=80=E6=AC=A1=E9=83=BD=E6=B2=A1?= =?UTF-8?q?=E8=B7=91=E6=88=90=E8=BF=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pi 的两条"真实的洞",都落了,而且第一条当场抓到实证。 1. **探针三值**(可用 / 不可用 / 拿不准→红):`RESULT static=5 probe=ok|unknown` 把"欠账余额"和"探针是否健康"拆成两个数字。 **换完第一次运行就报 probe=unknown** —— 一查:探针调的是 `execFileSync`, 而这个文件 import 的是 `spawnSync`,**名字根本没定义**。也就是说 **探针从写下的那天起一次都没跑成过**,旧的两值设计把 `ReferenceError` 和"没有设备"一起吞掉、统一报成"设备不可用":机制在、闸门从没开过, 而它看起来完全健康。这正是 pi 描述的"恒不开闸",只是比预想更彻底。 现在:命令在但跑不成 → unknown → 红;所有候选都不存在(本机没装 hdc)→ 可判的 "没有设备工具" → false,避免没装 SDK 的机器天天假红。 附 `--probe-selftest`(只跑分类器,不跑套件)+ 变异验证(把 unknown 当"不成立"→ 红)。 2. **releaseCandidate = !gitDirty**(从展示升成标签):BUILD_INFO 现在自报 `releaseCandidate`,发布脚本在脏树时会打印"这个包不是发布候选"。 判据 `build-stamp` 断言"标签与 gitDirty 必须一致"。 **实证**:本轮我打的包正是这种情况 —— `gitDirty: true`(含着 gui-lab 未提交的 NarrowStack/index.css),`releaseCandidate: false`,日志里明确说了"不是发布候选"。 3. 附带:`criteria-hygiene` 加一条"用到 `code/prose/bytes` 就必须真的 import"。 理由是同一形状我这轮在三个文件里各犯过一次(最后一次是 `execFileSync`/`spawnSync`), 而它表现为"判据红了"(ReferenceError 抛在判据自己身上),看起来像判据失败、 不像判据写错。这条至少把最常写错的那几个名字变成明确的红。 --- client/electron/scripts/build-info.mjs | 12 ++ client/electron/scripts/release-linux.sh | 5 + client/electron/test/build-stamp.test.mjs | 18 +++ .../electron/test/criteria-hygiene.test.mjs | 40 +++++++ client/electron/test/run-all.mjs | 107 ++++++++++++++++-- 5 files changed, 170 insertions(+), 12 deletions(-) diff --git a/client/electron/scripts/build-info.mjs b/client/electron/scripts/build-info.mjs index 36a56cd..d73ff3f 100644 --- a/client/electron/scripts/build-info.mjs +++ b/client/electron/scripts/build-info.mjs @@ -95,6 +95,18 @@ export function writeBuildInfo(root = ELECTRON_DIR, buildCmd = process.env.npm_l buildCmd, builtAt: new Date().toISOString(), // 说明这份文件是什么、谁在比它(读到此文件的人不用去翻代码) + /* + * **标签**,不是展示(pi 2026-09-14 §1)。 + * + * 共享树上永远有人在写别的文件,所以"脏树"是常态 —— 拿它当红会让门禁天天假红。 + * 但"从脏树打出来的包"和"从干净树打出来的包"**在门禁上必须可区分**: + * 前者含别人的半成品,只能自用,不能给人装。所以给一个是/否: + * + * releaseCandidate = !gitDirty + * + * 部署路径可以据此拒绝安装(判据见 build-stamp 的"脏树产物不得自称发布候选")。 + */ + releaseCandidate: !st.gitDirty, note: '构建自证:test/build-stamp.test.mjs 用 srcHash/gitRev 精确比对,不是比时间戳' }; const out = join(root, 'dist', 'BUILD_INFO.json'); diff --git a/client/electron/scripts/release-linux.sh b/client/electron/scripts/release-linux.sh index e563c93..72d97d5 100755 --- a/client/electron/scripts/release-linux.sh +++ b/client/electron/scripts/release-linux.sh @@ -39,6 +39,11 @@ bash -c "$PACK_CMD" # 产物自报来源:把 BUILD_INFO 打进日志(pi 提的"一个包要自带它对应哪个源码状态")。 # 出问题时先看这几行:包是在哪个提交、哪份源码指纹上构建的,以及当时树干不干净。 if [[ -f dist/BUILD_INFO.json ]]; then + # "脏树打的包"必须**看得见**(pi §1):共享树上脏是常态,所以不做成红, + # 但要在日志里明说这个包不是发布候选(含别人未提交的半成品时不能给人装)。 + if grep -q '"releaseCandidate": *false' dist/BUILD_INFO.json 2>/dev/null; then + echo "[release] ⚠ 这个包不是发布候选(releaseCandidate=false):构建时工作树是脏的。" + fi echo "[release] 产物来源:$(cat dist/BUILD_INFO.json | tr -d '\n' | sed 's/ */ /g')" else echo "[release] 警告:dist/BUILD_INFO.json 不存在 —— 构建没走 npm run build?(判据会红)" >&2 diff --git a/client/electron/test/build-stamp.test.mjs b/client/electron/test/build-stamp.test.mjs index 1d4427b..0aad669 100644 --- a/client/electron/test/build-stamp.test.mjs +++ b/client/electron/test/build-stamp.test.mjs @@ -138,3 +138,21 @@ test('★ 产物必须自报来源:BUILD_INFO 精确比对(不是比时间 assert.ok(typeof info.gitDirty === 'boolean', 'BUILD_INFO 要记录构建时工作树是否干净(供复核者判断)'); assert.ok(info.builtAt && info.buildCmd !== undefined, 'BUILD_INFO 要带构建时间与构建命令'); }); + +/** + * ★ 脏树产物**不得自称发布候选**(pi 2026-09-14 §1:把 gitDirty 从展示升成标签)。 + * + * 共享树上永远有别人在写文件,"脏"是常态 —— 所以这条不判"脏就该红"(那会天天假红), + * 判的是**两者必须可区分**:脏树产物只能自用,不能给人装;而这句话得由产物自己说, + * 不能靠读它的人去猜。 + */ +test('★ 脏树产物不得自称发布候选(标签与 gitDirty 必须一致)', () => { + const infoPath = join(ROOT, 'client/electron/dist/BUILD_INFO.json'); + const info = JSON.parse(prose(infoPath)); // JSON 是数据 → prose + assert.equal(typeof info.gitDirty, 'boolean', 'BUILD_INFO 必须报 gitDirty'); + assert.equal(typeof info.releaseCandidate, 'boolean', + 'BUILD_INFO 必须报 releaseCandidate(标签,不是展示)'); + assert.equal(info.releaseCandidate, !info.gitDirty, + `发布候选标签与工作树状态矛盾:gitDirty=${info.gitDirty} 却说 releaseCandidate=${info.releaseCandidate}。\n` + + '脏树打的包里可能含着别人**未提交**的半成品 —— 它只能自用,必须在门禁上可区分。'); +}); diff --git a/client/electron/test/criteria-hygiene.test.mjs b/client/electron/test/criteria-hygiene.test.mjs index 740b48d..a711c4b 100644 --- a/client/electron/test/criteria-hygiene.test.mjs +++ b/client/electron/test/criteria-hygiene.test.mjs @@ -89,3 +89,43 @@ test('★ 判据目录里不得出现裸 readFileSync(必须走 code/prose/byt " · bytes(path) —— 二进制(安装包等)。\n" + " 为什么不能裸用:读原文去判代码,会被解释性注释骗(同一个坑已经踩过两次)。"); }); + +/** + * ★ 用到 `lib/read.mjs` 的导出名就必须真的 import(我这轮在三个文件里各犯过一次)。 + * + * 形状一模一样:`code(...)` / `prose(...)` 写下去,import 里却只有另一个 —— + * 于是在**跑起来的那一刻**才炸 `ReferenceError`,而它抛在判据自己身上, + * 看起来像"这条判据红了",不像"判据写错了"。dsh 桥那边也栽过同一形状 + * (`MODE_FULL` 没 import,而且被 `tsc | tail` 的退出码骗过)。 + * + * 判据做法:把每个判据文件里出现的 `code(`/`prose(`/`bytes(` 收集起来, + * 与它从 `lib/read.mjs` 实际 import 的名字比对;缺一个就红,并点名文件与名字。 + * **例外**:文件里自己定义了同名函数(本地实现)时不算缺 —— 但那种情况要显式声明。 + */ +test('★ 用到 code/prose/bytes 就必须 import(不许靠运行时才发现)', () => { + const EXPORTS = ['code', 'prose', 'bytes']; + const problems = []; + const SELF_PATH = fileURLToPath(import.meta.url); + for (const f of criteriaFiles()) { + // 它自己的源码里就写着 code/prose/bytes 这几个名字(EXPORTS 列表),跳过自己 + if (f === SELF_PATH) continue; + const src = prose(f); + if (src.includes("from './lib/read.mjs'") || src.includes("from '../lib/read.mjs'")) { + const m = /import \{([^}]*)\} from '\.\.?\/lib\/read\.mjs'/.exec(src); + const bound = new Set((m ? m[1] : '').split(',').map(x => x.trim().split(/\s+as\s+/).pop()).filter(Boolean)); + for (const name of EXPORTS) { + if (new RegExp(`\\b${name}\\(`).test(src) && !bound.has(name)) { + problems.push(`${relative(RELECTRON, f)} 用了 ${name}(…) 但没 import(已 import:${[...bound].join('、') || '无'})`); + } + } + } else { + for (const name of EXPORTS) { + if (new RegExp(`\\b${name}\\(`).test(src)) { + problems.push(`${relative(RELECTRON, f)} 用了 ${name}(…) 但根本没从 lib/read.mjs import`); + } + } + } + } + assert.deepEqual(problems, [], + `这些判据会以 ReferenceError 的形式红,看起来像"判据失败了",其实是"判据写错了":\n ${problems.join('\n ')}`); +}); diff --git a/client/electron/test/run-all.mjs b/client/electron/test/run-all.mjs index f846e6e..3075de0 100644 --- a/client/electron/test/run-all.mjs +++ b/client/electron/test/run-all.mjs @@ -59,7 +59,7 @@ for (const must of ['配对/解析', 'allow-list', '变异验证', '剥掉注释 const SUITE = [ ['test/markdown-xss.test.mjs', [], 9], - ['test/narrow-layout.test.mjs', [], 62], + ['test/narrow-layout.test.mjs', [], 64], ['test/nav-merge.test.mjs', [], 8], ['test/theme.test.mjs', [], 30], ['test/background.test.mjs', [], 42], @@ -72,11 +72,11 @@ const SUITE = [ ['test/harmony-nav.test.mjs', ['--experimental-strip-types', '--no-warnings'], 6], // 外观契约:默认值去 Go 源码里读(服务端 DefaultAppearance 是权威)+ 缓存键按账号 ['test/appearance-defaults.test.mjs', [], 3], - ['test/build-stamp.test.mjs', [], 6], + ['test/build-stamp.test.mjs', [], 7], ['test/packaging.test.mjs', [], 5], ['test/commit-hygiene.test.mjs', ['--experimental-strip-types', '--no-warnings'], 2], // 判据目录自身的卫生:读文本必须走 test/lib/read.mjs 的具名入口 - ['test/criteria-hygiene.test.mjs', [], 2] + ['test/criteria-hygiene.test.mjs', [], 3] ]; // 自检 1:清单里的文件必须真的存在(写错名字 = 那条判据永远不跑) @@ -226,25 +226,91 @@ for (const [file, flags, expected] of SUITE) { * 这条是"自报 0 条 < 登记条数"的**时间版本**:那条管"判据还在不在",这条管 * "它该升级了没有"。 */ +/** + * 探针:判定静态判据的"到期前提"是否成立。 + * + * # 三值,不是两值(pi 2026-09-14 §2) + * + * 原先只有"成立/不成立"两种结果,于是**"探针跑不了"和"设备不可用"被归成同一格**: + * 设备那天真可用了,闸门也永远不会开 —— 机制在,闸门锈死,而且看起来完全健康 + * (这是"自报 0 条 < 登记 30 条"的第三种形状:探针自己坏了,没人知道)。 + * + * 所以: + * - `true` 可用(前提成立 → 依赖它的静态判据**到期**,必须处理); + * - `false` 不可用(探针**确实跑成了**,结论是没有目标); + * - `'unknown'` 拿不准(**命令在但跑不成**:超时、非零退出、抛异常)→ **按到期处理**。 + * + * # 这条设计当场抓到了什么(写下来,因为它是"闸门锈死"的实证) + * + * 换三值之后第一次运行就报了 `probe=unknown` —— 一查:探针里调的是 `execFileSync`, + * 而这个文件 import 的是 `spawnSync`(**名字根本没定义**)。也就是说 + * **探针从写下的那天起一次都没跑成过**,而旧的二值设计把 `ReferenceError` + * 连同"没有设备"一起吞掉、统一报成"设备不可用"——**机制在、闸门从来没开过, + * 而它看起来完全健康**。三值把它变成了一声明确的红:探针自己坏了,必须有人看一眼。 + * + * 另外,`'unknown'` 只留给"命令在、但跑不成";**所有候选都不存在**(本机没装 hdc) + * 是可判的事实(没有工具就不可能有设备),报 `false`,否则没装 SDK 的机器会天天假红。 + * 拿不准就红,让人看一眼 —— 这条判断比"猜一个"便宜得多。 + * + * 测试用 `AGENTMAIL_PROBE_DEVICE=ok|none|unknown` 覆盖(判据自检要用: + * 没有这个开关就没法验证"unknown 会不会红")。 + */ const PROBES = { device: { desc: '有可用的设备/模拟器(hdc 看得到目标)', run() { + const override = process.env.AGENTMAIL_PROBE_DEVICE; + if (override === 'ok') return true; + if (override === 'none') return false; + if (override === 'unknown') return 'unknown'; const sdkHdc = '/opt/huawei/command-line-tools/sdk/default/openharmony/toolchains/hdc'; const candidates = [sdkHdc, 'hdc']; + let ranOnce = false; // 至少有一条命令**跑成过**(哪怕是"没有目标") + let sawEmpty = false; + let anomaly = ''; // 命令在,但跑不成(超时/非零退出/…)—— 这类是"拿不准" + let allMissing = true; // 所有候选都不存在 = 本机没装设备工具(这是**可判**的:不可能有设备) for (const bin of candidates) { - try { - const out = execFileSync(bin, ['list', 'targets'], { encoding: 'utf8', timeout: 15000 }); - const t = out.trim(); - if (t && !/\[Empty\]/.test(t)) return true; - } catch { /* 没有 hdc 或超时:前提不成立 */ } + const r = spawnSync(bin, ['list', 'targets'], { encoding: 'utf8', timeout: 15000 }); + if (r.error && r.error.code === 'ENOENT') continue; // 这个候选不存在,看下一个 + allMissing = false; + if (r.error) { anomaly = r.error.code || String(r.error.message || r.error); continue; } + if (r.status !== 0) { anomaly = `exit ${r.status}`; continue; } + ranOnce = true; + const t = (r.stdout || '').trim(); + if (t && !/\[Empty\]/.test(t)) return true; // 明确可用 + sawEmpty = true; // 明确"没有目标" } - return false; + if (ranOnce && sawEmpty) return false; // 跑成了,且确实没有目标 + if (anomaly) return 'unknown'; // 命令在、跑不成 → 拿不准(要红) + if (allMissing) return false; // 本机根本没有 hdc → 不可能有设备 + return 'unknown'; } } }; +/** 探针结论 → 是否等于"前提成立(到期)" */ +const probeIsDue = v => v === true || v === 'unknown'; + /** 只能验形态的判据:文件 + 为什么只能静态 + 到期前提 */ +/* + * 探针自检(`--probe-selftest`):它判的是**判据自己的分辨力** —— + * "unknown 到底会不会红"。没有这条,`probeIsDue` 哪天被改成 `v === true` + * 也没人会发现,而那正是"闸门锈死"的写法。 + */ +if (process.argv.includes('--probe-selftest')) { + const cases = [ + ['ok 可用 → 到期', true, true], + ['none 不可用 → 不到期', false, false], + ['unknown 拿不准 → 到期(必须红)', 'unknown', true], + ]; + let bad = 0; + for (const [what, value, want] of cases) { + const got = probeIsDue(value); + console.log(`${got === want ? 'ok ' : 'RED '} ${what}(probeIsDue(${JSON.stringify(value)}) = ${got})`); + if (got !== want) bad++; + } + process.exit(bad ? 1 : 0); +} const STATIC_ONLY = [ ['test/harmony-nav.test.mjs', '底栏结构/命中区常量/挂载关系:`.ets` 要 hvigorw 才能编译、要设备才能点', 'device'], ['test/harmony-appearance.test.mjs', '壁纸/令牌/遮罩渲染:观感与运行期换肤要设备', 'device'], @@ -263,9 +329,18 @@ for (const [file, , probe] of STATIC_ONLY) { process.exit(1); } } -const dueStatic = STATIC_ONLY.filter(([, , probe]) => PROBES[probe].run()); +const probeResults = {}; +for (const [, , probe] of STATIC_ONLY) { + if (probeResults[probe] === undefined) probeResults[probe] = PROBES[probe].run(); +} + +const dueStatic = STATIC_ONLY.filter(([, , probe]) => probeIsDue(probeResults[probe])); +const unknownProbes = Object.entries(probeResults).filter(([, v]) => v === 'unknown').map(([k]) => k); if (dueStatic.length > 0) { - console.error(`\n✗ 静态判据**到期**了:${PROBES[dueStatic[0][2]].desc} 现在是成立的 ——`); + const firstUnknown = unknownProbes.includes(dueStatic[0][2]); + console.error(firstUnknown + ? `\n✗ 探针**跑不了**(${PROBES[dueStatic[0][2]].desc})—— 拿不准就按到期处理,别让闸门锈死:` + : `\n✗ 静态判据**到期**了:${PROBES[dueStatic[0][2]].desc} 现在是成立的 ——`); for (const [file, why, probe] of dueStatic) { console.error(` - ${file}(到期前提:${PROBES[probe].desc};当初只能静态的原因:${why})`); } @@ -277,7 +352,15 @@ if (dueStatic.length > 0) { ); process.exit(1); } -console.log(`RESULT static=${STATIC_ONLY.length}(只能验形态的判据:到期前提成立就自动变红)`); +/* + * 汇总里"欠账余额"和"探针是否健康"是两个不同的数字(pi §2): + * static=5 —— 还欠着 5 条只能验形态的判据; + * probe=ok —— 探针自己是好的(unknown 说明闸门可能锈死了,得人看一眼)。 + */ +const probeSummary = unknownProbes.length ? 'unknown' : 'ok'; +console.log(`RESULT static=${STATIC_ONLY.length} probe=${probeSummary}` + + (unknownProbes.length ? `(探针跑不了:${unknownProbes.join('、')} —— 已按到期处理)` + : '(只能验形态的判据:到期前提成立就自动变红)')); console.log(`\n========== 判据汇总 ==========`); if (reds.length === 0) {