@danceiny/gotry 0.2.0-rc.27 → 0.2.0-rc.28
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/README.zh-CN.md +2 -2
- package/bin/gotry-bootstrap.js +47 -3
- package/dist/capabilities/anything.js +26 -0
- package/dist/capabilities/doctor.js +11 -4
- package/dist/capabilities/ground-transfer.js +168 -1
- package/dist/capabilities/provider-detail-sanitize.js +64 -0
- package/dist/capabilities/route-provider-conformance.js +341 -0
- package/dist/scripts/anything-path-measure-tests.js +133 -0
- package/dist/scripts/anything-path-measure.js +150 -0
- package/dist/scripts/anything-tests.js +136 -2
- package/dist/scripts/booking-copilot-dsh-warmer-proof.js +5 -2
- package/dist/scripts/bootstrap-tests.js +71 -1
- package/dist/scripts/city-scenario-tier-tests.js +234 -0
- package/dist/scripts/doctor-tests.js +37 -1
- package/dist/scripts/drill-multiuser-ledger-tests.js +1076 -0
- package/dist/scripts/drill-sdk-descendant-cleanup-tests.js +606 -0
- package/dist/scripts/drill-w2a-sensor-tests.js +788 -0
- package/dist/scripts/dsh-llm-tests.js +437 -0
- package/dist/scripts/g5-guard-tests.js +20 -1
- package/dist/scripts/g5-guard.js +5 -1
- package/dist/scripts/m3-cohort-tests.js +714 -0
- package/dist/scripts/m3-cohort.js +310 -0
- package/dist/scripts/nightly-evidence-tests.js +44 -2
- package/dist/scripts/nightly-evidence.js +21 -4
- package/dist/scripts/outcome-projection-tests.js +427 -0
- package/dist/scripts/persona-sim-tests.js +603 -0
- package/dist/scripts/persona-sim.js +1081 -0
- package/dist/scripts/replay-async.js +2 -0
- package/dist/scripts/replay.js +2 -0
- package/dist/scripts/route-provider-conformance-tests.js +391 -0
- package/dist/scripts/session-zone-crash.js +49 -0
- package/dist/scripts/session-zone-ledger-tests.js +432 -0
- package/dist/scripts/session-zone-observation-tests.js +492 -0
- package/dist/scripts/session-zone-observe.js +135 -0
- package/dist/scripts/session-zone-wiring-tests.js +566 -0
- package/dist/scripts/session-zones-tests.js +17 -1
- package/dist/scripts/spawn-bounded-abort-tests.js +3 -0
- package/dist/scripts/state-cli.js +16 -0
- package/dist/scripts/time-eval-tests.js +2 -0
- package/dist/scripts/unified-tests.js +37 -1
- package/dist/scripts/z3-race-tests.js +6 -1
- package/dist/src/city-scenario-tier.js +378 -0
- package/dist/src/dsh-llm.js +84 -14
- package/dist/src/index.js +160 -1
- package/dist/src/loop.js +16 -5
- package/dist/src/m3-cohort.js +1577 -0
- package/dist/src/outcome-projection.js +746 -0
- package/dist/src/session-zone-ledger.js +312 -0
- package/dist/src/session-zone-observation.js +354 -0
- package/dist/src/session-zone-wiring.js +658 -0
- package/dist/src/session-zones.js +7 -4
- package/dist/src/state-ledger.js +293 -40
- package/dist/src/unified.js +69 -5
- package/extension/manifest.json +2 -2
- package/package.json +1 -1
- package/ts/capabilities/anything.ts +27 -0
- package/ts/scripts/state-cli.ts +18 -0
- package/ts/src/contracts.ts +4 -0
- package/ts/src/dsh-llm.ts +111 -9
- package/ts/src/index.ts +175 -2
- package/ts/src/loop.ts +15 -5
- package/ts/src/state-ledger.ts +294 -40
- package/ts/src/unified.ts +91 -18
|
@@ -10,15 +10,25 @@
|
|
|
10
10
|
* 6. 超时:fake hbcli hang — AbortError → verdict error + 不抛错
|
|
11
11
|
* 7. 关键词为空:不调 hbcli — verdict error
|
|
12
12
|
* 8. 缺 hbcli(spawn 失败):用户面是人话(不留 `(exit null)`/ENOENT 进程噪音)
|
|
13
|
+
* 9. 离线开关 GOTRY_HBCLI_LIVE=0|false|off(issue #617):计数 fake hbcli **零次**被调起,
|
|
14
|
+
* 返回既有降级面(与缺 hbcli 同形:ok:false / hbcli-anything-error / verdict:error + 人话原因);
|
|
15
|
+
* 含 PATH 解析的真实调用面(runTurn 的 PoI 探针不传 hbcliBin)
|
|
16
|
+
* 10. 开关未设或任何其他取值:行为与此前完全一致——fake 被调起,hit/miss/error 三值语义不变
|
|
17
|
+
* 11. 既有入参的既有结果在开关关闭时逐字节不变:空 keyword / pre-aborted 先于离线判定
|
|
18
|
+
*
|
|
19
|
+
* 本套件独占 GOTRY_HBCLI_LIVE:开头清掉外部值(`GOTRY_HBCLI_LIVE=0 ./scripts/run-all-tests.sh` 也不得改变 fake 断言),
|
|
20
|
+
* 结束还原。
|
|
13
21
|
*
|
|
14
22
|
* 运行: cd ts && npx tsx scripts/anything-tests.ts
|
|
15
23
|
*/
|
|
16
24
|
import assert from 'node:assert/strict';
|
|
17
|
-
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
|
|
25
|
+
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
|
18
26
|
import { tmpdir } from 'node:os';
|
|
19
27
|
import { join } from 'node:path';
|
|
20
28
|
import { anythingSearch } from '../capabilities/anything.js';
|
|
21
29
|
const tmp = await mkdtemp(join(tmpdir(), 'anything-test-'));
|
|
30
|
+
const savedLive = process.env.GOTRY_HBCLI_LIVE;
|
|
31
|
+
delete process.env.GOTRY_HBCLI_LIVE;
|
|
22
32
|
try {
|
|
23
33
|
async function fakeBin(name, behaviour) {
|
|
24
34
|
const p = join(tmp, name);
|
|
@@ -144,8 +154,132 @@ while :; do sleep 5; done
|
|
|
144
154
|
assert.ok(/hbcli/.test(r8.error), `原因须指明是 hbcli,实得:${r8.error}`);
|
|
145
155
|
assert.ok(r8.evidence.includes('[实时API:hbcli-anything@error@'), '证据链 tag 形态不变');
|
|
146
156
|
console.log(`8. 缺 hbcli → 人话降级 OK(${r8.error})`);
|
|
147
|
-
|
|
157
|
+
// ---- 9-11. 离线开关 GOTRY_HBCLI_LIVE(issue #617) -------------------------------------------
|
|
158
|
+
// 计数 fake:每被调起一次就往 calls.log 追加一行(只用 shell 内建,PATH 为空也能跑),再按 kind 回包。
|
|
159
|
+
const callsLog = join(tmp, 'calls.log');
|
|
160
|
+
const invocations = async () => {
|
|
161
|
+
try {
|
|
162
|
+
return (await readFile(callsLog, 'utf8')).split('\n').filter(Boolean).length;
|
|
163
|
+
}
|
|
164
|
+
catch {
|
|
165
|
+
return 0;
|
|
166
|
+
}
|
|
167
|
+
};
|
|
168
|
+
async function countedBin(name, kind, dir = tmp) {
|
|
169
|
+
const p = join(dir, name);
|
|
170
|
+
const reply = kind === 'hit'
|
|
171
|
+
? `printf '%s' '{"candidates":[{"type":"city","matchScore":9,"region":{"id":"d1","name":{"zh":"大理市"}}}]}'`
|
|
172
|
+
: kind === 'miss'
|
|
173
|
+
? `printf '%s' '{"candidates":[]}'`
|
|
174
|
+
: `printf 'hotelbe down\\n' >&2\nexit 1`;
|
|
175
|
+
await writeFile(p, `#!/bin/sh\nprintf 'call\\n' >> '${callsLog}'\n${reply}\n`, { mode: 0o755 });
|
|
176
|
+
return p;
|
|
177
|
+
}
|
|
178
|
+
async function withLive(value, body) {
|
|
179
|
+
const prev = process.env.GOTRY_HBCLI_LIVE;
|
|
180
|
+
if (value === undefined)
|
|
181
|
+
delete process.env.GOTRY_HBCLI_LIVE;
|
|
182
|
+
else
|
|
183
|
+
process.env.GOTRY_HBCLI_LIVE = value;
|
|
184
|
+
try {
|
|
185
|
+
return await body();
|
|
186
|
+
}
|
|
187
|
+
finally {
|
|
188
|
+
if (prev === undefined)
|
|
189
|
+
delete process.env.GOTRY_HBCLI_LIVE;
|
|
190
|
+
else
|
|
191
|
+
process.env.GOTRY_HBCLI_LIVE = prev;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
const countedHit = await countedBin('hbcli-counted-hit', 'hit');
|
|
195
|
+
const countedMiss = await countedBin('hbcli-counted-miss', 'miss');
|
|
196
|
+
const countedFail = await countedBin('hbcli-counted-fail', 'fail');
|
|
197
|
+
// 9. 关闭:0/false/off(不分大小写、忽略首尾空白)→ 零 spawn + 既有降级面
|
|
198
|
+
for (const off of ['0', 'false', 'off', 'OFF', 'False', ' 0 ', ' off\n']) {
|
|
199
|
+
const before = await invocations();
|
|
200
|
+
const r9 = await withLive(off, () => anythingSearch({ keyword: '大理', hbcliBin: countedHit }));
|
|
201
|
+
assert.equal(await invocations(), before, `GOTRY_HBCLI_LIVE=${JSON.stringify(off)}:fake hbcli 必须被调起 0 次`);
|
|
202
|
+
assert.equal(r9.ok, false);
|
|
203
|
+
assert.equal(r9.via, 'hbcli-anything-error');
|
|
204
|
+
assert.equal(r9.verdict, 'error', '「没查」不得说成「查无」:走 error 降级面而非 miss');
|
|
205
|
+
assert.equal(r9.hits, undefined);
|
|
206
|
+
assert.deepEqual(Object.keys(r9).sort(), Object.keys(r8).sort(), '与缺 hbcli 降级同形(同一组字段)');
|
|
207
|
+
assert.match(r9.evidence, /^\[实时API:hbcli-anything@offline@[^\]]+\]/, `证据链应标 offline,实得:${r9.evidence}`);
|
|
208
|
+
assert.ok(r9.error && /hbcli/.test(r9.error) && /GOTRY_HBCLI_LIVE/.test(r9.error), `原因须说明 hbcli 通道被开关关闭,实得:${r9.error}`);
|
|
209
|
+
assert.ok(!/(exit null|ENOENT|spawn |timeout)/i.test(r9.error), `用户面不得出现进程噪音(也不得含 timeout:效应层只重试 timeout 类),实得:${r9.error}`);
|
|
210
|
+
}
|
|
211
|
+
// 9b. 真实调用面:不传 hbcliBin('hbcli' 由 PATH 解析,runTurn 的 PoI 探针即如此)。
|
|
212
|
+
// 未设开关的 positive control 证明 fake 确实可达——否则「零次」可能是空断言。
|
|
213
|
+
const pathBin = join(tmp, 'pathbin');
|
|
214
|
+
await mkdir(pathBin);
|
|
215
|
+
await countedBin('hbcli', 'hit', pathBin);
|
|
216
|
+
const savedPath = process.env.PATH;
|
|
217
|
+
const savedHome = process.env.HOME;
|
|
218
|
+
process.env.PATH = `${pathBin}:${savedPath ?? ''}`;
|
|
219
|
+
process.env.HOME = join(tmp, 'empty-home'); // 已知安装位回退落在空目录,碰不到开发机真实 hbcli
|
|
220
|
+
try {
|
|
221
|
+
const base = await invocations();
|
|
222
|
+
const control = await withLive(undefined, () => anythingSearch({ keyword: '大理' }));
|
|
223
|
+
assert.equal(control.verdict, 'hit', `positive control:未设开关时 PATH 上的 fake 应被调起,实得:${control.evidence}`);
|
|
224
|
+
assert.equal(await invocations(), base + 1, 'positive control 恰好调起一次');
|
|
225
|
+
const off = await withLive('0', () => anythingSearch({ keyword: '大理' }));
|
|
226
|
+
assert.equal(off.verdict, 'error');
|
|
227
|
+
assert.match(off.evidence, /@offline@/);
|
|
228
|
+
assert.equal(await invocations(), base + 1, 'GOTRY_HBCLI_LIVE=0:PATH 上的 hbcli 零次调起');
|
|
229
|
+
}
|
|
230
|
+
finally {
|
|
231
|
+
if (savedPath === undefined)
|
|
232
|
+
delete process.env.PATH;
|
|
233
|
+
else
|
|
234
|
+
process.env.PATH = savedPath;
|
|
235
|
+
if (savedHome === undefined)
|
|
236
|
+
delete process.env.HOME;
|
|
237
|
+
else
|
|
238
|
+
process.env.HOME = savedHome;
|
|
239
|
+
}
|
|
240
|
+
console.log('9. GOTRY_HBCLI_LIVE=0|false|off → 零 spawn + 既有降级面(显式 hbcliBin 与 PATH 解析两种调用面)OK');
|
|
241
|
+
// 10. 未设或任何其他取值 = 与此前完全一致:fake 被调起,hit/miss/error 三值语义不变
|
|
242
|
+
for (const on of [undefined, '1']) {
|
|
243
|
+
const before = await invocations();
|
|
244
|
+
const h = await withLive(on, () => anythingSearch({ keyword: '大理', hbcliBin: countedHit }));
|
|
245
|
+
const m = await withLive(on, () => anythingSearch({ keyword: 'nothing', hbcliBin: countedMiss }));
|
|
246
|
+
const f = await withLive(on, () => anythingSearch({ keyword: 'x', hbcliBin: countedFail }));
|
|
247
|
+
assert.equal(await invocations(), before + 3, `GOTRY_HBCLI_LIVE=${String(on)}:三只 fake 各被调起一次`);
|
|
248
|
+
assert.equal(h.verdict, 'hit');
|
|
249
|
+
assert.equal(h.hits[0].name, '大理市');
|
|
250
|
+
assert.match(h.evidence, /^\[实时API:hbcli-anything@\d{4}-/, 'hit 证据链形态不变');
|
|
251
|
+
assert.equal(m.verdict, 'miss');
|
|
252
|
+
assert.equal(m.hits.length, 0);
|
|
253
|
+
assert.equal(f.verdict, 'error');
|
|
254
|
+
assert.match(f.evidence, /^\[实时API:hbcli-anything@error@/, 'error 证据链形态不变(不是 offline)');
|
|
255
|
+
assert.equal(f.error, 'hbcli 报错:hotelbe down', '既有原因串逐字节不变');
|
|
256
|
+
}
|
|
257
|
+
// 非 0/false/off 的取值一律视为开启(与此前一致):空串/1/true/on/yes/no/disabled/00/任意字符串
|
|
258
|
+
for (const other of ['', 'true', 'on', 'yes', 'no', 'disabled', '00', 'maybe']) {
|
|
259
|
+
const before = await invocations();
|
|
260
|
+
const r = await withLive(other, () => anythingSearch({ keyword: '大理', hbcliBin: countedHit }));
|
|
261
|
+
assert.equal(r.verdict, 'hit', `GOTRY_HBCLI_LIVE=${JSON.stringify(other)} 不是关闭值,应保持 live,实得:${r.evidence}`);
|
|
262
|
+
assert.equal(await invocations(), before + 1);
|
|
263
|
+
}
|
|
264
|
+
console.log('10. 开关未设/其他取值 → 与此前一致(hit/miss/error 三值 + 既有原因串)OK');
|
|
265
|
+
// 11. 关闭时,既有入参的既有结果逐字节不变:空 keyword / pre-aborted 先于离线判定(两者本就零 spawn)
|
|
266
|
+
const before11 = await invocations();
|
|
267
|
+
const rEmpty = await withLive('0', () => anythingSearch({ keyword: ' ', hbcliBin: countedHit }));
|
|
268
|
+
assert.equal(rEmpty.error, 'keyword is required');
|
|
269
|
+
assert.match(rEmpty.evidence, /^\[实时API:hbcli-anything@error@[^\]]+\] keyword empty$/);
|
|
270
|
+
const preAborted = new AbortController();
|
|
271
|
+
preAborted.abort();
|
|
272
|
+
const rAbort = await withLive('0', () => anythingSearch({ keyword: '大理', hbcliBin: countedHit, signal: preAborted.signal }));
|
|
273
|
+
assert.equal(rAbort.error, 'aborted by host signal');
|
|
274
|
+
assert.match(rAbort.evidence, /^\[实时API:hbcli-anything@abort@[^\]]+\] pre-aborted, zero spawn$/);
|
|
275
|
+
assert.equal(await invocations(), before11, '两条既有降级路径均零 spawn');
|
|
276
|
+
console.log('11. 关闭态下空 keyword / pre-aborted 的既有结果不变 OK');
|
|
277
|
+
console.log('\nANYTHING TESTS: 11/11 OK(hbcli fake + wire 对齐 + 降级诚实 + #617 离线开关零 spawn)');
|
|
148
278
|
}
|
|
149
279
|
finally {
|
|
280
|
+
if (savedLive === undefined)
|
|
281
|
+
delete process.env.GOTRY_HBCLI_LIVE;
|
|
282
|
+
else
|
|
283
|
+
process.env.GOTRY_HBCLI_LIVE = savedLive;
|
|
150
284
|
await rm(tmp, { recursive: true, force: true });
|
|
151
285
|
}
|
|
@@ -15,9 +15,12 @@ mkdirSync(markerDir, { recursive: true });
|
|
|
15
15
|
const actualBin = pathToFileURL(join(dirname(fileURLToPath(import.meta.resolve('@deepseek-ai/dsh/package.json'))), 'lib/bin.js')).href;
|
|
16
16
|
const gateBin = join(root, 'blocked-dsh.mjs');
|
|
17
17
|
writeFileSync(gateBin, `
|
|
18
|
-
import { existsSync, writeFileSync } from 'node:fs'
|
|
18
|
+
import { existsSync, renameSync, writeFileSync } from 'node:fs'
|
|
19
19
|
const marker = ${JSON.stringify(markerDir)} + '/gate-' + process.pid + '.json'
|
|
20
|
-
|
|
20
|
+
// Atomic publish: the proof parses each marker the moment the directory lists it, so a marker must never be visible half-written (writeFileSync creates the empty file first).
|
|
21
|
+
const staging = ${JSON.stringify(root)} + '/gate-' + process.pid + '.staging'
|
|
22
|
+
writeFileSync(staging, JSON.stringify({ pid: process.pid, workerPid: process.ppid }))
|
|
23
|
+
renameSync(staging, marker)
|
|
21
24
|
while (!existsSync(${JSON.stringify(releaseFile)})) await new Promise((resolve) => setTimeout(resolve, 10))
|
|
22
25
|
const { runCli } = await import(${JSON.stringify(actualBin)})
|
|
23
26
|
await runCli()
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
* 4. GOTRY_SETUP_EXTENSION=0 单项跳过
|
|
11
11
|
* 5. wizard --dry-run 与真实路径(probe 失败 exit 1)
|
|
12
12
|
* 8. doctor 子命令:体检清单/LLM key 让渡/报告落盘(状态面回归)
|
|
13
|
+
* 24. hbcli 凭证判定(#623):CLI doctor 按 whoami 三档 JSON 判而非退出码,并与工具层 doctor 对拍同结论
|
|
13
14
|
*
|
|
14
15
|
* 不测真实安装(浏览器商店一键装已上架,扩展就位检测走 runHealthWatch 的回环端口)。
|
|
15
16
|
* 运行: cd ts && npx tsx scripts/bootstrap-tests.ts
|
|
@@ -20,6 +21,7 @@ import { EventEmitter } from 'node:events';
|
|
|
20
21
|
import { chmodSync, copyFileSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
|
|
21
22
|
import { tmpdir } from 'node:os';
|
|
22
23
|
import { join, dirname, delimiter } from 'node:path';
|
|
24
|
+
import { runDoctorChecks } from '../capabilities/doctor.js';
|
|
23
25
|
const repoRoot = join(import.meta.dirname, '..', '..');
|
|
24
26
|
const bootstrap = join(repoRoot, 'bin', 'gotry-bootstrap.js');
|
|
25
27
|
function runBootstrap(extraArgs, extraEnv, bootstrapPath = bootstrap) {
|
|
@@ -1476,4 +1478,72 @@ console.log('22. win32 platform boundary(hbcli/reach/sidebar → unavailable +
|
|
|
1476
1478
|
}
|
|
1477
1479
|
}
|
|
1478
1480
|
console.log('23. result 通道排他写入(预存文件不被覆盖 + symlink 不跟随 victim,mode 0600 + flag wx)OK');
|
|
1479
|
-
|
|
1481
|
+
// 24. hbcli 凭证判定(#623):`gotry doctor` 的 CLI 面按 `hbcli --json auth whoami` 的三档 JSON 判,不看退出码——
|
|
1482
|
+
// whoami 没有凭证时也退 0,旧判法对「没登录」恒报「凭证有效」(rc.27 已发布版实测 14/14)。
|
|
1483
|
+
// CLI(bin/gotry-bootstrap.js)与工具层(ts/capabilities/doctor.ts)是同一检查的两份实现,
|
|
1484
|
+
// 故同一份假 hbcli 夹具同时喂两边对拍:结论必须一致(漂移即红)。受控 PATH 只含 node 软链 + 假 hbcli,
|
|
1485
|
+
// 隔离 HOME,不碰真机 hbcli(开发机常把真 hbcli 装在 node 同目录)。
|
|
1486
|
+
{
|
|
1487
|
+
const { classifyHbcliWhoami } = await import(bootstrap);
|
|
1488
|
+
const allFalse = '{"env":"uat","api_key":{"configured":false},"portal":{"configured":false},"customer":{"configured":false}}';
|
|
1489
|
+
const unit = (out) => classifyHbcliWhoami(out);
|
|
1490
|
+
assert.deepEqual(unit(allFalse), { ok: false, customer: false }, '三档全 false → 未配置');
|
|
1491
|
+
assert.deepEqual(unit(allFalse.replace('"customer":{"configured":false}', '"customer":{"configured":true}')), { ok: true, customer: true }, 'customer 档 → 有效且标注 customer');
|
|
1492
|
+
assert.deepEqual(unit(allFalse.replace('"api_key":{"configured":false}', '"api_key":{"configured":true}')), { ok: true, customer: false }, 'api_key 档 → 有效');
|
|
1493
|
+
assert.deepEqual(unit('not json'), { ok: true, customer: false }, '非 JSON(超旧版/异常)保守视为有效,与工具层同语义');
|
|
1494
|
+
assert.deepEqual(unit(''), { ok: true, customer: false }, '空输出同属不可解析,保守视为有效');
|
|
1495
|
+
assert.deepEqual(unit(null), { ok: false, customer: false }, '取不到输出(非零退出/超时/spawn 失败)→ 未确认');
|
|
1496
|
+
const fakeHbcli = (whoamiArm) => `#!/bin/sh\ncase "$*" in\n *--version*) echo '0.0.4';;\n *version*) echo '0.0.4';;\n *whoami*) ${whoamiArm};;\nesac\n`;
|
|
1497
|
+
const cases = [
|
|
1498
|
+
{ name: 'allfalse', arm: `echo '${allFalse}'`, expect: 'degraded' },
|
|
1499
|
+
{ name: 'customer', arm: `echo '${allFalse.replace('"customer":{"configured":false}', '"customer":{"configured":true}')}'`, expect: 'ok', customer: true },
|
|
1500
|
+
{ name: 'apikey', arm: `echo '${allFalse.replace('"api_key":{"configured":false}', '"api_key":{"configured":true}')}'`, expect: 'ok' },
|
|
1501
|
+
{ name: 'plaintext', arm: `echo 'not json'`, expect: 'ok' },
|
|
1502
|
+
{ name: 'failing', arm: `echo boom >&2; exit 3`, expect: 'degraded' },
|
|
1503
|
+
];
|
|
1504
|
+
const emptyRepo = mkdtempSync(join(tmpdir(), 'gotry-hbcli-parity-repo-'));
|
|
1505
|
+
const prevPath = process.env.PATH;
|
|
1506
|
+
try {
|
|
1507
|
+
for (const c of cases) {
|
|
1508
|
+
const root = mkdtempSync(join(tmpdir(), `gotry-hbcli-${c.name}-`));
|
|
1509
|
+
try {
|
|
1510
|
+
const binDir = join(root, 'bin');
|
|
1511
|
+
const home = join(root, 'home');
|
|
1512
|
+
mkdirSync(binDir);
|
|
1513
|
+
mkdirSync(home);
|
|
1514
|
+
symlinkSync(process.execPath, join(binDir, 'node'));
|
|
1515
|
+
writeFileSync(join(binDir, 'hbcli'), fakeHbcli(c.arm), { mode: 0o755 });
|
|
1516
|
+
// CLI 面
|
|
1517
|
+
const cli = runBootstrap(['doctor'], { HOME: home, PATH: binDir, FLYAI_API_KEY: '' });
|
|
1518
|
+
const line = cli.out.split('\n').find((l) => l.includes('hbcli(酒店实时源)'));
|
|
1519
|
+
assert.ok(line, `${c.name}: CLI doctor 应输出 hbcli 行\n${cli.out}`);
|
|
1520
|
+
const cliLevel = line.includes('已安装且凭证有效') ? 'ok' : line.includes('二进制在,但凭证未配置/失效') ? 'degraded' : 'other';
|
|
1521
|
+
assert.equal(cliLevel, c.expect, `${c.name}: CLI doctor 的 hbcli 结论应为 ${c.expect}\n${line}`);
|
|
1522
|
+
if (c.customer)
|
|
1523
|
+
assert.ok(line.includes('customer 档在用'), `${c.name}: CLI 应标注 customer 档在用\n${line}`);
|
|
1524
|
+
// 工具层(同一夹具、同一受控 PATH)
|
|
1525
|
+
process.env.PATH = binDir;
|
|
1526
|
+
const tool = (await runDoctorChecks({ repoRoot: emptyRepo, homeDir: home, env: {} })).items.find((i) => i.id === 'hbcli');
|
|
1527
|
+
process.env.PATH = prevPath;
|
|
1528
|
+
assert.equal(tool.status, c.expect, `${c.name}: 工具层 doctor 的 hbcli 结论应为 ${c.expect},实际 ${tool.status}`);
|
|
1529
|
+
assert.equal(cliLevel, tool.status, `${c.name}: CLI 与工具层必须同结论(两份实现不得漂移)`);
|
|
1530
|
+
}
|
|
1531
|
+
finally {
|
|
1532
|
+
process.env.PATH = prevPath;
|
|
1533
|
+
try {
|
|
1534
|
+
rmSync(root, { recursive: true, force: true });
|
|
1535
|
+
}
|
|
1536
|
+
catch { /* ignore */ }
|
|
1537
|
+
}
|
|
1538
|
+
}
|
|
1539
|
+
}
|
|
1540
|
+
finally {
|
|
1541
|
+
process.env.PATH = prevPath;
|
|
1542
|
+
try {
|
|
1543
|
+
rmSync(emptyRepo, { recursive: true, force: true });
|
|
1544
|
+
}
|
|
1545
|
+
catch { /* ignore */ }
|
|
1546
|
+
}
|
|
1547
|
+
}
|
|
1548
|
+
console.log('24. hbcli 凭证判定(CLI doctor 按 whoami 三档 JSON 判,不看退出码;与工具层 doctor 对拍 5 夹具同结论:全 false=degraded / customer·api_key=ok / 非 JSON=保守 ok / 非零退出=degraded)OK');
|
|
1549
|
+
console.log('BOOTSTRAP TESTS: 26/26 OK(扩展就位 + 跳过开关 / wizard --dry-run / wizard 真实 / 扩展分发通道 / doctor 体检面 / calendar setup 状态面 / 显式跳过 + auto 跳过 + 单项跳过 / 启动摘要 / sidebar 落盘状态复核 / onboarding 分类+计划+env opt-out+跳过原因+yes+partial-failure+幂等+prompt+CLI 跳过+--scan / 编排缝 orchestrateWebLaunch × 6 / prompt waiter failure deterministic process-group reap / waiter 原始错误 + cleanup timeout/failure handled / 临时安装包 spawned inner 21a TTY-eligible 真实 prompt→n→web + 摘要抑制 + 无残留 / 21b non-TTY 零 prompt / 21c POSIX 信号清理 SIGTERM→清 result+patch 目录 + 无残留子进程 / 21f yes-path installer 子树信号清理 / 21g yes-path timeout installer 子树清理 / 21d timeout 诊断 + 无残留 / 21e dsh-lifecycle 信号转发 / win32 平台边界 / result 通道排他写入 / hbcli 凭证判定 CLI↔工具层对拍)');
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* City × scenario tiering MECHANISM tests (issue #339 contract slice,
|
|
3
|
+
* SIMULATED-TRIGGER DRILL — fully offline, zero network, zero state).
|
|
4
|
+
*
|
|
5
|
+
* ⚠ Evidence label: `simulated_trigger_drill` / `fixture_contract`. #339's real
|
|
6
|
+
* trigger (reviewable real usage samples + an explicit scenario vocabulary and
|
|
7
|
+
* success metric) has NOT fired. Every registry used below is a TEST-ONLY shape
|
|
8
|
+
* probe constructed with `{ triggerFired: true }`; its city/scenario strings are
|
|
9
|
+
* deliberately meaningless placeholders (`city-a`, `scenario-x`) so that nothing
|
|
10
|
+
* here can be mistaken for a taxonomy. The product registry stays frozen EMPTY —
|
|
11
|
+
* #339 forbids fabricating a profile from fixtures.
|
|
12
|
+
*
|
|
13
|
+
* Sections:
|
|
14
|
+
* A. Default-off proof: trigger frozen false, registry frozen empty in SOURCE,
|
|
15
|
+
* admission refused before any validation, every resolution neutral.
|
|
16
|
+
* B. Admission shape: mandatory provenance (evidence grade / real sample source /
|
|
17
|
+
* ≥3 sample refs / reviewer / freeze instant) and retirement (condition +
|
|
18
|
+
* review deadline postdating the freeze); each omission fail-closed.
|
|
19
|
+
* C. Bounded modifier: integer ppm inside declared bounds; 0, negative,
|
|
20
|
+
* out-of-range, float and non-numeric refused — no tier can annihilate a score.
|
|
21
|
+
* D. Neutral fallback: unknown city, unknown scenario, conflicting evidence,
|
|
22
|
+
* empty registry; a conflict elects no winner.
|
|
23
|
+
* E. Ranking-only falsification: a modifier reorders but can NEVER remove a
|
|
24
|
+
* candidate (incl. lower bound × zero semantic), the result exposes no filter
|
|
25
|
+
* surface, and the mechanical no-hard-filter check passes.
|
|
26
|
+
* F. Determinism + versioning: identical inputs → byte-identical output; ties keep
|
|
27
|
+
* declaration order; a defective registry entry is refused, never clamped.
|
|
28
|
+
*
|
|
29
|
+
* Run (from ts/): npx tsx scripts/city-scenario-tier-tests.ts
|
|
30
|
+
*/
|
|
31
|
+
import { readFileSync } from 'node:fs';
|
|
32
|
+
import { join } from 'node:path';
|
|
33
|
+
import { fileURLToPath } from 'node:url';
|
|
34
|
+
import { CITY_SCENARIO_TIER_REGISTRY, CITY_SCENARIO_TIER_SCHEMA, CITY_SCENARIO_TIER_TRIGGER_FIRED, TIER_APPLICATION_SURFACES, TIER_EVIDENCE_GRADES, TIER_KEY_MAX_CHARS, TIER_MIN_SAMPLE_REFS, TIER_MODIFIER_PPM_MAX, TIER_MODIFIER_PPM_MIN, TIER_MODIFIER_PPM_NEUTRAL, TIER_NEUTRAL_REASONS, TIER_SAMPLE_SOURCES, admitTierEntry, applyTierRanking, normalizeTierKey, resolveTier, tierRankingFilterViolation, } from '../src/city-scenario-tier.js';
|
|
35
|
+
let passed = 0;
|
|
36
|
+
function check(cond, msg) {
|
|
37
|
+
if (cond) {
|
|
38
|
+
passed++;
|
|
39
|
+
console.log(` ok - ${msg}`);
|
|
40
|
+
}
|
|
41
|
+
else {
|
|
42
|
+
console.error(` FAIL - ${msg}`);
|
|
43
|
+
process.exitCode = 1;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
function expectRefusal(result, code, needle, msg) {
|
|
47
|
+
if (result.ok) {
|
|
48
|
+
check(false, `${msg} (expected refusal ${code}, got ok)`);
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
check(result.code === code && result.detail.includes(needle), `${msg} (refused ${result.code}${result.code === code ? '' : ` ≠ ${code}`}, evidence contains "${needle}": ${result.detail.includes(needle)})`);
|
|
52
|
+
}
|
|
53
|
+
const FROZEN_AT = '2026-10-04T00:00:00.000Z';
|
|
54
|
+
const REVIEW_BY = '2027-04-04T00:00:00.000Z';
|
|
55
|
+
/** TEST-ONLY shape probe. The keys are meaningless placeholders, not a taxonomy. */
|
|
56
|
+
function probeEntryInput(overrides = {}) {
|
|
57
|
+
return {
|
|
58
|
+
tier_id: 'probe-tier-1',
|
|
59
|
+
city_key: 'city-a',
|
|
60
|
+
scenario_key: 'scenario-x',
|
|
61
|
+
modifier_ppm: 1_100_000,
|
|
62
|
+
applies_to: 'ranking',
|
|
63
|
+
provenance: {
|
|
64
|
+
evidence_grade: 'controlled_replay',
|
|
65
|
+
sample_source: 'real_usage_sample',
|
|
66
|
+
sample_refs: ['sample-ref-1', 'sample-ref-2', 'sample-ref-3'],
|
|
67
|
+
reviewer: 'drill-reviewer',
|
|
68
|
+
frozen_at: FROZEN_AT,
|
|
69
|
+
},
|
|
70
|
+
retirement: { condition: 'withdraw if the controlled replay no longer reproduces the ordering', review_by: REVIEW_BY },
|
|
71
|
+
taxonomy_version: 'probe.v0',
|
|
72
|
+
...overrides,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
function admittedProbe(overrides = {}, registry = []) {
|
|
76
|
+
const r = admitTierEntry(probeEntryInput(overrides), { triggerFired: true, registry });
|
|
77
|
+
if (!r.ok)
|
|
78
|
+
throw new Error(`probe admission failed unexpectedly: ${r.code} ${r.detail}`);
|
|
79
|
+
return r.value;
|
|
80
|
+
}
|
|
81
|
+
// ---------------------------------------------------------------------------
|
|
82
|
+
// A. Default-off proof
|
|
83
|
+
// ---------------------------------------------------------------------------
|
|
84
|
+
console.log('A. default-off: trigger frozen false, registry frozen EMPTY, nothing admitted');
|
|
85
|
+
check(CITY_SCENARIO_TIER_TRIGGER_FIRED === false, '#339 trigger gate frozen false');
|
|
86
|
+
check(CITY_SCENARIO_TIER_REGISTRY.length === 0, 'the runtime taxonomy registry is empty');
|
|
87
|
+
check(CITY_SCENARIO_TIER_SCHEMA === 'gotry_city_scenario_tier.v1', 'the taxonomy schema id is versioned');
|
|
88
|
+
const MODULE_SOURCE = readFileSync(join(fileURLToPath(new URL('.', import.meta.url)), '..', 'src', 'city-scenario-tier.ts'), 'utf8');
|
|
89
|
+
check(MODULE_SOURCE.includes('export const CITY_SCENARIO_TIER_REGISTRY: readonly CityScenarioTierEntry[] = []'), 'the registry declaration is literally `= []` in source (no taxonomy content in code)');
|
|
90
|
+
check(MODULE_SOURCE.includes('export const CITY_SCENARIO_TIER_TRIGGER_FIRED = false'), 'the trigger declaration is literally `= false` in source');
|
|
91
|
+
expectRefusal(admitTierEntry(probeEntryInput()), 'trigger_not_fired', '不凭 fixture 造画像', 'with the default trigger, admission is refused before any validation');
|
|
92
|
+
expectRefusal(admitTierEntry({ tier_id: '', city_key: '', scenario_key: '', modifier_ppm: -1, taxonomy_version: '' }), 'trigger_not_fired', '#339 trigger has not fired', 'even a wholly invalid entry is refused by the trigger gate first (pre-trigger refusal precedes validation)');
|
|
93
|
+
const defaultResolution = resolveTier({ cityKey: 'any-city', scenarioKey: 'any-scenario' });
|
|
94
|
+
check(defaultResolution.ok && defaultResolution.value.status === 'neutral' && defaultResolution.value.modifier_ppm === TIER_MODIFIER_PPM_NEUTRAL, 'with the default trigger every pair resolves to the neutral modifier ×1.0');
|
|
95
|
+
check(defaultResolution.ok && defaultResolution.value.status === 'neutral' && defaultResolution.value.reason === 'trigger_not_fired', 'the neutral reason is trigger_not_fired, stated rather than implied');
|
|
96
|
+
check(TIER_MODIFIER_PPM_NEUTRAL === 1_000_000, 'the neutral modifier is exactly ×1.0');
|
|
97
|
+
check(TIER_NEUTRAL_REASONS.length === 5 && TIER_NEUTRAL_REASONS.includes('conflicting_evidence'), 'the neutral-reason set is closed and names conflicting evidence');
|
|
98
|
+
// ---------------------------------------------------------------------------
|
|
99
|
+
// B. Admission shape (mandatory provenance + retirement)
|
|
100
|
+
// ---------------------------------------------------------------------------
|
|
101
|
+
console.log('B. admission shape: provenance and retirement are mandatory per tier');
|
|
102
|
+
const admitted = admitTierEntry(probeEntryInput(), { triggerFired: true, registry: [] });
|
|
103
|
+
check(admitted.ok && admitted.value.schema === CITY_SCENARIO_TIER_SCHEMA && admitted.value.taxonomy_version === 'probe.v0', 'a complete probe entry is admitted and carries its schema + taxonomy version');
|
|
104
|
+
check(admitted.ok && admitted.value.applies_to === 'ranking' && TIER_APPLICATION_SURFACES.length === 1, 'the only application surface is `ranking` (there is no hard-filter surface)');
|
|
105
|
+
check(admitted.ok && admitted.value.provenance.sample_refs.length === 3 && admitted.value.retirement.review_by === REVIEW_BY, 'provenance sample refs and the retirement deadline are preserved');
|
|
106
|
+
expectRefusal(admitTierEntry(probeEntryInput({ provenance: undefined }), { triggerFired: true }), 'provenance_required', 'a tier without a stated evidence source is not evidence', 'missing provenance refused');
|
|
107
|
+
expectRefusal(admitTierEntry(probeEntryInput({ retirement: undefined }), { triggerFired: true }), 'retirement_required', 'a tier that cannot expire is not evidence', 'missing retirement refused');
|
|
108
|
+
expectRefusal(admitTierEntry(probeEntryInput({ provenance: { ...probeEntryInput().provenance, sample_source: 'fixture' } }), { triggerFired: true }), 'closed_set', '不凭 fixture 造画像', 'a fixture-sourced sample is refused by name (#339 red line)');
|
|
109
|
+
check(TIER_SAMPLE_SOURCES.length === 1 && TIER_SAMPLE_SOURCES[0] === 'real_usage_sample', 'the admitted sample-source set has exactly one member: real_usage_sample');
|
|
110
|
+
expectRefusal(admitTierEntry(probeEntryInput({ provenance: { ...probeEntryInput().provenance, evidence_grade: 'vibes' } }), { triggerFired: true }), 'closed_set', TIER_EVIDENCE_GRADES[0], 'an evidence grade outside the RFC S2 ordering is refused');
|
|
111
|
+
expectRefusal(admitTierEntry(probeEntryInput({ provenance: { ...probeEntryInput().provenance, sample_refs: ['only-one'] } }), { triggerFired: true }), 'provenance_required', 'a single anecdote is not a tier', `fewer than ${TIER_MIN_SAMPLE_REFS} sample refs refused`);
|
|
112
|
+
expectRefusal(admitTierEntry(probeEntryInput({ provenance: { ...probeEntryInput().provenance, reviewer: '' } }), { triggerFired: true }), 'provenance_required', 'reviewer', 'a tier with no accountable reviewer refused');
|
|
113
|
+
expectRefusal(admitTierEntry(probeEntryInput({ provenance: { ...probeEntryInput().provenance, frozen_at: '2026-10-04' } }), { triggerFired: true }), 'bad_ts', 'date-only', 'a date-only freeze instant refused');
|
|
114
|
+
expectRefusal(admitTierEntry(probeEntryInput({ retirement: { condition: '', review_by: REVIEW_BY } }), { triggerFired: true }), 'retirement_required', 'retirement.condition', 'an empty retirement condition refused');
|
|
115
|
+
expectRefusal(admitTierEntry(probeEntryInput({ retirement: { condition: 'c', review_by: FROZEN_AT } }), { triggerFired: true }), 'retirement_required', 'must postdate', 'a review deadline that does not postdate the freeze refused');
|
|
116
|
+
expectRefusal(admitTierEntry(probeEntryInput({ applies_to: 'hard_filter' }), { triggerFired: true }), 'closed_set', 'memory never enters a hard filter', 'applies_to = hard_filter refused by name');
|
|
117
|
+
expectRefusal(admitTierEntry(probeEntryInput({ taxonomy_version: '' }), { triggerFired: true }), 'binding_incomplete', 'taxonomy_version', 'an unversioned tier refused');
|
|
118
|
+
expectRefusal(admitTierEntry(probeEntryInput({ tier_id: ' ' }), { triggerFired: true }), 'binding_incomplete', 'tier_id', 'a blank tier id refused');
|
|
119
|
+
expectRefusal(admitTierEntry(probeEntryInput({ city_key: '' }), { triggerFired: true }), 'bad_key', 'must not be blank', 'a blank city key refused (blank ≠ unknown)');
|
|
120
|
+
expectRefusal(admitTierEntry(probeEntryInput({ scenario_key: 'x'.repeat(TIER_KEY_MAX_CHARS + 1) }), { triggerFired: true }), 'bad_key', 'never prose', 'an over-long scenario key refused');
|
|
121
|
+
const oneEntry = [admittedProbe()];
|
|
122
|
+
expectRefusal(admitTierEntry(probeEntryInput({ tier_id: 'probe-tier-2' }), { triggerFired: true, registry: oneEntry }), 'duplicate_tier', 'conflicting evidence, not an additional tier', 'a second tier for the same (city, scenario) pair is refused at admission');
|
|
123
|
+
expectRefusal(admitTierEntry(probeEntryInput({ city_key: 'city-b' }), { triggerFired: true, registry: oneEntry }), 'duplicate_tier', 'already exists', 'a duplicate tier_id refused');
|
|
124
|
+
// ---------------------------------------------------------------------------
|
|
125
|
+
// C. Bounded modifier
|
|
126
|
+
// ---------------------------------------------------------------------------
|
|
127
|
+
console.log('C. bounded modifier: declared [min,max], lower bound > 0 by design');
|
|
128
|
+
check(TIER_MODIFIER_PPM_MIN > 0 && TIER_MODIFIER_PPM_MIN < TIER_MODIFIER_PPM_NEUTRAL && TIER_MODIFIER_PPM_MAX > TIER_MODIFIER_PPM_NEUTRAL, 'bounds straddle neutral and the lower bound is strictly positive');
|
|
129
|
+
check(admitTierEntry(probeEntryInput({ modifier_ppm: TIER_MODIFIER_PPM_MIN }), { triggerFired: true }).ok, 'the lower bound itself is admissible (inclusive)');
|
|
130
|
+
check(admitTierEntry(probeEntryInput({ modifier_ppm: TIER_MODIFIER_PPM_MAX }), { triggerFired: true }).ok, 'the upper bound itself is admissible (inclusive)');
|
|
131
|
+
expectRefusal(admitTierEntry(probeEntryInput({ modifier_ppm: 0 }), { triggerFired: true }), 'modifier_bounds', 'never annihilate a candidate', 'a zero modifier is refused (it would annihilate a candidate score)');
|
|
132
|
+
expectRefusal(admitTierEntry(probeEntryInput({ modifier_ppm: -1_000_000 }), { triggerFired: true }), 'modifier_bounds', 'outside the declared bounds', 'a negative modifier refused');
|
|
133
|
+
expectRefusal(admitTierEntry(probeEntryInput({ modifier_ppm: TIER_MODIFIER_PPM_MIN - 1 }), { triggerFired: true }), 'modifier_bounds', 'outside the declared bounds', 'one ppm below the lower bound refused');
|
|
134
|
+
expectRefusal(admitTierEntry(probeEntryInput({ modifier_ppm: TIER_MODIFIER_PPM_MAX + 1 }), { triggerFired: true }), 'modifier_bounds', 'outside the declared bounds', 'one ppm above the upper bound refused');
|
|
135
|
+
expectRefusal(admitTierEntry(probeEntryInput({ modifier_ppm: 1_100_000.5 }), { triggerFired: true }), 'modifier_bounds', 'ppm integers are exact', 'a fractional ppm refused');
|
|
136
|
+
expectRefusal(admitTierEntry(probeEntryInput({ modifier_ppm: '1100000' }), { triggerFired: true }), 'modifier_bounds', 'must be an integer ppm', 'a string modifier refused');
|
|
137
|
+
expectRefusal(admitTierEntry(probeEntryInput({ modifier_ppm: Number.NaN }), { triggerFired: true }), 'modifier_bounds', 'must be an integer ppm', 'NaN modifier refused');
|
|
138
|
+
// ---------------------------------------------------------------------------
|
|
139
|
+
// D. Neutral fallback, never a guess
|
|
140
|
+
// ---------------------------------------------------------------------------
|
|
141
|
+
console.log('D. neutral fallback: unknown city / unknown scenario / conflicting evidence');
|
|
142
|
+
const probeRegistry = [admittedProbe()];
|
|
143
|
+
const hit = resolveTier({ cityKey: 'city-a', scenarioKey: 'scenario-x' }, { registry: probeRegistry, triggerFired: true });
|
|
144
|
+
check(hit.ok && hit.value.status === 'hit' && hit.value.modifier_ppm === 1_100_000, 'a known (city, scenario) pair resolves to its bounded modifier');
|
|
145
|
+
check(hit.ok && hit.value.status === 'hit' && hit.value.explanation.includes('retire when:'), 'a hit explains itself and surfaces its retirement condition');
|
|
146
|
+
check(hit.ok && hit.value.status === 'hit' && hit.value.evidence_grade === 'controlled_replay' && hit.value.taxonomy_version === 'probe.v0', 'a hit carries the evidence grade and taxonomy version');
|
|
147
|
+
const unknownCity = resolveTier({ cityKey: 'city-zz', scenarioKey: 'scenario-x' }, { registry: probeRegistry, triggerFired: true });
|
|
148
|
+
check(unknownCity.ok && unknownCity.value.status === 'neutral' && unknownCity.value.reason === 'unknown_city' && unknownCity.value.modifier_ppm === TIER_MODIFIER_PPM_NEUTRAL, 'unknown city → neutral ×1.0 with reason unknown_city');
|
|
149
|
+
check(unknownCity.ok && unknownCity.value.status === 'neutral' && unknownCity.value.explanation.includes('never guessed from a neighbour'), 'the unknown-city fallback states that no neighbour is substituted');
|
|
150
|
+
const unknownScenario = resolveTier({ cityKey: 'city-a', scenarioKey: 'scenario-zz' }, { registry: probeRegistry, triggerFired: true });
|
|
151
|
+
check(unknownScenario.ok && unknownScenario.value.status === 'neutral' && unknownScenario.value.reason === 'unknown_scenario', 'unknown scenario → neutral ×1.0 with reason unknown_scenario');
|
|
152
|
+
const emptyRegistry = resolveTier({ cityKey: 'city-a', scenarioKey: 'scenario-x' }, { registry: [], triggerFired: true });
|
|
153
|
+
check(emptyRegistry.ok && emptyRegistry.value.status === 'neutral' && emptyRegistry.value.reason === 'empty_registry', 'empty registry → neutral ×1.0, not a zero weight');
|
|
154
|
+
/** Conflicting registry built by bypassing the admission duplicate guard (a defective registry). */
|
|
155
|
+
const conflicting = [
|
|
156
|
+
admittedProbe({ tier_id: 'probe-conflict-a', modifier_ppm: 1_100_000 }),
|
|
157
|
+
admittedProbe({ tier_id: 'probe-conflict-b', modifier_ppm: 900_000 }),
|
|
158
|
+
];
|
|
159
|
+
const conflict = resolveTier({ cityKey: 'city-a', scenarioKey: 'scenario-x' }, { registry: conflicting, triggerFired: true });
|
|
160
|
+
check(conflict.ok && conflict.value.status === 'neutral' && conflict.value.reason === 'conflicting_evidence' && conflict.value.modifier_ppm === TIER_MODIFIER_PPM_NEUTRAL, 'conflicting evidence → neutral ×1.0, no winner elected');
|
|
161
|
+
check(conflict.ok && conflict.value.status === 'neutral' && conflict.value.conflicting_tier_ids.join(',') === 'probe-conflict-a,probe-conflict-b', 'the conflict surfaces both tier ids for diagnosis (without choosing)');
|
|
162
|
+
check(conflict.ok && conflict.value.tier_id === null, 'a neutral resolution never reports a tier id');
|
|
163
|
+
check(normalizeTierKey(' City-A ', 'k').ok, 'keys normalize: trim + lowercase');
|
|
164
|
+
const n1 = normalizeTierKey(' City A ', 'k');
|
|
165
|
+
const n2 = normalizeTierKey('city a', 'k');
|
|
166
|
+
check(n1.ok && n2.ok && n1.value === n2.value, 'whitespace collapse + case folding are deterministic and agree');
|
|
167
|
+
const caseHit = resolveTier({ cityKey: 'CITY-A', scenarioKey: ' Scenario-X ' }, { registry: probeRegistry, triggerFired: true });
|
|
168
|
+
check(caseHit.ok && caseHit.value.status === 'hit', 'a case/whitespace variant of a known key still hits (normalization, not fuzzy matching)');
|
|
169
|
+
expectRefusal(resolveTier({ cityKey: ' ', scenarioKey: 'scenario-x' }, { registry: probeRegistry, triggerFired: true }), 'bad_key', 'must not be blank', 'a blank city key is a caller error, not a silent neutral');
|
|
170
|
+
expectRefusal(resolveTier({ cityKey: 42, scenarioKey: 'scenario-x' }, { registry: probeRegistry, triggerFired: true }), 'bad_key', 'must be a string', 'a non-string key is a caller error');
|
|
171
|
+
// ---------------------------------------------------------------------------
|
|
172
|
+
// E. Ranking-only falsification
|
|
173
|
+
// ---------------------------------------------------------------------------
|
|
174
|
+
console.log('E. FALSIFICATION: a modifier may reorder, it can NEVER remove a candidate');
|
|
175
|
+
const candidates = [
|
|
176
|
+
{ id: 'cand-boosted', cityKey: 'city-a', semantic: 0.50 },
|
|
177
|
+
{ id: 'cand-plain-1', cityKey: 'city-b', semantic: 0.54 },
|
|
178
|
+
{ id: 'cand-plain-2', cityKey: 'city-c', semantic: 0.10 },
|
|
179
|
+
{ id: 'cand-zero', cityKey: 'city-a', semantic: 0 },
|
|
180
|
+
];
|
|
181
|
+
const neutralRank = applyTierRanking(candidates, { scenarioKey: 'scenario-x' });
|
|
182
|
+
check(neutralRank.ok && neutralRank.value.length === candidates.length, 'default (trigger off): all candidates survive');
|
|
183
|
+
check(neutralRank.ok && neutralRank.value.map(r => r.id).join(',') === 'cand-plain-1,cand-boosted,cand-plain-2,cand-zero', 'default ordering is pure semantic order (every modifier neutral)');
|
|
184
|
+
check(neutralRank.ok && neutralRank.value.every(r => r.modifier_ppm === TIER_MODIFIER_PPM_NEUTRAL && r.tier_id === null), 'default: every row carries the neutral modifier and no tier id');
|
|
185
|
+
check(neutralRank.ok && tierRankingFilterViolation(candidates, neutralRank.value) === null, 'the mechanical no-hard-filter check passes for the default ranking');
|
|
186
|
+
const boostedRank = applyTierRanking(candidates, { scenarioKey: 'scenario-x' }, { registry: probeRegistry, triggerFired: true });
|
|
187
|
+
check(boostedRank.ok && boostedRank.value.map(r => r.id).join(',') === 'cand-boosted,cand-plain-1,cand-plain-2,cand-zero', 'a ×1.1 tier REORDERS: 0.50×1.1 = 0.55 overtakes the plain 0.54');
|
|
188
|
+
check(boostedRank.ok && boostedRank.value.length === candidates.length, 'reordering preserves the candidate count exactly');
|
|
189
|
+
check(boostedRank.ok && tierRankingFilterViolation(candidates, boostedRank.value) === null, 'the mechanical no-hard-filter check passes for the reordered ranking');
|
|
190
|
+
check(boostedRank.ok && boostedRank.value.every(r => candidates.some(c => c.id === r.id && c.semantic === r.semantic)), 'every row preserves its original semantic score (the unmodified ranking is always recoverable)');
|
|
191
|
+
check(boostedRank.ok && (boostedRank.value.find(r => r.id === 'cand-boosted')?.explanation ?? '').includes('probe-tier-1'), 'the applied bias is disclosed per candidate (ranking-disclosure precedent)');
|
|
192
|
+
/** The most hostile admissible tier: the lower bound applied to a zero semantic score. */
|
|
193
|
+
const minTier = [admittedProbe({ tier_id: 'probe-min', modifier_ppm: TIER_MODIFIER_PPM_MIN })];
|
|
194
|
+
const minRank = applyTierRanking(candidates, { scenarioKey: 'scenario-x' }, { registry: minTier, triggerFired: true });
|
|
195
|
+
check(minRank.ok && minRank.value.length === candidates.length, 'FALSIFICATION: even the lower-bound modifier removes nobody (count unchanged)');
|
|
196
|
+
check(minRank.ok && minRank.value.some(r => r.id === 'cand-zero' && r.score === 0), 'FALSIFICATION: a zero-semantic candidate scores 0 and is still PRESENT (score 0 ≠ removal)');
|
|
197
|
+
check(minRank.ok && tierRankingFilterViolation(candidates, minRank.value) === null, 'FALSIFICATION: the no-hard-filter check passes at the hostile bound');
|
|
198
|
+
check(minRank.ok && !Object.keys(minRank.value[0]).some(k => /exclud|filter|drop|reject|threshold/i.test(k)), 'the ranked row shape exposes no exclude/filter/drop/threshold field (no filter surface exists)');
|
|
199
|
+
let everPresent = true;
|
|
200
|
+
for (let semantic = 0; semantic <= 10; semantic++) {
|
|
201
|
+
for (const registry of [[], probeRegistry, minTier]) {
|
|
202
|
+
const probe = applyTierRanking([{ id: 'solo', cityKey: 'city-a', semantic: semantic / 10 }], { scenarioKey: 'scenario-x' }, { registry, triggerFired: true });
|
|
203
|
+
if (!probe.ok || probe.value.length !== 1 || probe.value[0].id !== 'solo')
|
|
204
|
+
everPresent = false;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
check(everPresent, 'FALSIFICATION battery: across 11 semantic scores × 3 registries the candidate is never dropped');
|
|
208
|
+
expectRefusal(applyTierRanking([{ id: '', cityKey: 'city-a', semantic: 1 }], { scenarioKey: 'scenario-x' }), 'bad_candidate', 'non-empty id', 'a blank candidate id refused');
|
|
209
|
+
expectRefusal(applyTierRanking([{ id: 'a', cityKey: 'city-a', semantic: Number.NaN }], { scenarioKey: 'scenario-x' }), 'bad_candidate', 'must be finite', 'a non-finite semantic score refused');
|
|
210
|
+
expectRefusal(applyTierRanking([{ id: 'a', cityKey: 'city-a', semantic: -0.5 }], { scenarioKey: 'scenario-x' }), 'bad_candidate', 'would invert every modifier', 'a NEGATIVE semantic score is refused: the contract is multiplicative, so a boost would demote it');
|
|
211
|
+
expectRefusal(applyTierRanking([{ id: 'ok', cityKey: 'city-b', semantic: 0.5 }, { id: 'neg', cityKey: 'city-a', semantic: -1 }], { scenarioKey: 'scenario-x' }, { registry: probeRegistry, triggerFired: true }), 'bad_candidate', 'must be non-negative', 'one negative candidate refuses the whole ranking (no partial ordering over inverted semantics)');
|
|
212
|
+
check(applyTierRanking([{ id: 'zero', cityKey: 'city-a', semantic: 0 }], { scenarioKey: 'scenario-x' }, { registry: probeRegistry, triggerFired: true }).ok, 'zero is admissible (it is the boundary, not a negative)');
|
|
213
|
+
check(applyTierRanking([{ id: 'negzero', cityKey: 'city-a', semantic: -0 }], { scenarioKey: 'scenario-x' }).ok, 'negative zero is admissible (it equals zero, not below it)');
|
|
214
|
+
expectRefusal(applyTierRanking([{ id: 'a', cityKey: 'city-a', semantic: 1 }, { id: 'a', cityKey: 'city-b', semantic: 2 }], { scenarioKey: 'scenario-x' }), 'bad_candidate', 'duplicate candidate id', 'duplicate candidate ids refused (ranking inputs are a set)');
|
|
215
|
+
check(tierRankingFilterViolation(candidates, []) !== null, 'the mechanical check CATCHES a dropped-candidate violation (the check itself is falsifiable)');
|
|
216
|
+
check(tierRankingFilterViolation(candidates, (neutralRank.ok ? neutralRank.value : []).slice(0, 3)) !== null, 'the mechanical check catches a partial drop');
|
|
217
|
+
// ---------------------------------------------------------------------------
|
|
218
|
+
// F. Determinism + versioning
|
|
219
|
+
// ---------------------------------------------------------------------------
|
|
220
|
+
console.log('F. determinism, stable ties, defective registry refused');
|
|
221
|
+
const runA = applyTierRanking(candidates, { scenarioKey: 'scenario-x' }, { registry: probeRegistry, triggerFired: true });
|
|
222
|
+
const runB = applyTierRanking(candidates, { scenarioKey: 'scenario-x' }, { registry: probeRegistry, triggerFired: true });
|
|
223
|
+
check(runA.ok && runB.ok && JSON.stringify(runA.value) === JSON.stringify(runB.value), 'identical inputs produce byte-identical output (deterministic)');
|
|
224
|
+
const ties = [
|
|
225
|
+
{ id: 'tie-1', cityKey: 'city-b', semantic: 0.4 },
|
|
226
|
+
{ id: 'tie-2', cityKey: 'city-c', semantic: 0.4 },
|
|
227
|
+
{ id: 'tie-3', cityKey: 'city-d', semantic: 0.4 },
|
|
228
|
+
];
|
|
229
|
+
const tieRank = applyTierRanking(ties, { scenarioKey: 'scenario-x' }, { registry: probeRegistry, triggerFired: true });
|
|
230
|
+
check(tieRank.ok && tieRank.value.map(r => r.id).join(',') === 'tie-1,tie-2,tie-3', 'ties keep declaration order (stable sort)');
|
|
231
|
+
const defective = [{ ...admittedProbe(), modifier_ppm: 5_000_000 }];
|
|
232
|
+
expectRefusal(resolveTier({ cityKey: 'city-a', scenarioKey: 'scenario-x' }, { registry: defective, triggerFired: true }), 'modifier_bounds', 'never clamped silently', 'a defective registry entry is refused at read time, never clamped silently');
|
|
233
|
+
expectRefusal(applyTierRanking(candidates, { scenarioKey: 'scenario-x' }, { registry: defective, triggerFired: true }), 'modifier_bounds', 'defective registry entry', 'ranking propagates the defective-registry refusal instead of degrading');
|
|
234
|
+
console.log(`\nCITY-SCENARIO TIER MECHANISM TESTS (#339, simulated_trigger_drill / fixture_contract; registry stays EMPTY): ${passed} pass${process.exitCode ? ', FAIL' : ' (all green)'}`);
|
|
@@ -15,15 +15,17 @@
|
|
|
15
15
|
* 11. rejected/cancelled + unavailable 通道:零执行 + 结构化 verdict='approval-denied'
|
|
16
16
|
* 12. user-action 项绝不安装;scope 过滤只考虑 items 范围内 id
|
|
17
17
|
* 13. tool-level gotry_doctor 注册 + observable chain(diagnose → repair)
|
|
18
|
+
* 14. hbcli whoami 三档解析;14b. probeStdout 在 stdio 关闭(close)后结算,exit 先于 data 不丢输出
|
|
18
19
|
*
|
|
19
20
|
* 运行: cd ts && npx tsx scripts/doctor-tests.ts
|
|
20
21
|
*/
|
|
21
22
|
import assert from 'node:assert/strict';
|
|
23
|
+
import { EventEmitter } from 'node:events';
|
|
22
24
|
import { existsSync } from 'node:fs';
|
|
23
25
|
import { mkdtemp, mkdir, writeFile, rm, readFile } from 'node:fs/promises';
|
|
24
26
|
import { tmpdir } from 'node:os';
|
|
25
27
|
import { dirname, join } from 'node:path';
|
|
26
|
-
import { runDoctorChecks, renderDoctorReportMd, nodeOk, scopeKeyFor, createRepairApprovalGate, runDoctorRepair } from '../capabilities/doctor.js';
|
|
28
|
+
import { runDoctorChecks, renderDoctorReportMd, nodeOk, probeStdout, scopeKeyFor, createRepairApprovalGate, runDoctorRepair } from '../capabilities/doctor.js';
|
|
27
29
|
import { endpointFingerprint, maskFlyaiKey, resolveFlyaiEndpoint, sha256Hex, writeFlyaiVerification } from '../capabilities/flyai-config.js';
|
|
28
30
|
import { apply } from '../src/index.js';
|
|
29
31
|
const tmp = await mkdtemp(join(tmpdir(), 'gotry-doctor-test-'));
|
|
@@ -561,4 +563,38 @@ console.log('13. tool-level gotry_doctor 注册 + observable chain(diagnose→re
|
|
|
561
563
|
}
|
|
562
564
|
}
|
|
563
565
|
console.log('14. hbcli whoami 三档解析(未配置=degraded+注册指引 / customer 档=ok)OK');
|
|
566
|
+
// 14b. probeStdout 必须等 stdio 关闭('close')再结算,不能在 'exit' 结算:
|
|
567
|
+
// Node 文档明言 'exit' 触发时 stdout 可能还没读完。秒退的 whoami 子进程若 exit 先于 data,
|
|
568
|
+
// 输出丢成空串,空串走「不可解析=保守视为有效」分支,把无凭证 hbcli 报成 ok
|
|
569
|
+
// (Node 22 CI 偶发红 14 的 degraded 断言,与被测 PR 无关)。注入假子进程,确定性复现该次序。
|
|
570
|
+
{
|
|
571
|
+
const fakeSpawn = (script) => (() => {
|
|
572
|
+
const child = new EventEmitter();
|
|
573
|
+
child.stdout = new EventEmitter();
|
|
574
|
+
child.kill = () => true;
|
|
575
|
+
setImmediate(() => script(child, child.stdout));
|
|
576
|
+
return child;
|
|
577
|
+
});
|
|
578
|
+
const whoami = '{"env":"uat","api_key":{"configured":false},"portal":{"configured":false},"customer":{"configured":true}}';
|
|
579
|
+
const probe = (impl, timeoutMs = 1_000) => probeStdout('hbcli', ['--json', 'auth', 'whoami'], timeoutMs, impl);
|
|
580
|
+
assert.equal(await probe(fakeSpawn((child, stdout) => {
|
|
581
|
+
child.emit('exit', 0, null); // exit 先到,管道数据尚未送达
|
|
582
|
+
stdout.emit('data', Buffer.from(`${whoami}\n`));
|
|
583
|
+
child.emit('close', 0, null); // stdio 关闭后才 close
|
|
584
|
+
})), whoami, 'exit 先于 stdout data 时,输出必须完整返回(旧实现在 exit 结算,得到空串)');
|
|
585
|
+
assert.equal(await probe(fakeSpawn((child, stdout) => {
|
|
586
|
+
stdout.emit('data', Buffer.from('{"a":'));
|
|
587
|
+
stdout.emit('data', Buffer.from('1}\n'));
|
|
588
|
+
child.emit('exit', 0, null);
|
|
589
|
+
child.emit('close', 0, null);
|
|
590
|
+
})), '{"a":1}', '多段 data 拼接后 trim');
|
|
591
|
+
assert.equal(await probe(fakeSpawn((child, stdout) => {
|
|
592
|
+
child.emit('exit', 2, null);
|
|
593
|
+
stdout.emit('data', Buffer.from('boom'));
|
|
594
|
+
child.emit('close', 2, null);
|
|
595
|
+
})), null, '非零退出码一律 null(无论输出)');
|
|
596
|
+
assert.equal(await probe(fakeSpawn((child) => { child.emit('error', new Error('spawn ENOENT')); })), null, 'spawn error → null');
|
|
597
|
+
assert.equal(await probe(fakeSpawn(() => { }), 50), null, '子进程不结束 → 超时 null,不悬挂');
|
|
598
|
+
}
|
|
599
|
+
console.log('14b. probeStdout 在 stdio 关闭后结算(exit 先于 data 不丢输出 / 非零退出 null / error null / 超时 null)OK');
|
|
564
600
|
console.log('doctor-tests: 全部通过');
|