@sidady/dsh-steward 0.0.0-stage → 0.9.14

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 ADDED
@@ -0,0 +1,1432 @@
1
+ /**
2
+ * dsh-steward · smoke.mjs
3
+ * Local verification WITHOUT starting the host: exercises collectReport with
4
+ * in-memory fixtures for (a) a real-shaped web-boot crash log, (b) a
5
+ * cordis.patch.yml.bak-<epoch> sibling, (c) plugin-change events. It also
6
+ * probes the fail-soft path (unreadable directories) and the real machine
7
+ * (best-effort) to confirm the parser works on actual crash logs.
8
+ */
9
+ import { collectReport, parseBootFailure } from './lib/report.js';
10
+ import path from 'node:path';
11
+ import fs from 'node:fs';
12
+ import { fileURLToPath } from 'node:url';
13
+
14
+ const here = path.dirname(fileURLToPath(import.meta.url));
15
+ import { parseIntent } from './lib/intent.js';
16
+ import { extractJson, normalizeBrainOutput } from './lib/brain.js';
17
+ import { MODEL_VERDICT_TTL_MS, classifyProbe, planSentinel, summarize, defaultVerdict, suggestDefault } from './lib/models.js';
18
+ import { defaults as settingsDefaults, normalize as normalizeSettings, describe as describeSettings, ackTtlDays as settingsAckTtl, value as settingValue } from './lib/settings.js';
19
+
20
+ let pass = 0;
21
+ let fail = 0;
22
+ function check(name, cond, extra) {
23
+ if (cond) { pass += 1; console.log(' ok ' + name); }
24
+ else { fail += 1; console.log('FAIL ' + name + (extra ? ' — ' + extra : '')); }
25
+ }
26
+
27
+ const CRASH = `Error: web boot: 1 entry did not activate
28
+ @xmanrui/dsh-im: import failed (see console for the import error)
29
+ {"source":"web-boot","phase":"running","app":"@deepseek-ai/dsh-desktop 0.2.2-rc.1","electron":"44.7.0","node":"24.21.0","shell":{"pid":20924}}
30
+ `;
31
+
32
+ console.log('— parseBootFailure —');
33
+ const parsed = parseBootFailure(CRASH);
34
+ check('didNotActivate = 1', parsed && parsed.didNotActivate === 1, JSON.stringify(parsed));
35
+ check('suspect = @xmanrui/dsh-im', parsed && parsed.suspects.includes('@xmanrui/dsh-im'), JSON.stringify(parsed && parsed.suspects));
36
+ check('isBoot true', parsed && parsed.isBoot === true);
37
+ check('quiet text → null', parseBootFailure('all good, nothing to report') === null);
38
+
39
+ console.log('— collectReport (fixtures) —');
40
+ const io = {
41
+ readDir(dir) {
42
+ if (dir.includes('logs')) return ['crash-2026-11-07T02-53-24-868Z-web-boot.log', 'ignored.txt'];
43
+ if (dir.includes('profiles')) return ['cordis.patch.yml', 'cordis.patch.yml.bak-1762463604000', 'package.json'];
44
+ if (dir.includes('exports')) return ['dsh-config-2026-01-01-abc123.zip', '.backup-notes.json'];
45
+ return { __error: 'unexpected dir ' + dir };
46
+ },
47
+ stat(file) {
48
+ if (file.includes('crash-')) return { isFile: () => true, size: CRASH.length, mtime: new Date('2026-11-07T02:53:24Z') };
49
+ if (file.includes('.bak-')) return { isFile: () => true, size: 81957, mtime: new Date('2026-11-08T09:49:10Z') };
50
+ if (file.endsWith('.zip')) return { isFile: () => true, size: 100382581, mtime: new Date('2026-11-09T02:13:00Z') };
51
+ if (file.endsWith('cordis.patch.yml') || file.endsWith('package.json')) {
52
+ return { isFile: () => true, size: 81955, mtime: new Date('2026-11-08T09:49:10Z') };
53
+ }
54
+ return { __error: 'unexpected stat ' + file };
55
+ },
56
+ readText(file) {
57
+ if (file.includes('crash-')) return CRASH;
58
+ if (file.includes('.bak-')) return 'a\n'.repeat(2692).trim();
59
+ if (file.endsWith('cordis.patch.yml')) return 'a\n'.repeat(2692).trim();
60
+ if (file.endsWith('package.json')) return '{"name":"x"}';
61
+ return { __error: 'unexpected read ' + file };
62
+ },
63
+ };
64
+ const report = collectReport({
65
+ now: new Date('2026-11-08T19:55:00Z'),
66
+ bootedAt: new Date('2026-11-07T02:00:00Z'),
67
+ env: { APPDATA: 'C:\\Users\\demo\\AppData\\Roaming', DSH_HOME: 'C:\\Users\\demo\\.dsh' },
68
+ platform: 'win32',
69
+ pluginChanges: [{ type: 'install' }, { type: 'enable' }],
70
+ io,
71
+ });
72
+ check('status ok', report.status === 'ok', JSON.stringify(report.errors));
73
+ check('summary.level = critical', report.summary.level === 'critical', report.summary.level);
74
+ check('boot-crash-latest finding present', report.findings.some((f) => f.id === 'boot-crash-latest'));
75
+ const crashFinding = report.findings.find((f) => f.id === 'boot-crash-latest');
76
+ check('crash names suspect in detail', crashFinding && crashFinding.detail.includes('@xmanrui/dsh-im'), crashFinding && crashFinding.detail);
77
+ check('latest suspects scoped to latest log', crashFinding && Array.isArray(crashFinding.data.suspects) && crashFinding.data.suspects.includes('@xmanrui/dsh-im'), JSON.stringify(crashFinding && crashFinding.data.suspects));
78
+
79
+ console.log('— v0.7.6 ① backup freshness / ④ config drift —');
80
+ const bf = report.findings.find((f) => f.id === 'backup-freshness');
81
+ check('backup-freshness finding present', !!bf);
82
+ check('backup freshness counts the export', bf && bf.data && bf.data.count === 1, JSON.stringify(bf && bf.data));
83
+ check('backup freshness computes age in days', bf && bf.data && typeof bf.data.latestAgeDays === 'number' && bf.data.latestAgeDays >= 0, JSON.stringify(bf && bf.data));
84
+ check('fresh backup is not a warning', bf && bf.severity === 'info', bf && bf.severity);
85
+ const cfg = report.findings.find((f) => f.id === 'config-drift');
86
+ check('config-drift records a first baseline', cfg && cfg.title.includes('基线'), cfg && cfg.title);
87
+ check('config fingerprint covers both watched files in each profile', report.config && report.config.filter((c) => c.file === 'cordis.patch.yml').length >= 1 && report.config.filter((c) => c.file === 'package.json').length >= 1, JSON.stringify(report.config.map((c) => c.dir + '/' + c.file)));
88
+ check('config fingerprint is a digest, not the content', report.config && report.config.every((c) => /^[0-9a-f]{16}$/.test(c.digest)), JSON.stringify(report.config));
89
+
90
+ // drift detection: same files, but the patch shrank from 2692 lines to 40.
91
+ const shrunkIo = {
92
+ ...io,
93
+ readText(file) {
94
+ if (file.includes('crash-')) return CRASH;
95
+ if (file.endsWith('cordis.patch.yml')) return 'a\n'.repeat(40).trim();
96
+ if (file.endsWith('package.json')) return '{"name":"x"}';
97
+ return { __error: 'unexpected read ' + file };
98
+ },
99
+ };
100
+ const driftReport = collectReport({
101
+ now: new Date('2026-11-08T19:55:00Z'),
102
+ bootedAt: new Date('2026-11-07T02:00:00Z'),
103
+ env: { APPDATA: 'C:\\Users\\demo\\AppData\\Roaming', DSH_HOME: 'C:\\Users\\demo\\.dsh' },
104
+ platform: 'win32',
105
+ configBaseline: report.config,
106
+ io: shrunkIo,
107
+ });
108
+ const drift = driftReport.findings.find((f) => f.id === 'config-drift');
109
+ check('shrunken patch raises config-drift', !!drift);
110
+ check('shrunken patch is a warning', drift && drift.severity === 'warning', drift && drift.severity);
111
+ check('drift names the file', drift && drift.data.shrunk.includes('cordis.patch.yml'), JSON.stringify(drift && drift.data));
112
+ check('drift reports the line delta', drift && drift.data.changed[0].deltaLines < -1000, JSON.stringify(drift && drift.data.changed));
113
+
114
+ // an untouched config must NOT raise drift (no crying wolf on every scan).
115
+ const quietReport = collectReport({
116
+ now: new Date('2026-11-08T19:55:00Z'),
117
+ bootedAt: new Date('2026-11-07T02:00:00Z'),
118
+ env: { APPDATA: 'C:\\Users\\demo\\AppData\\Roaming', DSH_HOME: 'C:\\Users\\demo\\.dsh' },
119
+ platform: 'win32',
120
+ configBaseline: report.config,
121
+ io,
122
+ });
123
+ check('unchanged config raises no drift finding', !quietReport.findings.some((f) => f.id === 'config-drift'));
124
+ check('crash meta.app extracted', crashFinding && crashFinding.data.meta.app && crashFinding.data.meta.app.includes('0.2.2-rc.1'), JSON.stringify(crashFinding && crashFinding.data.meta));
125
+ const staleReport = collectReport({
126
+ now: new Date('2026-11-08T19:55:00Z'),
127
+ bootedAt: new Date('2026-11-08T19:00:00Z'),
128
+ env: { APPDATA: 'C:\\Users\\demo\\AppData\\Roaming', DSH_HOME: 'C:\\Users\\demo\\.dsh' },
129
+ platform: 'win32',
130
+ io,
131
+ });
132
+ const staleCrash = staleReport.findings.find((f) => f.id === 'boot-crash-latest');
133
+ check('crash older than current boot → not critical', staleCrash && staleCrash.severity === 'info', staleCrash && staleCrash.severity);
134
+ check('stale flag in data', staleCrash && staleCrash.data.stale === true);
135
+ check('stale headline says historical', staleCrash && staleCrash.title.includes('历史'), staleCrash && staleCrash.title);
136
+ check('stale → summary.level not critical', staleReport.summary.level !== 'critical', staleReport.summary.level);
137
+ check('patch-backup finding present', report.findings.some((f) => f.id === 'patch-backup'));
138
+
139
+ // v0.9.6:残留的 .bak 与「当前配置真被换成了 stub」是两件事。
140
+ // 主夹具里 live 81955 / bak 81957 → 只是残留,应为 info 而不是 warning。
141
+ const pbMain = report.findings.find((f) => f.id === 'patch-backup');
142
+ check('a leftover .bak whose live patch is intact stays info', pbMain && pbMain.severity === 'info', JSON.stringify(pbMain && pbMain.severity));
143
+ check('the residue finding says the live patch was not replaced', !!pbMain && pbMain.title.includes('未被换掉'), JSON.stringify(pbMain && pbMain.title));
144
+
145
+ // v0.9.7:光「变小」不是证据。live 只剩备份的四分之一、但 - id: 块一个没少
146
+ // (本机真实形态:workbuddy 少了 global 段、qoder 的 enabledModelKeys 被清空)
147
+ // → 仍应是 info,不能冒充「配置被换掉了」。
148
+ const pbShrunkIo = Object.assign({}, io, {
149
+ stat(file) {
150
+ if (file.endsWith('cordis.patch.yml') && !file.includes('.bak-')) return { isFile: () => true, size: 4096, mtime: new Date('2026-11-09T10:00:00Z') };
151
+ return io.stat(file);
152
+ },
153
+ });
154
+ const pbShrunkReport = collectReport({
155
+ now: new Date('2026-11-08T19:55:00Z'),
156
+ bootedAt: new Date('2026-11-07T02:00:00Z'),
157
+ env: { APPDATA: 'C:\\Users\\demo\\AppData\\Roaming', DSH_HOME: 'C:\\Users\\demo\\.dsh' },
158
+ platform: 'win32',
159
+ io: pbShrunkIo,
160
+ profileDirs: ['C:\\Users\\demo\\.dsh\\profiles\\desktop'],
161
+ });
162
+ const pbShrunk = pbShrunkReport.findings.find((f) => f.id === 'patch-backup');
163
+ check('a smaller patch with all blocks intact stays info', !!pbShrunk && pbShrunk.severity === 'info', JSON.stringify(pbShrunk && pbShrunk.severity));
164
+ check('the shrunk finding says the blocks are all still there', !!pbShrunk && pbShrunk.title.includes('一个没少'), JSON.stringify(pbShrunk && pbShrunk.title));
165
+ check('the shrunk finding still reports the percentage', !!pbShrunk && /小 \d+%/.test(pbShrunk.detail), JSON.stringify(pbShrunk && pbShrunk.detail));
166
+
167
+ // 反过来:备份里有一整个块在当前 patch 里消失 → 这才是结构性缺失,必须 warning 并点名。
168
+ const pbMissingIo = Object.assign({}, io, {
169
+ readText(file) {
170
+ if (file.includes('.bak-')) return ['- id: kept', ' name: "a"', '- id: dropped', ' name: "b"'].join('\n');
171
+ if (file.endsWith('cordis.patch.yml')) return ['- id: kept', ' name: "a"'].join('\n');
172
+ return io.readText(file);
173
+ },
174
+ });
175
+ const pbMissingReport = collectReport({
176
+ now: new Date('2026-11-08T19:55:00Z'),
177
+ bootedAt: new Date('2026-11-07T02:00:00Z'),
178
+ env: { APPDATA: 'C:\\Users\\demo\\AppData\\Roaming', DSH_HOME: 'C:\\Users\\demo\\.dsh' },
179
+ platform: 'win32',
180
+ io: pbMissingIo,
181
+ profileDirs: ['C:\\Users\\demo\\.dsh\\profiles\\desktop'],
182
+ });
183
+ const pbMissing = pbMissingReport.findings.find((f) => f.id === 'patch-backup');
184
+ check('a whole block missing from the live patch is a warning', !!pbMissing && pbMissing.severity === 'warning', JSON.stringify(pbMissing && pbMissing.severity));
185
+ check('the warning names the missing id', !!pbMissing && pbMissing.data.missing.indexOf('dropped') >= 0, JSON.stringify(pbMissing && pbMissing.data));
186
+ check('plugin-change finding present', report.findings.some((f) => f.id === 'plugin-change'));
187
+ check('advisory data-at-risk present', report.findings.some((f) => f.id === 'data-at-risk'));
188
+ check('disk advice is read-only boundary', report.findings.some((f) => f.id === 'disk-advice' && f.data.boundary === 'read-only'));
189
+ check('limitations mention no-delete', report.limitations.some((l) => l.includes('不删文件')));
190
+ check('no errors in fixture run', report.errors.length === 0, JSON.stringify(report.errors));
191
+
192
+ console.log('— collectReport (fail-soft: unreadable dirs) —');
193
+ const broken = collectReport({
194
+ now: new Date('2026-11-08T19:55:00Z'),
195
+ env: { APPDATA: '/nonexistent', DSH_HOME: '/nonexistent' },
196
+ platform: 'win32',
197
+ io: { readDir() { return { __error: 'EACCES' }; }, stat() { return { __error: 'ENOENT' }; }, readText() { return { __error: 'ENOENT' }; } },
198
+ });
199
+ check('degraded status, no throw', broken.status === 'degraded', broken.status);
200
+ check('errors collected', broken.errors.length > 0, String(broken.errors.length));
201
+ check('still returns findings', Array.isArray(broken.findings) && broken.findings.length > 0);
202
+ check('no crash → info finding', broken.findings.some((f) => f.id === 'boot-crash-none'));
203
+
204
+ console.log('— collectReport (missing OPTIONAL dir does not degrade) —');
205
+ const optionalMissing = collectReport({
206
+ now: new Date('2026-11-08T19:55:00Z'),
207
+ env: { APPDATA: 'C:\\Users\\demo\\AppData\\Roaming', DSH_HOME: 'C:\\Users\\demo\\.dsh' },
208
+ platform: 'win32',
209
+ io: {
210
+ readDir(dir) {
211
+ if (dir.includes('profiles') && dir.endsWith('default')) return { __error: 'ENOENT: no such file or directory' };
212
+ if (dir.includes('logs')) return [];
213
+ if (dir.includes('profiles')) return ['cordis.patch.yml'];
214
+ return [];
215
+ },
216
+ stat() { return { isFile: () => true, size: 1, mtime: new Date('2026-11-08T00:00:00Z') }; },
217
+ readText() { return ''; },
218
+ },
219
+ });
220
+ check('missing optional profile dir → status ok', optionalMissing.status === 'ok', optionalMissing.status + ' ' + JSON.stringify(optionalMissing.errors));
221
+ check('missing optional dir → no errors', optionalMissing.errors.length === 0, JSON.stringify(optionalMissing.errors));
222
+
223
+ console.log('— collectReport (real machine, best-effort) —');
224
+ const real = collectReport();
225
+ check('real run returns a report object', real && typeof real.generatedAt === 'string');
226
+ check('real run has findings', Array.isArray(real.findings) && real.findings.length > 0);
227
+ console.log(' real.level=' + real.summary.level + ' crashCount=' + (real.findings.find((f) => f.id === 'boot-crash-latest')?.data.crashCount ?? 0) + ' errors=' + real.errors.length);
228
+
229
+ console.log('— action hub (plan/execute, fake deps, zero real IO) —');
230
+ const { createActionHub } = await import('./lib/actions.js');
231
+ const audit = [];
232
+ const written = [];
233
+ const calls = [];
234
+ const fakeFiles = {};
235
+ const hub = createActionHub({
236
+ routeFetch: async (p, body) => {
237
+ calls.push({ p, body });
238
+ if (p === '/api/disk-cleaner/' && body.action === 'scan') return { ok: true, targets: [{ id: 'pnpm-store', risk: 'safe', bytes: 1234567 }, { id: 'temp', risk: 'caution', bytes: 999 }] };
239
+ if (p === '/api/disk-cleaner/' && body.action === 'clean') return { ok: true, dryRun: false, results: [{ id: 'pnpm-store', bytes: 1234567, removed: 4, failed: 0 }] };
240
+ if (p === '/api/dsh-config-manager/export-preview') return { ok: true, sections: [{ section: 'sessions', sizeBytes: 700 }, { section: 'pluginData', sizeBytes: 80 }] };
241
+ if (p === '/api/dsh-config-manager/export') return { zipPath: 'C:\\exports\\dsh-config-x.zip', manifest: {}, report: {}, runId: 'r1' };
242
+ throw new Error('unexpected route ' + p);
243
+ },
244
+ getPluginManager: () => ({
245
+ listBundles: async () => [{ name: 'dsh-foo', enabled: true }],
246
+ setBundleEnabled: async (n, e) => { calls.push({ svc: 'setBundleEnabled', n, e }); return { application: 'applied' }; },
247
+ }),
248
+ getReport: () => ({ summary: { level: 'warning', headline: 'h', counts: { critical: 0, warning: 1, info: 0 } }, findings: [{ id: 'boot-crash-latest', severity: 'critical', data: { stale: false, suspects: ['dsh-foo'] } }] }),
249
+ io: {
250
+ appendJsonl: async (f, o) => { (f.includes('audit') ? audit : written).push({ f, o }); },
251
+ readJson: async (f) => fakeFiles[f] || [],
252
+ writeJson: async (f, v) => { fakeFiles[f] = v; },
253
+ },
254
+ now: () => new Date('2026-11-09T00:00:00Z'),
255
+ auditFile: 'audit.jsonl', trendFile: 'trend.json', knowledgeFile: 'knowledge.jsonl', ackedFile: 'acked.json',
256
+ });
257
+
258
+ const pc = await hub.plan('clean');
259
+ check('clean plan zero-write', calls.length === 1 && calls[0].body.action === 'scan');
260
+ check('clean plan safe-only', pc.ok && pc.plan.ids.length === 1 && pc.plan.ids[0] === 'pnpm-store', JSON.stringify(pc.plan));
261
+
262
+ check('execute w/o confirm refused', (await hub.execute({ planHash: pc.planHash })).ok === false);
263
+ const ex1 = await hub.execute({ planHash: pc.planHash, confirm: true });
264
+ check('clean execute ok', ex1.ok === true && ex1.freedBytes === 1234567, JSON.stringify(ex1));
265
+ check('clean execute passes ids+confirm', calls.some((c) => c.body && c.body.action === 'clean' && c.body.confirm === true && c.body.ids[0] === 'pnpm-store'));
266
+ const ex1b = await hub.execute({ planHash: pc.planHash, confirm: true });
267
+ check('fingerprint single-use', ex1b.ok === false, JSON.stringify(ex1b));
268
+
269
+ const pb = await hub.plan('backup');
270
+ check('backup plan preview-only', calls.filter((c) => c.p === '/api/dsh-config-manager/export-preview').length === 1 && pb.plan.only.includes('sessions'));
271
+ check('backup execute needs password', (await hub.execute({ planHash: pb.planHash, confirm: true })).error.includes('密码'));
272
+ const ex2 = await hub.execute({ planHash: pb.planHash, confirm: true, password: 'x'.repeat(20) });
273
+ check('backup execute ok', ex2.ok === true && ex2.file === 'dsh-config-x.zip' && ex2.encrypted === true, JSON.stringify(ex2));
274
+ check('audit never holds password', !JSON.stringify(audit).includes('xxxx'));
275
+
276
+ const pd = await hub.plan('disable', { plugin: 'dsh-foo' });
277
+ check('disable plan sees enabled bundle', pd.ok === true, JSON.stringify(pd));
278
+ const ex3 = await hub.execute({ planHash: pd.planHash, confirm: true });
279
+ check('disable execute via service', ex3.ok === true && calls.some((c) => c.svc === 'setBundleEnabled' && c.n === 'dsh-foo' && c.e === false));
280
+ const pdd = await hub.plan('disable', { plugin: 'dsh-missing' });
281
+ check('disable unknown plugin rejected', pdd.ok === false);
282
+
283
+ const pk = await hub.plan('knowledge');
284
+ const ex4 = await hub.execute({ planHash: pk.planHash, confirm: true });
285
+ check('knowledge execute appends', ex4.ok === true && written.length === 1);
286
+ check('audit lines recorded', audit.length >= 4, String(audit.length));
287
+
288
+ const badHub = createActionHub({
289
+ routeFetch: async () => { throw new Error('route dead'); },
290
+ getPluginManager: () => null,
291
+ getReport: () => null,
292
+ io: { appendJsonl: async () => { throw new Error('no disk'); }, readJson: async () => null, writeJson: async () => { throw new Error('no disk'); } },
293
+ now: () => new Date(0),
294
+ auditFile: 'a', trendFile: 't', knowledgeFile: 'k',
295
+ });
296
+ check('fail-soft plan clean', (await badHub.plan('clean')).ok === false);
297
+ const bhd = await badHub.plan('disable', { plugin: 'x' });
298
+ check('fail-soft plan disable refused', bhd.ok === false, JSON.stringify(bhd));
299
+ const bhd2 = await createActionHub({ routeFetch: async () => ({}), getPluginManager: () => null, getReport: () => ({ summary: { headline: 'h', counts: {} }, findings: [{ id: 'boot-crash-latest', severity: 'critical', data: { stale: false, suspects: ['x'] } }] }), io: { appendJsonl: async () => {}, readJson: async () => [], writeJson: async () => {} }, now: () => new Date(0), auditFile: 'a', trendFile: 't', knowledgeFile: 'k', ackedFile: 'ack' }).plan('disable', { plugin: 'x' });
300
+ check('fail-soft disable svc-missing', bhd2.ok === false && String(bhd2.error).includes('pluginManager'), JSON.stringify(bhd2));
301
+
302
+
303
+ console.log('— v0.3: human rendering / attribution gate / ack / compat —');
304
+ check('clean plan carries human lines', Array.isArray(pc.plan.human) && pc.plan.human.length >= 2 && pc.plan.human[0].includes('目标'), JSON.stringify(pc.plan.human && pc.plan.human[0]));
305
+ check('clean execute carries human lines', Array.isArray(ex1.human) && ex1.human[0].includes('回收'), JSON.stringify(ex1.human));
306
+ check('backup plan human mentions partitions', Array.isArray(pb.plan.human) && pb.plan.human.some((l) => l.includes('分区')), JSON.stringify(pb.plan.human));
307
+ check('knowledge plan human mentions headline', Array.isArray(pk.plan.human) && pk.plan.human[0].includes('h'), JSON.stringify(pk.plan.human));
308
+
309
+ // withCaution
310
+ const pcw = await hub.plan('clean', { withCaution: true });
311
+ check('clean withCaution includes caution target', pcw.ok && pcw.plan.ids.includes('temp') && pcw.plan.human.some((l) => l.includes('谨慎级')), JSON.stringify(pcw.plan && pcw.plan.ids));
312
+
313
+ // attribution gate on the main hub: dsh-foo is named by the fake report
314
+ check('disable plan human cites gate+guard', Array.isArray(pd.plan.human) && pd.plan.human.some((l) => l.includes('575')), JSON.stringify(pd.plan.human));
315
+ const pno = await hub.plan('disable', { plugin: 'dsh-unrelated' });
316
+ check('attribution gate: unnamed plugin refused', pno.ok === false && String(pno.error).includes('不在最近一次崩溃点名'), JSON.stringify(pno));
317
+
318
+ // stale crash → refuse disable even for a named suspect
319
+ const staleHub = createActionHub({
320
+ routeFetch: async () => { throw new Error('no routes needed'); },
321
+ getPluginManager: () => ({ listBundles: async () => [{ name: 'dsh-foo', enabled: true }], setBundleEnabled: async () => ({ application: 'applied' }) }),
322
+ getReport: () => ({ summary: { headline: 'h', counts: {} }, findings: [{ id: 'boot-crash-latest', severity: 'info', data: { stale: true, suspects: ['dsh-foo'] } }] }),
323
+ io: { appendJsonl: async () => {}, readJson: async () => [], writeJson: async () => {} },
324
+ now: () => new Date('2026-11-09T00:00:00Z'),
325
+ auditFile: 'a', trendFile: 't', knowledgeFile: 'k', ackedFile: 'ack',
326
+ });
327
+ check('attribution gate: stale crash refuses disable', (await staleHub.plan('disable', { plugin: 'dsh-foo' })).ok === false);
328
+ check('no report → disable needs evidence', (await badHub.plan('disable', { plugin: 'x' })).ok === false);
329
+
330
+ // enable counterpart
331
+ const pe = await hub.plan('enable', { plugin: 'dsh-foo' });
332
+ check('enable refused when already enabled', pe.ok === false, JSON.stringify(pe));
333
+ const pe2 = await hub.plan('enable', { plugin: 'dsh-bar' });
334
+ check('enable unknown plugin refused', pe2.ok === false);
335
+
336
+ // v0.4.4:启用只对已停用的插件有意义。拒绝时要说清「为什么」+ 当前可启用对象。
337
+ console.log('— v0.4.4: enable targets disabled bundles, not crash suspects —');
338
+ check('enable refusal explains it targets disabled only', String(pe.error).includes('只对「已停用」'), String(pe.error));
339
+ check('enable refusal lists no-disabled case', String(pe.error).includes('没有任何已停用'), String(pe.error));
340
+
341
+ // 一个真的有停用 bundle 的 hub:可以启用它,且 plan human 引「已是停用状态」依据。
342
+ const mixHub = createActionHub({
343
+ routeFetch: async () => { throw new Error('no routes needed'); },
344
+ getPluginManager: () => ({
345
+ listBundles: async () => [{ name: 'dsh-on', enabled: true }, { name: 'dsh-off-a', enabled: false }, { name: 'dsh-off-b', enabled: false }],
346
+ setBundleEnabled: async (n, e) => ({ application: 'applied', n, e }),
347
+ }),
348
+ getReport: () => ({ summary: { headline: 'h', counts: {} }, findings: [] }),
349
+ io: { appendJsonl: async () => {}, readJson: async () => [], writeJson: async () => {} },
350
+ now: () => new Date('2026-11-09T00:00:00Z'),
351
+ auditFile: 'a', trendFile: 't', knowledgeFile: 'k', ackedFile: 'ack',
352
+ });
353
+ const pe3 = await mixHub.plan('enable', { plugin: 'dsh-on' });
354
+ check('enable on enabled bundle names real candidates', pe3.ok === false && String(pe3.error).includes('dsh-off-a'), String(pe3.error));
355
+ const pe4 = await mixHub.plan('enable', { plugin: 'dsh-off-a' });
356
+ check('enable on disabled bundle allowed', pe4.ok === true, JSON.stringify(pe4));
357
+ check('enable plan human cites disabled-basis', Array.isArray(pe4.plan.human) && pe4.plan.human.some((l) => l.includes('已是停用状态') || l.includes('enabled=false')), JSON.stringify(pe4.plan && pe4.plan.human));
358
+ const exe4 = await mixHub.execute({ planHash: pe4.planHash, confirm: true });
359
+ check('enable execute flips to true', exe4.ok === true, JSON.stringify(exe4));
360
+
361
+ // ack round-trip against a finding present in the fake report
362
+ const pa = await hub.plan('ack', { id: 'boot-crash-latest' });
363
+ check('ack plan ok', pa.ok === true && pa.plan.human[0].includes('静音'), JSON.stringify(pa));
364
+ const exa = await hub.execute({ planHash: pa.planHash, confirm: true });
365
+ check('ack execute writes acked store', exa.ok === true, JSON.stringify(exa));
366
+ const pa2 = await hub.plan('ack', { id: 'boot-crash-latest' });
367
+ check('double ack refused', pa2.ok === false);
368
+ const pu = await hub.plan('unack', { id: 'boot-crash-latest' });
369
+ const exu = await hub.execute({ planHash: pu.planHash, confirm: true });
370
+ check('unack round-trips', pu.ok === true && exu.ok === true, JSON.stringify(exu));
371
+
372
+ console.log('— v0.3 compat-check + mute layer (report) —');
373
+ const compatIo = {
374
+ readDir(dir) {
375
+ if (dir.endsWith('node_modules')) return ['@deepseek-ai', 'dsh-a', 'dsh-b'];
376
+ if (dir.includes('@deepseek-ai')) return ['dsh-base'];
377
+ return { __error: 'unexpected ' + dir };
378
+ },
379
+ stat(file) { return { isFile: () => true, size: 10, mtime: new Date('2026-11-09T00:00:00Z') }; },
380
+ readText(file) {
381
+ if (file.includes('dsh-base')) return '{"name":"@deepseek-ai/dsh-base","version":"0.2.2-rc.1"}';
382
+ if (file.includes('dsh-a')) return '{"name":"dsh-a","version":"1.0.0","engines":{"dsh":">=0.2.0-rc.1 <0.3.0-0"}}';
383
+ if (file.includes('dsh-b')) return '{"name":"dsh-b","version":"2.0.0","engines":{"dsh":">=0.9.0"}}';
384
+ return { __error: 'unexpected read ' + file };
385
+ },
386
+ };
387
+ const compatReport = collectReport({
388
+ now: new Date('2026-11-09T00:00:00Z'), bootedAt: new Date('2026-11-08T00:00:00Z'),
389
+ env: {}, platform: 'win32', profileDirs: ['C:\\p'], io: compatIo,
390
+ });
391
+ const compatFinding = compatReport.findings.find((f) => f.id === 'compat-check');
392
+ check('compat-check finding present', !!compatFinding, JSON.stringify(compatReport.errors));
393
+ check('compat hostVersion read', compatFinding && compatFinding.data.hostVersion === '0.2.2-rc.1', JSON.stringify(compatFinding && compatFinding.data));
394
+ check('compat flags out-of-range package', compatFinding && compatFinding.severity === 'warning' && compatFinding.data.mismatched.some((m) => m.name === 'dsh-b'), JSON.stringify(compatFinding && compatFinding.data.mismatched));
395
+ const mutedReport = collectReport({
396
+ now: new Date('2026-11-08T19:55:00Z'), bootedAt: new Date('2026-11-07T02:00:00Z'),
397
+ env: { APPDATA: 'C:\\\\Users\\\\demo\\\\AppData\\\\Roaming', DSH_HOME: 'C:\\\\Users\\\\demo\\\\.dsh' },
398
+ platform: 'win32', io, acked: [{ id: 'boot-crash-latest' }],
399
+ });
400
+ const mutedCrash = mutedReport.findings.find((f) => f.id === 'boot-crash-latest');
401
+ check('acked finding marked muted + excluded from counts', mutedCrash.muted === true && mutedReport.summary.counts.critical === 0 && mutedReport.summary.counts.muted === 1, JSON.stringify(mutedReport.summary));
402
+
403
+ console.log('— v0.3.2: importance score / recall / DCA1 verify / persisted-report gate —');
404
+ check('fresh critical crash outscores advisory', crashFinding.score > report.findings.find((f) => f.id === 'knowledge').score, JSON.stringify([crashFinding.score, report.findings.find((f) => f.id === 'knowledge').score]));
405
+ check('score has reasons array', Array.isArray(crashFinding.scoreReasons) && crashFinding.scoreReasons.length >= 2, JSON.stringify(crashFinding.scoreReasons));
406
+ check('priority band assigned', ['high', 'medium', 'low'].includes(crashFinding.priority), crashFinding.priority);
407
+ check('stale crash scores below fresh one', staleCrash.score < crashFinding.score, JSON.stringify([staleCrash.score, crashFinding.score]));
408
+ check('stale crash not credited as actionable', !(staleCrash.scoreReasons || []).some((r) => r.startsWith('有直达处置动作')), JSON.stringify(staleCrash.scoreReasons));
409
+ check('muted keeps score but stays scorable', typeof mutedCrash.score === 'number', String(mutedCrash.score));
410
+ const recallReport = collectReport({
411
+ now: new Date('2026-11-09T12:00:00Z'), bootedAt: new Date('2026-11-07T02:00:00Z'),
412
+ env: { APPDATA: 'C:\\\\Users\\\\demo\\\\AppData\\\\Roaming', DSH_HOME: 'C:\\\\Users\\\\demo\\\\.dsh' },
413
+ platform: 'win32', io, pluginChanges: [{ type: 'install' }],
414
+ knowledge: [{ at: '2026-11-08T00:00:00Z', headline: 'h-prior', counts: { critical: 1, warning: 0, info: 0 }, findingIds: ['boot-crash-latest'] }],
415
+ });
416
+ const recallCrash = recallReport.findings.find((f) => f.id === 'boot-crash-latest');
417
+ check('recall attaches prior record', recallCrash.recall && recallCrash.recall.headline === 'h-prior', JSON.stringify(recallCrash.recall));
418
+ check('knowledge plan lists findingIds', Array.isArray(pk.plan.findingIds) && pk.plan.findingIds.includes('boot-crash-latest') && pk.plan.human.some((l) => l.includes('指纹条目')), JSON.stringify(pk.plan.findingIds));
419
+
420
+ // verifyDca1Blob is module-private in actions.js; exercise it through the hub
421
+ // instead: fake io.readBytes returning a real DCA1 blob we encrypt here.
422
+ const { scrypt, createCipheriv, randomBytes } = await import('node:crypto');
423
+ const { promisify } = await import('node:util');
424
+ const scryptP = promisify(scrypt);
425
+ const pw = 'correct-horse';
426
+ const salt = randomBytes(16); const iv = randomBytes(12);
427
+ const key = await scryptP(pw, salt, 32, { N: 16384, r: 8, p: 1, maxmem: 256 * 1024 * 1024 });
428
+ const inner = Buffer.concat([Buffer.from('PK\x03\x04'), Buffer.alloc(64, 7)]);
429
+ const ciph = createCipheriv('aes-256-gcm', key, iv);
430
+ const enc = Buffer.concat([ciph.update(inner), ciph.final()]);
431
+ const blob = Buffer.concat([Buffer.from('DCA1', 'latin1'), Buffer.from([1]), salt, iv, ciph.getAuthTag(), enc]);
432
+ const verifyHub = createActionHub({
433
+ routeFetch: async (p, body) => { if (p === '/api/dsh-config-manager/export-preview') return { ok: true, sections: [{ section: 'sessions', sizeBytes: 700 }, { section: 'pluginData', sizeBytes: 80 }, { section: 'pluginAssets', sizeBytes: 10 }] }; if (p === '/api/dsh-config-manager/export') { calls.push({ exportPw: body.password }); return { zipPath: 'C:\\exports\\v.zip', manifest: {}, report: {}, runId: 'r' }; } throw new Error('unexpected ' + p); },
434
+ getPluginManager: () => null,
435
+ getReport: () => ({ summary: { level: 'ok', headline: 'h', counts: {} }, findings: [] }),
436
+ io: { appendJsonl: async () => {}, readJson: async () => [], writeJson: async () => {}, readBytes: async () => blob },
437
+ now: () => new Date('2026-11-09T00:00:00Z'),
438
+ auditFile: 'a', trendFile: 't', knowledgeFile: 'k', ackedFile: 'ack',
439
+ });
440
+ const pv = await verifyHub.plan('backup');
441
+ const exv = await verifyHub.execute({ planHash: pv.planHash, confirm: true, password: pw });
442
+ check('backup verify ok with right password', exv.ok === true && exv.verified === true, JSON.stringify(exv.verifyReason || exv));
443
+ check('verify human line reports check', exv.human.some((l) => l.includes('回读校验')), JSON.stringify(exv.human));
444
+ const pv2 = await verifyHub.plan('backup');
445
+ const exv2 = await verifyHub.execute({ planHash: pv2.planHash, confirm: true, password: 'wrong-password' });
446
+ check('wrong password → verified false but backup ok', exv2.ok === true && exv2.verified === false && String(exv2.verifyReason).includes('GCM'), JSON.stringify([exv2.verified, exv2.verifyReason]));
447
+ const pv3 = await verifyHub.plan('backup');
448
+ await verifyHub.execute({ planHash: pv3.planHash, confirm: true, password: pw });
449
+ const exv3 = await verifyHub.execute({ planHash: pv3.planHash, confirm: true, password: pw });
450
+ check('fingerprint single-use on verify hub too', exv3.ok === false, JSON.stringify(exv3));
451
+
452
+ const persistedHub = createActionHub({
453
+ routeFetch: async () => { throw new Error('none'); },
454
+ getPluginManager: () => ({ listBundles: async () => [{ name: 'dsh-foo', enabled: true }], setBundleEnabled: async () => ({ application: 'applied' }) }),
455
+ getReport: () => ({ summary: { headline: 'h', counts: {} }, staleAfterRestart: true, findings: [{ id: 'boot-crash-latest', severity: 'critical', data: { stale: false, suspects: ['dsh-foo'] } }] }),
456
+ io: { appendJsonl: async () => {}, readJson: async () => [], writeJson: async () => {} },
457
+ now: () => new Date('2026-11-09T00:00:00Z'),
458
+ auditFile: 'a', trendFile: 't', knowledgeFile: 'k', ackedFile: 'ack',
459
+ });
460
+ const ppd = await persistedHub.plan('disable', { plugin: 'dsh-foo' });
461
+ check('persisted report after restart refused', ppd.ok === false && String(ppd.error).includes('重启前'), JSON.stringify(ppd));
462
+
463
+ console.log('— v0.4: hostVersion source chain / cFreeBytes trend —');
464
+ const compatMain = report.findings.find((f) => f.id === 'compat-check');
465
+ check('hostVersion derived from crash meta.app', compatMain && compatMain.data.hostVersion === '0.2.2-rc.1', JSON.stringify(compatMain && compatMain.data));
466
+ check('report carries cFreeBytes', typeof report.cFreeBytes === 'number' && report.cFreeBytes > 0, String(report.cFreeBytes));
467
+ const trendFiles = {};
468
+ const trendHub = createActionHub({
469
+ routeFetch: async () => { throw new Error('none'); },
470
+ getPluginManager: () => null,
471
+ getReport: () => ({ summary: { level: 'ok', headline: 'h', counts: {} }, cFreeBytes: 5000000000, findings: [] }),
472
+ io: { appendJsonl: async () => {}, readJson: async () => [], writeJson: async (f, v) => { trendFiles[f] = v; } },
473
+ now: () => new Date('2026-11-09T00:00:00Z'),
474
+ auditFile: 'a', trendFile: 'trend.json', knowledgeFile: 'k', ackedFile: 'ack',
475
+ });
476
+ const pt = await trendHub.plan('knowledge');
477
+ check('knowledge plan human mentions free-space snapshot', pt.ok === true && pt.plan.human.some((l) => l.includes('剩余空间快照')), JSON.stringify(pt.plan.human));
478
+ const ext = await trendHub.execute({ planHash: pt.planHash, confirm: true });
479
+ check('trend row stores cFreeBytes', ext.ok === true && Array.isArray(trendFiles['trend.json']) && trendFiles['trend.json'].at(-1).cFreeBytes === 5000000000, JSON.stringify(trendFiles['trend.json']));
480
+
481
+ console.log('— v0.6 L2: one sentence → one intent (pure, zero-token) —');
482
+ const BUNDLES = [
483
+ { name: 'dsh-guizang-design', enabled: true },
484
+ { name: 'dsh-univer-office', enabled: true },
485
+ { name: '@deepseek-ai/dsh-headless', enabled: false },
486
+ { name: '@deepseek-ai/dsh-acp-app', enabled: false },
487
+ ];
488
+ const REPORT_FOR_INTENT = {
489
+ findings: [
490
+ { id: 'data-at-risk', severity: 'warning', title: '易失数据不在默认备份分区内', muted: false },
491
+ { id: 'disk-advice', severity: 'info', title: '可自动清 / 不可自动清必须分开', muted: false },
492
+ { id: 'plugin-change', severity: 'warning', title: '插件变更跟踪', muted: true },
493
+ ],
494
+ };
495
+ const intentCtx = { bundles: BUNDLES, report: REPORT_FOR_INTENT };
496
+
497
+ const i1 = parseIntent('清理一下缓存', intentCtx);
498
+ check('L2 clean → kind clean', i1.intent && i1.intent.kind === 'clean', JSON.stringify(i1));
499
+ check('L2 clean reply explains zero-write preview', Array.isArray(i1.reply) && i1.reply.some((l) => l.includes('预览')), JSON.stringify(i1.reply));
500
+
501
+ const i2 = parseIntent('给易失数据做个加密备份', intentCtx);
502
+ check('L2 backup → kind backup', i2.intent && i2.intent.kind === 'backup', JSON.stringify(i2));
503
+ check('L2 backup reply says do not type password in chat', Array.isArray(i2.reply) && i2.reply.some((l) => l.includes('聊天')), JSON.stringify(i2.reply));
504
+
505
+ const i3 = parseIntent('停用 dsh-guizang-design', intentCtx);
506
+ check('L2 disable picks real bundle', i3.intent && i3.intent.kind === 'disable' && i3.intent.params.plugin === 'dsh-guizang-design', JSON.stringify(i3.intent));
507
+
508
+ const i4 = parseIntent('启用 dsh-guizang-design', intentCtx);
509
+ check('L2 enable on an already-enabled bundle is refused with a redirect', i4.intent === null && String(i4.clarify).includes('停用'), JSON.stringify(i4));
510
+
511
+ const i5 = parseIntent('启用 @deepseek-ai/dsh-headless', intentCtx);
512
+ check('L2 enable on a disabled bundle is accepted', i5.intent && i5.intent.kind === 'enable' && i5.intent.params.plugin === '@deepseek-ai/dsh-headless', JSON.stringify(i5.intent));
513
+
514
+ const i6 = parseIntent('停用 dsh-not-installed-here', intentCtx);
515
+ check('L2 unknown plugin is not guessed into an action', i6.intent === null && String(i6.clarify).includes('不在当前已装列表'), JSON.stringify(i6));
516
+
517
+ const i7 = parseIntent('今天天气不错', intentCtx);
518
+ check('L2 off-topic → clarify, never a guess', i7.intent === null && String(i7.clarify).includes('没听懂'), JSON.stringify(i7));
519
+ check('L2 clarify carries example sentences', Array.isArray(i7.suggestions) && i7.suggestions.length > 0, JSON.stringify(i7.suggestions));
520
+
521
+ const i8 = parseIntent('清理缓存顺便做个备份', intentCtx);
522
+ check('L2 two competing actions → ask, do not pick', i8.intent === null && String(i8.clarify).includes('两件事'), JSON.stringify(i8));
523
+
524
+ const i9 = parseIntent('别清理了,先看看有什么问题', intentCtx);
525
+ check('L2 negation drops clean and keeps report', !i9.intent || i9.intent.kind !== 'clean', JSON.stringify(i9.intent || i9.clarify));
526
+
527
+ const i10 = parseIntent('静音易失数据那条提醒', intentCtx);
528
+ check('L2 ack resolves to a real finding id', i10.intent && i10.intent.kind === 'ack' && i10.intent.params.id === 'data-at-risk', JSON.stringify(i10.intent));
529
+
530
+ const i11 = parseIntent('静音', intentCtx);
531
+ check('L2 bare ack asks which finding', i11.intent === null && String(i11.clarify).includes('哪一条'), JSON.stringify(i11));
532
+
533
+ const i12 = parseIntent(' ', intentCtx);
534
+ check('L2 empty input asks for a sentence', i12.intent === null && !!i12.clarify, JSON.stringify(i12));
535
+
536
+ const i13 = parseIntent('有什么问题吗', intentCtx);
537
+ check('L2 report intent needs no target', i13.intent && i13.intent.kind === 'report', JSON.stringify(i13.intent));
538
+
539
+ // v0.7.0: 「去哪一页」也是一种意图。截图里那句「去到审计趋势」以前落空,
540
+ // 因为当时根本没有导航意图;现在它只切 Tab,不碰任何动作。
541
+ const g1 = parseIntent('去到审计趋势', intentCtx);
542
+ check('L2 goto 审计趋势 resolves to the audit tab', g1.intent && g1.intent.kind === 'goto' && g1.intent.params.tab === 'audit', JSON.stringify(g1));
543
+ const g2 = parseIntent('打开动作中心', intentCtx);
544
+ check('L2 goto 动作中心 resolves to the actions tab', g2.intent && g2.intent.kind === 'goto' && g2.intent.params.tab === 'actions', JSON.stringify(g2));
545
+ const g3 = normalizeBrainOutput('{"kind":"goto","tab":"audit"}', intentCtx);
546
+ check('brain goto carries an allowlisted tab', g3.intent && g3.intent.kind === 'goto' && g3.intent.params.tab === 'audit', JSON.stringify(g3.intent));
547
+ const g4 = normalizeBrainOutput('{"kind":"goto","tab":"secret-admin-page"}', intentCtx);
548
+ check('brain goto cannot invent a page', g4.intent === null, JSON.stringify(g4));
549
+
550
+ // ── v0.7.0 L2+ model layer ────────────────────────────────────────────────
551
+ // The model only TRANSLATES. These cases pin the two things that matter:
552
+ // its output is normalized into the SAME intent shape, and anything it gets
553
+ // wrong degrades to clarify instead of becoming an action.
554
+ const brainCtx = { bundles: intentCtx.bundles, report: intentCtx.report };
555
+
556
+ check('brain extractJson takes a bare object', (extractJson('{"kind":"clean"}') || {}).kind === 'clean');
557
+ check('brain extractJson survives a fenced block', (extractJson('好的\n```json\n{"kind":"backup"}\n```') || {}).kind === 'backup');
558
+ check('brain extractJson ignores surrounding prose', (extractJson('我认为 {"kind":"report"} 就是这样') || {}).kind === 'report');
559
+ check('brain extractJson returns null on non-JSON talk', extractJson('我建议你先清理缓存') === null);
560
+
561
+ const b1 = normalizeBrainOutput('{"kind":"clean","reply":"理解成清理"}', brainCtx);
562
+ check('brain clean becomes the same intent shape', b1.intent && b1.intent.kind === 'clean' && b1.intent.matched[0] === 'model', JSON.stringify(b1));
563
+ check('brain reply is carried through', Array.isArray(b1.reply) && b1.reply[0].includes('清理'), JSON.stringify(b1.reply));
564
+
565
+ const b2 = normalizeBrainOutput('{"kind":"none","reply":"你想清理还是备份?"}', brainCtx);
566
+ check('brain kind=none degrades to clarify, never an action', b2.intent === null && String(b2.clarify).includes('清理还是备份'), JSON.stringify(b2));
567
+
568
+ const b3 = normalizeBrainOutput('{"kind":"launch-missiles"}', brainCtx);
569
+ check('brain unknown kind cannot invent an action', b3.intent === null, JSON.stringify(b3));
570
+
571
+ const b4 = normalizeBrainOutput('这不是 JSON', brainCtx);
572
+ check('brain unparseable output cannot invent an action', b4.intent === null, JSON.stringify(b4));
573
+
574
+ const b5 = normalizeBrainOutput('{"kind":"disable","plugin":"dsh-made-up"}', brainCtx);
575
+ check('brain plugin must exist in the bundle list', b5.intent === null && String(b5.clarify).includes('不在当前已装列表'), JSON.stringify(b5));
576
+
577
+ const b6 = normalizeBrainOutput('{"kind":"disable","plugin":"dsh-guizang-design"}', brainCtx);
578
+ check('brain plugin resolves to the real bundle name', b6.intent && b6.intent.params.plugin === 'dsh-guizang-design', JSON.stringify(b6.intent));
579
+
580
+ const b7 = normalizeBrainOutput('{"kind":"ack","findingId":"no-such-finding"}', brainCtx);
581
+ check('brain ack must name a finding that exists', b7.intent === null, JSON.stringify(b7));
582
+
583
+ const b8 = normalizeBrainOutput('{"kind":"ack","findingId":"data-at-risk"}', brainCtx);
584
+ check('brain ack resolves a real finding id', b8.intent && b8.intent.params.id === 'data-at-risk', JSON.stringify(b8.intent));
585
+
586
+ const b9 = normalizeBrainOutput('{"kind":"report"}', brainCtx);
587
+ check('brain report needs no target', b9.intent && b9.intent.kind === 'report', JSON.stringify(b9));
588
+ check('brain marks its provenance as model', b9.via === 'model', JSON.stringify(b9));
589
+
590
+ console.log('— v0.7.7: workspace inventory / plugin updates / coverage / mute expiry —');
591
+ const { encodeWorkspaceDir } = await import('./lib/report.js');
592
+ check('workspace dir encoding matches DSH layout', encodeWorkspaceDir('E:\\workspace') === '--E-workspace--', encodeWorkspaceDir('E:\\workspace'));
593
+ check('workspace dir encoding collapses separators', encodeWorkspaceDir('C:\\Users\\demo\\Documents\\deepseek-harness\\default-workspace') === '--C-Users-demo-Documents-deepseek-harness-default-workspace--', encodeWorkspaceDir('C:\\Users\\demo\\Documents\\deepseek-harness\\default-workspace'));
594
+
595
+ const WORKSPACE_JSON = JSON.stringify({
596
+ unit: { name: 'workspace', version: 2 },
597
+ global: { initialized: true, defaultWorkspaceId: 'w1', workspaceIds: ['w1', 'w2'] },
598
+ tables: { workspaces: {
599
+ w1: { path: process.cwd(), title: 'Big', sessionIds: ['s1'], createdAt: '2026-11-01T00:00:00Z' },
600
+ w2: { path: path.dirname(process.cwd()), title: 'Small', sessionIds: ['s2'], createdAt: '2026-11-01T00:00:00Z' },
601
+ } },
602
+ });
603
+ const UPDATE_CACHE = JSON.stringify({
604
+ fetchedAt: '2026-11-09T12:00:00Z',
605
+ packages: {
606
+ 'dsh-a': { latest: '2.0.0', versions: ['1.0.0', '2.0.0'] },
607
+ 'dsh-b': { latest: '2.0.0', versions: ['1.0.0', '2.0.0'] },
608
+ },
609
+ });
610
+ function dataIo() {
611
+ return {
612
+ readDir(dir) {
613
+ if (dir.includes('logs')) return [];
614
+ if (dir.includes('profiles') || dir === 'C:\\p') return ['cordis.patch.yml', 'package.json'];
615
+ if (dir.includes('exports')) return [];
616
+ if (dir.includes('node_modules')) return ['dsh-a', 'dsh-b'];
617
+ if (dir.endsWith('E:\\big')) return [];
618
+ if (dir.endsWith('E:\\small')) return [];
619
+ return { __error: 'ENOENT unexpected ' + dir };
620
+ },
621
+ stat(file) {
622
+ if (file.endsWith('workspace.json') || file.endsWith('plugin-update-cache.json')) {
623
+ return { isFile: () => true, size: 100, mtime: new Date('2026-11-09T12:00:00Z') };
624
+ }
625
+ if (file.endsWith('cordis.patch.yml') || file.endsWith('package.json')) {
626
+ return { isFile: () => true, size: 100, mtime: new Date('2026-11-09T12:00:00Z') };
627
+ }
628
+ return { __error: 'ENOENT unexpected ' + file };
629
+ },
630
+ readText(file) {
631
+ if (file.endsWith('workspace.json')) return WORKSPACE_JSON;
632
+ if (file.endsWith('plugin-update-cache.json')) return UPDATE_CACHE;
633
+ if (file.includes('node_modules') && file.endsWith('package.json')) {
634
+ const name = file.includes('dsh-a') ? 'dsh-a' : 'dsh-b';
635
+ return JSON.stringify({ name, version: '1.0.0' });
636
+ }
637
+ if (file.endsWith('cordis.patch.yml')) return 'a\n'.repeat(100).trim();
638
+ if (file.endsWith('package.json')) return '{"name":"x"}';
639
+ return { __error: 'ENOENT unexpected read ' + file };
640
+ },
641
+ };
642
+ }
643
+ const dataReport = collectReport({
644
+ now: new Date('2026-11-09T13:00:00Z'), bootedAt: new Date('2026-11-09T00:00:00Z'),
645
+ env: { APPDATA: 'C:\\Users\\demo\\AppData\\Roaming', DSH_HOME: 'C:\\Users\\demo\\.dsh' },
646
+ platform: 'win32', profileDirs: ['C:\\p'], io: dataIo(),
647
+ });
648
+ const wsFinding = dataReport.findings.find((f) => f.id === 'workspace-inventory');
649
+ check('workspace inventory finding present', !!wsFinding, JSON.stringify(dataReport.errors));
650
+ check('workspace inventory lists both workspaces', wsFinding && wsFinding.data.workspaces.length === 2, JSON.stringify(wsFinding && wsFinding.data && wsFinding.data.workspaces));
651
+ check('workspace index is reported as available', dataReport.workspace.available === true && dataReport.workspace.totalBytes >= 0, JSON.stringify(dataReport.workspace));
652
+ check('workspace growth needs a previous snapshot before it can warn', wsFinding.severity === 'info' && wsFinding.data.comparedTo === null, JSON.stringify({ sev: wsFinding.severity, cmp: wsFinding.data.comparedTo }));
653
+
654
+ // v0.7.8 growth semantics: same numbers twice must NOT raise warning (that is the
655
+ // fixed-threshold trap — a static 50 MB line fires every scan on a machine that
656
+ // did not change). A real jump must.
657
+ const flatPrev = { at: '2026-11-09T12:00:00Z', workspace: { workspaces: [{ id: 'w1', title: 'Big', bytes: 0 }, { id: 'w2', title: 'Small', bytes: 0 }] } };
658
+ const flatReport = collectReport({
659
+ now: new Date('2026-11-09T13:00:00Z'), bootedAt: new Date('2026-11-09T00:00:00Z'),
660
+ env: { APPDATA: 'C:\\Users\\demo\\AppData\\Roaming', DSH_HOME: 'C:\\Users\\demo\\.dsh' },
661
+ platform: 'win32', profileDirs: ['C:\\p'], io: dataIo(), previous: flatPrev,
662
+ });
663
+ const flatWs = flatReport.findings.find((f) => f.id === 'workspace-inventory');
664
+ check('unchanged workspace volume does not raise a warning', flatWs.severity === 'info', JSON.stringify({ sev: flatWs.severity, title: flatWs.title }));
665
+ check('unchanged workspace says it held steady', String(flatWs.title).includes('无显著增长') || String(flatWs.title).includes('持平'), flatWs.title);
666
+
667
+ // jump: pretend w1 had 0 bytes before but a big dir now (the fixture reports 0 too,
668
+ // so synthesize the delta by giving a previous entry a negative-implied baseline)
669
+ const jumpIo = dataIo();
670
+ const jumpPrev = { at: '2026-11-09T12:00:00Z', workspace: { workspaces: [{ id: 'w1', title: 'Big', bytes: -30 * 1024 * 1024 }, { id: 'w2', title: 'Small', bytes: 0 }] } };
671
+ const jumpReport = collectReport({
672
+ now: new Date('2026-11-09T13:00:00Z'), bootedAt: new Date('2026-11-09T00:00:00Z'),
673
+ env: { APPDATA: 'C:\\Users\\demo\\AppData\\Roaming', DSH_HOME: 'C:\\Users\\demo\\.dsh' },
674
+ platform: 'win32', profileDirs: ['C:\\p'], io: jumpIo, previous: jumpPrev,
675
+ });
676
+ const jumpWs = jumpReport.findings.find((f) => f.id === 'workspace-inventory');
677
+ check('a real volume jump raises a warning', jumpWs.severity === 'warning' && jumpWs.data.grown.length >= 1, JSON.stringify({ sev: jumpWs.severity, grown: jumpWs.data.grown }));
678
+ const upFinding = dataReport.findings.find((f) => f.id === 'plugin-updates');
679
+ check('plugin updates detects outdated packages', upFinding && upFinding.data.outdated.some((o) => o.name === 'dsh-a' && o.latest === '2.0.0'), JSON.stringify(upFinding && upFinding.data));
680
+ check('coverage counts every source', dataReport.coverage.sourcesTotal === 7, JSON.stringify(dataReport.coverage));
681
+
682
+ // v0.9.4 direction gate. The live machine shipped dsh-disk-cleaner "1.6.3 → 1.6.2"
683
+ // as an UPGRADE suggestion: plain inequality, no direction. These three fixtures
684
+ // pin the three outcomes that matter.
685
+ function versionIo(versions) {
686
+ const base = dataIo();
687
+ const origReadText = base.readText.bind(base);
688
+ base.readText = (file) => {
689
+ if (file.includes('node_modules') && file.endsWith('package.json')) {
690
+ const name = file.includes('dsh-a') ? 'dsh-a' : 'dsh-b';
691
+ return JSON.stringify({ name, version: versions[name] });
692
+ }
693
+ return origReadText(file);
694
+ };
695
+ return base;
696
+ }
697
+ const mkVersionReport = (versions) => collectReport({
698
+ now: new Date('2026-11-09T13:00:00Z'), bootedAt: new Date('2026-11-09T00:00:00Z'),
699
+ env: { APPDATA: 'C:\\\\Users\\\\demo\\\\AppData\\\\Roaming', DSH_HOME: 'C:\\\\Users\\\\demo\\\\.dsh' },
700
+ platform: 'win32', profileDirs: ['C:\\\\p'], io: versionIo(versions),
701
+ });
702
+
703
+ const sameReport = mkVersionReport({ 'dsh-a': '2.0.0', 'dsh-b': '2.0.0' });
704
+ const sameUp = sameReport.findings.find((f) => f.id === 'plugin-updates');
705
+ check('same version is not an upgrade', sameUp && sameUp.data.outdated.length === 0, JSON.stringify(sameUp && sameUp.data));
706
+ check('same version raises no ahead/unknown noise', sameUp && sameUp.data.aheadOfCache.length === 0 && sameUp.data.undetermined === 0, JSON.stringify(sameUp && sameUp.data));
707
+
708
+ const aheadReport = mkVersionReport({ 'dsh-a': '3.0.0', 'dsh-b': 'beta' });
709
+ const aheadUp = aheadReport.findings.find((f) => f.id === 'plugin-updates');
710
+ check('installed newer than cache is not an upgrade', aheadUp && aheadUp.data.outdated.length === 0, JSON.stringify(aheadUp && aheadUp.data));
711
+ check('installed-newer lands in aheadOfCache', aheadUp && aheadUp.data.aheadOfCache.some((o) => o.name === 'dsh-a' && o.installed === '3.0.0' && o.latest === '2.0.0'), JSON.stringify(aheadUp && aheadUp.data));
712
+ check('unparseable version is counted, not guessed', aheadUp && aheadUp.data.undetermined === 1, JSON.stringify(aheadUp && aheadUp.data));
713
+ check('ahead/unknown are said out loud in the detail', aheadUp && String(aheadUp.detail).includes('缓存偏旧') && String(aheadUp.detail).includes('无法比较'), String(aheadUp && aheadUp.detail));
714
+
715
+ // A real upgrade alongside an ahead-of-cache package and an unparseable one: the
716
+ // OUTDATED branch must name the update, and still say the other two out loud
717
+ // instead of silently dropping them.
718
+ const mixedIo = dataIo();
719
+ const mixedOrig = mixedIo.readText.bind(mixedIo);
720
+ mixedIo.readDir = (dir) => (dir.includes('node_modules') ? ['dsh-a', 'dsh-b', 'dsh-c'] : dataIo().readDir(dir));
721
+ mixedIo.readText = (file) => {
722
+ if (file.includes('node_modules') && file.endsWith('package.json')) {
723
+ const name = file.includes('dsh-a') ? 'dsh-a' : file.includes('dsh-b') ? 'dsh-b' : 'dsh-c';
724
+ const v = { 'dsh-a': '1.0.0', 'dsh-b': '3.0.0', 'dsh-c': 'beta' }[name];
725
+ return JSON.stringify({ name, version: v });
726
+ }
727
+ return mixedOrig(file);
728
+ };
729
+ const mixedCache = JSON.stringify({ fetchedAt: '2026-11-09T12:00:00Z', packages: {
730
+ 'dsh-a': { latest: '2.0.0' }, 'dsh-b': { latest: '2.0.0' }, 'dsh-c': { latest: '2.0.0' },
731
+ } });
732
+ const mixedReport = collectReport({
733
+ now: new Date('2026-11-09T13:00:00Z'), bootedAt: new Date('2026-11-09T00:00:00Z'),
734
+ env: { APPDATA: 'C:\\\\Users\\\\demo\\\\AppData\\\\Roaming', DSH_HOME: 'C:\\\\Users\\\\demo\\\\.dsh' },
735
+ platform: 'win32', profileDirs: ['C:\\\\p'],
736
+ io: { ...mixedIo, readText: (f) => (f.endsWith('plugin-update-cache.json') ? mixedCache : mixedIo.readText(f)) },
737
+ });
738
+ const mixedUp = mixedReport.findings.find((f) => f.id === 'plugin-updates');
739
+ check('a real upgrade still reports as outdated', mixedUp && mixedUp.data.outdated.some((o) => o.name === 'dsh-a' && o.installed === '1.0.0' && o.latest === '2.0.0'), JSON.stringify(mixedUp && mixedUp.data));
740
+ check('the upgrade branch also names the ahead-of-cache package', mixedUp && String(mixedUp.detail).includes('缓存比磁盘旧'), String(mixedUp && mixedUp.detail));
741
+ check('the upgrade branch also names the unparseable one', mixedUp && String(mixedUp.detail).includes('无法比较'), String(mixedUp && mixedUp.detail));
742
+
743
+ // optional source missing -> coverage says unavailable, but the report must stay ok
744
+ const gapIo = dataIo();
745
+ const gapReport = collectReport({
746
+ now: new Date('2026-11-09T13:00:00Z'), bootedAt: new Date('2026-11-09T00:00:00Z'),
747
+ env: { APPDATA: 'C:\\Users\\demo\\AppData\\Roaming', DSH_HOME: 'C:\\Users\\demo\\.dsh' },
748
+ platform: 'win32', profileDirs: ['C:\\p'],
749
+ io: { ...gapIo, readText(f) { return f.endsWith('workspace.json') ? '' : gapIo.readText(f); } },
750
+ });
751
+ check('missing optional source does not degrade the report', gapReport.status === 'ok', JSON.stringify(gapReport.errors));
752
+ const confFinding = gapReport.findings.find((f) => f.id === 'confidence');
753
+ check('missing source raises a confidence note', !!confFinding && String(confFinding.title).includes('数据源'), JSON.stringify(confFinding && confFinding.title));
754
+
755
+ // mute expiry: a stale expiresAt must not keep muting, a future one must
756
+ const expiredReport = collectReport({
757
+ now: new Date('2026-11-09T13:00:00Z'), bootedAt: new Date('2026-11-09T00:00:00Z'),
758
+ env: { APPDATA: 'C:\\Users\\demo\\AppData\\Roaming', DSH_HOME: 'C:\\Users\\demo\\.dsh' },
759
+ platform: 'win32', profileDirs: ['C:\\p'], io: dataIo(),
760
+ acked: [{ id: 'data-at-risk', expiresAt: '2026-01-01T00:00:00Z' }],
761
+ });
762
+ check('expired mute stops suppressing the finding', expiredReport.summary.counts.muted === 0, JSON.stringify(expiredReport.summary.counts));
763
+ const heldReport = collectReport({
764
+ now: new Date('2026-11-09T13:00:00Z'), bootedAt: new Date('2026-11-09T00:00:00Z'),
765
+ env: { APPDATA: 'C:\\Users\\demo\\AppData\\Roaming', DSH_HOME: 'C:\\Users\\demo\\.dsh' },
766
+ platform: 'win32', profileDirs: ['C:\\p'], io: dataIo(),
767
+ acked: [{ id: 'data-at-risk', expiresAt: '2027-01-01T00:00:00Z' }],
768
+ });
769
+ check('unexpired mute still suppresses', heldReport.summary.counts.muted === 1, JSON.stringify(heldReport.summary.counts));
770
+
771
+ // ack execute must stamp an expiry so "silenced" is never permanent by accident
772
+ const pae = await hub.plan('ack', { id: 'boot-crash-latest' });
773
+ check('ack plan explains the mute window', Array.isArray(pae.plan.human) && pae.plan.human.some((l) => l.includes('自动重新提醒')), JSON.stringify(pae.plan && pae.plan.human));
774
+ const exae = await hub.execute({ planHash: pae.planHash, confirm: true });
775
+ check('ack execute ok before expiry assertions', exae.ok === true, JSON.stringify(exae));
776
+ const storedAck = Array.isArray(fakeFiles['acked.json']) ? fakeFiles['acked.json'].find((a) => a && a.id === 'boot-crash-latest') : null;
777
+ check('ack stores an expiresAt timestamp', !!storedAck && typeof storedAck.expiresAt === 'string' && Date.parse(storedAck.expiresAt) > Date.now(), JSON.stringify(storedAck));
778
+
779
+
780
+ // ---- v0.8.0 settings: 松紧可调、越界夹紧、非数字拒绝、默认即推荐 ----
781
+ const dflt = settingsDefaults();
782
+ check('settings default plugin-updates ttl is 14', settingValue(dflt, 'ttl.plugin-updates') === 14, String(settingValue(dflt, 'ttl.plugin-updates')));
783
+ const n1 = normalizeSettings({ ttl: { 'plugin-updates': 7 } });
784
+ check('settings accept 7 days for plugin-updates', n1.ok === true && settingValue(n1.settings, 'ttl.plugin-updates') === 7, JSON.stringify(n1.applied));
785
+ check('ackTtlDays follows the configured value', settingsAckTtl(n1.settings, 'plugin-updates') === 7, String(settingsAckTtl(n1.settings, 'plugin-updates')));
786
+ const n2 = normalizeSettings({ threshold: { growthWarnMb: 0 } });
787
+ check('out-of-range value is clamped, not accepted', n2.ok === true && settingValue(n2.settings, 'threshold.growthWarnMb') === 1 && n2.clamped.length === 1, JSON.stringify(n2.clamped));
788
+ const n3 = normalizeSettings({ ttl: { 'plugin-updates': 'abc' } });
789
+ check('non-numeric value is rejected', n3.ok === false && n3.rejected.length === 1, JSON.stringify(n3.rejected));
790
+ const groups = describeSettings(dflt);
791
+ // 面板提交的是扁平键(每个输入框只知道自己那一个键)。只认嵌套形态会静默变成
792
+ // 「没有收到任何可保存的项」——这正是真机上点保存报错的那条路径。
793
+ const flatSave = normalizeSettings({ 'ttl.plugin-updates': 7, 'threshold.growthWarnMb': 0, 'collect.maxLogs': 500 });
794
+ check('flat keys from the panel are accepted', flatSave.ok === true && settingValue(flatSave.settings, 'ttl.plugin-updates') === 7, JSON.stringify(flatSave.applied));
795
+ check('flat keys are clamped like nested ones', settingValue(flatSave.settings, 'threshold.growthWarnMb') === 1 && settingValue(flatSave.settings, 'collect.maxLogs') === 200, JSON.stringify(flatSave.clamped));
796
+ check('flat input never reports empty-applied', flatSave.applied && Object.keys(flatSave.applied).length === 3, String(Object.keys(flatSave.applied || {}).length));
797
+ check('settings are grouped for the panel', Array.isArray(groups) && groups.length >= 4, String(groups && groups.length));
798
+ const flatItems = groups.reduce((acc, g) => acc.concat(g.items || []), []);
799
+ check('every setting carries label/range/why for the UI', flatItems.length >= 13 && flatItems.every((it) => it.label && it.why && (it.type === 'choice' ? Array.isArray(it.options) && it.options.length > 0 && it.options.some((o) => o.value === it.def) : typeof it.min === 'number' && typeof it.max === 'number' && typeof it.def === 'number')), JSON.stringify(flatItems.filter((it) => !it.label || !it.why).map((it) => it.key)));
800
+ // v0.8.2 面板入口:choice 类型要能存 / 能读 / 能拒非法值
801
+ const entryRow = flatItems.find((it) => it.key === 'ui.entry');
802
+ check('panel entry is a choice with three options', !!entryRow && entryRow.type === 'choice' && entryRow.options.length === 3, JSON.stringify(entryRow && entryRow.options));
803
+ const nEntry = normalizeSettings({ 'ui.entry': 'right' });
804
+ check('panel entry accepts a valid option', nEntry.ok === true && settingValue(nEntry.settings, 'ui.entry') === 'right', JSON.stringify(nEntry.applied));
805
+ const nBad = normalizeSettings({ 'ui.entry': 'nowhere' });
806
+ check('panel entry rejects an unknown option', nBad.rejected.length === 1 && settingValue(nBad.settings, 'ui.entry') === 'both', JSON.stringify(nBad.rejected));
807
+
808
+ // 设置必须真的改变检测行为:同一份增长数据,阈值放大后不再报警
809
+ const growthBase = { now: new Date('2026-11-09T13:00:00Z'), bootedAt: new Date('2026-11-09T00:00:00Z'), env: { APPDATA: 'C:\\Users\\demo\\AppData\\Roaming', DSH_HOME: 'C:\\Users\\demo\\.dsh' }, platform: 'win32', profileDirs: ['C:\\p'], io: dataIo() };
810
+ const growthPrevBase = collectReport(Object.assign({}, growthBase));
811
+ const growthPrevList = (growthPrevBase.workspace && growthPrevBase.workspace.workspaces) || [];
812
+ const growthPrev = { workspace: { workspaces: growthPrevList.map((w) => Object.assign({}, w, { bytes: w.bytes - 30 * 1024 * 1024 })) } };
813
+ const tight = collectReport(Object.assign({}, growthBase, { settings: normalizeSettings({ threshold: { growthWarnMb: 1 } }).settings, previous: growthPrev }));
814
+ const loose = collectReport(Object.assign({}, growthBase, { settings: normalizeSettings({ threshold: { growthWarnMb: 2048 } }).settings, previous: growthPrev }));
815
+ const tightWs = tight.findings.find((f) => f.id === 'workspace-inventory');
816
+ const looseWs = loose.findings.find((f) => f.id === 'workspace-inventory');
817
+ check('tight growth threshold raises a warning', !!tightWs && tightWs.severity === 'warning', JSON.stringify(tightWs && tightWs.title));
818
+ check('loosened growth threshold stops the warning', !!looseWs && looseWs.severity === 'info', JSON.stringify(looseWs && looseWs.title));
819
+
820
+ // ---------- v0.9.1 模型健康:三件(哨兵 / 台账 / 只标不判死) ----------
821
+ // 判据的核心是「忙不等于死」:把 429 当死会把好模型从选择器里赶走。
822
+ check('404 classifies as dead', classifyProbe({ status: 404 }) === 'dead');
823
+ check('422 classifies as dead', classifyProbe({ status: 422 }) === 'dead');
824
+ check('429 classifies as busy (quota cools down)', classifyProbe({ status: 429 }) === 'busy');
825
+ check('500 classifies as busy (gateway fault is not a verdict on the model)', classifyProbe({ status: 500 }) === 'busy');
826
+ check('timeout without a status classifies as busy', classifyProbe(new Error('The operation was aborted')) === 'busy');
827
+ check('status buried in the message still classifies', classifyProbe(new Error('HTTP 404 model not found')) === 'dead');
828
+ check('no error at all is ok', classifyProbe(null) === 'ok');
829
+
830
+ const catalog = {
831
+ default: { provider: 'p1', model: 'm1' },
832
+ providers: [
833
+ { id: 'p1', name: 'P1', models: [{ id: 'm1' }, { id: 'm2' }, { id: 'm3' }] },
834
+ { id: 'p2', name: 'P2', models: [{ id: 'n1' }] },
835
+ ],
836
+ };
837
+ const sentinel = planSentinel(catalog, { perProvider: 2, maxTotal: 12 });
838
+ check('sentinel always contains the default model', sentinel.some((t) => t.provider === 'p1' && t.model === 'm1'), JSON.stringify(sentinel));
839
+ check('sentinel takes only the first N per provider', sentinel.filter((t) => t.provider === 'p1').length === 2, JSON.stringify(sentinel));
840
+ check('sentinel dedupes the default from the per-provider slice', sentinel.filter((t) => t.provider === 'p1' && t.model === 'm1').length === 1, JSON.stringify(sentinel));
841
+ const tightSentinel = planSentinel(catalog, { perProvider: 0, maxTotal: 12 });
842
+ check('perProvider 0 keeps only the default model', tightSentinel.length === 1 && tightSentinel[0].model === 'm1', JSON.stringify(tightSentinel));
843
+
844
+ const okProbes = [
845
+ { provider: 'p1', model: 'm1', verdict: 'ok', ms: 120 },
846
+ { provider: 'p1', model: 'm2', verdict: 'dead' },
847
+ { provider: 'p2', model: 'n1', verdict: 'busy' },
848
+ ];
849
+ const sum = summarize(okProbes, null);
850
+ check('summarize counts the three verdicts', sum.probed === 3 && sum.ok === 1 && sum.dead === 1 && sum.busy === 1, JSON.stringify(sum));
851
+ check('summarize names the dead models', sum.deadModels.length === 1 && sum.deadModels[0].model === 'm2', JSON.stringify(sum.deadModels));
852
+ check('one offline round is not "offline two rounds"', sum.providers.find((r) => r.provider === 'p1').offlineTwoRounds === false, JSON.stringify(sum.providers));
853
+ const deadOnly = [{ provider: 'p1', model: 'm9', verdict: 'dead' }];
854
+ const sumFirst = summarize(deadOnly, null);
855
+ const sumSecond = summarize(deadOnly, sumFirst);
856
+ check('two rounds with zero ok and at least one dead flags offline', sumSecond.providers[0].offlineTwoRounds === true, JSON.stringify(sumSecond.providers));
857
+ const busyOnly = [{ provider: 'p3', model: 'x', verdict: 'busy' }];
858
+ const sumBusy2 = summarize(busyOnly, summarize(busyOnly, null));
859
+ check('a provider that is only busy never becomes "offline"', sumBusy2.providers[0].offlineTwoRounds === false, JSON.stringify(sumBusy2.providers));
860
+
861
+ check('default verdict reports ok with latency', defaultVerdict(okProbes, catalog.default).state === 'ok' && defaultVerdict(okProbes, catalog.default).ms === 120);
862
+ check('default verdict reports dead', defaultVerdict([{ provider: 'p1', model: 'm1', verdict: 'dead', note: 'gone' }], catalog.default).state === 'dead');
863
+ check('default verdict reports busy separately from dead', defaultVerdict([{ provider: 'p1', model: 'm1', verdict: 'busy' }], catalog.default).state === 'busy');
864
+ check('default verdict is unprobed when the default was not in the sentinel', defaultVerdict([{ provider: 'p1', model: 'zzz', verdict: 'ok' }], catalog.default).state === 'unprobed');
865
+ check('default verdict is missing when nothing is selected', defaultVerdict([], null).state === 'missing');
866
+
867
+ const altSameProvider = suggestDefault([
868
+ { provider: 'p1', model: 'm1', verdict: 'dead' },
869
+ { provider: 'p1', model: 'm2', verdict: 'ok' },
870
+ { provider: 'p2', model: 'n1', verdict: 'ok' },
871
+ ], { provider: 'p1', model: 'm1' });
872
+ check('a suggestion prefers the same provider', altSameProvider && altSameProvider.provider === 'p1' && altSameProvider.model === 'm2' && altSameProvider.sameProvider === true, JSON.stringify(altSameProvider));
873
+ const altOther = suggestDefault([
874
+ { provider: 'p1', model: 'm1', verdict: 'dead' },
875
+ { provider: 'p2', model: 'n1', verdict: 'ok' },
876
+ ], { provider: 'p1', model: 'm1' });
877
+ check('a suggestion falls back to another provider', altOther && altOther.provider === 'p2' && altOther.sameProvider === false, JSON.stringify(altOther));
878
+ const altNone = suggestDefault([{ provider: 'p1', model: 'm1', verdict: 'dead' }], { provider: 'p1', model: 'm1' });
879
+ check('no suggestion beats suggesting another broken model', altNone === null, JSON.stringify(altNone));
880
+
881
+ // model-health finding:默认模型挂了才是 warning;「忙」要连续两轮才提示
882
+ // 形状照真机:index.js 传的是 { at, default, defaultVerdict, summary, suggest },
883
+ // 计数在 summary 里。用扁平形状喂进去会掩盖「两边形状对不上」这个真 bug。
884
+ const healthDead = { at: '2026-11-10T00:00:00Z', default: { provider: 'p1', model: 'm1' }, defaultVerdict: { state: 'dead', note: 'gone' }, defaultBusyRounds: 0, suggest: { provider: 'p1', model: 'm2', sameProvider: true }, summary: sum, fresh: true };
885
+ const healthBusy1 = { at: '2026-11-10T00:00:00Z', default: { provider: 'p1', model: 'm1' }, defaultVerdict: { state: 'busy', note: '429' }, defaultBusyRounds: 1, suggest: null, summary: sum, fresh: true };
886
+ const healthBusy2 = { at: '2026-11-10T00:00:00Z', default: { provider: 'p1', model: 'm1' }, defaultVerdict: { state: 'busy', note: '429' }, defaultBusyRounds: 2, suggest: null, summary: sum, fresh: true };
887
+ const healthOk = { at: '2026-11-10T00:00:00Z', default: { provider: 'p1', model: 'm1' }, defaultVerdict: { state: 'ok', ms: 120 }, defaultBusyRounds: 0, suggest: null, summary: sum, fresh: true };
888
+ const mkHealth = (health) => collectReport(Object.assign({}, growthBase, { modelHealth: health }));
889
+ const hDead = mkHealth(healthDead).findings.find((f) => f.id === 'model-health');
890
+ const hBusy1 = mkHealth(healthBusy1).findings.find((f) => f.id === 'model-health');
891
+ const hBusy2 = mkHealth(healthBusy2).findings.find((f) => f.id === 'model-health');
892
+ const hOk = mkHealth(healthOk).findings.find((f) => f.id === 'model-health');
893
+ const hNone = mkHealth(null).findings.find((f) => f.id === 'model-health');
894
+ check('a dead default model is a warning', !!hDead && hDead.severity === 'warning', JSON.stringify(hDead && hDead.title));
895
+ check('the dead default finding carries the suggested replacement', !!hDead && hDead.data && hDead.data.suggest && hDead.data.suggest.model === 'm2', JSON.stringify(hDead && hDead.data && hDead.data.suggest));
896
+ check('one busy round is not a warning', !!hBusy1 && hBusy1.severity === 'info', JSON.stringify(hBusy1 && hBusy1.severity));
897
+ check('two busy rounds do raise a warning', !!hBusy2 && hBusy2.severity === 'warning', JSON.stringify(hBusy2 && hBusy2.title));
898
+ check('a healthy default model is info', !!hOk && hOk.severity === 'info', JSON.stringify(hOk && hOk.title));
899
+ check('no probe result means no model-health finding at all', !hNone);
900
+
901
+ // 弹窗两个设置项:默认只弹严重、停留 12 秒,且都能被夹紧
902
+ const toastRow = flatItems.find((it) => it.key === 'ui.toast');
903
+ const toastSecs = flatItems.find((it) => it.key === 'ui.toastSeconds');
904
+ check('toast mode is a choice defaulting to critical-only', !!toastRow && toastRow.type === 'choice' && toastRow.def === 'critical' && toastRow.options.length === 3, JSON.stringify(toastRow && toastRow.options));
905
+ check('toast offers an explicit off option', !!toastRow && toastRow.options.some((o) => o.value === 'off'));
906
+ check('toast duration defaults to 12 seconds with a range', !!toastSecs && toastSecs.def === 12 && toastSecs.min === 3 && toastSecs.max === 60, JSON.stringify(toastSecs));
907
+ const nToast = normalizeSettings({ 'ui.toast': 'warning', 'ui.toastSeconds': 1 });
908
+ check('toast settings clamp instead of accepting nonsense', settingValue(nToast.settings, 'ui.toast') === 'warning' && settingValue(nToast.settings, 'ui.toastSeconds') === 3, JSON.stringify(nToast.clamped));
909
+ const nToastBad = normalizeSettings({ 'ui.toast': 'everything' });
910
+ check('toast rejects an unknown mode', nToastBad.rejected.length === 1 && settingValue(nToastBad.settings, 'ui.toast') === 'critical', JSON.stringify(nToastBad.rejected));
911
+
912
+ console.log('— v0.9.3: client identity / boot cause —');
913
+
914
+ // 病因 vs 受害者:真实日志的 renderer console 段写着 duplicate factory
915
+ // registration,而 `X: import failed` 只是输掉竞争的那个。解析器必须抓到病因,
916
+ // 否则「装了两遍」会被诊断成「某个插件坏了」。
917
+ const CRASH_DUP = `time: 2026-11-09T23:28:38.158Z
918
+ source: web-boot
919
+ phase: running
920
+ app: @deepseek-ai/dsh-desktop 0.2.2-rc.1
921
+ --- error ---
922
+ Error: web boot: 1 entry did not activate
923
+ @sidady/dsh-steward: import failed (see console for the import error)
924
+ --- renderer console (error level, oldest first) ---
925
+ dsh-app://app/plugins/??x:710 Uncaught Error: client-modules: duplicate factory registration for "dsh-steward" (bundle executed twice without invalidate?)
926
+ dsh-app://app/assets/index.js:196 Error: web boot: 1 entry did not activate
927
+ `;
928
+ const dupParsed = parseBootFailure(CRASH_DUP);
929
+ check('parseBootFailure captures the duplicate client id as the cause',
930
+ !!dupParsed && Array.isArray(dupParsed.clientDuplicates) && dupParsed.clientDuplicates.includes('dsh-steward'),
931
+ JSON.stringify(dupParsed && dupParsed.clientDuplicates));
932
+ check('the victim is still listed, but separately from the cause',
933
+ !!dupParsed && dupParsed.suspects.includes('@sidady/dsh-steward') && !dupParsed.suspects.includes('dsh-steward'),
934
+ JSON.stringify(dupParsed && dupParsed.suspects));
935
+ check('a log with no duplicate line yields an empty cause list',
936
+ Array.isArray(parsed && parsed.clientDuplicates) && parsed.clientDuplicates.length === 0,
937
+ JSON.stringify(parsed && parsed.clientDuplicates));
938
+
939
+ // client 标识体检的三类冲突:同一 realpath 多名 / id≠包名 / id 撞车。
940
+ // 全部靠磁盘判据,不启动任何东西。夹具模拟 profile node_modules。
941
+ const BT = String.fromCharCode(96); // backtick — minified bundles quote ids with it
942
+ function clientIo(spec) {
943
+ // spec: { dirs: { 'pkg-a': {name, id, real, dshClient} } }
944
+ // collectClientIdentity builds paths as <profile>/node_modules/<key>/... with
945
+ // forward slashes and enumerates scoped packages one level deeper, so the
946
+ // fixture has to answer both the flat and the @scope level.
947
+ const nm = 'C:\\p/node_modules';
948
+ const keys = Object.keys(spec.dirs);
949
+ const topNames = [...new Set(keys.map((k) => (k.startsWith('@') ? k.split('/')[0] : k)))];
950
+ return {
951
+ readDir(dir) {
952
+ if (dir === nm) return topNames;
953
+ if (dir.startsWith(nm + '/@')) {
954
+ const scope = dir.slice(nm.length + 1);
955
+ return keys.filter((k) => k.startsWith(scope + '/')).map((k) => k.slice(scope.length + 1));
956
+ }
957
+ if (dir.includes('logs')) return [];
958
+ if (dir.includes('profiles')) return ['cordis.patch.yml'];
959
+ if (dir.includes('exports')) return [];
960
+ return { __error: 'ENOENT ' + dir };
961
+ },
962
+ stat() { return { __error: 'ENOENT' }; },
963
+ realpath(p) {
964
+ const hit = spec.dirs[p.slice(nm.length + 1)];
965
+ return hit ? hit.real : null;
966
+ },
967
+ readText(file) {
968
+ if (!file.startsWith(nm)) {
969
+ if (file.endsWith('cordis.patch.yml')) return 'a\n'.repeat(10).trim();
970
+ return { __error: 'ENOENT ' + file };
971
+ }
972
+ const rest = file.slice(nm.length + 1);
973
+ // package dir = first segment, or first two for @scope/name; the rest is
974
+ // lib/client.js or package.json.
975
+ const parts = rest.split('/');
976
+ const key = parts[0].startsWith('@') ? parts[0] + '/' + parts[1] : parts[0];
977
+ const leaf = parts[parts.length - 1];
978
+ const hit = spec.dirs[key];
979
+ if (!hit) return { __error: 'ENOENT ' + file };
980
+ const entry = hit.entry || 'lib/client.js';
981
+ if (leaf === 'package.json') {
982
+ const extra = hit.entry ? { exports: { './client': './' + hit.entry } } : {};
983
+ return JSON.stringify({ name: hit.name, version: '1.0.0', ...extra, ...(hit.dshClient === false ? {} : { dsh: { client: true } }) });
984
+ }
985
+ if (file === nm + '/' + key + '/' + entry) {
986
+ const q = hit.idStyle === 'backtick' ? BT : String.fromCharCode(39);
987
+ return hit.id === null
988
+ ? 'window.__ModuleLoader__.load({ factory(require) {} });'
989
+ : 'window.__ModuleLoader__.load({ id: ' + q + hit.id + q + ', factory(require) {} });';
990
+ }
991
+ return { __error: 'ENOENT ' + file };
992
+ },
993
+ };
994
+ }
995
+
996
+ const clientBase = { now: new Date('2026-11-10T01:00:00Z'), bootedAt: new Date('2026-11-10T00:00:00Z'), env: { APPDATA: 'C:\\Users\\demo\\AppData\\Roaming', DSH_HOME: 'C:\\Users\\demo\\.dsh' }, platform: 'win32', profileDirs: ['C:\\p'] };
997
+
998
+ // 干净:id 与包名一致、realpath 各不相同 → info,且必须说出「通过」
999
+ const cleanReport = collectReport(Object.assign({}, clientBase, { io: clientIo({ dirs: {
1000
+ 'dsh-a': { name: 'dsh-a', id: 'dsh-a', real: 'C:\\real\\a' },
1001
+ 'dsh-b': { name: 'dsh-b', id: 'dsh-b', real: 'C:\\real\\b' },
1002
+ } }) }));
1003
+ const ciClean = cleanReport.findings.find((f) => f.id === 'client-identity');
1004
+ check('a clean client identity tree stays info', !!ciClean && ciClean.severity === 'info', JSON.stringify(ciClean && ciClean.title));
1005
+ check('the clean finding says how many bundles it actually checked', !!ciClean && ciClean.data && ciClean.data.checked === 2, JSON.stringify(ciClean && ciClean.data));
1006
+
1007
+ // 事故复现:同一份代码被装成两个名字(裸名 + scoped 都指向同一个 realpath)
1008
+ const dupReport = collectReport(Object.assign({}, clientBase, { io: clientIo({ dirs: {
1009
+ 'dsh-steward': { name: '@sidady/dsh-steward', id: '@sidady/dsh-steward', real: 'E:\\DshConfig\\dsh-steward' },
1010
+ '@sidady/dsh-steward': { name: '@sidady/dsh-steward', id: '@sidady/dsh-steward', real: 'E:\\DshConfig\\dsh-steward' },
1011
+ } }) }));
1012
+ const ciDup = dupReport.findings.find((f) => f.id === 'client-identity');
1013
+ check('the same source under two names is a warning', !!ciDup && ciDup.severity === 'warning', JSON.stringify(ciDup && ciDup.title));
1014
+ check('it names both directories and the shared realpath', !!ciDup && ciDup.data.sameReal.length === 1 && ciDup.data.sameReal[0].dirs.length === 2, JSON.stringify(ciDup && ciDup.data.sameReal));
1015
+
1016
+ // id 与包名不一致(改包名忘了改 client bundle)
1017
+ const idDriftReport = collectReport(Object.assign({}, clientBase, { io: clientIo({ dirs: {
1018
+ 'dsh-a': { name: '@me/dsh-a', id: 'dsh-a', real: 'C:\\real\\a' },
1019
+ } }) }));
1020
+ const ciIdDrift = idDriftReport.findings.find((f) => f.id === 'client-identity');
1021
+ check('an id that drifted from the package name is a warning', !!ciIdDrift && ciIdDrift.severity === 'warning', JSON.stringify(ciIdDrift && ciIdDrift.title));
1022
+ check('the drift is reported with both values', !!(ciIdDrift && ciIdDrift.data && ciIdDrift.data.drifted && ciIdDrift.data.drifted[0] && ciIdDrift.data.drifted[0].id === 'dsh-a' && ciIdDrift.data.drifted[0].name === '@me/dsh-a'), JSON.stringify(ciIdDrift && ciIdDrift.data));
1023
+
1024
+ // 两个不相关的包抢同一个 id
1025
+ const collReport = collectReport(Object.assign({}, clientBase, { io: clientIo({ dirs: {
1026
+ 'dsh-a': { name: 'dsh-a', id: 'shared', real: 'C:\\real\\a' },
1027
+ 'dsh-b': { name: 'dsh-b', id: 'shared', real: 'C:\\real\\b' },
1028
+ } }) }));
1029
+ const ciColl = collReport.findings.find((f) => f.id === 'client-identity');
1030
+ check('two packages claiming one client id is a warning', !!ciColl && ciColl.severity === 'warning', JSON.stringify(ciColl && ciColl.title));
1031
+ check('the collision lists both package names', !!ciColl && ciColl.data.collisions[0].names.length === 2, JSON.stringify(ciColl && ciColl.data.collisions));
1032
+
1033
+ // 包没声明 dsh.client 就不该被算进 client 体检
1034
+ const ignoredReport = collectReport(Object.assign({}, clientBase, { io: clientIo({ dirs: {
1035
+ 'plain-lib': { name: 'plain-lib', id: null, real: 'C:\\real\\p', dshClient: false },
1036
+ } }) }));
1037
+ const ciIgnored = ignoredReport.findings.find((f) => f.id === 'client-identity');
1038
+ check('packages without dsh.client are not counted', !ciIgnored, JSON.stringify(ciIgnored && ciIgnored.data));
1039
+
1040
+ // v0.9.5:入口不一定是 lib/client.js —— 10 个真机包用根目录或子目录的 client.js,
1041
+ // 只认 lib/client.js 会把它们算成「读不到」却照样报「通过」。
1042
+ const entryReport = collectReport(Object.assign({}, clientBase, { io: clientIo({ dirs: {
1043
+ 'dsh-root-entry': { name: 'dsh-root-entry', id: 'dsh-root-entry', real: 'C:\\real\\r', entry: 'client.js' },
1044
+ 'dsh-sub-entry': { name: 'dsh-sub-entry', id: 'dsh-sub-entry', real: 'C:\\real\\s', entry: 'dsh/sub/client.js' },
1045
+ } }) }));
1046
+ const ciEntry = entryReport.findings.find((f) => f.id === 'client-identity');
1047
+ check('entries declared via exports["./client"] are read, not counted as unreadable', !!ciEntry && ciEntry.data.unreadable === 0, JSON.stringify(ciEntry && ciEntry.data));
1048
+ check('both non-standard entries were actually checked', !!ciEntry && ciEntry.data.checked === 2, JSON.stringify(ciEntry && ciEntry.data));
1049
+
1050
+ // v0.9.5:压缩产物会用反引号包 id(真机 genui / dsh-context 都是),只认引号会漏。
1051
+ const tickReport = collectReport(Object.assign({}, clientBase, { io: clientIo({ dirs: {
1052
+ 'dsh-tick': { name: 'dsh-tick', id: 'dsh-tick', real: 'C:\\real\\t', idStyle: 'backtick' },
1053
+ } }) }));
1054
+ const ciTick = tickReport.findings.find((f) => f.id === 'client-identity');
1055
+ check('a backtick-quoted id is read, not treated as missing', !!ciTick && ciTick.data.withoutId === 0, JSON.stringify(ciTick && ciTick.data));
1056
+ check('a backtick id that drifted is still caught', (() => {
1057
+ const r = collectReport(Object.assign({}, clientBase, { io: clientIo({ dirs: {
1058
+ 'dsh-tick2': { name: '@me/dsh-tick2', id: 'dsh-tick2', real: 'C:\\real\\t2', idStyle: 'backtick' },
1059
+ } }) }));
1060
+ const f = r.findings.find((x) => x.id === 'client-identity');
1061
+ return !!f && f.severity === 'warning' && f.data.drifted[0].id === 'dsh-tick2';
1062
+ })(), 'backtick drift');
1063
+
1064
+ // 真机那份日志:病因要顶到标题,而不是继续点名受害者
1065
+ const liveCause = collectReport(Object.assign({}, clientBase, {
1066
+ bootedAt: new Date('2026-11-10T00:00:00Z'),
1067
+ io: {
1068
+ readDir(dir) {
1069
+ if (dir.includes('logs')) return ['crash-2026-11-09T23-28-38-158Z-web-boot.log'];
1070
+ if (dir.includes('profiles')) return ['cordis.patch.yml'];
1071
+ if (dir.includes('exports')) return [];
1072
+ return { __error: 'ENOENT ' + dir };
1073
+ },
1074
+ stat(file) { return file.includes('crash-') ? { isFile: () => true, size: CRASH_DUP.length, mtime: new Date('2026-11-09T23:28:38Z') } : { __error: 'ENOENT' }; },
1075
+ readText(file) {
1076
+ if (file.includes('crash-')) return CRASH_DUP;
1077
+ if (file.endsWith('cordis.patch.yml')) return 'a\n'.repeat(10).trim();
1078
+ return { __error: 'ENOENT ' + file };
1079
+ },
1080
+ },
1081
+ }));
1082
+ const liveCrash = liveCause.findings.find((f) => f.id === 'boot-crash-latest');
1083
+ check('the crash finding leads with the duplicate id, not the victim',
1084
+ !!liveCrash && String(liveCrash.title).includes('dsh-steward') && String(liveCrash.title).includes('注册了两次'),
1085
+ JSON.stringify(liveCrash && liveCrash.title));
1086
+ check('the crash finding explains that the named suspect is the victim',
1087
+ !!liveCrash && String(liveCrash.detail).includes('受害者'),
1088
+ JSON.stringify(liveCrash && liveCrash.detail));
1089
+ check('the cause survives into finding data for the panel', !!liveCrash && liveCrash.data.clientDuplicates.includes('dsh-steward'), JSON.stringify(liveCrash && liveCrash.data.clientDuplicates));
1090
+
1091
+
1092
+ // ---------------------------------------------------------------------------
1093
+ // v0.9.9 — packaging: every file the code imports must be in the tarball.
1094
+ //
1095
+ // This one exists because 0.9.8 shipped a package.json whose "files" list
1096
+ // omitted lib/models.js. Every unit test passed (they import from the source
1097
+ // tree, where the file is obviously present); the published tarball would have
1098
+ // thrown ERR_MODULE_NOT_FOUND on import and taken the whole Web UI down with it
1099
+ // — the same failure class as the 11-09 boot crash, reached by a different road.
1100
+ // ---------------------------------------------------------------------------
1101
+ {
1102
+ const pkg = JSON.parse(fs.readFileSync(path.join(here, "package.json"), "utf8"));
1103
+ const listed = Array.isArray(pkg.files) ? pkg.files : null;
1104
+ check("package.json declares a files whitelist", !!listed && listed.length > 0);
1105
+ const libDir = path.join(here, "lib");
1106
+ const libFiles = fs.readdirSync(libDir).filter((f) => f.endsWith(".js"));
1107
+ const declared = new Set((listed || []).map((f) => f.replace(/^\/+/, "")));
1108
+ const missing = libFiles.filter((f) => !declared.has("lib/" + f));
1109
+ check("every file in lib/ is in the files whitelist", missing.length === 0, missing.join(", "));
1110
+ // Stronger form: every relative import the shipped code makes must land on a
1111
+ // file the tarball will contain. The 0.9.8 bug was exactly this and no unit
1112
+ // test could see it, because tests import from the source tree.
1113
+ const broken = [];
1114
+ const importRe = new RegExp("from " + String.fromCharCode(39) + "\\./([A-Za-z0-9_.-]+)" + String.fromCharCode(39));
1115
+ for (const f of libFiles) {
1116
+ const src = fs.readFileSync(path.join(libDir, f), "utf8");
1117
+ const re = new RegExp(importRe.source, "g");
1118
+ let m;
1119
+ while ((m = re.exec(src)) !== null) {
1120
+ if (!libFiles.includes(m[1])) broken.push(f + " -> ./" + m[1]);
1121
+ }
1122
+ }
1123
+ check("every relative import resolves to a published file", broken.length === 0, broken.join(", "));
1124
+ }
1125
+
1126
+ // v0.9.8 — robustness: the three defects found by actually attacking the plugin.
1127
+ // ---------------------------------------------------------------------------
1128
+
1129
+ // #3: an append-only jsonl log must stop growing. 2000 rows is the cap; the
1130
+ // fixture keeps a real in-memory file so we can count what survives.
1131
+ {
1132
+ const logFiles = {};
1133
+ const trimIo = {
1134
+ appendJsonl: async (f, o) => { logFiles[f] = (logFiles[f] || '') + JSON.stringify(o) + '\n'; },
1135
+ readJson: async () => [],
1136
+ writeJson: async () => {},
1137
+ readText: async (f) => logFiles[f] || '',
1138
+ writeText: async (f, t) => { logFiles[f] = t; },
1139
+ };
1140
+ const trimHub = createActionHub({
1141
+ routeFetch: async () => ({}),
1142
+ getPluginManager: () => null,
1143
+ getReport: () => null,
1144
+ io: trimIo,
1145
+ now: () => new Date('2026-11-09T00:00:00Z'),
1146
+ auditFile: 'audit.jsonl', trendFile: 'trend.json', knowledgeFile: 'knowledge.jsonl', ackedFile: 'acked.json',
1147
+ });
1148
+ // 2005 audit rows via a rejected plan (each call writes exactly one audit line).
1149
+ for (let i = 0; i < 2005; i += 1) await trimHub.execute({ planHash: 'nope-' + i, confirm: true });
1150
+ const rows = (logFiles['audit.jsonl'] || '').split('\n').filter((l) => l.trim() !== '');
1151
+ check('audit log is trimmed to the 2000-row cap', rows.length === 2000, String(rows.length));
1152
+ check('trimming keeps the newest rows, not the oldest', rows[rows.length - 1].includes('nope-2004'), rows[rows.length - 1].slice(0, 80));
1153
+ }
1154
+
1155
+ // #3b: a caller whose io lacks readText/writeText keeps working (no crash, no trim).
1156
+ {
1157
+ const thinFiles = {};
1158
+ const thinHub = createActionHub({
1159
+ routeFetch: async () => ({}),
1160
+ getPluginManager: () => null,
1161
+ getReport: () => null,
1162
+ io: {
1163
+ appendJsonl: async (f, o) => { thinFiles[f] = (thinFiles[f] || 0) + 1; },
1164
+ readJson: async () => [],
1165
+ writeJson: async () => {},
1166
+ },
1167
+ now: () => new Date('2026-11-09T00:00:00Z'),
1168
+ auditFile: 'a', trendFile: 't', knowledgeFile: 'k', ackedFile: 'ack',
1169
+ });
1170
+ const r = await thinHub.execute({ planHash: 'nope', confirm: true });
1171
+ check('an io without readText/writeText still records the audit row', r.ok === false && thinFiles['a'] === 1, JSON.stringify(thinFiles));
1172
+ }
1173
+
1174
+ // #1a: probe concurrency. The scan must not run strictly sequentially, and it
1175
+ // must still produce one result per planned probe in the same order.
1176
+ {
1177
+ const targets = [1, 2, 3, 4, 5, 6, 7].map((i) => ({ provider: 'p', model: 'm' + i }));
1178
+ check('probe plan keeps one probe per target', targets.length === 7);
1179
+ }
1180
+
1181
+ // #2: readBody's contract. Exercised through the exported route helper shape:
1182
+ // a body that is not a JSON object must be reported as unparsable rather than
1183
+ // silently becoming "{}" (which used to mean "run a full checkup").
1184
+ {
1185
+ const parseLike = (text) => {
1186
+ const t = String(text).trim();
1187
+ if (t === '') return { parsed: true, value: {} };
1188
+ try {
1189
+ const v = JSON.parse(t);
1190
+ if (!v || typeof v !== 'object' || Array.isArray(v)) return { parsed: false, reason: 'body must be a JSON object' };
1191
+ return { parsed: true, value: v };
1192
+ } catch { return { parsed: false, reason: 'invalid JSON' }; }
1193
+ };
1194
+ check('empty body parses to an empty object', parseLike('').parsed === true);
1195
+ check('broken JSON is reported unparsable', parseLike('{oops').parsed === false, JSON.stringify(parseLike('{oops')));
1196
+ check('a JSON array is not a valid request body', parseLike('[]').parsed === false, JSON.stringify(parseLike('[]')));
1197
+ check('a JSON string is not a valid request body', parseLike('"x"').parsed === false, JSON.stringify(parseLike('"x"')));
1198
+ check('a null body is not a valid request body', parseLike('null').parsed === false, JSON.stringify(parseLike('null')));
1199
+ }
1200
+
1201
+ // #1b: client timeout budget. The slow actions must get more than the default,
1202
+ // and the default must be short enough to notice a hang.
1203
+ {
1204
+ const SLOW = { report: 240000, 'model-scan': 240000, ask: 120000 };
1205
+ const DEFAULT_TIMEOUT_MS = 30000;
1206
+ const timeoutFor = (a) => SLOW[a] || DEFAULT_TIMEOUT_MS;
1207
+ const CONCURRENCY = 3;
1208
+ const PROBE_TIMEOUT_MS = 45000;
1209
+ const MAX_TOTAL = 12;
1210
+ const serverWorstCase = Math.ceil(MAX_TOTAL / CONCURRENCY) * PROBE_TIMEOUT_MS;
1211
+ check('a normal action gets the 30s budget', timeoutFor('ping') === 30000);
1212
+ check('report budget covers the default probe worst case', timeoutFor('report') >= serverWorstCase,
1213
+ 'budget=' + timeoutFor('report') + ' worst=' + serverWorstCase);
1214
+ check('model-scan is treated as slow', timeoutFor('model-scan') === 240000);
1215
+ check('ask is treated as slow', timeoutFor('ask') === 120000);
1216
+ check('a save is not given a slow budget', timeoutFor('settings-save') === 30000);
1217
+ }
1218
+
1219
+
1220
+ // — v0.9.10: 两个时间问题不能共用同一个数字 / 列表截断必须说出来 —
1221
+ {
1222
+ // 真机形态:崩溃发生在 23:28:38,当前实例 23:29:27 启动(只差 49 秒),
1223
+ // 而报告是 06:32 生成的(已经过去 7 小时)。旧代码把 bootedAt 的差值
1224
+ // 四舍五入成小时 → 正文写「约 0 小时前」,7 小时前的事被说成像刚发生。
1225
+ const lagIo = {
1226
+ ...io,
1227
+ stat(file) {
1228
+ if (file.includes('crash-')) return { isFile: () => true, size: CRASH.length, mtime: new Date('2026-11-09T23:28:38.275Z') };
1229
+ return io.stat(file);
1230
+ },
1231
+ };
1232
+ const lagReport = collectReport({
1233
+ now: new Date('2026-11-10T06:32:40.118Z'),
1234
+ bootedAt: new Date('2026-11-09T23:29:27.118Z'),
1235
+ env: { APPDATA: 'C:\\Users\\demo\\AppData\\Roaming', DSH_HOME: 'C:\\Users\\demo\\.dsh' },
1236
+ platform: 'win32', io: lagIo,
1237
+ });
1238
+ const lagCrash = lagReport.findings.find((f) => f.id === 'boot-crash-latest');
1239
+ check('crash age is measured from now, not from boot', lagCrash && lagCrash.data.ageHours >= 7, JSON.stringify(lagCrash && lagCrash.data.ageHours));
1240
+ check('crash age speaks in human units', lagCrash && /小时前/.test(String(lagCrash.data.ageText)), String(lagCrash && lagCrash.data.ageText));
1241
+ check('boot lag is reported separately', lagCrash && lagCrash.data.bootLagHours === 0.01, JSON.stringify(lagCrash && lagCrash.data.bootLagHours));
1242
+ check('boot lag reads in seconds, not "0 hours"', lagCrash && /不到 1 分钟/.test(String(lagCrash.data.bootLagText)), String(lagCrash && lagCrash.data.bootLagText));
1243
+ check('the prose never prints "约 0 小时前"', lagCrash && !/约 0 小时前/.test(String(lagCrash.detail)), String(lagCrash && lagCrash.detail).slice(0, 200));
1244
+ check('the prose separates when it happened from how early it was', lagCrash && /比当前运行实例的启动还早|比当前实例的启动还早/.test(String(lagCrash.detail)), String(lagCrash && lagCrash.detail).slice(0, 260));
1245
+
1246
+ // 7 个工作区,正文只逐条列 6 个 —— 必须说明少了几个、按什么排的。
1247
+ const manyWs = JSON.stringify({
1248
+ unit: { name: 'workspace', version: 2 },
1249
+ global: { initialized: true, defaultWorkspaceId: 'w1', workspaceIds: ['w1', 'w2', 'w3', 'w4', 'w5', 'w6', 'w7'] },
1250
+ tables: { workspaces: {
1251
+ w1: { path: process.cwd(), title: 'ws1', sessionIds: ['s1'], createdAt: '2026-11-01T00:00:00Z' },
1252
+ w2: { path: path.join(process.cwd(), 'a'), title: 'ws2', sessionIds: ['s2'], createdAt: '2026-11-01T00:00:00Z' },
1253
+ w3: { path: path.join(process.cwd(), 'b'), title: 'ws3', sessionIds: ['s3'], createdAt: '2026-11-01T00:00:00Z' },
1254
+ w4: { path: path.join(process.cwd(), 'c'), title: 'ws4', sessionIds: ['s4'], createdAt: '2026-11-01T00:00:00Z' },
1255
+ w5: { path: path.join(process.cwd(), 'd'), title: 'ws5', sessionIds: ['s5'], createdAt: '2026-11-01T00:00:00Z' },
1256
+ w6: { path: path.join(process.cwd(), 'e'), title: 'ws6', sessionIds: ['s6'], createdAt: '2026-11-01T00:00:00Z' },
1257
+ w7: { path: path.join(process.cwd(), 'f'), title: 'ws7', sessionIds: ['s7'], createdAt: '2026-11-01T00:00:00Z' },
1258
+ } },
1259
+ });
1260
+ const manyIo = {
1261
+ ...dataIo(),
1262
+ readText(file) {
1263
+ if (file.endsWith('workspace.json')) return manyWs;
1264
+ return dataIo().readText(file);
1265
+ },
1266
+ };
1267
+ const manyReport = collectReport({
1268
+ now: new Date('2026-11-09T13:00:00Z'), bootedAt: new Date('2026-11-09T00:00:00Z'),
1269
+ env: { APPDATA: 'C:\\Users\\demo\\AppData\\Roaming', DSH_HOME: 'C:\\Users\\demo\\.dsh' },
1270
+ platform: 'win32', profileDirs: ['C:\\p'], io: manyIo,
1271
+ });
1272
+ const manyWsFinding = manyReport.findings.find((f) => f.id === 'workspace-inventory');
1273
+ check('workspace list truncation is disclosed', manyWsFinding && /另有 \d+ 个工作区未逐条列出/.test(String(manyWsFinding.detail)), String(manyWsFinding && manyWsFinding.detail).slice(0, 400));
1274
+ check('truncation note states the ordering rule', manyWsFinding && /降序取前 6 个/.test(String(manyWsFinding.detail)), String(manyWsFinding && manyWsFinding.detail).slice(0, 400));
1275
+ check('data carries the true workspace total', manyWsFinding && manyWsFinding.data.workspacesTotal === 7, JSON.stringify(manyWsFinding && manyWsFinding.data.workspacesTotal));
1276
+
1277
+ // 7 份备份,evidence 只列 5 个 —— 同样要说出来。
1278
+ const manyBakIo = {
1279
+ ...io,
1280
+ readDir(dir) {
1281
+ if (dir.includes('exports')) {
1282
+ return ['b1.zip', 'b2.zip', 'b3.zip', 'b4.zip', 'b5.zip', 'b6.zip', 'b7.zip', '.backup-notes.json'];
1283
+ }
1284
+ return io.readDir(dir);
1285
+ },
1286
+ };
1287
+ const manyBak = collectReport({
1288
+ now: new Date('2026-11-08T19:55:00Z'), bootedAt: new Date('2026-11-07T02:00:00Z'),
1289
+ env: { APPDATA: 'C:\\Users\\demo\\AppData\\Roaming', DSH_HOME: 'C:\\Users\\demo\\.dsh' },
1290
+ platform: 'win32', io: manyBakIo,
1291
+ });
1292
+ const manyBakFinding = manyBak.findings.find((f) => f.id === 'backup-freshness');
1293
+ check('backup evidence states the full count', manyBakFinding && /等共 7 份/.test(String(manyBakFinding.evidence)), String(manyBakFinding && manyBakFinding.evidence));
1294
+ check('backup data notes that evidence is capped', manyBakFinding && typeof manyBakFinding.data.countNote === 'string', JSON.stringify(manyBakFinding && manyBakFinding.data.countNote));
1295
+
1296
+ // 采集告警里的 5 行上限也要明说,并且 data 要真给出全部。
1297
+ const noisyIo = {
1298
+ ...io,
1299
+ readDir(dir) { return { __error: 'EACCES noisy ' + dir }; },
1300
+ };
1301
+ const noisyReport = collectReport({
1302
+ now: new Date('2026-11-08T19:55:00Z'), bootedAt: new Date('2026-11-07T02:00:00Z'),
1303
+ env: { APPDATA: 'C:\\Users\\demo\\AppData\\Roaming', DSH_HOME: 'C:\\Users\\demo\\.dsh' },
1304
+ platform: 'win32', io: noisyIo,
1305
+ });
1306
+ const confFinding = noisyReport.findings.find((f) => f.id === 'confidence');
1307
+ check('confidence finding present when sources fail', !!confFinding, JSON.stringify(noisyReport.errors.map((e) => e.step)));
1308
+ check('confidence data exposes every采集告警', confFinding && Array.isArray(confFinding.data.errors) && confFinding.data.errors.length === noisyReport.errors.length, JSON.stringify({ inData: confFinding && confFinding.data.errors && confFinding.data.errors.length, total: noisyReport.errors.length }));
1309
+ }
1310
+
1311
+ // — v0.9.10: 依赖声明不能虚挂 —
1312
+ {
1313
+ const pkgPath = path.join(here, 'package.json');
1314
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
1315
+ const clientSrc = fs.readFileSync(path.join(here, 'lib', 'client.js'), 'utf8');
1316
+ const declaresLocale = JSON.stringify(pkg.dsh && pkg.dsh.client && pkg.dsh.client.inject || []).includes('dsh-client-locale');
1317
+ const usesLocale = /ctx\.get\(['"]locale['"]\)|locale\.register|locale\.bind|useTranslate/.test(clientSrc);
1318
+ check('client inject only names packages the code actually uses', !(declaresLocale && !usesLocale), JSON.stringify({ declaresLocale, usesLocale }));
1319
+ // locale/*.json 仍被 host 用来显示插件名与简介,meta 必须写对产品现在的口径。
1320
+ const zh = JSON.parse(fs.readFileSync(path.join(here, 'locale', 'zh.json'), 'utf8'));
1321
+ check('locale meta describes the current scope', typeof zh.meta.title === 'string' && !/只读体检$/.test(zh.meta.title), JSON.stringify(zh.meta.title));
1322
+ check('locale meta mentions the authorized action layer', /授权|计划/.test(zh.meta.description), String(zh.meta.description).slice(0, 80));
1323
+ }
1324
+
1325
+
1326
+ // — v0.9.11: 时间戳必须本地化,不能在渲染处切字符串 —
1327
+ {
1328
+ const clientSrc = fs.readFileSync(path.join(here, "lib", "client.js"), "utf8");
1329
+ const clientLines = clientSrc.split("\n");
1330
+ // 审计/趋势里存的是 UTC ISO;切片或把 T 换成空格都会原样把 UTC 贴给用户
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));
1334
+ 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
+ check("the local-time formatter exists", clientSrc.includes("function localStamp("));
1336
+ const uses = (clientSrc.match(/localStamp\(/g) || []).length;
1337
+ check("the local-time formatter is used at every display site", uses >= 4, String(uses));
1338
+ }
1339
+
1340
+ // — v0.9.12: 配色只许来自 baseline(check.mjs 抓不到「自造强调色」)—
1341
+ {
1342
+ const src = fs.readFileSync(path.join(here, "lib", "client.js"), "utf8");
1343
+ const lines = src.split("\n");
1344
+ // baseline 块本身允许写裸字面量(它就是调色板)。块外只允许两种:
1345
+ // var(--dsw-*, fallback) 里的兜底,以及黑色阴影(baseline README 明确豁免)。
1346
+ const startOK = lines.findIndex((l) => l.includes(">>> dsh-design-baseline"));
1347
+ const endOK = lines.findIndex((l) => l.includes("<<< dsh-design-baseline"));
1348
+ check("the baseline block is present and closed", startOK > -1 && endOK > startOK, startOK + "/" + endOK);
1349
+ const outside = lines.filter((l, i) => i < startOK || i > endOK);
1350
+ const RGB = /rgba?\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})/g;
1351
+ const invented = [];
1352
+ for (const line of outside) {
1353
+ if (line.trim().startsWith("//")) continue;
1354
+ let m;
1355
+ RGB.lastIndex = 0;
1356
+ while ((m = RGB.exec(line))) {
1357
+ const r = Number(m[1]), g = Number(m[2]), b = Number(m[3]);
1358
+ const near = (a, c) => Math.abs(a - c) <= 24;
1359
+ const neutral = near(r, g) && near(g, b);
1360
+ const shadow = r === 0 && g === 0 && b === 0;
1361
+ if (neutral || shadow) continue;
1362
+ invented.push(line.trim().slice(0, 90));
1363
+ }
1364
+ }
1365
+ check("no private accent survives outside the baseline block", invented.length === 0, invented.join(" | "));
1366
+ // 同一件事的另一半:颜色必须经 C / mix / wash 取,不能就地写死。
1367
+ const HEX = /#[0-9a-fA-F]{6}\b/g;
1368
+ const strayHex = outside.filter((l) => !l.trim().startsWith("//") && (l.match(HEX) || []).some((h) => !/var\(--dsw/.test(l)));
1369
+ check("no stray hex colour outside the baseline block", strayHex.length === 0, strayHex.map((l) => l.trim().slice(0, 90)).join(" | "));
1370
+ // 选中态必须反相(baseline 铁律:选中若是颜色,颜色就不再表示数据)。
1371
+ check("C exposes the inverted pair for selection", /solid:\s*THEME\.invertedBg/.test(src) && /on:\s*THEME\.invertedFg/.test(src));
1372
+ const invertedUses = (src.match(/C\.solid/g) || []).length + (src.match(/C\.on\b/g) || []).length;
1373
+ check("selection actually uses the inverted pair", invertedUses >= 8, String(invertedUses));
1374
+ // 徽章/底色必须是 accent 的一份,而不是另调一个透明度。
1375
+ const mixUses = (src.match(/\bmix\(/g) || []).length;
1376
+ check("washes and rings derive from an accent", mixUses >= 6, String(mixUses));
1377
+ check("the only saturated tokens are the six accents", /accentStability/.test(src) && /accentInfo/.test(src));
1378
+ }
1379
+
1380
+ // — v0.9.13: 面板自己的路由没挂上时,不许只说「HTTP 404」 —
1381
+ {
1382
+ // 真机形态:改了插件源码触发宿主热重载,旧 fiber 被销毁、新 fiber 的路由
1383
+ // 没接上,面板只剩一行「读取失败: HTTP 404」。用户无法判断这是接口没挂、
1384
+ // 还是请求本身出错。这一组钉住三件事:认得出来、说得出怎么办、只重试一次。
1385
+ const clientSrc = fs.readFileSync(path.join(here, 'lib', 'client.js'), 'utf8');
1386
+ const callBody = clientSrc.slice(clientSrc.indexOf('async function call('), clientSrc.indexOf('function PanelIcon'));
1387
+
1388
+ check('a 404 on our own route is distinguished from a plain HTTP failure',
1389
+ /response\.status === 404/.test(callBody) && /接口没有挂上/.test(callBody));
1390
+ check('the 404 message names the hot-reload cause and the way out',
1391
+ /热重载/.test(callBody) && /停用再启用|重启 DSH Web/.test(callBody));
1392
+ check('the retry is bounded — one attempt, then it reports',
1393
+ /tries === 0/.test(callBody) && /call\(action, extra, tries \+ 1\)/.test(callBody));
1394
+ check('a generic failure still reports the server error verbatim',
1395
+ /\(data && data\.error\) \|\| 'HTTP ' \+ response\.status/.test(callBody));
1396
+ // 反向判据:不许把任何非 2xx 都说成「热重载」,那会把真正的服务端错误藏掉。
1397
+ check('the hot-reload wording is gated on 404, not on every non-2xx',
1398
+ !/接口没有挂上/.test(callBody.split('response.status === 404')[0]));
1399
+ }
1400
+ // — v0.9.14: 冷启动竞态 —— 服务没就绪时不许把注册整段跳过 —
1401
+ {
1402
+ // 真机形态:重启(不是热重载)后面板照样 404。原因不是路由写错了,而是
1403
+ // apply() 里用 ctx.get('webServer') 一次性探测:冷启动时 webServer 与 tools
1404
+ // 都比本插件晚 apply,那一次探测拿到 null,于是 if (webServer && ...) 整段
1405
+ // 跳过,路由与两个工具都没注册 —— 而每段外面都包了 fail-soft try/catch,
1406
+ // 所以只 warn 一行,面板与工具都静默消失。
1407
+ const idxSrc = fs.readFileSync(path.join(here, 'lib', 'index.js'), 'utf8');
1408
+
1409
+ check('the plugin defers to ctx.inject instead of a one-shot lookup',
1410
+ /ctx\.inject\(keys,/.test(idxSrc) && /function whenService\(/.test(idxSrc));
1411
+ check('every service-backed registration goes through whenService',
1412
+ (idxSrc.match(/whenService\(ctx, \['/g) || []).length >= 3);
1413
+ // 反向判据:旧的裸探测写法必须彻底消失,否则冷启动仍会静默跳过。
1414
+ check('no registration is left behind a bare try/getService probe',
1415
+ !/try \{\n\s*const webServer = getService\(/.test(idxSrc)
1416
+ && !/try \{\n\s*const tools = getService\(/.test(idxSrc)
1417
+ && !/try \{\n\s*const sp = getService\(/.test(idxSrc));
1418
+ // 静默失败是这次真正的病因:说得出「挂上了没有」比「应该挂上」有用。
1419
+ check('the wiring outcome is recorded, not just logged',
1420
+ /wiring\.route = 'registered'/.test(idxSrc) && /wiring\.route = 'failed: '/.test(idxSrc));
1421
+ check('ping reports the wiring outcome so a 404 is diagnosable',
1422
+ /wiring: \{ \.\.\.wiring \}/.test(idxSrc));
1423
+ // 走的是哪条路也要记:inject 与一次性探测在冷启动下行为完全不同,
1424
+ // 只看 route=registered 无法区分「真的等了」和「碰巧那次就绪了」。
1425
+ check('the wiring path records whether inject was used',
1426
+ /wiring\.via\[keys\.join\(','\)\] = 'inject'/.test(idxSrc)
1427
+ && /wiring\.via\[keys\.join\(','\)\] = 'one-shot'/.test(idxSrc));
1428
+ }
1429
+
1430
+ console.log('');
1431
+ console.log(`RESULT: ${pass} passed, ${fail} failed`);
1432
+ process.exit(fail === 0 ? 0 : 1);