From 4f386199cfa15402c74cbd852a223314c7d4151b Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Fri, 18 Sep 2026 04:54:59 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D:=20=E9=9B=86=E5=90=88?= =?UTF-8?q?=E6=8C=87=E7=BA=B9**=E4=B8=8D=E6=98=AF=E9=9B=86=E5=90=88?= =?UTF-8?q?=E7=9A=84=E5=87=BD=E6=95=B0**=EF=BC=88=E9=A1=BA=E5=BA=8F?= =?UTF-8?q?=E6=95=8F=E6=84=9F=20+=20=E5=8F=AA=E5=90=AB=20name:size?= =?UTF-8?q?=EF=BC=89=EF=BC=9B"=E8=AF=BB=E4=B8=8D=E5=88=B0"=E6=9B=BE?= =?UTF-8?q?=E9=9D=99=E9=BB=98=E6=8A=A5=E6=88=90"=E5=85=A8=200=20=E4=B8=94?= =?UTF-8?q?=E6=8C=87=E7=BA=B9=E6=AD=A3=E5=B8=B8"=EF=BC=9B"=E8=AE=BE?= =?UTF-8?q?=E5=A4=87=E5=BF=99"=E7=9A=84=E8=B7=B3=E8=BF=87**=E5=8A=A0?= =?UTF-8?q?=E7=95=8C**=EF=BC=9Bcriteria-hygiene=20=E4=B8=A4=E5=A4=84?= =?UTF-8?q?=E6=8C=89=E5=AD=97=E9=9D=A2=E9=87=8F=E8=A3=81=E5=B0=84=E7=A8=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pi 2026-09-18 报的两个洞我**都当场复现了**(不是我信了,是跑出来了),修完都验了阳性对照。 ## 1. 集合指纹必须是集合的函数(pi 洞 1,两个都复现) 原来 `'\n'.join(f'{f}:{getsize(f)}' for f in listed)`。实测: · **顺序敏感**:只把 `jobs.manifest.json` 反序(集合/内容/计数全不变) ⇒ `sha=02502771` → `3439e049`。于是"集合变了 ⇒ 一眼看得出"失效, **每次清单整理都假变**。 · **只含 name:size**:字节级等长改写(`jobs-one.json` 的 `name: 'image'` → `'imoge'`, 265 字节不变)⇒ **指纹仍是 02502771**。更尖锐的是 file 路径改成等长的 `ApiClienX.ets`(指向不存在的文件)时 `ran/skipped/on_new_criteria` 全变而 **指纹不变** —— **"集合没变而数变了"恰恰是它声称要抓的情况,它抓不到。** ⇒ `sorted()` + 内容哈希。实测:原序/反序/排序三种**同一哈希**(`33ff3bea`); 等长改内容 ⇒ 变(`89b2aa41`)。 ## 2. "读不到" ≠ "不存在"(pi 洞 2,复现 + 比 pi 说的更糟) 隔离副本 + `runuser -u nobody` + `chmod 644 jobs/` 实测: ``` nobody: mutants=0 ran=0 skipped=0 … 原始条目 0 sha=e3b0c442 rc=0 ``` `e3b0c442` = **空字符串的 sha256**;而且退出码 **0**。根因:`exists()` 对 **不可进入目录里的文件**返回 **False**(实测),12 个全被跳过;而 `glob` 那一半 照样列得出 12 个 ⇒ `unlisted`/`ghosts` 全空 ⇒ **"清单与磁盘不一致"那条警告一声不响**。 **同一份权限,两个半边给出互相矛盾的结论。** ⚠️ 比 pi 说的更糟的一点:那行**同时**印着 `清单 12 个 job 文件` 和 `sha=e3b0c442` —— 一句话里说"12 个"却一个都没读到。 ⇒ ① 判目录可进入性(`access(R_OK|X_OK)`),读不到就 `✗✗` 明说数字**全部无效**、 并点明"这不是空集合";② `open` 接住 `OSError`,把读不到的文件**攒起来一次报全** (原来会死在第一个文件上抛 `PermissionError`,让人以为"就这一个"); ③ 退出码 **2**(本仓约定:2=环境)—— 读不到就是没读数,而**没读数不是成功**。 ④ `unreadable` 与 `ghosts` **分开报**:修法完全不同(修权限 vs 删条目)。 ★ 这里我先写了个**不可达的分支**:`unreadable` 先探后读,而真读时 `PermissionError` 会提前抛出 ⇒ 那段报告永远走不到("判据在,但走不到",这次长在报告分支上)。 自己查出来并改成"读的时候接住",才让它成为可达的真分支。 ## 3. "不抢前台"的跳过**加界**(pi 2026-09-18 §3,我接受) 只跳过不设界,"设备忙"会变成到期判据的**永久灰区**:不算红不算绿 ⇒ 永远不必被升级 —— 到期机制要防的正是这个,只是入口换成了"设备忙"。⇒ 连续 K(默认 3)轮没跑成, **跳过自己变红**并给出接管路径(K 轮内是礼貌,K 轮外是闹钟)。 边界:**只对"设备在、前台不是我们的"计数**;**设备不在不计数也不变红** —— 那是 `PROBES.device` 的既有裁定(没装 SDK 的机器不该天天假红),超出本模块职责。 账本 `.tmp/harmony-busy-skips.json`(已 gitignore):判"**这台机器上**连续多少轮没验成", 换机器不继承。实测 1→2→3 轮 skip、第 4 轮起 FAIL;跑成一次即清零、再从 1 重新数。 自检(不连设备)把 ①K 轮内礼貌 ②超了必红 ③跑成清零 ④上限可覆盖 都钉住, 变异验证 M7(over 恒 false)⇒自检②红、M8(noteRan 不清零)⇒自检③红。 ## 4. 顺带修掉 criteria-hygiene 自己两处"按字面量裁射程" 我按纪律把账本读取从裸 `readFileSync` 改成 `prose()`(`criteria-hygiene` 立刻红, **那条判据是对的、我错了**),接着暴露出该判据自身两个洞: · **import 按写法硬匹配**:只认 `'./lib/read.mjs'`/`'../lib/read.mjs'`, 而 `harmony-device.mjs` **就在 `lib/` 里**、按惯例写 `'./read.mjs'` ⇒ 假红"根本没 import"(其实 import 了、运行时完全正常)。改成**解析说明符后与 `SELF` 比**(本仓已用"解析后比较"解决过同一族:`abspath`、`join(HERE,…)`)。 · **用 `prose()`(原文)判"用没用"** ⇒ **注释里**写 `prose(…)` 就算用了 (实测该文件命中 3 处、只有 1 处是真调用)。改成 `code()`。这与它上面那条 "不许裸用 readFileSync"踩过的是同一个坑,我在那条上写了理由、**这条漏了**。 两处修法都做了承重验证:删真 import ⇒ 红;import 换成 `code as prose2`(别名)⇒ 红; 只在注释里写 `prose(` ⇒ **绿**(对照旧实现:同一份样本 ⇒ **红**)。 ## 验证 · 全套件 `checks=459 pass=455 fail=4 skip=0 red=9` —— 红的仍是同样 9 条(都是并发 会话的"自报 > 清单"与 4 条 exit 1),我中途引入的两条(裸 readFileSync、import 假红) 已消。`mutants=48` 不变。 · 所有 chmod/清单反序/等长改写**都已还原**,`git status` 只剩本次 5 个文件。 --- .../electron/test/criteria-hygiene.test.mjs | 49 ++++++--- client/electron/test/harmony-nav.test.mjs | 85 ++++++++++++++- client/electron/test/lib/harmony-device.mjs | 84 +++++++++++++++ client/electron/test/mutants/summary.py | 101 ++++++++++++++++-- client/electron/test/run-all.mjs | 2 +- 5 files changed, 295 insertions(+), 26 deletions(-) diff --git a/client/electron/test/criteria-hygiene.test.mjs b/client/electron/test/criteria-hygiene.test.mjs index 03fd1ef..cf10124 100644 --- a/client/electron/test/criteria-hygiene.test.mjs +++ b/client/electron/test/criteria-hygiene.test.mjs @@ -24,7 +24,7 @@ import assert from 'node:assert/strict'; import { readdirSync, unlinkSync, writeFileSync } from 'node:fs'; import { spawnSync } from 'node:child_process'; -import { dirname, join, relative } from 'node:path'; +import { dirname, join, relative, resolve } from 'node:path'; import { test } from 'node:test'; import { fileURLToPath } from 'node:url'; @@ -157,20 +157,39 @@ test('★ 用到 code/prose/bytes 就必须 import(不许靠运行时才发现 * (pi 2026-09-15 指出的形状:**射程/豁免按目录名裁,等于给逃逸指路**)。 */ if (f === DEFINES_THEM) 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`); - } + /* + * ★ 判"有没有 import"要**解析说明符**,不能按写法硬匹配(2026-09-18 实测,我自己撞的)。 + * 原来只认 `'./lib/read.mjs'` / `'../lib/read.mjs'` 两个字面量 —— + * 而 `test/lib/harmony-device.mjs` **就在 `lib/` 里**,它按同目录惯例写 `'./read.mjs'`, + * 两种都不匹配 ⇒ 假红"**根本没从 lib/read.mjs import**"(其实 import 了,而且 + * 运行时完全正常)。本仓已经用"解析后比较"解决过同一族(`abspath` 之于写死的相对路径、 + * `join(HERE,…)` 之于硬编码仓库根),这是又一次 ⇒ 同样改成 + * "把说明符解析成绝对路径,与 `SELF` 比"。**豁免/射程按字面量裁,等于给逃逸指路。** + * + * ★ 判"用没用"也必须剥注释(`code()`):这里原来是 `prose(f)`(原文), + * 于是**注释里**写下 `prose(…)` 就算"用了"。实测:`harmony-device.mjs` 的注释里 + * 提到 `prose()`,正则命中 **3 处,其中只有 1 处是真调用**。 + * 这与上面那条"不许裸用 readFileSync"踩过的是同一个坑 —— + * 我在那条上用了 `code()` 并写了理由,**这条漏了**。 + * 纪律的另一面:**注释说明禁令 ≠ 违反禁令**。 + */ + const src = code(f); + const dir = dirname(f); + const bound = new Set(); + let importsRead = false; + for (const m of src.matchAll(/import\s*\{([^}]*)\}\s*from\s*['"]([^'"]+)['"]/g)) { + const spec = m[2]; + if (!spec.startsWith('.')) continue; // 只认相对说明符(裸包名与这里无关) + if (resolve(dir, spec) !== SELF) continue; // ← 解析后比,而不是比写法 + importsRead = true; + for (const n of m[1].split(',') + .map(x => x.trim().split(/\s+as\s+/).pop()).filter(Boolean)) bound.add(n); + } + for (const name of EXPORTS) { + if (new RegExp(`\\b${name}\\(`).test(src) && !bound.has(name)) { + problems.push(importsRead + ? `${relative(RELECTRON, f)} 用了 ${name}(…) 但没 import(已 import:${[...bound].join('、') || '无'})` + : `${relative(RELECTRON, f)} 用了 ${name}(…) 但根本没从 lib/read.mjs import`); } } } diff --git a/client/electron/test/harmony-nav.test.mjs b/client/electron/test/harmony-nav.test.mjs index ad416cc..2074247 100644 --- a/client/electron/test/harmony-nav.test.mjs +++ b/client/electron/test/harmony-nav.test.mjs @@ -11,7 +11,8 @@ // 且内容底部让出的高度 ≥ 条高 + 离底留白(否则最后一行压在条底下:看得见、点不到)。 import { readdirSync } from 'node:fs'; import { code, prose, stripComments } from './lib/read.mjs'; -import { findHdc, hasTarget, foregroundBundle, dumpLayout, walk } from './lib/harmony-device.mjs'; +import { findHdc, hasTarget, foregroundBundle, dumpLayout, walk, + noteBusySkip, noteRan, busyLimit, busyStreak } from './lib/harmony-device.mjs'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { test } from 'node:test'; @@ -796,14 +797,38 @@ function navItemsOf(root) { } test('★ 行为(设备):底栏真渲染了可点的导航项(dumpLayout 实测,live ⊆ source)', async (t) => { + const CRIT = 'harmony-nav/底栏可点项'; const hdc = findHdc(); if (!hdc || !hasTarget(hdc)) { + // 设备不在 ⇒ **不计数、不超限**:这是 run-all.mjs 里 PROBES.device 的既有裁定 + // (没装 SDK / 没起模拟器的机器不该天天假红),探针已经决定"不到期"。 + noteRan(CRIT); return t.skip('设备不在 —— 行为部分本次不跑(静态层仍把住源码契约)'); } const fg = foregroundBundle(hdc); if (fg !== 'com.agentmail.harmony') { - return t.skip(`应用不在前台(当前是 ${fg || '空'})—— 设备被别的会话占用,不抢前台`); + /* + * ★ 设备在、但前台不是我们的 ⇒ 记一次"忙",**连续超 K 轮就变红**(pi 2026-09-18 §3)。 + * + * 只跳过不设界,"设备忙"会变成到期判据的**永久灰区**:不算红也不算绿, + * 于是永远不需要被升级 —— 到期机制要防的正是这个。所以 K 轮之内是礼貌, + * K 轮之外是闹钟。 + */ + const { streak, over } = noteBusySkip(CRIT); + if (over) { + assert.fail( + `设备连续 ${streak} 轮被别的会话占着(前台是 ${fg || '空'}),本条行为判据` + + `**连续 ${busyLimit()} 轮以上没能真跑** —— 不再当作礼貌跳过。\n` + + ` 这说明"不抢前台"已经变成永久状态:判据既不绿也不红 ⇒ 永远不必被升级。\n` + + ` 需要人来接管:要么给本会话排到设备时间,要么显式决定这台设备上不再跑行为判据` + + `(那就要回到静态并写清理由,不能靠"一直忙"糊过去)。\n` + + ` (账本:.tmp/harmony-busy-skips.json,已连续 ${streak} 轮;` + + `跑成一次即清零。上限 K=${busyLimit()},可用 AGENTMAIL_BUSY_SKIP_LIMIT 覆盖。)`); + } + return t.skip(`应用不在前台(当前是 ${fg || '空'})—— 设备被别的会话占用,不抢前台` + + `(连续第 ${streak}/${busyLimit()} 轮,超过就要变红)`); } + noteRan(CRIT); // 真跑成了 ⇒ 清掉连续计数 const root = dumpLayout(hdc); assert.ok(screenHeightOf(root) > 100, `要能从 dumpLayout 里量出屏幕高度(实际 ${screenHeightOf(root)})—— 拿不到说明树是空的`); @@ -897,3 +922,59 @@ test('★ 判据自检:底栏取值逻辑 —— 好样本取得到、缺底 '★ 自检失败:自洽可点 Text(叶子 FAB / 带子文本的可点 Text)被当成了导航项 —— ' + '它们会去核 NAV_ITEMS 标签而**误红**;这正是"排除 Text"那行必须存在的理由'); }); + +/* + * ★ 判据自检:**"设备忙"的跳过必须有界**(pi 2026-09-18 §3)。 + * + * 上面那条行为判据在"应用不在前台"时跳过(别人的会话在用模拟器)。只跳过不设界, + * "设备忙"就会变成到期判据的**永久灰区**:不算红也不算绿 ⇒ 永远不必被升级 —— + * 这正是到期机制要防的东西("不等谁想起来"),只是入口换成了"设备忙"。 + * + * 所以给 skip 加了界:连续 K 轮没跑成 ⇒ 跳过**自己变红**(K 轮内是礼貌,K 轮外是闹钟)。 + * 这里把那条界的**取值逻辑**钉住 —— 不连设备、走临时账本,所以每次跑都真的在跑: + * ① K 轮以内 ⇒ `over=false`(礼貌); + * ② 第 K+1 轮 ⇒ `over=true`(闹钟,调用方必须红); + * ③ 真跑成一次(`noteRan`)⇒ 计数清零,从 1 重新数("跑成了就不该再记前账"); + * ④ **设备不在**那条路径不计数 —— 那是探针的既有裁定,不是"忙"。 + */ +test('★ 判据自检:设备忙的跳过有界 —— K 轮内礼貌、超了必红、跑成即清零', async () => { + const { mkdtempSync, rmSync } = await import('node:fs'); + const { tmpdir } = await import('node:os'); + const { join: pjoin } = await import('node:path'); + // 账本与上限都走 env 覆盖到临时位置(默认那份是 `.tmp/` 下的真账本,自检不许碰它) + const dir = mkdtempSync(pjoin(tmpdir(), 'busy-ledger-')); + const prevLedger = process.env.AGENTMAIL_BUSY_LEDGER; + const prevLimit = process.env.AGENTMAIL_BUSY_SKIP_LIMIT; + process.env.AGENTMAIL_BUSY_LEDGER = pjoin(dir, 'ledger.json'); + process.env.AGENTMAIL_BUSY_SKIP_LIMIT = '3'; + try { + const C = 'selftest/忙'; + // ① K 轮以内是礼貌 + for (let i = 1; i <= 3; i++) { + const r = noteBusySkip(C); + assert.equal(r.streak, i, `第 ${i} 次跳过应当记到 streak=${i}(实际 ${r.streak})`); + assert.equal(r.over, false, `第 ${i}/${busyLimit()} 轮还不该红(礼貌期内)`); + } + // ② 第 K+1 轮 ⇒ 必须红 + const over = noteBusySkip(C); + assert.equal(over.streak, 4, `第 4 次应当 streak=4(实际 ${over.streak})`); + assert.equal(over.over, true, + '★ 自检失败:连续跳过超过 K 轮仍不红 —— 那"设备忙"就成了永久灰区,' + + '到期判据既不绿也不红、永远不必被升级(这正是要防的那件事)'); + // ③ 真跑成一次 ⇒ 清零,再忙从 1 重新数 + noteRan(C); + assert.equal(busyStreak(C), 0, '★ 自检失败:跑成之后计数没清零 —— 会攒出假超限'); + assert.equal(noteBusySkip(C).streak, 1, '清零之后应当从 1 重新数'); + // ④ 上限可覆盖(否则判据自检没法验边界,测不了的边界等于没写) + process.env.AGENTMAIL_BUSY_SKIP_LIMIT = '1'; + noteRan(C); + assert.equal(noteBusySkip(C).over, false, 'K=1 时第 1 轮仍是礼貌'); + assert.equal(noteBusySkip(C).over, true, 'K=1 时第 2 轮必须红'); + } finally { + if (prevLedger === undefined) delete process.env.AGENTMAIL_BUSY_LEDGER; + else process.env.AGENTMAIL_BUSY_LEDGER = prevLedger; + if (prevLimit === undefined) delete process.env.AGENTMAIL_BUSY_SKIP_LIMIT; + else process.env.AGENTMAIL_BUSY_SKIP_LIMIT = prevLimit; + rmSync(dir, { recursive: true, force: true }); + } +}); diff --git a/client/electron/test/lib/harmony-device.mjs b/client/electron/test/lib/harmony-device.mjs index 8b2ee5e..5116a1e 100644 --- a/client/electron/test/lib/harmony-device.mjs +++ b/client/electron/test/lib/harmony-device.mjs @@ -17,9 +17,93 @@ // 这套是**到期之后跑行为判据时用的**手 —— 探针说"到期"了,这里替你 // 真去点。两条逻辑刻意分开,免得"探针和手是同一只"时改一处引坏另一处。 import { spawnSync } from 'node:child_process'; +import { mkdirSync, writeFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +// 读盘走 test/lib 的具名入口(本仓纪律:判据目录里不许裸 readFileSync)。 +// 这里要的是**原文**(账本是个 JSON 文本,读出来自己解析)⇒ `prose()` 正是那个入口。 +// ★ 实测教训:我第一版在这儿裸用了 readFileSync,`criteria-hygiene` 立刻红了 +// (报 `test/lib/harmony-device.mjs:57`)—— 那条判据是对的,错的是我这段代码。 +// 顺带说明那条判据的形状为什么对:它扫的是"**能不能换用更准的入口**", +// 而我当时的理由是"这是运行时状态、不是仓库源码"——那理由不成立, +// `prose()` 读的就是任意文本,跟文件性质无关。 +import { prose } from './read.mjs'; const TOOLCHAIN_HDC = '/opt/huawei/command-line-tools/sdk/default/openharmony/toolchains/hdc'; +/* + * ─── 有界的"不抢前台"(pi 2026-09-18 §3)─── + * + * 问题:本模块按"应用不在前台就跳过"设计(别人的会话在用这台模拟器)。 + * 但**跳过必须有界** —— 如果设备一直被占着,那 7 条到期判据会永远停在 + * "既不绿也不红",这恰恰是到期机制要防的东西("不等谁想起来")。 + * "不抢前台"一旦变成永久状态,就等于给到期判据开了一个**永久灰区**: + * 它们不算红、不算绿,也就永远不需要被升级 —— "判据存在但永远不会响"的又一个变体, + * 只是这次入口是"设备忙"。 + * + * ⇒ 连续跳过 K 轮之后,**跳过自己变红**(报"设备连续被占,本次仍未验证")。 + * K 轮之内是礼貌(不抢别人),K 轮之外是闹钟(到期机制不许静默)。 + * + * 账本落在 `.tmp/`(已在 .gitignore 里):它是**本机状态**,不是仓库内容 —— + * 换一台机器/清掉 .tmp 就等于"重新开始数",这是对的:判的是"**这台机器上** + * 连续多少轮没验成",换机器不继承。 + * + * ★ 只对"设备在、但前台不是我们的"计数(busy)。**设备不在**不计数、也不变红 —— + * 那是 run-all.mjs 里 PROBES.device 的既有裁定("没装 SDK 的机器不该天天假红"), + * 超出本模块的职责:探针已经决定"不到期",行为部分自然不该自己变红。 + * + * ⚠️ 账本路径与上限都**惰性读 env**(不是 import 时定死):否则判据自检没法验 + * "超限会不会红" —— 测不了的边界等于没写(本仓纪律)。与 run-all.mjs 里 + * `AGENTMAIL_PROBE_DEVICE` 覆盖同一个理由。 + */ +const ledgerPath = () => process.env.AGENTMAIL_BUSY_LEDGER + || join(dirname(fileURLToPath(import.meta.url)), '..', '..', '..', '..', '.tmp', 'harmony-busy-skips.json'); +const busyLimitOf = () => Number(process.env.AGENTMAIL_BUSY_SKIP_LIMIT || 3); + +function readLedger() { + try { + const d = JSON.parse(prose(ledgerPath())); + return (d && typeof d === 'object') ? d : {}; + } catch { + return {}; // 没有/坏了都当"从 0 开始数",不是错误 + } +} + +function writeLedger(d) { + try { + mkdirSync(dirname(ledgerPath()), { recursive: true }); + writeFileSync(ledgerPath(), JSON.stringify(d, null, 2)); + } catch { /* 写不了账本不该让判据崩:退化成"永远不超限",由下面的返回值明说 */ } +} + +/** + * 记一次"设备忙,跳过",返回 `{ streak, over }`。 + * + * `over === true` ⇒ 连续跳过已超 K 轮,调用方**必须变红**(不再是礼貌跳过)。 + * 调 `noteRan()` 清零(真跑成了就不该再记前账)。 + */ +export function noteBusySkip(criterion) { + const d = readLedger(); + const n = (d[criterion] || 0) + 1; + d[criterion] = n; + writeLedger(d); + return { streak: n, over: n > busyLimitOf() }; +} + +/** 该判据真的跑成了 ⇒ 连续计数清零。 */ +export function noteRan(criterion) { + const d = readLedger(); + if (d[criterion]) { delete d[criterion]; writeLedger(d); } +} + +/** 只读查询当前连续跳过轮数(判据自检用)。 */ +export function busyStreak(criterion) { + return readLedger()[criterion] || 0; +} + +/** 供报文用:本机繁忙上限 K。 */ +export const busyLimit = () => busyLimitOf(); + /** * 找到能跑的 hdc 二进制。候选顺序:SDK 文档安装根下的 hdc,再 PATH 上的 hdc。 * 返回**跑通了 `list targets`** 的那条(status 0);都不行返回 null。 diff --git a/client/electron/test/mutants/summary.py b/client/electron/test/mutants/summary.py index ed268b8..f133ec2 100644 --- a/client/electron/test/mutants/summary.py +++ b/client/electron/test/mutants/summary.py @@ -58,13 +58,46 @@ def main(): for p in glob.glob(os.path.join(JOBS_DIR, 'jobs*.json'))) unlisted = [f for f in on_disk if f not in listed] ghosts = [f for f in listed if f not in on_disk] + # ★★ "读不到" ≠ "不存在"(pi 2026-09-18 实测的第二个洞)。 + # + # 实测(隔离副本 + `runuser -u nobody` + `chmod 644 jobs/`): + # root(jobs/ 755):mutants=48 ran=0 skipped=48 … 原始条目 74 sha=02502771 + # nobody(jobs/ 644):mutants=0 ran=0 skipped=0 … 原始条目 0 sha=e3b0c442 rc=0 + # `e3b0c442` 是**空字符串的 sha256** ⇒ "什么都没读到"被报成"集合为空且指纹正常", + # **而且退出码 0**。它比 `PermissionError` 糟:读不到 = 数字全 0 + 看着像正常读数。 + # + # 根因:`os.path.exists()` 对**不可进入目录里的文件**返回 **False**(实测), + # 于是清单里 12 个全被当成"不存在"跳过;而 `glob` 那一半照样列得出 12 个 ⇒ + # `unlisted`/`ghosts` 都是空 ⇒ 下面那条"清单与磁盘不一致"的警告**一声不响**。 + # 同一份权限,两个半边给出互相矛盾的结论。 + # + # ⇒ 先判**目录**能不能读(这一层才判得准),再判单个文件: + # · 目录不可进入 ⇒ 磁盘上看不清,判不了"有没有"⇒ `blind`,**大声失败**; + # · 目录能进、某文件 exists 但读不了 ⇒ 单列 `unreadable`(不是 ghosts)。 + # 这与 `check-file-modes.sh` 补的"目录可进入性"是**同一件事**:那个只在部署时跑, + # 而这里是套件自己的读数器 —— 同一个目录权限,不该在部署门禁里红、在套件里静默绿。 + dir_readable = os.access(JOBS_DIR, os.R_OK | os.X_OK) + blind = not dir_readable + # `unreadable` **在读循环里**填(见下面 `except OSError`):先探后读会漏掉"TOCTOU" + # 且两处判定要一致 —— 判"读不到"的唯一可靠时机就是**真去读**的那一次。 + unreadable = [] for f in listed: # 只读**清单里**的,顺序也按清单 p = os.path.join(JOBS_DIR, f) if not os.path.exists(p): continue - for j in json.load(open(p, encoding='utf-8')): - j['_from'] = f - entries.append(j) + # ★ `open` 要接住 OSError:不接的话,"单个 job 文件不可读"会抛 + # `PermissionError` 直接死在这儿,**下面那条 `unreadable` 报告永远走不到** + # —— 我第一版就是那样,等于留了一段不可达的死代码("判据在,但走不到", + # 这次长在报告分支上)。接住之后:读得到的照读,读不到的**攒起来一次报全**, + # 而不是死在第一个文件上(后者会让人以为"就这一个有问题")。 + try: + with open(p, encoding='utf-8') as fh: + for j in json.load(fh): + j['_from'] = f + entries.append(j) + except OSError: + if f not in unreadable: + unreadable.append(f) active = [e for e in entries if not e.get('retired')] retired = len(entries) - len(active) @@ -147,19 +180,61 @@ def main(): # ★ 集合自证:数字必须能回答"读的是哪个集合"(pi 2026-09-18 的第二个建议)。 # 指纹让"48 还是 52"变成可判的:集合没变而数变了 ⇒ 真算错; # 集合变了 ⇒ 只是换了快照,**一眼看得出**,不用再互相复算一遍。 - fp = hashlib.sha256('\n'.join(f'{f}:{os.path.getsize(os.path.join(JOBS_DIR, f))}' - for f in listed if os.path.exists(os.path.join(JOBS_DIR, f))) - .encode()).hexdigest()[:8] + # + # ★★ 指纹必须是**集合的函数**(pi 2026-09-18 实测的两个洞,都当场复现): + # ① **顺序敏感**:原来直接按 `listed` 顺序拼 —— 实测"只把清单反序、集合/内容/计数全不变" + # ⇒ `sha=02502771` 变 `sha=3439e049`。于是"集合变了 ⇒ 一眼看得出"失效: + # **每次清单整理都假变**。(修:`sorted()`) + # ② **只含 name:size** ⇒ 同大小改内容抓不到。实测字节级等长改写 + # (`jobs-one.json` 的 `name: 'image'` → `name: 'imoge'`,265 字节不变) + # ⇒ **指纹仍是 `02502771`**。更尖锐的是把 file 改成等长的 `ApiClienX.ets` + # (指向不存在的文件)时 `ran/skipped/on_new_criteria` 全变、**指纹不变** —— + # **"集合没变而数变了"恰恰是它声称要抓的情况,而它抓不到。**(修:内容哈希) + # ⇒ 指纹的全部存在理由就是"集合变了要看得出来",所以它必须对**顺序不敏感、 + # 对内容敏感**。名字排序 + 内容哈希两格都补上。 + # + # ⚠️ 但要记准它**不是**什么:它是**集合指纹**(读到的那些文件的身份), + # **不是"数字对不对"的证明**。数字由上面那套口径算,指纹只回答"读的是哪一堆"。 + def _fp_of(names): + parts = [] + for f in sorted(names): + p = os.path.join(JOBS_DIR, f) + if not os.path.exists(p): + continue + try: + h = hashlib.sha256(open(p, 'rb').read()).hexdigest()[:8] + except OSError: + h = 'UNREADABLE' # 读不到也要进指纹(否则"读不到"就静默等于"没有") + parts.append(f'{f}:{h}') + return hashlib.sha256('\n'.join(parts).encode()).hexdigest()[:8] + fp = _fp_of(listed) + # 一个 job 都没读到 ⇒ **不许**报成正常读数(`e3b0c442` 是空串的 sha256, + # 而"空集合 + 正常指纹 + rc=0"正是最坏的那种读数:看着像成功)。 print(f' 集合:清单 {len(listed)} 个 job 文件(未跟踪的**不会**静默进入统计)' f' sha={fp} 集合指纹') + if blind: + print(f' ✗✗ **读不到 jobs/ 目录**({JOBS_DIR})—— 上面的数字**全部无效**:') + print(f' 清单里有 {len(listed)} 个文件,但目录不可读/不可进入,一个都没读成。') + print(f' 这一行**不是**"集合为空":`sha=e3b0c442` 是空字符串的 sha256。') + print(f' 修法:修 jobs/ 目录权限(至少要 r+x;`check-file-modes.sh` 判的同一件事),' + f'或确认是不是在错误的用户/挂载下跑。') + if len(entries) == 0 and len(listed) > 0: + print(f' ✗✗ 清单里有 {len(listed)} 个 job 文件,却读到 **0 条条目** —— ' + f'别把 0 当成"没有变异体",先查上面那两条(读不到 / 全是空文件)。') # ★ 清单与磁盘不一致 ⇒ 明说(与 `run-all.mjs` 自检 2 同形状:清单外即红)。 - if unlisted or ghosts: + if unlisted or ghosts or unreadable: print(' ★ 清单与磁盘不一致 —— 上面的数字**不代表"磁盘上现在有多少个变异体"**:') for f in unlisted: print(f' 未列入清单:{f}(新加的 job 文件必须显式加进 jobs.manifest.json)') for f in ghosts: print(f' 清单里有、磁盘上没有:{f}') - print(' 修法:把该加的文件加进 jobs.manifest.json,或把该删的条目删掉。') + # ★ 与 `ghosts` **分开报**:这两个的修法完全不同 —— + # 一个是"文件真没了"(删清单条目),一个是"文件在、我读不到"(修权限)。 + # 合在一起报会让人去删一个其实存在的条目(pi 说的是"读不到 ≠ 不存在")。 + for f in unreadable: + print(f' 清单里有、**在但读不到**:{f}(权限问题,不是缺失 —— 改权限,别删条目)') + print(' 修法:把该加的文件加进 jobs.manifest.json,或把该删的条目删掉;' + '读不到的那种去修权限。') print(' (为什么不能靠 glob:未跟踪的文件会**静默**进入统计,' '同一段代码两个数、而没人知道读的是哪个集合。)') if skipped_detail: @@ -169,6 +244,16 @@ def main(): print(f' hits={h} {key[0]} 「{group[0].get("why", "")}」 ({len(group)} 条条目:{srcs})') print(' ⚠️ hits=0 通常是**过期条目**(锚点是旧写法)—— 请标 retired 或删除,') print(' 否则它会把 skipped 一直抬高(方向与"让欠账显形"相反)。') + # ★ 读不到 ⇒ **非零退出**(pi 2026-09-18 的洞 2 的关键:原来 rc=0)。 + # 读不到就是"没读数",而没读数**不是成功** —— 与本仓"失败要说清是环境问题、 + # 不要让它冒充代码缺陷"是同一套:这里更该退非零,因为它连"是环境还是代码"都判不了。 + # 退出码按本仓约定用 **2 = 环境问题**(见 `env-defaults.sh:25`): + # 目录不可进入 / 有 job 文件读不到,都是环境,不是"变异体少了"。 + # (`run-all.mjs` 只 grep `RESULT mutants=` 片段、不看退出码,所以那边也会看到 + # `mutants=0` —— 但这一行现在自己带 ✗✗ 说明,且 `原始条目 0` 与 `清单 12` 并排, + # 不再可能被读成"集合为空且一切正常"。) + if blind or unreadable: + return 2 return 0 diff --git a/client/electron/test/run-all.mjs b/client/electron/test/run-all.mjs index 8d862c1..56b0c90 100644 --- a/client/electron/test/run-all.mjs +++ b/client/electron/test/run-all.mjs @@ -75,7 +75,7 @@ const SUITE = [ // P4 外观同步:跑 model/Appearance.ts(纯逻辑),所以也要 strip-types ['test/harmony-appearance.test.mjs', ['--experimental-strip-types', '--no-warnings'], 25], // P5 悬浮玻璃导航:点击配对 / index 决定挂载 / 命中区 ≥44vp / 悬浮与让位 - ['test/harmony-nav.test.mjs', ['--experimental-strip-types', '--no-warnings'], 17], + ['test/harmony-nav.test.mjs', ['--experimental-strip-types', '--no-warnings'], 18], // 外观契约:默认值去 Go 源码里读(服务端 DefaultAppearance 是权威)+ 缓存键按账号 ['test/appearance-defaults.test.mjs', [], 4], ['test/build-stamp.test.mjs', [], 7],