@sidady/dsh-steward 0.9.14 → 0.9.16

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/smoke.mjs CHANGED
@@ -6,7 +6,7 @@
6
6
  * probes the fail-soft path (unreadable directories) and the real machine
7
7
  * (best-effort) to confirm the parser works on actual crash logs.
8
8
  */
9
- import { collectReport, parseBootFailure } from './lib/report.js';
9
+ import { collectReport, parseBootFailure, dutyBriefing, analyzeUpgrades, collectFreeLaneHealth } from './lib/report.js';
10
10
  import path from 'node:path';
11
11
  import fs from 'node:fs';
12
12
  import { fileURLToPath } from 'node:url';
@@ -1329,8 +1329,9 @@ check('the cause survives into finding data for the panel', !!liveCrash && liveC
1329
1329
  const clientLines = clientSrc.split("\n");
1330
1330
  // 审计/趋势里存的是 UTC ISO;切片或把 T 换成空格都会原样把 UTC 贴给用户
1331
1331
  // (东八区差 8 小时)。本地化必须走 localStamp,不能用字符串处理假装。
1332
- const TQ = String.fromCharCode(39);
1333
- const naive = clientLines.filter((l) => l.includes(".replace(") && l.includes(TQ + "T" + TQ + ", " + TQ + " " + TQ));
1332
+ const TQ = String.fromCharCode(39), TDQ = String.fromCharCode(34);
1333
+ const swap = (q) => q + "T" + q + ", " + q + " " + q;
1334
+ const naive = clientLines.filter((l) => l.includes(".replace(") && (l.includes(swap(TQ)) || l.includes(swap(TDQ))));
1334
1335
  check("no render path formats a timestamp by swapping the ISO T", naive.length === 0, naive.map((l) => l.trim().slice(0, 70)).join(" | "));
1335
1336
  check("the local-time formatter exists", clientSrc.includes("function localStamp("));
1336
1337
  const uses = (clientSrc.match(/localStamp\(/g) || []).length;
@@ -1427,6 +1428,293 @@ check('the cause survives into finding data for the panel', !!liveCrash && liveC
1427
1428
  && /wiring\.via\[keys\.join\(','\)\] = 'one-shot'/.test(idxSrc));
1428
1429
  }
1429
1430
 
1431
+
1432
+ // — v0.9.15: 职能缺口补齐(P0 修真 bug / P1 误报反馈 / P2 趋势与直达)—
1433
+ {
1434
+ const clientSrc = fs.readFileSync(path.join(here, "lib", "client.js"), "utf8");
1435
+ const indexSrc = fs.readFileSync(path.join(here, "lib", "index.js"), "utf8");
1436
+ const actionsSrc = fs.readFileSync(path.join(here, "lib", "actions.js"), "utf8");
1437
+ const reportSrc = fs.readFileSync(path.join(here, "lib", "report.js"), "utf8");
1438
+ const clientLines = clientSrc.split(String.fromCharCode(10));
1439
+
1440
+ // P0-a: 保存时间的本地化不许再用「把 T 换成空格」的写法,两种引号都查。
1441
+ const SQ = String.fromCharCode(39), DQ = String.fromCharCode(34);
1442
+ const swaps = [SQ, DQ].map((q) => q + "T" + q + ", " + q + " " + q);
1443
+ const naive = clientLines.filter((l) => l.includes(".replace(") && swaps.some((s) => l.includes(s)));
1444
+ check("P0-a no ISO-T swap survives in either quote style", naive.length === 0, naive.map((l) => l.trim().slice(0, 60)).join(" | "));
1445
+ const savedLine = clientLines.find((l) => l.includes("setSaved(") && l.includes("res.savedAt"));
1446
+ check("P0-a the saved-at display calls localStamp", !!savedLine && savedLine.includes("localStamp("));
1447
+
1448
+ // P0-b: autoScanMinutes 必须是活设置,不是写死的 10 分钟。
1449
+ check("P0-b the client reads autoScanMinutes from the report payload", clientSrc.includes("cache.autoScanMinutes"));
1450
+ check("P0-b the hardcoded 10-minute threshold is gone", !clientSrc.includes("10 * 60 * 1000"));
1451
+ check("P0-b the host sends autoScanMinutes from settings", indexSrc.includes("autoScanMinutes: settingValue(currentSettings(), " + SQ + "ui.autoScanMinutes" + SQ + ")"));
1452
+
1453
+ // P1: 误报反馈是一个有数据文件、可 plan/execute、能影响评分、可撤销的真动作。
1454
+ check("P1 feedback has its own data file", indexSrc.includes("feedback: path.join(dataDir(), " + SQ + "feedback.json" + SQ + ")"));
1455
+ check("P1 plan handles feedback and unfeedback", actionsSrc.includes("kind === " + SQ + "feedback" + SQ + " || kind === " + SQ + "unfeedback" + SQ));
1456
+ check("P1 execute persists the feedback list", actionsSrc.includes("writeJson(deps.feedbackFile, list)"));
1457
+ check("P1 report marks falsePositive from options.feedback", reportSrc.includes("f.falsePositive = feedbackIds.has(f.id)"));
1458
+ check("P1 a false positive loses the actionable bonus", reportSrc.includes("&& !f.falsePositive && !gateBlocked"));
1459
+ check("P1 the score states the false-positive penalty", reportSrc.includes("用户标记判错 -40"));
1460
+ check("P1 the panel offers a 判错 button", clientSrc.includes("这条判错了"));
1461
+
1462
+ // P2-a: 趋势结论由服务端算好人话,恢复/连续恶化会主动说。
1463
+ check("P2-a summarizeTrend is exported from report.js", reportSrc.includes("export function summarizeTrend("));
1464
+ check("P2-a collectReport returns a trendVerdict", reportSrc.includes("trendVerdict,"));
1465
+ check("P2-a the text report states the trend", indexSrc.includes("趋势: ${report.trendVerdict.text}"));
1466
+ check("P2-a toasts fire on recovery",
1467
+ clientSrc.includes("tv.state === " + SQ + "recovered" + SQ + " ||") && clientSrc.includes("pushToast({ kind: " + SQ + "trend" + SQ));
1468
+ check("P2-a ToastCard can paint an ok tone", clientSrc.includes("t.severity === " + SQ + "ok" + SQ + " ? C.ok"));
1469
+ check("P2-a AuditTab prints the trend verdict", clientSrc.includes("趋势结论 · "));
1470
+
1471
+ // P2-b: 不假造直达动作;运行期自检结果要给用户看见。
1472
+ check("P2-b the phantom model-default shortcut is gone", !clientSrc.includes("" + SQ + "model-health" + SQ + ": " + SQ + "model-default" + SQ) && clientSrc.includes("" + SQ + "backup-freshness" + SQ + ": " + SQ + "backup" + SQ));
1473
+ check("P2-b the host sends wiring with the report", indexSrc.includes("wiring: { ...wiring }"));
1474
+ check("P2-b the panel shows the wiring outcome", clientSrc.includes("接口已挂上"));
1475
+ }
1476
+
1477
+ {
1478
+ // ---- v0.9.16: L0 当班环(只读、可观测、绝不自动执行) ----
1479
+ const dutyIndex = fs.readFileSync(path.join(here, 'lib', 'index.js'), 'utf8');
1480
+ const dutyClient = fs.readFileSync(path.join(here, 'lib', 'client.js'), 'utf8');
1481
+ const dutyReport = fs.readFileSync(path.join(here, 'lib', 'report.js'), 'utf8');
1482
+
1483
+ const mkDutyReport = (findings, level, text) => ({
1484
+ summary: { level: level || 'warning', counts: { critical: 0, warning: 2, info: 3, muted: 0 } },
1485
+ trendVerdict: text ? { state: 'stable', text } : null,
1486
+ findings: findings || [],
1487
+ });
1488
+ const dOk = { id: 'a', severity: 'warning', recommendation: '去动作中心看看 A', muted: false, falsePositive: false };
1489
+ const dMuted = { id: 'b', severity: 'info', recommendation: '这条被静音', muted: true, falsePositive: false };
1490
+ const dFalse = { id: 'c', severity: 'critical', recommendation: '这条被判错', muted: false, falsePositive: true };
1491
+ const dB = dutyBriefing(mkDutyReport([dOk, dMuted, dFalse]), { at: '2026-11-12T22:00:00.000Z', trigger: 'boot' });
1492
+ check('dutyBriefing is pure: at + trigger pass through unchanged', dB.at === '2026-11-12T22:00:00.000Z' && dB.trigger === 'boot', JSON.stringify({ at: dB.at, trigger: dB.trigger }));
1493
+ check('dutyBriefing records state=observe-only and executed=0', dB.state === 'observe-only' && dB.executed === 0);
1494
+ check('dutyBriefing excludes muted and false-positive findings', dB.suggestions.length === 1 && dB.suggestions[0].id === 'a', JSON.stringify(dB.suggestions));
1495
+ check('dutyBriefing writes a human "seen" line', typeof dB.seen === 'string' && dB.seen.indexOf('3 条发现') >= 0 && dB.seen.indexOf('1 条仍在报') >= 0, dB.seen);
1496
+ check('dutyBriefing explains WHY nothing was executed', typeof dB.action === 'string' && dB.action.indexOf('未执行任何动作') >= 0 && dB.action.indexOf('1 条处置建议') >= 0, dB.action);
1497
+ check('dutyBriefing prefers the server-computed trend verdict', dutyBriefing(mkDutyReport([], 'warning', '连续 2 次体检都在变差')).verdict.indexOf('连续 2 次') >= 0);
1498
+ const dSame1 = dutyBriefing(mkDutyReport([], 'ok'), { at: '2026-11-12T22:00:00.000Z', trigger: 'scheduled' });
1499
+ const dSame2 = dutyBriefing(mkDutyReport([], 'ok'), { at: '2026-11-12T22:00:00.000Z', trigger: 'scheduled' });
1500
+ check('dutyBriefing is deterministic for identical input', JSON.stringify(dSame1) === JSON.stringify(dSame2));
1501
+ const manyF = Array.from({ length: 9 }, (_, i) => ({ id: 'f' + i, severity: 'info', recommendation: '建议' + i }));
1502
+ check('dutyBriefing caps the prepared suggestions at 6', dutyBriefing(mkDutyReport(manyF)).suggestions.length === 6);
1503
+
1504
+ // 服务端:周期当班 + 重入/去抖 + 三处返回体带 duty + 绝不自动执行
1505
+ check('L0 duty loop is driven by setInterval', dutyIndex.indexOf('setInterval(dutyTick') >= 0);
1506
+ // 裸定时器在 fiber 被 HMR 换掉 / 停用插件时不会停,每重载一次多一个永不停止的当班环。
1507
+ // 钉死它挂在 ctx.effect 上并且 dispose 里 clearInterval。
1508
+ // 用正则钉「完整 label」:indexOf 会被 'L0 duty-DISABLED' 之类前缀命中而永远绿(v0.9.15 的教训)。
1509
+ const dutyEffectLabel = /'dsh-steward: L0 duty'/.exec(dutyIndex);
1510
+ const dutyEffectAt = dutyEffectLabel ? dutyEffectLabel.index : -1;
1511
+ const dutyEffectBlock = dutyEffectAt >= 0 ? dutyIndex.slice(dutyIndex.lastIndexOf('ctx.effect(', dutyEffectAt), dutyEffectAt) : '';
1512
+ check('the duty timer is owned by a ctx.effect with a clearInterval dispose',
1513
+ dutyEffectAt >= 0
1514
+ && dutyEffectBlock.indexOf('setInterval(dutyTick') >= 0
1515
+ && dutyEffectBlock.indexOf('return () =>') >= 0
1516
+ && dutyEffectBlock.indexOf('clearInterval(dutyTimer)') >= 0,
1517
+ dutyEffectBlock.slice(0, 140));
1518
+ check('the duty interval follows ui.autoScanMinutes', dutyIndex.indexOf("settingValue(currentSettings(), 'ui.autoScanMinutes')") >= 0 && dutyIndex.indexOf('dutyIntervalMs') >= 0);
1519
+ check('the duty loop has a re-entry guard', dutyIndex.indexOf('if (dutyRunning) return null') >= 0);
1520
+ check('the duty loop has a debounce', dutyIndex.indexOf('Date.now() - dutyLastAt < 1000') >= 0);
1521
+ const rdStart = dutyIndex.indexOf('async function runDuty(');
1522
+ const rdEnd = dutyIndex.indexOf('const hub = createActionHub(', rdStart);
1523
+ const rdBody = rdStart >= 0 && rdEnd > rdStart ? dutyIndex.slice(rdStart, rdEnd) : '';
1524
+ check('runDuty reuses the read-only report and never calls plan/execute', rdBody.indexOf("await run('report', {})") >= 0 && rdBody.indexOf('hub.plan(') < 0 && rdBody.indexOf('hub.execute(') < 0, rdBody.slice(0, 160));
1525
+ check('the duty ledger is persisted to duty.jsonl', dutyIndex.indexOf("duty: path.join(dataDir(), 'duty.jsonl')") >= 0 && dutyIndex.indexOf('io.appendJsonl(files.duty, briefing)') >= 0);
1526
+ // readDutyLog 的 split 参数必须真的是「换行符」。写成 '\\n'(双反斜杠)会永远读不出
1527
+ // 分班记录,而静态判据只看得见「有 split」。这里把源码里的字面量求值,钉死它等于一个换行符。
1528
+ const dutySplitLine = dutyIndex.split('\n').find((l) => l.indexOf('raw.split(') >= 0 && l.indexOf('DUTY_LOG_CAP') >= 0) || '';
1529
+ const dsi = dutySplitLine.indexOf('raw.split(') + 'raw.split('.length;
1530
+ const dsArg = dutySplitLine.slice(dsi);
1531
+ const dsQuote = dsArg[0];
1532
+ const dsEnd = dsQuote ? dsArg.indexOf(dsQuote, 1) : -1;
1533
+ const dsExpr = dsEnd > 0 ? dsArg.slice(0, dsEnd + 1) : '';
1534
+ let dsVal = null;
1535
+ try { dsVal = Function('return ' + dsExpr)(); } catch { /* 求值失败就让它红 */ }
1536
+ check('readDutyLog splits on a real newline (runtime-evaluated), not a literal backslash-n', dsVal === '\n' && dsVal.length === 1, JSON.stringify({ dsExpr, len: dsVal && dsVal.length }));
1537
+ check('ping / report / audit all expose duty', (dutyIndex.match(/duty: await dutySnapshot\(\)/g) || []).length === 3);
1538
+ check('the boot checkup goes through runDuty (not a bare report)', dutyIndex.indexOf("runDuty('boot')") >= 0);
1539
+
1540
+ // 面板:当班状态 + 当班台账(服务端算好人话、客户端只渲染)
1541
+ check('the panel shows the duty status line', dutyClient.indexOf('当班 · ') >= 0 && dutyClient.indexOf('cache.duty') >= 0);
1542
+ check('the audit tab shows the duty ledger', dutyClient.indexOf('当班台账 · ') >= 0 && dutyClient.indexOf('data.duty.suggestions') >= 0);
1543
+ check('the panel caches duty from report responses', (dutyClient.match(/cache\.duty = data\.duty/g) || []).length >= 2);
1544
+ check('the duty briefing is computed in report.js, not in client.js', dutyReport.indexOf('export function dutyBriefing(') >= 0 && dutyClient.indexOf('function dutyBriefing(') < 0);
1545
+
1546
+ // 版本与文档
1547
+ check('v0.9.16 version is consistent', JSON.parse(fs.readFileSync(path.join(here, 'package.json'), 'utf8')).version === '0.9.16');
1548
+ check('README documents the L0 duty loop', fs.readFileSync(path.join(here, 'README.md'), 'utf8').indexOf('v0.9.16(L0 当班环') >= 0);
1549
+ }
1550
+
1551
+ {
1552
+ // ---- 白色按钮回归:primary 按钮必须反相,绝不能被调用方的 tone 顶掉 ----
1553
+ // C.text 与 C.solid 都映射到 --dsw-alias-label-primary,深色主题下解析成白色;
1554
+ // 一旦 color 用了 tone,primary 就变成白底白字 = 看不见字的纯白矩形。
1555
+ const wbClient = fs.readFileSync(path.join(here, 'lib', 'client.js'), 'utf8');
1556
+ check('Btn forces the inverted foreground for primary buttons',
1557
+ wbClient.indexOf('color: props.primary && !props.disabled ? C.on : (props.tone || C.text)') >= 0);
1558
+ check('the old tone-wins colour expression is gone',
1559
+ wbClient.indexOf('color: props.tone || (props.primary') < 0);
1560
+ check('no primary button call site passes a tone',
1561
+ (wbClient.match(/primary:\s*true\s*,\s*tone:/g) || []).length === 0);
1562
+ check('the finding go-to-action button stays a plain primary button',
1563
+ wbClient.indexOf('h(Btn, { onClick: props.onGo, primary: true }') >= 0);
1564
+ }
1565
+
1566
+ {
1567
+ // — v0.9.16(未发布):升级利弊分析 (m13463) + 免费通道运行期健康 (m13398) —
1568
+ const AU = analyzeUpgrades;
1569
+ const PKG_HI = { 'dsh-x': { latest: '2.0.0', versions: ['1.0.0', '2.0.0'], engines: { dsh: '^0.3.0' } } };
1570
+ const hi = AU([{ name: 'dsh-x', installed: '1.0.0', latest: '2.0.0' }], PKG_HI, '0.2.2-rc.1');
1571
+ check('upgrade advice: a host-version mismatch is high risk', hi.rows[0].risk === 'high', JSON.stringify(hi.rows[0]));
1572
+ check('upgrade advice: high risk names the boot consequence', hi.rows[0].reasons.join(' ').includes('起不来'), JSON.stringify(hi.rows[0].reasons));
1573
+ const md = AU([{ name: 'dsh-y', installed: '1.0.0', latest: '2.0.0-beta.1' }], { 'dsh-y': { latest: '2.0.0-beta.1', versions: ['1.0.0', '2.0.0-beta.1'] } }, '0.2.2-rc.1');
1574
+ check('upgrade advice: a prerelease target is medium risk', md.rows[0].risk === 'medium', JSON.stringify(md.rows[0]));
1575
+ const sk = AU([{ name: 'dsh-z', installed: '1.0.0', latest: '9.0.0' }], { 'dsh-z': { latest: '9.0.0', versions: ['1.0.0', '2.0.0', '3.0.0', '4.0.0', '5.0.0', '6.0.0', '9.0.0'] } }, '0.2.2-rc.1');
1576
+ check('upgrade advice: skipping >=5 releases is medium risk', sk.rows[0].risk === 'medium' && sk.rows[0].skipped >= 5, JSON.stringify(sk.rows[0]));
1577
+ const lo = AU([{ name: 'dsh-w', installed: '1.0.0', latest: '1.0.1' }], { 'dsh-w': { latest: '1.0.1', versions: ['1.0.0', '1.0.1'] } }, null);
1578
+ check('upgrade advice: a plain small step is low risk', lo.rows[0].risk === 'low', JSON.stringify(lo.rows[0]));
1579
+ check('upgrade advice: text states benefits without inventing a changelog', hi.text.includes('优点') && hi.text.includes('不编造具体修复内容'), hi.text);
1580
+ check('upgrade advice: text states the boot risk', hi.text.includes('boot'), hi.text);
1581
+ check('upgrade advice: recommendation is read-only, no auto-upgrade', hi.recommendation.includes('不代升级') && hi.recommendation.includes('逐个'), hi.recommendation);
1582
+ check('upgrade advice: toast words are written by the server', hi.toastTitle.includes('风险') && hi.toastBody.includes('优点'), hi.toastTitle);
1583
+ const hi2 = AU([{ name: 'dsh-x', installed: '1.0.0', latest: '2.0.0' }], PKG_HI, '0.2.2-rc.1');
1584
+ check('upgrade advice: fingerprint is a stable 8-hex digest', /^[0-9a-f]{8}$/.test(hi.fingerprint) && hi.fingerprint === hi2.fingerprint, hi.fingerprint);
1585
+ const fpA = AU([{ name: 'a', installed: '1.0.0', latest: '2.0.0' }, { name: 'b', installed: '1.0.0', latest: '3.0.0' }], {}, '0.2.2-rc.1');
1586
+ const fpB = AU([{ name: 'b', installed: '1.0.0', latest: '3.0.0' }, { name: 'a', installed: '1.0.0', latest: '2.0.0' }], {}, '0.2.2-rc.1');
1587
+ check('upgrade advice: fingerprint is order-independent', fpA.fingerprint === fpB.fingerprint, fpA.fingerprint + ' vs ' + fpB.fingerprint);
1588
+ const emp = AU([], {}, null);
1589
+ check('upgrade advice: no outdated items yields count 0 but a fingerprint', emp.outdatedCount === 0 && /^[0-9a-f]{8}$/.test(emp.fingerprint), emp.fingerprint);
1590
+ const AVAIL = JSON.stringify({ version: 1, at: 0, results: {
1591
+ 'model-a': { state: 'available', detail: 'OpenCode free inference requires a local Bun executable', at: '2026-11-13T12:00:00Z', stale: true },
1592
+ 'model-b': { state: 'unknown', detail: 'upstream request failed', at: '2026-11-13T13:00:00Z', stale: true },
1593
+ 'model-c': { state: 'available', at: '2026-11-13T14:00:00Z', stale: true },
1594
+ } });
1595
+ const flIo = { readDir: () => [], stat: () => ({ __error: 'x' }), readText: (f) => (f.endsWith('availability.json') ? AVAIL : { __error: 'no' }) };
1596
+ const fl = collectFreeLaneHealth({ dshHome: 'H' }, flIo);
1597
+ check('free lane: contradictory available-but-failing is caught', fl.available && fl.contradictory.length === 1 && fl.contradictory[0].name === 'model-a', JSON.stringify(fl));
1598
+ check('free lane: both detail-bearing models are collected as failing', fl.failing.length === 2, JSON.stringify(fl.failing));
1599
+ check('free lane: allStale is true when every entry is stale', fl.allStale === true && fl.checked === 3, JSON.stringify(fl));
1600
+ check('free lane: newestAt is the max probe timestamp', fl.newestAt === '2026-11-13T14:00:00Z', String(fl.newestAt));
1601
+ const flMiss = collectFreeLaneHealth({ dshHome: 'H' }, { readText: () => ({ __error: 'ENOENT' }) });
1602
+ check('free lane: a missing private ledger is a silent, observable skip', flMiss.available === false && flMiss.failing.length === 0, JSON.stringify(flMiss));
1603
+ function flDataIo(availText) {
1604
+ const base = dataIo();
1605
+ return { readDir: base.readDir, stat: base.stat,
1606
+ readText(file) { return file.endsWith('availability.json') ? availText : base.readText(file); } };
1607
+ }
1608
+ const flReport = collectReport({
1609
+ now: new Date('2026-11-09T13:00:00Z'), bootedAt: new Date('2026-11-09T00:00:00Z'),
1610
+ env: { APPDATA: 'C:\\\\Users\\\\demo\\\\AppData\\\\Roaming', DSH_HOME: 'C:\\\\Users\\\\demo\\\\.dsh' },
1611
+ platform: 'win32', profileDirs: ['C:\\\\p'], io: flDataIo(AVAIL),
1612
+ });
1613
+ check('free lane: report surfaces a read-only info finding', flReport.findings.some((f) => f.id === 'free-lane-health'), JSON.stringify(flReport.errors));
1614
+ check('free lane: an optional signal adds NO coverage source', flReport.coverage.sourcesTotal === 7, JSON.stringify(flReport.coverage));
1615
+ check('free lane: reading the private ledger adds no collection error', !flReport.errors.some((e) => String(e.step || '').includes('free-lane')), JSON.stringify(flReport.errors));
1616
+ check('free lane: report body exposes freeLane for observability', !!(flReport.freeLane && flReport.freeLane.available === true), JSON.stringify(flReport.freeLane));
1617
+ check('free lane: with no ledger the report shows a skip, not a finding', !!(dataReport.freeLane && dataReport.freeLane.available === false) && !dataReport.findings.some((f) => f.id === 'free-lane-health'), JSON.stringify(dataReport.freeLane));
1618
+ check('upgrade advice: report.updates.advice is present with a count', !!(flReport.updates && flReport.updates.advice && flReport.updates.advice.outdatedCount === 2), JSON.stringify(flReport.updates && flReport.updates.advice));
1619
+ const pu = flReport.findings.find((f) => f.id === 'plugin-updates');
1620
+ check('upgrade advice: plugin-updates detail carries the analysis', !!(pu && pu.detail.includes('优点') && pu.detail.includes('不编造')), pu && pu.detail);
1621
+ check('upgrade advice: plugin-updates recommendation came from advice', !!(pu && pu.recommendation.includes('不代升级')), pu && pu.recommendation);
1622
+ const vbClient = fs.readFileSync(path.join(here, 'lib', 'client.js'), 'utf8');
1623
+ check('upgrade toast: client reads report.updates.advice', vbClient.includes('report.updates.advice') && vbClient.includes("'version-' + ua.fingerprint"));
1624
+ check('upgrade toast: the version toast is gated and de-duplicated', vbClient.includes("toastConfig.mode !== 'off'") && vbClient.includes('!toastedIds.has(vkey)'));
1625
+ const vbReport = fs.readFileSync(path.join(here, 'lib', 'report.js'), 'utf8');
1626
+ check('upgrade advice: collectUpdates takes hostVersion and returns advice', vbReport.includes('export function collectUpdates(paths, io, errors, hostVersion)') && vbReport.includes('analyzeUpgrades(outdated, packages, hostVersion)'));
1627
+ check('free lane: reads only the one private ledger, no error/coverage key', vbReport.includes("'our-free-model', 'availability.json'") && !vbReport.includes("step: 'free-lane'"));
1628
+ }
1629
+
1630
+ // — v0.9.16(未发布)右侧栏 tab 正文修复:静态 + 运行期判据 —
1631
+ {
1632
+ const rightSrc = fs.readFileSync(path.join(here, 'lib', 'client.js'), 'utf8');
1633
+
1634
+ check('right tab body is registered at the top level of mountRight',
1635
+ rightSrc.includes("rightDisposers.push(ctx.slots.inject('sidebar.right.pane.tab'") &&
1636
+ !rightSrc.includes('const disposers = [];'));
1637
+ check('mountRight has an idempotent guard (rightMounted)',
1638
+ rightSrc.includes('if (rightMounted) return;'));
1639
+ check('toast overlay is registered via its own ctx.effect, not the entry disposer list',
1640
+ rightSrc.includes("'dsh-steward: toast overlay'") &&
1641
+ !rightSrc.includes("currentDisposers.push(ctx.slots.inject('shell.overlay'"));
1642
+ check('scoped ctx effect owns the right tab type lifecycle',
1643
+ rightSrc.includes('c.effect(() => {') && rightSrc.includes("'dsh-steward: right tab type'"));
1644
+ check('ui.entry keeps its three values and per-value mount decisions',
1645
+ rightSrc.includes("entry !== 'right'") && rightSrc.includes("entry !== 'left'") &&
1646
+ rightSrc.includes("v === 'left' || v === 'right' || v === 'both'"));
1647
+ }
1648
+
1649
+ // 运行期:把真正的 apply() body 抠出来,用假 Cordis ctx 跑一遍「both → settings=right」重挂,
1650
+ // 数一数右侧正文槽位到底被注册/拆掉了几次。静态判据看不出的二次重挂竞态,这里能看出来。
1651
+ await (async () => {
1652
+ let R = { error: null, bodyCount: -1, bodyLive: false, toastCount: -1, toastLive: false, leftLive: -1 };
1653
+ try {
1654
+ const src = fs.readFileSync(path.join(here, 'lib', 'client.js'), 'utf8');
1655
+ const head = ' apply(ctx) {';
1656
+ const tail = ' },' + String.fromCharCode(10) + ' };' + String.fromCharCode(10) + ' },' + String.fromCharCode(10) + '});';
1657
+ const hs = src.indexOf(head);
1658
+ const ts = src.lastIndexOf(tail);
1659
+ if (hs < 0 || ts <= hs) throw new Error('apply body anchors not found');
1660
+ const bodyText = src.slice(hs + head.length, ts);
1661
+
1662
+ const regs = [];
1663
+ const live = new Set();
1664
+ const slots = {
1665
+ inject: (name, factory) => {
1666
+ const reg = factory();
1667
+ return () => { live.delete(reg); };
1668
+ },
1669
+ register: (opts) => {
1670
+ const reg = { slot: opts && opts.name, key: opts && (opts.key || opts.id) };
1671
+ regs.push(reg);
1672
+ live.add(reg);
1673
+ return reg;
1674
+ },
1675
+ };
1676
+ const scoped = {
1677
+ slots,
1678
+ sidebarRightTabs: { register: () => () => {} },
1679
+ effect: (fn) => { fn(); return () => {}; },
1680
+ get: () => undefined,
1681
+ };
1682
+ const ctx = {
1683
+ slots,
1684
+ effect: (fn) => { fn(); return () => {}; },
1685
+ inject: (deps, cb) => { cb(scoped); return () => {}; },
1686
+ get: () => undefined,
1687
+ };
1688
+ const call = () => Promise.resolve({ settings: { ui: { entry: 'right' } } });
1689
+ const prelude = 'var ENTRY_APPLIER = null; var PANEL_NAVIGATOR = null; var PANEL_ACK = null;' + String.fromCharCode(10);
1690
+ const run = new Function(
1691
+ 'ctx', 'PANEL_ID', 'Panel', 'PanelIcon', 'h', 'ToastHost', 'call',
1692
+ prelude + bodyText
1693
+ );
1694
+ run(ctx, 'dsh-steward', function () {}, function () {}, function () {}, function () {}, call);
1695
+ await new Promise((resolve) => setTimeout(resolve, 0));
1696
+
1697
+ const bodies = regs.filter((r) => r.slot === 'sidebar.right.pane.tab' && r.key === 'dsh-steward');
1698
+ const toasts = regs.filter((r) => r.slot === 'shell.overlay');
1699
+ const left = regs.filter((r) => r.slot === 'sidebar.panellist' || r.slot === 'main');
1700
+ R.bodyCount = bodies.length;
1701
+ R.bodyLive = bodies.length === 1 && live.has(bodies[0]);
1702
+ R.toastCount = toasts.length;
1703
+ R.toastLive = toasts.length === 1 && live.has(toasts[0]);
1704
+ R.leftLive = left.filter((r) => live.has(r)).length;
1705
+ } catch (e) {
1706
+ R.error = String(e);
1707
+ }
1708
+ const why = R.error || JSON.stringify(R);
1709
+ check('runtime: exactly one right-tab body registration survives a settings-driven remount',
1710
+ !R.error && R.bodyCount === 1, why);
1711
+ check('runtime: the right-tab body stays live after the remount',
1712
+ !R.error && R.bodyLive === true, why);
1713
+ check('runtime: the toast overlay stays live after the remount',
1714
+ !R.error && R.toastLive === true, why);
1715
+ check('runtime: ui.entry=right drops the left/main entries',
1716
+ !R.error && R.leftLive === 0, why);
1717
+ })();
1430
1718
  console.log('');
1431
1719
  console.log(`RESULT: ${pass} passed, ${fail} failed`);
1432
1720
  process.exit(fail === 0 ? 0 : 1);