fix(判据): 探针三值 + 发布候选标签 —— 顺带查出探针从写下那天起一次都没跑成过

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 抛在判据自己身上),看起来像判据失败、
   不像判据写错。这条至少把最常写错的那几个名字变成明确的红。
This commit is contained in:
2026-09-14 16:35:40 +08:00
parent 357662ed08
commit 317f3265e3
5 changed files with 170 additions and 12 deletions

View File

@ -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) {