被发现的方式值得记:那条判据从没红过(它从不执行断言),是**"跳过也要有界"**
那条闹钟把 42 轮连续跳过顶成红,才露出来的。这正是设界要抓的形状 ——
判据既不算红也不算绿 ⇒ 永远不必被升级。
## 根因:包名有四处字面量,改名只改了三处
AGC 拒绝 `com.agentmail.harmony`(`harmony` 是包名保留字),于是改成
`com.jianf.agentmail`(见 `docs/ALIGN-REFS.json` 的 `agc.packageName` 与
`align-refs.test` 那条一致性判据)。但那个字面量在判据里是**各自抄的**:
· `AppScope/app.json5` ← 唯一权威
· `harmony-deviceprobe.test.mjs:24` ← 改了(它判 AGC 匹配)
· `harmony-nav.test.mjs:810` ← **没改** ⇒ 前台判定永不成立
· `lib/harmony-device.mjs:280` 注释 ← 没改(只是注释,但同样在传播旧名)
于是 `foregroundBundle(hdc) === 'com.agentmail.harmony'` 恒假,行为判据每次
都记一次"设备忙"。账本 `.tmp/harmony-busy-skips.json` 实测数到 **45**。
## 修法
1. `lib/harmony-device.mjs` 新增 `ourBundle()` —— 从 `AppScope/app.json5`
**唯一权威处**读包名(惰性、读不到返回 null,由调用方决定)。
2. `harmony-nav.test.mjs` 的比较改成 `fg !== ourBundle()`。
3. 清除 `.tmp/harmony-busy-skips.json` 里那 45 轮**假忙计数** ——
它们不是设备争用,是字面量漂移,留着会污染"跳过余额"的含义。
## 判据(防止这一类漂移再发生)
`harmony-deviceprobe.test.mjs` 新增 3 条(8 → 11,已同步 run-all 棘轮):
· 判据目录里**不许再出现包名字面量**(除 lib/ 唯一读取处与本文件——它要拿
真机样本 `fixtures/aa-dump-l-*.txt` 里的包名做**逐字**对照,属具名例外)
· 判据自检:ourBundle() 读得出值、且不带 `harmony`(AGC 保留字)
· 变异自检:把一条消费方改回硬编码 ⇒ 必须判红(已验证)
且注释里的历史说明**不许误红**(`align-refs` 正靠注释解释为什么换名)
验证:harmony-deviceprobe 11/0、harmony-nav 18/0(行为那条**真跑过**、
不再是跳过)、criteria-hygiene 6/0。
145 lines
9.1 KiB
JavaScript
145 lines
9.1 KiB
JavaScript
import test from 'node:test';
|
||
import assert from 'node:assert/strict';
|
||
import { readdirSync } from 'node:fs';
|
||
import { dirname, join } from 'node:path';
|
||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||
import { prose } from './lib/read.mjs';
|
||
import { ourBundle } from './lib/harmony-device.mjs';
|
||
|
||
/*
|
||
设备读回的**前置闸**(pi 邮件 `971c58fa` §4 / `bf583b0f` §2)。
|
||
|
||
危险形状不是"读不到",而是**读到别人的界面却当成我们的**:断言可能通过、也可能红,
|
||
**两者都不是在讲我们的界面** —— 前者是假绿。
|
||
|
||
★ 本判据喂的是**真机样本**(`fixtures/aa-dump-l-real.txt`,`hdc … shell "aa dump -l"` 实采),
|
||
不是我构造的形状。原因:我第一版按**猜的**格式写(`bundleName: com.x`),
|
||
真机上根本没有那种写法 ⇒ 闸会永远返回 `unverified`(安全但没法用);
|
||
而改成"取第一个 `bundle name`" ⇒ 真机多 mission、对方可能排在前面 ⇒ **取到别人的**(假绿)。
|
||
pi 独立复现过后者。所以样本必须是真机的。
|
||
*/
|
||
|
||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||
const MODEL = join(HERE, '..', '..', 'harmony', 'entry', 'src', 'main', 'ets', 'model');
|
||
const C = await import(pathToFileURL(join(MODEL, 'DeviceProbe.ts')).href);
|
||
|
||
const OURS = 'com.jianf.agentmail';
|
||
const THEIRS = 'com.example.homeagent';
|
||
const real = prose(join(HERE, 'fixtures', 'aa-dump-l-real.txt'));
|
||
|
||
test('★ 真机样本:两个 mission 并存,前台是**对方**(这份样本就是争用发生的那一刻)', () => {
|
||
const ms = C.parseMissions(real);
|
||
assert.equal(ms.length, 2, '真机 dump 里有两个 mission(我们 + 对方)');
|
||
assert.deepEqual(ms.map((m) => m.bundle), [THEIRS, OURS], '对方排在前面 —— 所以"取第一个 bundle name"必然取错');
|
||
assert.equal(C.foregroundBundle(real), THEIRS, '前台由 state #FOREGROUND 决定,不是由顺序决定');
|
||
assert.equal(C.foregroundVerdict(real, OURS), C.VERDICT_OTHER, '对方在前台 ⇒ other,绝不算我们的');
|
||
assert.equal(C.mayAssertOn(real, OURS), false, '拿不到独占 ⇒ 不许断言');
|
||
assert.ok(C.unverifiedReason(C.VERDICT_OTHER).includes('未验'));
|
||
});
|
||
|
||
test('★ 真样本②(12:08 实采):**我们真的在前台** ⇒ ours + 允许断言(严格正例)', () => {
|
||
// 此前这条正例是**对调状态合成**的,而且合成得自相矛盾(state FG 却 app state BG)——
|
||
// pi 抓到这点:那种样本不是合法 dump,正例等于建在非法输入上。现在换成**真机实采**的。
|
||
const oursReal = prose(join(HERE, 'fixtures', 'aa-dump-l-ours-foreground.txt'));
|
||
assert.equal(C.parseMissions(oursReal).length, 2, '真样本里两个 mission 都在(对方 + 我们)');
|
||
assert.equal(C.foregroundVerdict(oursReal, OURS), C.VERDICT_OURS);
|
||
assert.equal(C.mayAssertOn(oursReal, OURS), true, '★ 没有这条,"闸能放行"这个性质就没人验(失效方式是永远未验)');
|
||
assert.equal(C.foregroundBundle(oursReal), OURS);
|
||
});
|
||
|
||
test('★ 自相矛盾的 dump(state FG 但 app state BG)⇒ unverified,不许挑一个信', () => {
|
||
const contradictory = prose(join(HERE, 'fixtures', 'aa-dump-l-ours-foreground.txt'))
|
||
.split('\n').map((l) => l.includes('app state #FOREGROUND') && l.trim().startsWith('app state') ? l.replace('#FOREGROUND', '#BACKGROUND') : l).join('\n');
|
||
assert.equal(C.foregroundVerdict(contradictory, OURS), C.VERDICT_UNVERIFIED,
|
||
'同一块里 state 与 app state 打架 ⇒ 这份 dump 自己就不自洽 ⇒ 拿不准,而不是"挑一个信"');
|
||
});
|
||
|
||
test('★ 空 dump / 截断 / 格式变了 ⇒ unverified(缺证据 ≠ 没有那个现象)', () => {
|
||
assert.equal(C.foregroundVerdict('', OURS), C.VERDICT_UNVERIFIED);
|
||
assert.equal(C.foregroundVerdict('User ID #100\n current mission lists:{\n', OURS), C.VERDICT_UNVERIFIED);
|
||
assert.equal(C.mayAssertOn('{不是 dump', OURS), false);
|
||
assert.ok(C.unverifiedReason(C.VERDICT_UNVERIFIED).includes('缺证据'), '状态词必须点明"缺证据≠没有现象"');
|
||
});
|
||
|
||
test('★ 有 mission 但全都没有 FOREGROUND 状态 ⇒ unverified(不是"没我们就算别人")', () => {
|
||
const noFg = real.split('\n').map((l) => l.replace('#FOREGROUND', '#BACKGROUND')).join('\n');
|
||
assert.equal(C.foregroundBundle(noFg), '', '没有 FOREGROUND ⇒ 取不到前台');
|
||
assert.equal(C.foregroundVerdict(noFg, OURS), C.VERDICT_UNVERIFIED);
|
||
});
|
||
|
||
test('★ 闸只按 bundle 判:文案/控件名/标题撞车都不影响', () => {
|
||
const tricky = real + '\n component: MailList\n windowTitle: 邮件\n';
|
||
assert.equal(C.foregroundVerdict(tricky, OURS), C.VERDICT_OTHER, '加上与我们要判控件同名同文案的东西,也不许变成 ours');
|
||
});
|
||
|
||
test('★ 第二份真样本:单 mission(**我们根本不在列表里**)⇒ other,不是 unverified', () => {
|
||
// 由第一份真样本删掉我们那块得到;形状与 pi 12:03 那份活 dump 一致(他信里也是单 mission)
|
||
const single = prose(join(HERE, 'fixtures', 'aa-dump-l-single-theirs.txt'));
|
||
assert.equal(C.parseMissions(single).length, 1);
|
||
assert.equal(C.foregroundVerdict(single, OURS), C.VERDICT_OTHER,
|
||
'有 FOREGROUND 且不是我们 ⇒ other。若这里报 unverified,就把"别人占着"和"没读到"混成一种了');
|
||
assert.equal(C.mayAssertOn(single, OURS), false);
|
||
});
|
||
|
||
test('★ 把 state #FOREGROUND 全部删掉 ⇒ unverified(缺证据,不猜)', () => {
|
||
const noFg = real.split('\n').filter((l) => !l.includes('#FOREGROUND')).join('\n');
|
||
assert.equal(C.foregroundVerdict(noFg, OURS), C.VERDICT_UNVERIFIED);
|
||
});
|
||
|
||
/*
|
||
* ★★ 包名只能有**一个**权威来源(2026-09-18 实测事故的回归锁)。
|
||
*
|
||
* 事故:包名从 `com.agentmail.harmony` 改成 `com.jianf.agentmail`(AGC 拒 `harmony` 保留字),
|
||
* 而 `harmony-nav.test.mjs` 里的**比较字面量没跟着改** ⇒
|
||
* 那条行为判据的"前台是我们的"永远不成立 ⇒ **永远走"设备忙"跳过**,
|
||
* 账本一路数到 42 轮才被设界抓红。
|
||
*
|
||
* 值得记住的是它**怎么**暴露的:判据从没红过(它从不执行断言),
|
||
* 是"跳过也要有界"那条闹钟把它顶出来的 —— 否则它会一直"不算红也不算绿"地挂着。
|
||
*
|
||
* 所以这条判的是**漂移本身**:判据目录里除 `lib/`(唯一读取处)与本文件(对照真机样本)
|
||
* 之外,**不许再出现包名字面量**。改包名只需改 `AppScope/app.json5`,
|
||
* 消费方一律走 `ourBundle()`。
|
||
*/
|
||
test('★ 包名不许在判据里四处漂移:只有一个权威来源(我们刚被咬过一次)', () => {
|
||
const dir = join(HERE);
|
||
const files = readdirSync(dir).filter((f) => f.endsWith('.test.mjs'));
|
||
/*
|
||
* 本文件是**具名例外**,不是豁免:它拿真机样本(fixtures/aa-dump-l-*.txt)里的包名
|
||
* 做对照,字面量与被测样本**必须逐字相同**,走 `ourBundle()` 反而会让样本与断言脱节。
|
||
* 例外要**具名**(本仓纪律):写在这里,而不是把过滤条件写宽。
|
||
*/
|
||
const SELF = 'harmony-deviceprobe.test.mjs';
|
||
const offenders = [];
|
||
for (const f of files) {
|
||
if (f === SELF) continue;
|
||
const src = prose(join(dir, f));
|
||
// 只认**代码里的字符串字面量**,注释里的历史说明不算(`align-refs` 就写了旧名来解释为什么换)
|
||
const noComment = src.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:])\/\/[^\n]*/g, '$1');
|
||
const hits = [...noComment.matchAll(/['"`](com\.[a-z0-9_.]+)['"`]/gi)].map((m) => m[1]);
|
||
if (hits.length) offenders.push(`${f}: ${[...new Set(hits)].join(', ')}`);
|
||
}
|
||
assert.deepEqual(offenders, [],
|
||
`判据里出现了包名字面量(应当走 ourBundle(),从 AppScope/app.json5 读):\n` +
|
||
` ${offenders.join('\n ')}\n` +
|
||
` **正确修法**:改成 \`ourBundle()\`;本文件是个例外(它要拿真机样本里的包名做对照)。\n` +
|
||
` **最常见的错误修法**:改断言里的期望值 —— 那正是这次事故的形状(改一处忘一处)。`);
|
||
});
|
||
|
||
/* 变异自检:把一条消费方改回硬编码,上面那条必须判得出来。 */
|
||
test('★ 判据自检:硬编码包名字面量必须判红', () => {
|
||
const sample = `if (fg !== 'com.agentmail.harmony') { t.skip(); }`;
|
||
const noComment = sample.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:])\/\/[^\n]*/g, '$1');
|
||
const hits = [...noComment.matchAll(/['"`](com\.[a-z0-9_.]+)['"`]/gi)].map((m) => m[1]);
|
||
assert.ok(hits.length > 0, '硬编码包名必须被抓到(否则上面那条是真空通过)');
|
||
const commented = `// 历史说明:原来是 'com.agentmail.harmony'`;
|
||
assert.equal([...commented.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:])\/\/[^\n]*/g, '$1')
|
||
.matchAll(/['"`](com\.[a-z0-9_.]+)['"`]/gi)].length, 0, '注释里的历史说明不该误红');
|
||
});
|
||
|
||
test('★ 判据自检:ourBundle() 读得到值、且与 AppScope/app.json5 一致', () => {
|
||
const b = ourBundle();
|
||
assert.ok(b && /^com\.[a-z0-9.]+$/.test(b), `ourBundle() 要读出合法包名,实际 ${b}`);
|
||
assert.ok(!/harmony/i.test(b), `包名带 harmony 是 AGC 保留字:${b}`);
|
||
});
|