@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/lib/report.js ADDED
@@ -0,0 +1,1696 @@
1
+ /**
2
+ * dsh-steward · report.js
3
+ *
4
+ * Pure, read-only health-check data collection. ZERO imports from other DSH
5
+ * packages on purpose: this bundle must not inherit version drift from
6
+ * dshmarket / dsh-config-manager internals (that drift is pain point #1).
7
+ *
8
+ * Everything here is injectable so smoke.mjs can exercise it with fixtures
9
+ * without touching the real machine. All IO is best-effort: a failure becomes
10
+ * an entry in `errors`, never an exception that reaches the host boot fiber.
11
+ */
12
+ import fs from 'node:fs';
13
+ import path from 'node:path';
14
+ import os from 'node:os';
15
+ import crypto from 'node:crypto';
16
+ import { defaults as defaultSettings, value as settingValue } from './settings.js';
17
+
18
+ /**
19
+ * 不可由用户调整的硬限流(防止一份异常数据把体检拖死)。
20
+ *
21
+ * v0.8.0:原来这里还有 maxLogs / maxBytesPerLog,现在它们由用户在「设置」页
22
+ * 调(collect.maxLogs / collect.maxBytesPerLog),因为「读多少份日志」属于使用
23
+ * 偏好而不是安全底线。这两个仍留作兜底上限——即使用户填了极端值也不会越过。
24
+ */
25
+ export const LIMITS = {
26
+ maxLogs: 200,
27
+ maxBytesPerLog: 16 * 1024 * 1024,
28
+ maxPatchBackups: 50,
29
+ maxBackups: 20,
30
+ };
31
+
32
+ /** 由用户设置算出本次采集的实际限流(夹在用户值上,再夹在硬上限下)。 */
33
+ function limitsFor(settings) {
34
+ const logs = settingValue(settings, 'collect.maxLogs');
35
+ const bytesKb = settingValue(settings, 'collect.maxBytesPerLog');
36
+ return {
37
+ maxLogs: Math.min(LIMITS.maxLogs, logs),
38
+ maxBytesPerLog: Math.min(LIMITS.maxBytesPerLog, bytesKb * 1024),
39
+ maxPatchBackups: LIMITS.maxPatchBackups,
40
+ maxBackups: LIMITS.maxBackups,
41
+ };
42
+ }
43
+
44
+
45
+
46
+ /** `%APPDATA%\@deepseek-ai\dsh-desktop\logs\crash-*.log` and profile dirs. */
47
+ export function defaultPaths(env = process.env, platform = process.platform) {
48
+ const home = os.homedir();
49
+ const appData = env.APPDATA || path.join(home, 'AppData', 'Roaming');
50
+ const dshHome = env.DSH_HOME || path.join(home, '.dsh');
51
+ const logDirs = [path.join(appData, '@deepseek-ai', 'dsh-desktop', 'logs')];
52
+ if (platform !== 'win32') {
53
+ logDirs.push(path.join(home, 'Library', 'Application Support', '@deepseek-ai', 'dsh-desktop', 'logs'));
54
+ logDirs.push(path.join(env.XDG_CONFIG_HOME || path.join(home, '.config'), '@deepseek-ai', 'dsh-desktop', 'logs'));
55
+ }
56
+ return {
57
+ logDirs,
58
+ profileDirs: [
59
+ path.join(dshHome, 'profiles', 'desktop'),
60
+ path.join(dshHome, 'profiles', 'default'),
61
+ ],
62
+ dshHome,
63
+ appData,
64
+ };
65
+ }
66
+
67
+ /** Reproduces dshmarket/lib/recovery.js:92 `parseBootFailure` without importing it. */
68
+ export function parseBootFailure(text) {
69
+ if (typeof text !== 'string' || text.length === 0) return null;
70
+ const lines = text.split(/\r?\n/);
71
+ let didNotActivate = null;
72
+ let bootLine = null;
73
+ const suspects = [];
74
+ for (const line of lines) {
75
+ const m = /(\d+)\s+entr(?:y|ies) did not activate/i.exec(line);
76
+ if (m && didNotActivate === null) {
77
+ didNotActivate = Number(m[1]);
78
+ bootLine = line.trim();
79
+ }
80
+ const im = /([A-Za-z0-9@._-]+(?:\/[A-Za-z0-9@._-]+)?)\s*:\s*import failed/i.exec(line);
81
+ if (im && !suspects.includes(im[1])) suspects.push(im[1]);
82
+ }
83
+ // v0.9.3: parse the CAUSE, not just the victim. The renderer console section
84
+ // of a real crash log says it outright —
85
+ // client-modules: duplicate factory registration for "dsh-steward"
86
+ // (bundle executed twice without invalidate?)
87
+ // — while the `X: import failed` line only names the plugin that happened to
88
+ // lose the race. Reporting the second as the suspect is how a broken
89
+ // directory layout gets misdiagnosed as a broken plugin: the victim's own
90
+ // module imports fine.
91
+ const clientDuplicates = [];
92
+ for (const line of lines) {
93
+ const dm = /duplicate factory registration for ["']([^"']+)["']/i.exec(line);
94
+ if (dm && !clientDuplicates.includes(dm[1])) clientDuplicates.push(dm[1]);
95
+ }
96
+ const isBoot = didNotActivate !== null || suspects.length > 0 || clientDuplicates.length > 0 || /web boot/i.test(text);
97
+ if (!isBoot) return null;
98
+ return { didNotActivate, suspects, clientDuplicates, bootLine, isBoot };
99
+ }
100
+
101
+ function extractMeta(text) {
102
+ const meta = {};
103
+ // Real crash logs put a plain `key: value` header BEFORE `--- error ---`.
104
+ // Read that block first. JSON-style "key":"value" is only a fallback for
105
+ // synthetic fixtures; otherwise a stray "phase":"started" in the
106
+ // operation-history JSON further down would be misread as the boot phase.
107
+ const header = String(text).split(/---\s*error\s*---/i)[0];
108
+ const grab = (out, lineKey, jsonRe) => {
109
+ const lineRe = new RegExp('^\\s*' + lineKey + '\\s*:\\s*(.+?)\\s*$', 'im');
110
+ const lm = lineRe.exec(header);
111
+ if (lm && lm[1] && lm[1].trim()) { meta[out] = lm[1].trim(); return; }
112
+ const jm = jsonRe.exec(text);
113
+ if (jm) meta[out] = jm[1];
114
+ };
115
+ grab('app', 'app', /"app"\s*:\s*"([^"]+)"/);
116
+ grab('electron', 'electron', /"electron"\s*:\s*"([^"]+)"/);
117
+ grab('node', 'node', /"node"\s*:\s*"([^"]+)"/);
118
+ grab('source', 'source', /"source"\s*:\s*"([^"]+)"/);
119
+ grab('phase', 'phase', /"phase"\s*:\s*"([^"]+)"/);
120
+ grab('shellPid', 'shell pid', /"pid"\s*:\s*(\d+)/);
121
+ return meta;
122
+ }
123
+
124
+ export function parseCrashLog(text, file) {
125
+ const failure = parseBootFailure(text);
126
+ if (!failure) return null;
127
+ return {
128
+ file: file.name,
129
+ size: file.size,
130
+ mtime: file.mtime,
131
+ didNotActivate: failure.didNotActivate,
132
+ bootLine: failure.bootLine,
133
+ suspects: failure.suspects,
134
+ clientDuplicates: failure.clientDuplicates || [],
135
+ meta: extractMeta(text),
136
+ };
137
+ }
138
+
139
+ function safeIo(io) {
140
+ return {
141
+ readDir(dir) {
142
+ try { return (io?.readDir || fs.readdirSync)(dir, { withFileTypes: false }); }
143
+ catch (e) { return { __error: String(e && e.message || e) }; }
144
+ },
145
+ stat(file) {
146
+ try { return (io?.stat || fs.statSync)(file); }
147
+ catch (e) { return { __error: String(e && e.message || e) }; }
148
+ },
149
+ readText(file) {
150
+ try { return (io?.readText || ((p) => fs.readFileSync(p, 'utf8')))(file); }
151
+ catch (e) { return { __error: String(e && e.message || e) }; }
152
+ },
153
+ realpath(target) {
154
+ // Resolve a symlinked package dir to its true location. Two different
155
+ // names pointing at one realpath is exactly the state that broke boot on
156
+ // 2026-11-09 — and it is invisible to every name-based check.
157
+ try {
158
+ const fn = io?.realpath || (fs.realpathSync.native || fs.realpathSync);
159
+ return fn(target);
160
+ } catch { return null; }
161
+ },
162
+ };
163
+ }
164
+
165
+ function isAbsentDirError(err) {
166
+ return /ENOENT|no such file or directory/i.test(String(err || ''));
167
+ }
168
+
169
+ function collectCrashLogs(paths, io, errors, limits) {
170
+ const found = [];
171
+ for (let di = 0; di < paths.logDirs.length; di += 1) {
172
+ const dir = paths.logDirs[di];
173
+ const names = io.readDir(dir);
174
+ if (names && names.__error) {
175
+ // A missing CANDIDATE dir (non-primary platform layout) is expected.
176
+ // Don't let benign absence masquerade as a degraded report.
177
+ if (!(di > 0 && isAbsentDirError(names.__error))) {
178
+ errors.push({ step: 'crash-dir', dir, error: names.__error });
179
+ }
180
+ continue;
181
+ }
182
+ if (!Array.isArray(names)) continue;
183
+ for (const name of names) {
184
+ if (!/^crash-.*\.log$/i.test(name)) continue;
185
+ const full = path.join(dir, name);
186
+ const st = io.stat(full);
187
+ if (st && st.__error) { errors.push({ step: 'crash-stat', file: full, error: st.__error }); continue; }
188
+ if (!st || !st.isFile || !st.isFile()) continue;
189
+ if (st.size > limits.maxBytesPerLog) {
190
+ errors.push({ step: 'crash-size', file: full, error: 'exceeds maxBytesPerLog=' + limits.maxBytesPerLog });
191
+ continue;
192
+ }
193
+ if (found.length >= limits.maxLogs) { errors.push({ step: 'crash-limit', error: 'maxLogs=' + limits.maxLogs + ' reached' }); break; }
194
+ const text = io.readText(full);
195
+ if (text && text.__error) { errors.push({ step: 'crash-read', file: full, error: text.__error }); continue; }
196
+ const parsed = parseCrashLog(text, { name, size: st.size, mtime: st.mtime ? st.mtime.toISOString() : null });
197
+ if (parsed) found.push(parsed);
198
+ }
199
+ }
200
+ found.sort((a, b) => String(b.mtime || '').localeCompare(String(a.mtime || '')));
201
+ return found;
202
+ }
203
+
204
+ function collectPatchBackups(paths, io, errors) {
205
+ const found = [];
206
+ for (let di = 0; di < paths.profileDirs.length; di += 1) {
207
+ const dir = paths.profileDirs[di];
208
+ const names = io.readDir(dir);
209
+ if (names && names.__error) {
210
+ if (!(di > 0 && isAbsentDirError(names.__error))) {
211
+ errors.push({ step: 'profile-dir', dir, error: names.__error });
212
+ }
213
+ continue;
214
+ }
215
+ if (!Array.isArray(names)) continue;
216
+ for (const name of names) {
217
+ const m = /^cordis\.patch\.yml\.bak-(\d{10,16})$/.exec(name);
218
+ if (!m) continue;
219
+ if (found.length >= LIMITS.maxPatchBackups) break;
220
+ const full = path.join(dir, name);
221
+ const st = io.stat(full);
222
+ const live = io.stat(path.join(dir, 'cordis.patch.yml'));
223
+ const liveText = io.readText(path.join(dir, 'cordis.patch.yml'));
224
+ const bakText = io.readText(full);
225
+ found.push({
226
+ file: name,
227
+ dir,
228
+ epochMs: Number(m[1]),
229
+ size: st && st.size != null ? st.size : null,
230
+ liveBytes: live && live.size != null ? live.size : null,
231
+ liveText: typeof liveText === 'string' ? liveText : '',
232
+ text: typeof bakText === 'string' ? bakText : '',
233
+ });
234
+ }
235
+ }
236
+ return found;
237
+ }
238
+
239
+ /**
240
+ * ① Backup freshness (v0.7.6). Reads ONLY the export directory that
241
+ * dsh-config-manager already writes to — it does not produce backups itself and
242
+ * does not import the backup engine. The point is narrow and load-bearing: the
243
+ * report already says "volatile data is not in the default backup set", but it
244
+ * never knew whether the user had actually backed up recently. A caretaker that
245
+ * cannot answer "how many days since the last backup" is just a linter.
246
+ */
247
+ export function collectBackupExports(paths, io, errors) {
248
+ const dir = path.join(paths.dshHome, 'dsh-config-manager', 'exports');
249
+ const names = io.readDir(dir);
250
+ if (names && names.__error) {
251
+ if (!isAbsentDirError(names.__error)) {
252
+ errors.push({ step: 'backup-dir', dir, error: names.__error });
253
+ }
254
+ return { dir, found: false, backups: [] };
255
+ }
256
+ if (!Array.isArray(names)) return { dir, found: false, backups: [] };
257
+ const backups = [];
258
+ for (const name of names) {
259
+ if (!/\.zip$/i.test(name)) continue;
260
+ if (backups.length >= LIMITS.maxBackups) break;
261
+ const full = path.join(dir, name);
262
+ const st = io.stat(full);
263
+ if (!st || st.__error || (st.isFile && !st.isFile())) continue;
264
+ backups.push({
265
+ file: name,
266
+ size: st.size != null ? st.size : null,
267
+ mtime: st.mtime ? st.mtime.toISOString() : null,
268
+ });
269
+ }
270
+ backups.sort((a, b) => String(b.mtime || '').localeCompare(String(a.mtime || '')));
271
+ return { dir, found: true, backups };
272
+ }
273
+
274
+ /**
275
+ * ④ Config drift (v0.7.6). Hashes cordis.patch.yml and package.json.
276
+ *
277
+ * Why this is load-bearing: host-recovery's sanitizeProfile renames
278
+ * cordis.patch.yml to <patch>.bak-<epoch>, writes a default stub, and one
279
+ * reported incident silently dropped 22 KB of providers/models/permission
280
+ * presets. It is far cheaper to notice "my 2692-line patch became 40 lines" than
281
+ * to reconstruct it. We never edit the file — we only record its digest.
282
+ */
283
+ export function collectConfigFingerprint(paths, io, errors) {
284
+ const watched = ['cordis.patch.yml', 'package.json'];
285
+ const out = [];
286
+ for (const dir of paths.profileDirs) {
287
+ for (const name of watched) {
288
+ const full = path.join(dir, name);
289
+ const st = io.stat(full);
290
+ if (!st || st.__error) continue; // absent profile layout is normal
291
+ const text = io.readText(full);
292
+ if (text && text.__error) {
293
+ errors.push({ step: 'config-read', file: full, error: text.__error });
294
+ continue;
295
+ }
296
+ const buf = Buffer.from(String(text), 'utf8');
297
+ out.push({
298
+ file: name,
299
+ dir,
300
+ bytes: buf.length,
301
+ lines: String(text).split('\n').length,
302
+ digest: crypto.createHash('sha256').update(buf).digest('hex').slice(0, 16),
303
+ });
304
+ }
305
+ }
306
+ return out;
307
+ }
308
+
309
+ /**
310
+ * ② Workspace / session inventory (v0.7.7).
311
+ *
312
+ * Reads the workspace index the host itself maintains
313
+ * ($DSH_HOME/storages/workspace.json) and measures each workspace's session
314
+ * directory. Strictly read-only: it never archives or deletes anything — it can
315
+ * only tell you which workspace is eating the disk and how long since it moved.
316
+ *
317
+ * Session directories are named after the workspace path by replacing path
318
+ * separators with '-', so "C:\\work" becomes "--C-work--". We encode
319
+ * the same way rather than guessing from the directory listing, so a workspace
320
+ * whose sessions were all deleted shows up as 0 B instead of being invisible.
321
+ */
322
+ export function encodeWorkspaceDir(wsPath) {
323
+ if (typeof wsPath !== 'string' || !wsPath.trim()) return null;
324
+ const slug = wsPath.replace(/[:\\/]+/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '');
325
+ if (!slug) return null;
326
+ return '--' + slug + '--';
327
+ }
328
+
329
+ function dirSize(io, dir, depth = 0) {
330
+ if (depth > 3) return 0;
331
+ const names = io.readDir(dir);
332
+ if (!Array.isArray(names)) return 0;
333
+ let total = 0;
334
+ for (const name of names) {
335
+ const full = path.join(dir, name);
336
+ const st = io.stat(full);
337
+ if (!st || st.__error) continue;
338
+ if (st.isFile && st.isFile()) { total += st.size || 0; continue; }
339
+ if (st.isDirectory && st.isDirectory()) total += dirSize(io, full, depth + 1);
340
+ }
341
+ return total;
342
+ }
343
+
344
+ export function collectWorkspaces(paths, io, errors) {
345
+ const file = path.join(paths.dshHome, 'storages', 'workspace.json');
346
+ const raw = io.readText(file);
347
+ const sessionsDir0 = path.join(paths.dshHome, 'sessions');
348
+ // Optional source: absence or an unreadable index means "not available", which
349
+ // the confidence layer reports as degraded evidence. It must NOT push an error
350
+ // entry — that would flip the whole report to 'degraded' over an optional file.
351
+ if (!raw || raw.__error || !String(raw).trim()) {
352
+ return { available: false, workspaces: [], totalBytes: 0, sessionsDir: sessionsDir0 };
353
+ }
354
+ let parsed;
355
+ try { parsed = JSON.parse(raw); } catch (e) {
356
+ return { available: false, workspaces: [], totalBytes: 0, sessionsDir: sessionsDir0 };
357
+ }
358
+ const table = (parsed && parsed.tables && parsed.tables.workspaces) || {};
359
+ const ids = (parsed && parsed.global && Array.isArray(parsed.global.workspaceIds))
360
+ ? parsed.global.workspaceIds
361
+ : Object.keys(table);
362
+ const sessionsDir = path.join(paths.dshHome, 'sessions');
363
+ const out = [];
364
+ for (const id of ids) {
365
+ const w = table[id];
366
+ if (!w) continue;
367
+ const enc = encodeWorkspaceDir(w.path);
368
+ const dir = enc ? path.join(sessionsDir, enc) : null;
369
+ let bytes = 0;
370
+ let ageDays = null;
371
+ if (dir) {
372
+ const st = io.stat(dir);
373
+ if (st && !st.__error) {
374
+ bytes = dirSize(io, dir);
375
+ if (st.mtime) ageDays = Math.max(0, Math.floor((Date.now() - st.mtime.getTime()) / 864e5));
376
+ }
377
+ }
378
+ let exists = false;
379
+ try { exists = !!(w.path && fs.existsSync(w.path)); } catch { exists = false; }
380
+ out.push({
381
+ id,
382
+ title: w.title || path.basename(w.path || '') || id,
383
+ path: w.path || null,
384
+ dirName: enc,
385
+ exists,
386
+ sessionCount: Array.isArray(w.sessionIds) ? w.sessionIds.length : 0,
387
+ bytes,
388
+ ageDays,
389
+ });
390
+ }
391
+ out.sort((a, b) => b.bytes - a.bytes);
392
+ const totalBytes = out.reduce((s, w) => s + w.bytes, 0);
393
+ return { available: true, workspaces: out, totalBytes, sessionsDir };
394
+ }
395
+
396
+ /**
397
+ * ③ Plugin update inventory (v0.7.7).
398
+ *
399
+ * The comparison itself is trivial; the hard part is where to read it from. The
400
+ * market keeps a single cache file at $DSH_HOME/plugin-update-cache.json
401
+ * (311 KB on this machine, refreshed whenever the market checks for updates).
402
+ * We read THAT instead of rebuilding a registry client: no network, no auth, no
403
+ * inferred format. If the file is missing or stale we say so rather than
404
+ * pretending everything is current.
405
+ */
406
+ /**
407
+ * Direction between two published versions: 1 = a is newer, -1 = older,
408
+ * 0 = same, null = can't tell. `verCmp` is fed by the semver-range matcher and
409
+ * treats junk as 0, which is fine there but dangerous here — reading a tag like
410
+ * "beta" as "same version" would silently drop a real update. So gate on shape
411
+ * first and refuse to guess.
412
+ */
413
+ function versionDirection(a, b) {
414
+ const shaped = (v) => typeof v === 'string' && /^\d+(\.\d+){0,3}([-+][0-9A-Za-z.-]+)?$/.test(v.trim());
415
+ if (!shaped(a) || !shaped(b)) return null;
416
+ return verCmp(a, b);
417
+ }
418
+
419
+ export function collectUpdates(paths, io, errors) {
420
+ const file = path.join(paths.dshHome, 'plugin-update-cache.json');
421
+ const raw = io.readText(file);
422
+ const empty = { available: false, reason: '没有找到市场更新缓存', outdated: [], checked: 0, cacheAgeHours: null };
423
+ // Optional source, same rule as the workspace index: absence is information
424
+ // (and gets surfaced by the confidence layer), not a degraded report.
425
+ if (!raw || raw.__error || !String(raw).trim()) return empty;
426
+ let parsed;
427
+ try { parsed = JSON.parse(raw); } catch (e) {
428
+ return { ...empty, reason: '更新缓存无法解析' };
429
+ }
430
+ const packages = (parsed && parsed.packages) || {};
431
+ const fetchedAt = typeof parsed.fetchedAt === 'number' ? parsed.fetchedAt : null;
432
+ const cacheAgeHours = fetchedAt ? Math.max(0, Math.round((Date.now() - fetchedAt) / 36e5)) : null;
433
+
434
+ // Installed versions come from each profile's node_modules — the same place
435
+ // the compat check already reads, so no new contract is introduced.
436
+ const installed = new Map();
437
+ for (const dir of paths.profileDirs) {
438
+ const nm = path.join(dir, 'node_modules');
439
+ const entries = io.readDir(nm);
440
+ if (!Array.isArray(entries)) continue;
441
+ for (const name of entries) {
442
+ const readOne = (pkgName, base) => {
443
+ const pj = io.readText(path.join(base, 'package.json'));
444
+ if (!pj || pj.__error) return;
445
+ try {
446
+ const v = JSON.parse(pj);
447
+ if (v && v.name && v.version) installed.set(v.name, v.version);
448
+ } catch { /* unreadable package.json is not our problem */ }
449
+ };
450
+ if (name.startsWith('@')) {
451
+ const scoped = io.readDir(path.join(nm, name));
452
+ if (!Array.isArray(scoped)) continue;
453
+ for (const sub of scoped) readOne(name + '/' + sub, path.join(nm, name, sub));
454
+ } else {
455
+ readOne(name, path.join(nm, name));
456
+ }
457
+ }
458
+ }
459
+
460
+ const outdated = [];
461
+ const aheadOfCache = [];
462
+ let checked = 0;
463
+ let undetermined = 0;
464
+ for (const [pkgName, info] of Object.entries(packages)) {
465
+ const cur = installed.get(pkgName);
466
+ if (!cur) continue; // not installed here — the market cache lists everything
467
+ checked += 1;
468
+ const latest = info && typeof info.latest === 'string' ? info.latest : null;
469
+ if (!latest || latest === cur) continue;
470
+ // v0.9.4: "different" is not "newer". This machine's cache listed
471
+ // dsh-disk-cleaner 1.6.3 → 1.6.2 — a plain inequality test turned a stale
472
+ // cache into an "upgrade available" that would have been a DOWNGRADE.
473
+ // Compare direction, and say "undetermined" when a version is not parseable
474
+ // rather than guessing which way it points.
475
+ const dir = versionDirection(latest, cur);
476
+ if (dir === null) { undetermined += 1; continue; }
477
+ if (dir > 0) outdated.push({ name: pkgName, installed: cur, latest });
478
+ else if (dir < 0) aheadOfCache.push({ name: pkgName, installed: cur, latest });
479
+ }
480
+ outdated.sort((a, b) => a.name.localeCompare(b.name));
481
+ aheadOfCache.sort((a, b) => a.name.localeCompare(b.name));
482
+ return { available: true, reason: null, outdated, aheadOfCache, checked, undetermined, cacheAgeHours };
483
+ }
484
+
485
+ /**
486
+ * Advisory-only coverage notes. The steward does NOT re-implement disk scanning
487
+ * (dsh-disk-cleaner owns it), backup (dsh-config-manager), archiving or memory
488
+ * (Mnemon). It only points at the right existing capability and states the
489
+ * boundaries it will not cross. This is the "thin orchestration" layer.
490
+ */
491
+ function advisoryFindings() {
492
+ return [
493
+ {
494
+ id: 'data-at-risk',
495
+ severity: 'warning',
496
+ category: '备份覆盖',
497
+ title: '易失数据不在默认备份分区内',
498
+ detail: '定时/默认备份只含 defaultIncluded=true 分区;sessions、pluginData、pluginAssets、pluginFiles、secrets 不在其中,Mnemon 知识落在 sessions/*/mnemon-run-* 同样不进任何默认分区。会话历史与知识沉淀是最怕丢的部分。',
499
+ evidence: 'dsh-config-manager/lib/schema/section-registry.js:193-247、:307;lib/sync/backup-scheduler.js:163-200(导出不传 only);lib/core/exporter.js:268',
500
+ recommendation: '手动做一次带口令的加密导出(GUI 的加密导出,或 config_backup 后整体封成 DCA1),覆盖 sessions/pluginData/pluginAssets;恢复走合并式定向恢复,旧配置禁止整体回灌。凭据永不进备份。',
501
+ data: { sections: ['sessions', 'pluginData', 'pluginAssets', 'Mnemon'] },
502
+ },
503
+ {
504
+ id: 'disk-advice',
505
+ severity: 'info',
506
+ category: 'C 盘治理',
507
+ title: '可自动清 / 不可自动清必须分开',
508
+ detail: 'C 盘占用通常分成两类:一类是纯缓存(包管理器 store、浏览器缓存、日志、回收站),删掉可重建;另一类是 WSL ext4.vhdx、已安装程序、WinSxS 组件库、下载/文档等,属于「不可自动清理」。后者往往占大头,且清理需要提权、卸载或压缩虚拟盘,必须由用户自己决定。',
509
+ evidence: '判据:C 盘占用按「可自动清 / 不可自动清」分类,具体体积以 disk_cleaner action=audit 的实时结果为准(本条不写死数字,以免把某次快照当成现状)',
510
+ recommendation: '用现成的 disk_cleaner 看分级建议;管家只呈现「可清 vs 不可清」,不代跑 DISM / diskpart / 卸载命令,不自动删。',
511
+ data: { boundary: 'read-only' },
512
+ },
513
+ {
514
+ id: 'knowledge',
515
+ severity: 'info',
516
+ category: '知识积累',
517
+ title: '有效模式靠 Mnemon 沉淀,不靠重复造轮子',
518
+ detail: '核查结论、契约坑、设计定稿应写入项目知识库,供后续同风格工作复用;管家本轮只读不改任何知识。',
519
+ evidence: '定稿 mnemon 文档 f7e5a482(rev6);逐轮文档 7f939c34 / 19a0a790 / dcb90e6e',
520
+ recommendation: '有价值的结论经用户确认后写入 Mnemon;管家不自动写记忆、不自动改 profile。',
521
+ data: { boundary: 'read-only' },
522
+ },
523
+ ];
524
+ }
525
+
526
+ /**
527
+ * Build the full report. Pure and side-effect-free apart from injected IO.
528
+ * options: { now, env, platform, logDirs, profileDirs, pluginChanges, io }
529
+ */
530
+ /**
531
+ * Importance scoring — one auditable formula, four factors, each explained.
532
+ * Stays pure: reads only finding.data that already exists, mutates nothing
533
+ * outside the finding. Score is 0–100; `priority` is a coarse band so users
534
+ * can talk about it ("top-priority") without reading the number.
535
+ */
536
+ const SEV_BASE = { critical: 60, warning: 40, info: 20 };
537
+ const ACTIONABLE_IDS = new Set(['boot-crash-latest', 'data-at-risk', 'disk-advice', 'knowledge', 'backup-freshness', 'model-health']);
538
+ /** Human-readable byte size, used by the backup/config findings. */
539
+ function mbLabel(bytes) {
540
+ if (typeof bytes !== 'number' || !isFinite(bytes)) return '未知大小';
541
+ if (bytes >= 1024 ** 3) return (bytes / 1024 ** 3).toFixed(1) + ' GB';
542
+ if (bytes >= 1024 ** 2) return (bytes / 1024 ** 2).toFixed(1) + ' MB';
543
+ if (bytes >= 1024) return (bytes / 1024).toFixed(1) + ' KB';
544
+ return bytes + ' B';
545
+ }
546
+ function scoreFindings(findings, ctx) {
547
+ const bootedAt = ctx && ctx.bootedAt ? ctx.bootedAt : null;
548
+ for (const f of findings) {
549
+ const reasons = [];
550
+ const base = SEV_BASE[f.severity] != null ? SEV_BASE[f.severity] : 20;
551
+ reasons.push('级别 ' + f.severity + ' +' + base);
552
+ let score = base;
553
+
554
+ // freshness: evidence produced in this process lifetime counts more.
555
+ let fresh = 0;
556
+ if (f.id === 'boot-crash-latest') {
557
+ if (f.data && f.data.stale === false) { fresh = 15; reasons.push('崩溃是本次运行期内 +' + fresh); }
558
+ else if (f.data && f.data.stale === true) { fresh = -15; reasons.push('崩溃是历史记录 −' + Math.abs(fresh)); }
559
+ } else if (f.id === 'plugin-change') {
560
+ fresh = 10; reasons.push('变更是本会话内存计数 +' + fresh);
561
+ } else if (f.id === 'patch-backup') {
562
+ fresh = 10; reasons.push('备份文件此刻就在磁盘上 +' + fresh);
563
+ } else if (f.id === 'compat-check') {
564
+ fresh = 10; reasons.push('按当前宿主实测 +' + fresh);
565
+ } else if (f.id === 'client-identity') {
566
+ // Read straight off the install tree in this run — a live conflict is a
567
+ // live boot hazard, not a historical note.
568
+ fresh = 10; reasons.push('按当前安装目录实测 +' + fresh);
569
+ } else {
570
+ reasons.push('常备建议,不加分');
571
+ }
572
+ score += fresh;
573
+
574
+ // actionable: a one-click authorized path exists (or the user can act now).
575
+ // A stale crash does NOT count: its disable action is blocked by the
576
+ // attribution gate, so promising a shortcut there would be dishonest.
577
+ const gateBlocked = f.id === 'boot-crash-latest' && f.data && f.data.stale === true;
578
+ if (ACTIONABLE_IDS.has(f.id) && !f.muted && !gateBlocked) {
579
+ score += 15; reasons.push('有直达处置动作 +15');
580
+ } else if (gateBlocked) {
581
+ reasons.push('处置被归因门封锁,不计可直达');
582
+ }
583
+
584
+ // magnitude: how large the underlying evidence is.
585
+ let mag = 0;
586
+ const d = f.data || {};
587
+ if (f.id === 'boot-crash-latest') {
588
+ if (d.crashCount > 1) mag += Math.min(10, d.crashCount * 3);
589
+ if (Array.isArray(d.suspects) && !d.stale && d.suspects.length > 0) mag += 5;
590
+ } else if (f.id === 'patch-backup') {
591
+ if (Array.isArray(d.backups)) mag += Math.min(10, d.backups.length * 5);
592
+ } else if (f.id === 'plugin-change') {
593
+ if (Array.isArray(d.changes)) mag += Math.min(10, d.changes.length * 3);
594
+ } else if (f.id === 'compat-check') {
595
+ if (Array.isArray(d.mismatched) && d.mismatched.length > 0) mag += Math.min(10, d.mismatched.length * 3);
596
+ else if (d.declared) mag += Math.min(5, Math.floor(d.declared / 10));
597
+ } else if (f.id === 'model-health') {
598
+ if (f.severity === 'warning') mag += 10; // 默认模型挂了是当下真的用不了
599
+ if (Array.isArray(d.deadModels) && d.deadModels.length > 0) mag += Math.min(6, d.deadModels.length * 2);
600
+ } else if (f.id === 'data-at-risk') {
601
+ mag += 8; // the gap is constant but its blast radius is the whole volatile set
602
+ } else if (f.id === 'client-identity') {
603
+ // Every distinct kind of conflict is another way boot can die; a live one
604
+ // is by definition worth more than a declaration-mismatch tally.
605
+ const kinds = [d.sameReal, d.drifted, d.collisions].filter((x) => Array.isArray(x) && x.length > 0).length;
606
+ if (kinds > 0) mag += Math.min(10, kinds * 4);
607
+ }
608
+ if (mag) { score += mag; reasons.push('体量/条数 +' + mag); }
609
+
610
+ score = Math.max(0, Math.min(100, Math.round(score)));
611
+ f.score = score;
612
+ f.priority = score >= 70 ? 'high' : score >= 45 ? 'medium' : 'low';
613
+ f.scoreReasons = reasons;
614
+ // muted items never lead the queue regardless of raw score; the UI puts
615
+ // them in the last tier, so we record the intent without faking the number.
616
+ }
617
+ }
618
+
619
+ // ---- minimal semver-range helpers (v0.3.0 ③ compat check) ----
620
+ // Honest scope: handles the comparator forms actually observed in installed
621
+ // packages (">=x", "<x", "^x", plain "=" and "||" unions). Anything fancier
622
+ // degrades to "undetermined" — never a false all-clear.
623
+ function verCmp(a, b) {
624
+ const pa = String(a).replace(/^v/, '').split('-');
625
+ const pb = String(b).replace(/^v/, '').split('-');
626
+ const na = pa[0].split('.').map((x) => parseInt(x, 10) || 0);
627
+ const nb = pb[0].split('.').map((x) => parseInt(x, 10) || 0);
628
+ for (let i = 0; i < 3; i++) { const d = (na[i] || 0) - (nb[i] || 0); if (d) return d; }
629
+ if (pa[1] && !pb[1]) return -1; if (!pa[1] && pb[1]) return 1;
630
+ if (pa[1] && pb[1]) return pa[1] < pb[1] ? -1 : pa[1] > pb[1] ? 1 : 0;
631
+ return 0;
632
+ }
633
+ // caret 上界:^1.2.3 → <2.0.0;^0.2.3 → <0.3.0;^0.0.3 → <0.0.4;^0 → <1.0.0。
634
+ // 只取 '-' 前的数字段(prerelease 不参与上界计算),按 semver caret 语义递增
635
+ // 第一个非零段;全零(如 '0' / '0.0')退回 <1.0.0。
636
+ function caretUpperBound(val) {
637
+ const parts = val.replace(/^v/, '').split('-')[0].split('.').map((x) => parseInt(x, 10) || 0);
638
+ const major = parts[0] || 0, minor = parts[1] || 0, patch = parts[2] || 0;
639
+ if (major > 0) return (major + 1) + '.0.0';
640
+ if (minor > 0) return '0.' + (minor + 1) + '.0';
641
+ if (patch > 0) return '0.0.' + (patch + 1);
642
+ return '1.0.0';
643
+ }
644
+ function satisfiesToken(token, version) {
645
+ const m = /^(>=|<=|\^|>|<|=)?\s*(.+)$/.exec(String(token).trim());
646
+ if (!m) return null;
647
+ const [, op, valRaw] = m;
648
+ const val = valRaw.trim();
649
+ if (/[a-zA-Z*]/.test(val) && !/^\d/.test(val)) return null; // ranges like "latest", "*" → undetermined
650
+ switch (op) {
651
+ case '>=': return verCmp(version, val) >= 0;
652
+ case '<=': return verCmp(version, val) <= 0;
653
+ case '>': return verCmp(version, val) > 0;
654
+ case '<': return verCmp(version, val) < 0;
655
+ case '^': return verCmp(version, val) >= 0 && verCmp(version, caretUpperBound(val)) < 0;
656
+ default: return verCmp(version, val) === 0;
657
+ }
658
+ }
659
+ function satisfiesRange(range, version) {
660
+ const parts = String(range).split('||');
661
+ let sawUndetermined = false;
662
+ for (const part of parts) {
663
+ const tokens = part.trim().split(/\s+/).filter(Boolean);
664
+ let all = true; let anyUnknown = false;
665
+ for (const t of tokens) {
666
+ const r = satisfiesToken(t, version);
667
+ if (r === null) anyUnknown = true; else if (!r) all = false;
668
+ }
669
+ if (!anyUnknown && all) return true;
670
+ if (anyUnknown) sawUndetermined = true;
671
+ }
672
+ return sawUndetermined ? null : false;
673
+ }
674
+
675
+ /**
676
+ * Read installed bundles' engines.dsh declarations and the host dsh-base
677
+ * version straight off the profile's node_modules (read-only file IO).
678
+ * Returns { hostVersion: string|null, entries: [{name,version,range}], checked }.
679
+ */
680
+ function collectCompat(paths, io, hostVersionOverride) {
681
+ const entries = [];
682
+ let hostVersion = hostVersionOverride || null;
683
+ const seen = new Set();
684
+ for (const dir of paths.profileDirs) {
685
+ const nm = dir.replace(/[\\/]+$/, '') + '/node_modules';
686
+ if (!hostVersion) {
687
+ try {
688
+ const pkg = JSON.parse(io.readText(nm + '/@deepseek-ai/dsh-base/package.json'));
689
+ if (pkg && typeof pkg.version === 'string') hostVersion = pkg.version;
690
+ } catch { /* not readable — try next dir / stay null */ }
691
+ }
692
+ let tops = [];
693
+ try { tops = io.readDir(nm); if (tops && tops.__error) tops = []; } catch { tops = []; }
694
+ if (!Array.isArray(tops)) continue;
695
+ for (const t of tops.slice(0, 300)) {
696
+ const candidates = [];
697
+ if (String(t).startsWith('@')) {
698
+ let scoped = [];
699
+ try { scoped = io.readDir(nm + '/' + t); if (scoped && scoped.__error) scoped = []; } catch { scoped = []; }
700
+ if (Array.isArray(scoped)) for (const s of scoped.slice(0, 100)) candidates.push(nm + '/' + t + '/' + s + '/package.json');
701
+ } else {
702
+ candidates.push(nm + '/' + t + '/package.json');
703
+ }
704
+ for (const file of candidates) {
705
+ try {
706
+ const pkg = JSON.parse(io.readText(file));
707
+ if (!pkg || typeof pkg.name !== 'string' || seen.has(pkg.name)) continue;
708
+ const range = pkg.engines && (pkg.engines.dsh || pkg.engines['@deepseek-ai/dsh-base']);
709
+ if (typeof range !== 'string' || !range.trim()) continue;
710
+ seen.add(pkg.name);
711
+ entries.push({ name: pkg.name, version: String(pkg.version || ''), range: range.trim() });
712
+ } catch { /* package unreadable or not JSON — skip silently */ }
713
+ }
714
+ }
715
+ }
716
+ const checked = [];
717
+ for (const e of entries) {
718
+ if (!hostVersion) { checked.push({ ...e, state: 'unknown' }); continue; }
719
+ const r = satisfiesRange(e.range, hostVersion);
720
+ checked.push({ ...e, state: r === true ? 'ok' : r === false ? 'mismatch' : 'unknown' });
721
+ }
722
+ return { hostVersion, entries: checked };
723
+ }
724
+
725
+ /**
726
+ * v0.9.3 client identity check.
727
+ *
728
+ * DSH client bundles self-register by id inside `window.__ModuleLoader__.load(
729
+ * { id: 'X', ... })`. The renderer keeps a Map of factories keyed by that id,
730
+ * so a SECOND registration of the same id throws
731
+ * client-modules: duplicate factory registration for "X"
732
+ * and takes the whole web boot down with it — one duplicate id, no UI at all.
733
+ *
734
+ * Three ways to get there, none of which any existing check looks at:
735
+ * 1. one package installed under two node_modules names (this is what
736
+ * actually happened on 2026-11-09: bare `dsh-steward/` plus
737
+ * `@sidady/dsh-steward/` both linking to the same source dir);
738
+ * 2. a client.js whose declared id no longer matches its package name
739
+ * (renaming a package without touching the client bundle);
740
+ * 3. two unrelated packages picking the same id.
741
+ *
742
+ * dshmarket's static analysis and trial boot both stay green on all three:
743
+ * trialValidate replays the LOADER tree, and the loader is perfectly happy with
744
+ * duplicate client ids because it never sees them. Verified on synthetic
745
+ * fixtures 2026-11-10 — see the report section in the design doc.
746
+ *
747
+ * Read-only, and deliberately NOT actionable: the fix is deleting a symlink or
748
+ * renaming an id, i.e. edits to other packages' install state. We name the
749
+ * exact directories and stop there.
750
+ */
751
+ function collectClientIdentity(paths, io, errors) {
752
+ const entries = [];
753
+ // v0.9.5: where the host actually looks. package.json exports["./client"]
754
+ // first (that is the documented hook and what most packages use), then an
755
+ // explicit dsh.client.entry, then the two conventional paths.
756
+ const entryCandidates = (pkg) => {
757
+ const out = [];
758
+ const push = (v) => {
759
+ if (typeof v === 'string' && v) out.push(v.replace(/^\.\//, ''));
760
+ else if (v && typeof v === 'object') {
761
+ if (typeof v.default === 'string') out.push(v.default.replace(/^\.\//, ''));
762
+ }
763
+ };
764
+ const ex = pkg.exports;
765
+ if (ex && typeof ex === 'object') push(ex['./client']);
766
+ const dc = pkg.dsh && pkg.dsh.client;
767
+ if (typeof dc === 'string') push(dc);
768
+ else if (dc && typeof dc === 'object') { push(dc.entry); push(dc.path); push(dc.file); }
769
+ out.push('lib/client.js', 'client.js', 'dist/client.js');
770
+ return [...new Set(out)];
771
+ };
772
+ const seenDirs = new Set();
773
+ let scanned = 0;
774
+ const readId = (text) => {
775
+ // Quotes may be ', " or ` — minified bundles switch between them freely.
776
+ const m = /__ModuleLoader__\.load\(\s*\{[^}]*?\bid:\s*['"`]([^'"`]+)['"`]/.exec(text);
777
+ if (m) return m[1];
778
+ // Some bundles (dsh-cost-meter) route the call through a load-wrap proxy, so the
779
+ // literal __ModuleLoader__ token never appears in the module body. Fall back to
780
+ // the first object literal that carries an id, which is that same call argument.
781
+ const loose = /\{\s*id\s*:\s*['"`]([^'"`]+)['"`]/.exec(text);
782
+ return loose ? loose[1] : null;
783
+ };
784
+ for (const dir of paths.profileDirs) {
785
+ const nm = dir.replace(/[\\/]+$/, '') + '/node_modules';
786
+ let tops = [];
787
+ try { tops = io.readDir(nm); if (tops && tops.__error) tops = []; } catch { tops = []; }
788
+ if (!Array.isArray(tops)) continue;
789
+ scanned += 1;
790
+ const pkgDirs = [];
791
+ for (const t of tops.slice(0, 400)) {
792
+ const name = String(t);
793
+ if (name.startsWith('@')) {
794
+ let scoped = [];
795
+ try { scoped = io.readDir(nm + '/' + name); if (scoped && scoped.__error) scoped = []; } catch { scoped = []; }
796
+ if (Array.isArray(scoped)) for (const s of scoped.slice(0, 120)) pkgDirs.push(nm + '/' + name + '/' + s);
797
+ continue;
798
+ }
799
+ // Skip node_modules/.bin and friends — they never carry a package.json.
800
+ if (name.startsWith('.')) continue;
801
+ pkgDirs.push(nm + '/' + name);
802
+ }
803
+ for (const pkgDir of pkgDirs) {
804
+ if (seenDirs.has(pkgDir)) continue;
805
+ seenDirs.add(pkgDir);
806
+ let pkg = null;
807
+ try { pkg = JSON.parse(io.readText(pkgDir + '/package.json')); } catch { continue; }
808
+ if (!pkg || typeof pkg.name !== 'string') continue;
809
+ if (!(pkg.dsh && pkg.dsh.client)) continue;
810
+ let text = null;
811
+ let usedPath = null;
812
+ for (const rel of entryCandidates(pkg)) {
813
+ try {
814
+ const raw = io.readText(pkgDir + '/' + rel);
815
+ if (typeof raw === 'string' && raw.trim()) { text = raw; usedPath = rel; break; }
816
+ } catch { /* try the next candidate */ }
817
+ }
818
+ const id = text ? readId(text) : null;
819
+ entries.push({
820
+ dir: pkgDir.slice(nm.length + 1),
821
+ name: pkg.name,
822
+ id,
823
+ entry: usedPath,
824
+ readable: text !== null,
825
+ realpath: io.realpath(pkgDir),
826
+ });
827
+ }
828
+ }
829
+
830
+ // C1: same real directory answering to more than one node_modules name.
831
+ const byReal = new Map();
832
+ for (const e of entries) {
833
+ if (!e.realpath) continue;
834
+ if (!byReal.has(e.realpath)) byReal.set(e.realpath, []);
835
+ byReal.get(e.realpath).push(e.dir);
836
+ }
837
+ const sameReal = [...byReal.entries()]
838
+ .filter(([, dirs]) => dirs.length > 1)
839
+ .map(([realpath, dirs]) => ({ realpath, dirs }));
840
+
841
+ // C2: declared client id != package name.
842
+ const drifted = entries.filter((e) => e.id && e.id !== e.name);
843
+
844
+ // C3: two packages claiming one id.
845
+ const byId = new Map();
846
+ for (const e of entries) {
847
+ if (!e.id) continue;
848
+ if (!byId.has(e.id)) byId.set(e.id, []);
849
+ byId.get(e.id).push(e.name);
850
+ }
851
+ const collisions = [...byId.entries()]
852
+ .filter(([, names]) => new Set(names).size > 1)
853
+ .map(([id, names]) => ({ id, names: [...new Set(names)] }));
854
+
855
+ const unreadable = entries.filter((e) => !e.readable).length;
856
+ const withoutId = entries.filter((e) => e.readable && !e.id).length;
857
+ return { entries, sameReal, drifted, collisions, unreadable, withoutId, scanned };
858
+ }
859
+
860
+ export function collectReport(options = {}) {
861
+ const now = options.now || new Date();
862
+ const env = options.env || process.env;
863
+ const platform = options.platform || process.platform;
864
+ const paths = {
865
+ ...defaultPaths(env, platform),
866
+ ...(options.logDirs ? { logDirs: options.logDirs } : {}),
867
+ ...(options.profileDirs ? { profileDirs: options.profileDirs } : {}),
868
+ };
869
+ const io = safeIo(options.io);
870
+ const errors = [];
871
+ // v0.8.0:判据阈值与采集限流全部来自用户设置(默认值 = v0.7.8 行为)。
872
+ const settings = options.settings && typeof options.settings === 'object' ? options.settings : defaultSettings();
873
+ const limits = limitsFor(settings);
874
+
875
+ const bootedAt = options.bootedAt || new Date(Date.now() - Math.floor(process.uptime()) * 1000);
876
+ const crashes = collectCrashLogs(paths, io, errors, limits);
877
+ const patchBackups = collectPatchBackups(paths, io, errors);
878
+ const pluginChanges = Array.isArray(options.pluginChanges) ? options.pluginChanges.slice(0, 50) : [];
879
+
880
+ // v0.4 disk trend: system-drive free bytes via fs.statfsSync (native, zero
881
+ // deps, sync-safe at report time). Best-effort — failure yields null.
882
+ let cFreeBytes = null;
883
+ try {
884
+ const diskPath = platform === 'win32' ? 'C:\\' : '/';
885
+ const st = typeof fs.statfsSync === 'function' ? fs.statfsSync(diskPath) : null;
886
+ if (st && typeof st.bavail === 'number' && typeof st.bsize === 'number') cFreeBytes = st.bavail * st.bsize;
887
+ } catch { /* unavailable — trend row just omits it */ }
888
+
889
+ const findings = [];
890
+
891
+ if (crashes.length > 0) {
892
+ const latest = crashes[0];
893
+ const allSuspects = [...new Set(crashes.flatMap((c) => c.suspects))];
894
+ const latestSuspects = latest.suspects || [];
895
+ // Staleness gate: this report is produced INSIDE a running host, so the
896
+ // current instance booted successfully. DSH boot is all-or-nothing — if a
897
+ // crash log predates the current boot, every plugin it named has by
898
+ // construction loaded fine since. Reporting that as "critical" is noise.
899
+ const latestTime = latest.mtime ? new Date(latest.mtime) : null;
900
+ const stale = !!(latestTime && latestTime.getTime() <= bootedAt.getTime());
901
+ // v0.9.10: two different questions were sharing one number.
902
+ // "how long ago did this happen" -> now - latestTime (what the prose says)
903
+ // "how much earlier than this boot" -> bootedAt - latestTime (what the gate uses)
904
+ // Rounding the second one to whole hours printed "约 0 小时前" for a crash
905
+ // 49 seconds before boot, seven hours after it actually happened.
906
+ const ageMs = latestTime ? Math.max(0, now.getTime() - latestTime.getTime()) : null;
907
+ const bootLagMs = latestTime ? Math.max(0, bootedAt.getTime() - latestTime.getTime()) : null;
908
+ const ageHours = ageMs == null ? null : Math.round((ageMs / 36e5) * 10) / 10;
909
+ const ageText = ageMs == null ? null
910
+ : ageMs < 6e4 ? "不到 1 分钟前"
911
+ : ageMs < 36e5 ? Math.round(ageMs / 6e4) + " 分钟前"
912
+ : ageMs < 864e5 ? Math.round(ageMs / 36e5) + " 小时前"
913
+ : Math.round(ageMs / 864e5) + " 天前";
914
+ const bootLagText = bootLagMs == null ? null
915
+ : bootLagMs < 6e4 ? "不到 1 分钟"
916
+ : bootLagMs < 36e5 ? Math.round(bootLagMs / 6e4) + " 分钟"
917
+ : bootLagMs < 864e5 ? Math.round(bootLagMs / 36e5) + " 小时"
918
+ : Math.round(bootLagMs / 864e5) + " 天";
919
+ // v0.9.3: when the log states the cause, report the CAUSE. `X: import failed`
920
+ // names whoever lost the race; the renderer console line names the duplicate
921
+ // id. Leading with the victim is how a directory problem gets misread as a
922
+ // broken plugin.
923
+ const causes = latest.clientDuplicates || [];
924
+ const causeText = causes.length > 0
925
+ ? 'client id "' + causes.join('"、"') + '" 被注册了两次'
926
+ : null;
927
+ findings.push({
928
+ id: 'boot-crash-latest',
929
+ severity: stale ? 'info' : 'critical',
930
+ category: '启动诊断',
931
+ title: (stale ? '历史 web-boot 崩溃(当前实例启动正常):' : '最近一次 web-boot 崩溃:')
932
+ + (causeText || (latest.didNotActivate != null ? latest.didNotActivate + ' 个条目未激活' : '插件导入失败')),
933
+ detail: (causeText
934
+ ? '病因(日志原文):`client-modules: duplicate factory registration for "' + causes[0] + '"` —— '
935
+ + '同一个 client id 被注册了两次,渲染端直接抛错。'
936
+ + (latestSuspects.length > 0
937
+ ? '\n被点名「import failed」的 ' + latestSuspects.join('、') + ' 是这一局的受害者而不是病因:它的包本身单独 import 是成功的,'
938
+ + '输在 id 撞车。真正的修法是清理重复的安装目录或统一 id,不是去修那个插件。'
939
+ : '')
940
+ : (latestSuspects.length > 0
941
+ ? '崩溃日志点名疑似插件:' + latestSuspects.join('、') + '。'
942
+ + (stale
943
+ ? '这份日志比当前运行实例的启动还早 ' + bootLagText + ',也就是 ' + ageText + '发生的事'
944
+ + ';DSH 的 boot 是 all-or-nothing,当前界面能打开就证明这些插件后来全部加载成功了——属历史记录,不是现行风险。'
945
+ : 'DSH 的 boot 是 all-or-nothing:一个插件没激活,整个 Web UI 起不来;这与「插件变更 → boot 风险」是同一条链。')
946
+ + (crashes.length > 1 ? '另有 ' + (crashes.length - 1) + ' 份更早的崩溃日志,明细见 data.all。' : '')
947
+ : '日志显示 boot 期导入失败但未点名具体插件,请结合 crash 日志全文排查。'))
948
+ + (stale && causeText ? '\n这份日志比当前实例的启动还早 ' + bootLagText + ',是 ' + ageText + '发生的事,当前能打开就说明已经恢复。' : ''),
949
+ evidence: latest.file + (latest.bootLine ? ' — ' + latest.bootLine : '') + (latest.meta.app ? ' — app ' + latest.meta.app : ''),
950
+ recommendation: causeText
951
+ ? '看下面「client 标识体检」列出的具体路径:同一份代码只留一个 node_modules 名字、id 与包名对齐。管家只读不代改。'
952
+ : (stale
953
+ ? '已恢复,无需处置。再次发生启动失败时优先看这里点名的第一个插件;恢复动作前先备份 cordis.patch.yml。管家不自动拉起、不碰启动流程。'
954
+ : '先确认该插件的 node_modules 是否补齐、版本是否匹配宿主;恢复前备份 cordis.patch.yml,禁用坏插件而非反复重启。管家不自动拉起、不碰启动流程。'),
955
+ data: {
956
+ crashCount: crashes.length,
957
+ stale,
958
+ bootedAt: bootedAt.toISOString ? bootedAt.toISOString() : String(bootedAt),
959
+ ageHours,
960
+ bootLagHours: bootLagMs == null ? null : Math.round((bootLagMs / 36e5) * 100) / 100,
961
+ ageText,
962
+ bootLagText,
963
+ suspects: latestSuspects,
964
+ suspectsAllLogs: allSuspects,
965
+ clientDuplicates: causes,
966
+ meta: latest.meta,
967
+ all: crashes.map((c) => ({ file: c.file, mtime: c.mtime, didNotActivate: c.didNotActivate, suspects: c.suspects, clientDuplicates: c.clientDuplicates || [] })),
968
+ },
969
+ });
970
+ } else {
971
+ findings.push({
972
+ id: 'boot-crash-none',
973
+ severity: 'info',
974
+ category: '启动诊断',
975
+ title: '未发现 web-boot 崩溃日志',
976
+ detail: '在已知日志目录中没有解析到 crash-*.log 的「did not activate / import failed」记录。这不等于「从未崩过」——只代表当前可见日志里没有。',
977
+ evidence: '扫描目录:' + paths.logDirs.join('、'),
978
+ recommendation: '保持现状即可;崩溃日志由桌面端在下次启动时落盘,管家会在下次体检时读到。',
979
+ data: { crashCount: 0 },
980
+ });
981
+ }
982
+
983
+ if (patchBackups.length > 0) {
984
+ // v0.9.7:字节数不是判据。本机实测 patch 比备份小 42%,但 14 个 - id: 块一个都没少——
985
+ // 少的是块内字段(插件重写默认值,或用户改了选择)。拿总字节报「配置被换掉了」
986
+ // 是把「小」当成「丢」,正是这份报告一直在批评的毛病。
987
+ // 现在按结构判:块数少了 → 真丢了整块(warning);块都在只是缩水 → info,
988
+ // 把缩水的 id 与字节差列出来交给人判断,并明说「这不等于丢」。
989
+ const livePatch = patchBackups[0].liveText || '';
990
+ const liveIds = (livePatch.match(/^- id:\s*(.+)$/gm) || []).map((l) => l.replace(/^- id:\s*/, '').trim());
991
+ const bakIds = [];
992
+ for (const b of patchBackups) {
993
+ if (!b.text) continue;
994
+ for (const l of b.text.match(/^- id:\s*(.+)$/gm) || []) bakIds.push(l.replace(/^- id:\s*/, '').trim());
995
+ }
996
+ const missing = bakIds.filter((id) => liveIds.length > 0 && !liveIds.includes(id));
997
+ const kb = (n) => (n == null ? '?' : (n / 1024).toFixed(1) + ' KB');
998
+ const shrunk = patchBackups.filter((b) => b.size != null && b.liveBytes != null && b.liveBytes < b.size * 0.8);
999
+ if (missing.length > 0) {
1000
+ findings.push({
1001
+ id: 'patch-backup',
1002
+ severity: 'warning',
1003
+ category: '配置备份',
1004
+ title: '启动期备份里有 ' + missing.length + ' 个配置块在当前 patch 里不存在了',
1005
+ detail: '启动期 sanitizeProfile 会把 patch 改名为 bak-<epoch> 再写默认 stub,该动作曾有静默丢 providers/模型/权限预设的报告。\n'
1006
+ + '当前 patch 少了这些 id 的整块:' + missing.join('、') + '\n'
1007
+ + '这是结构性缺失,不是「块内字段变了」——值得对照 .bak 补回来。',
1008
+ evidence: patchBackups.map((b) => b.file + '(' + kb(b.size) + ')').join('、'),
1009
+ recommendation: '对照 .bak 逐块补回缺的 id,补之前先把现有 patch 另存一份。旧配置禁止整体回灌——只拣缺的块。管家只读不改 patch。',
1010
+ data: { backups: patchBackups.map((b) => ({ file: b.file, size: b.size, liveBytes: b.liveBytes })), missing },
1011
+ });
1012
+ } else if (shrunk.length > 0) {
1013
+ findings.push({
1014
+ id: 'patch-backup',
1015
+ severity: 'info',
1016
+ category: '配置备份',
1017
+ title: '当前 patch 比启动期备份小,但配置块一个没少',
1018
+ detail: '对比结果:备份里的 ' + bakIds.length + ' 个 - id: 块在当前 patch 里全都还在,缩水发生在块内字段。\n'
1019
+ + shrunk.map((b) => '· ' + b.file + ' 是 ' + kb(b.size) + ',当前 ' + kb(b.liveBytes)
1020
+ + '(小 ' + (100 - Math.round((b.liveBytes / b.size) * 100)) + '%)').join('\n')
1021
+ + '\n块内字段变少有两种可能:插件把默认值重写成了空(如 enabledModelKeys: []),或者你的选择被清掉了。'
1022
+ + '这**不等于配置丢了**,但值得你自己对着 .bak 看一眼具体是哪几个字段。',
1023
+ evidence: patchBackups.map((b) => b.file + '(' + kb(b.size) + ')').join('、')
1024
+ + ';结构对比:- id: 块数 live ' + liveIds.length + ' / bak ' + bakIds.length,
1025
+ recommendation: '如果发现某个插件的设置被重置了,从 .bak 里把那一个块挑回来;不要整体回灌。管家只读不改 patch。',
1026
+ data: { backups: patchBackups.map((b) => ({ file: b.file, size: b.size, liveBytes: b.liveBytes })), liveIds, bakIds, shrunk: shrunk.map((b) => b.file) },
1027
+ });
1028
+ } else {
1029
+ findings.push({
1030
+ id: 'patch-backup',
1031
+ severity: 'info',
1032
+ category: '配置备份',
1033
+ title: '留有 ' + patchBackups.length + ' 个启动期 patch 备份(当前配置未被换掉)',
1034
+ detail: '这些 cordis.patch.yml.bak-<epoch> 是启动期 sanitizeProfile 改名留下的历史残留。'
1035
+ + '对比过结构:当前 patch 的配置块与体积都没有明显缺失。',
1036
+ evidence: patchBackups.map((b) => b.file + '(' + kb(b.size) + ')').join('、'),
1037
+ recommendation: '可以留着做参照,也可以自己删;管家不代删。要还原请用市场自带的 .bak 检测还原入口。',
1038
+ data: { backups: patchBackups.map((b) => ({ file: b.file, size: b.size, liveBytes: b.liveBytes })) },
1039
+ });
1040
+ }
1041
+ }
1042
+
1043
+ if (pluginChanges.length > 0) {
1044
+ findings.push({
1045
+ id: 'plugin-change',
1046
+ severity: 'warning',
1047
+ category: '版本适配',
1048
+ title: '本会话记录到 ' + pluginChanges.length + ' 次插件变更',
1049
+ detail: '插件装/更/启停是 boot 风险的上游。DSH 市场在 enable 期已有 hostPeerGate 版本门槛与「#575 失败不落 enabled」守卫;管家不重复造拦截,只把变更标进报告供你对照。',
1050
+ evidence: '事件 plugin-manager/changed(本进程内存计数,重启清零);dshmarket/lib/routes.js:912-930、:1088-1100',
1051
+ recommendation: '变更后若出现启动失败,回到上面「启动诊断」点名的插件核对版本;pre-flight 预警以市场已有守卫为准,管家不介入安装流程。',
1052
+ data: { changes: pluginChanges },
1053
+ });
1054
+ }
1055
+
1056
+ findings.push(...advisoryFindings());
1057
+
1058
+ // ③ v0.7.7 plugin updates. Only reports "installed vs available" — it never
1059
+ // upgrades. Upgrades are exactly the change that produces boot failures, so
1060
+ // this stays an information item with a pointer, not a button.
1061
+ let updateInfo = { available: false, outdated: [], checked: 0, cacheAgeHours: null };
1062
+ try {
1063
+ updateInfo = collectUpdates(paths, io, errors);
1064
+ if (!updateInfo.available) {
1065
+ findings.push({
1066
+ id: 'plugin-updates', severity: 'info', category: '版本适配',
1067
+ title: '插件更新体检未执行:' + (updateInfo.reason || '数据源不可用'),
1068
+ detail: '市场把可安装版本缓存在 $DSH_HOME/plugin-update-cache.json;没读到它就不猜有没有新版。这不影响其他体检项。',
1069
+ evidence: path.join(paths.dshHome, 'plugin-update-cache.json'),
1070
+ recommendation: '在插件市场里打开一次列表页(会刷新该缓存),然后重新体检。',
1071
+ data: { available: false },
1072
+ });
1073
+ } else if (updateInfo.outdated.length > 0) {
1074
+ // v0.9.4: also name the packages whose installed copy is AHEAD of the cache.
1075
+ // Those are not updates — they mean the cache is older than the disk, which is
1076
+ // worth saying out loud exactly once instead of silently hiding them.
1077
+ const ahead = updateInfo.aheadOfCache || [];
1078
+ const aheadNote = ahead.length > 0
1079
+ ? '\n另有 ' + ahead.length + ' 个包的已装版本比缓存里的 latest 还新('
1080
+ + ahead.slice(0, 3).map((u) => u.name + ' ' + u.installed + ' > ' + u.latest).join('、')
1081
+ + (ahead.length > 3 ? ' 等' : '')
1082
+ + ')——那是缓存比磁盘旧,不是该升级。'
1083
+ : '';
1084
+ const unknownNote = updateInfo.undetermined > 0
1085
+ ? '\n还有 ' + updateInfo.undetermined + ' 个包的版本号写法无法比较,未计入。'
1086
+ : '';
1087
+ findings.push({
1088
+ id: 'plugin-updates',
1089
+ severity: 'info',
1090
+ category: '版本适配',
1091
+ title: updateInfo.outdated.length + ' 个已装插件有新版本可用',
1092
+ detail: updateInfo.outdated.slice(0, 10).map((u) => '· ' + u.name + ':' + u.installed + ' → ' + u.latest).join('\n')
1093
+ + (updateInfo.outdated.length > 10 ? '\n(共 ' + updateInfo.outdated.length + ' 个)' : '')
1094
+ + aheadNote + unknownNote
1095
+ + '\n\n升级是「插件变更 → boot 风险」的上游;升级前先确认真能备份到当前可用状态。',
1096
+ evidence: '来源:$DSH_HOME/plugin-update-cache.json(' + (updateInfo.cacheAgeHours != null ? updateInfo.cacheAgeHours + ' 小时前刷新' : '刷新时间未知') + ');对比 ' + updateInfo.checked + ' 个已装包;版本方向按 semver 比,只把「更新」算作可用',
1097
+ recommendation: '逐个升而不是批量升,升完看「启动诊断」;不要在要赶活的时候动它们。管家不代升级。',
1098
+ data: { outdated: updateInfo.outdated.slice(0, 20), aheadOfCache: ahead.slice(0, 20), undetermined: updateInfo.undetermined || 0, checked: updateInfo.checked, cacheAgeHours: updateInfo.cacheAgeHours },
1099
+ });
1100
+ } else if (updateInfo.checked > 0) {
1101
+ findings.push({
1102
+ id: 'plugin-updates', severity: 'info', category: '版本适配',
1103
+ title: '插件更新体检:' + updateInfo.checked + ' 个已装包都是最新',
1104
+ detail: '与市场缓存里的 latest 按 semver 比方向,没有落后项。'
1105
+ + ((updateInfo.aheadOfCache || []).length > 0
1106
+ ? '\n其中 ' + updateInfo.aheadOfCache.length + ' 个已装版本比缓存更新(缓存偏旧,不是该升级)。'
1107
+ : '')
1108
+ + (updateInfo.undetermined > 0 ? '\n另有 ' + updateInfo.undetermined + ' 个包的版本号写法无法比较,未计入。' : ''),
1109
+ evidence: '来源:$DSH_HOME/plugin-update-cache.json(' + (updateInfo.cacheAgeHours != null ? updateInfo.cacheAgeHours + ' 小时前刷新' : '刷新时间未知') + ')',
1110
+ recommendation: '无需处理。',
1111
+ data: { outdated: [], aheadOfCache: (updateInfo.aheadOfCache || []).slice(0, 20), undetermined: updateInfo.undetermined || 0, checked: updateInfo.checked, cacheAgeHours: updateInfo.cacheAgeHours },
1112
+ });
1113
+ }
1114
+ } catch (e) {
1115
+ errors.push({ step: 'plugin-updates', error: String((e && e.message) || e) });
1116
+ }
1117
+
1118
+ // ② v0.7.7 workspace / session inventory. The report previously mentioned
1119
+ // sessions only inside a prose string; this measures them.
1120
+ //
1121
+ // v0.7.8 — 「50 MB 超线」改判为增长而不是绝对体积。一个固定阀值在你没长胖时
1122
+ // 会天天亮,那不是发现问题,是把阀值当成了问题(ACCEPTANCE A5)。现在需要
1123
+ // 上一个快照才能判:体积没变化就降到 info,只有真涨了才 warning。没有上一个
1124
+ // 快照时(首跑、或日志被清)不给「超标」结论,只说当前体积。
1125
+ let workspaceInfo = { available: false, workspaces: [], totalBytes: 0 };
1126
+ try {
1127
+ workspaceInfo = collectWorkspaces(paths, io, errors);
1128
+ if (workspaceInfo.available && workspaceInfo.workspaces.length > 0) {
1129
+ const prev = options.previous && options.previous.workspace;
1130
+ const prevList = prev && Array.isArray(prev.workspaces) ? prev.workspaces : [];
1131
+ const prevAt = prev && prev.at ? Date.parse(prev.at) : NaN;
1132
+ const prevById = new Map(prevList.map((w) => [w.id || w.title, w]));
1133
+ const GROWTH_WARN_BYTES = settingValue(settings, 'threshold.growthWarnMb') * 1024 * 1024; // 由设置页控制,默认 20 MB
1134
+ const grown = [];
1135
+ for (const w of workspaceInfo.workspaces) {
1136
+ const before = prevById.get(w.id || w.title);
1137
+ if (!before || typeof before.bytes !== 'number') continue;
1138
+ const delta = w.bytes - before.bytes;
1139
+ if (delta >= GROWTH_WARN_BYTES) grown.push({ ...w, deltaBytes: delta, beforeBytes: before.bytes });
1140
+ }
1141
+ grown.sort((a, b) => b.deltaBytes - a.deltaBytes);
1142
+ const gone = workspaceInfo.workspaces.filter((w) => !w.exists);
1143
+ // v0.9.10: never let the list quietly stop earlier than the count in the title.
1144
+ const WS_SHOWN = 6;
1145
+ const wsRest = workspaceInfo.workspaces.length - WS_SHOWN;
1146
+ const lines = workspaceInfo.workspaces.slice(0, WS_SHOWN).map((w) => {
1147
+ const before = prevById.get(w.id || w.title);
1148
+ const delta = before && typeof before.bytes === 'number' ? w.bytes - before.bytes : null;
1149
+ const deltaTxt = delta == null ? ''
1150
+ : delta === 0 ? ',与上次持平'
1151
+ : ',较上次 ' + (delta > 0 ? '+' : '−') + mbLabel(Math.abs(delta));
1152
+ return '· ' + w.title + ':' + mbLabel(w.bytes) + ',' + w.sessionCount + ' 个会话'
1153
+ + deltaTxt
1154
+ + (w.ageDays != null ? ',目录 ' + w.ageDays + ' 天前有改动' : '')
1155
+ + (w.exists ? '' : '(目录已不存在)');
1156
+ }).join('\n');
1157
+ const compared = prevList.length > 0;
1158
+ const compareNote = compared
1159
+ ? '\n对照 ' + (isFinite(prevAt) ? new Date(prevAt).toISOString() : '上一次体检') + ' 的快照;净增 ≥ ' + mbLabel(GROWTH_WARN_BYTES) + ' 的工作区才升为 warning。'
1160
+ : '\n没有上一个快照可对照(首次或报告被清),本次只记体积,不判定是否「长胖」。';
1161
+ // Say how many were left out, and on what basis -- a title that says 7 while
1162
+ // the body lists 6 reads as a rendering bug, and a reader cannot tell whether
1163
+ // the missing one is the small one or the one that matters.
1164
+ const wsMoreNote = wsRest > 0
1165
+ ? '\n另有 ' + wsRest + ' 个工作区未逐条列出(上面按会话数据体积降序取前 ' + WS_SHOWN + ' 个,完整列表见 data.workspaces)。'
1166
+ : '';
1167
+ findings.push({
1168
+ id: 'workspace-inventory',
1169
+ severity: gone.length > 0 || grown.length > 0 ? 'warning' : 'info',
1170
+ category: '工作区与会话',
1171
+ title: gone.length > 0
1172
+ ? '有 ' + gone.length + ' 个工作区指向的目录已不存在'
1173
+ : grown.length > 0
1174
+ ? grown.length + ' 个工作区的会话数据在增长(合计净增 ' + mbLabel(grown.reduce((s, w) => s + w.deltaBytes, 0)) + ')'
1175
+ : workspaceInfo.workspaces.length + ' 个工作区,会话数据合计 ' + mbLabel(workspaceInfo.totalBytes) + '(与上次相比无显著增长)',
1176
+ detail: lines
1177
+ + compareNote
1178
+ + wsMoreNote
1179
+ + (gone.length > 0 ? '\n有 ' + gone.length + ' 个工作区指向的目录已不存在,其会话数据成了孤儿。' : ''),
1180
+ evidence: path.join(paths.dshHome, 'storages', 'workspace.json') + ' + ' + workspaceInfo.sessionsDir,
1181
+ recommendation: '会话数据不在默认备份分区内,建议先加密备份再清理;旧会话可用 @michengai/dsh-archive-manager 归档(它的入口在 Web UI,管家不代劳)。管家只报告,不删、不移。',
1182
+ data: {
1183
+ totalBytes: workspaceInfo.totalBytes,
1184
+ workspaces: workspaceInfo.workspaces.slice(0, 8),
1185
+ workspacesTotal: workspaceInfo.workspaces.length,
1186
+ grown: grown.map((w) => ({ title: w.title, deltaBytes: w.deltaBytes, bytes: w.bytes })),
1187
+ orphan: gone.map((w) => w.title),
1188
+ comparedTo: compared && isFinite(prevAt) ? new Date(prevAt).toISOString() : null,
1189
+ growthWarnBytes: GROWTH_WARN_BYTES,
1190
+ },
1191
+ });
1192
+ }
1193
+ } catch (e) {
1194
+ errors.push({ step: 'workspace-inventory', error: String((e && e.message) || e) });
1195
+ }
1196
+
1197
+ // ① v0.7.6 backup freshness. The advisory above says "not in the default
1198
+ // backup set"; this one answers the operational question the advisory cannot:
1199
+ // how long has it been since a real encrypted export landed on disk?
1200
+ let backupInfo = { dir: null, found: false, backups: [], latestAgeDays: null, latestHuman: null };
1201
+ try {
1202
+ const scanned = collectBackupExports(paths, io, errors);
1203
+ backupInfo = { ...scanned, latestAgeDays: null, latestHuman: null };
1204
+ if (scanned.backups.length > 0) {
1205
+ const latest = scanned.backups[0];
1206
+ const t = latest.mtime ? new Date(latest.mtime) : null;
1207
+ const ageDays = t ? Math.max(0, Math.floor((now.getTime() - t.getTime()) / 864e5)) : null;
1208
+ backupInfo.latestAgeDays = ageDays;
1209
+ backupInfo.latestHuman = t
1210
+ ? ageDays + ' 天前(' + latest.file + ',' + mbLabel(latest.size) + ')'
1211
+ : latest.file;
1212
+ }
1213
+ const staleDays = settingValue(settings, 'threshold.backupStaleDays');
1214
+ const never = !scanned.found || scanned.backups.length === 0;
1215
+ const tooOld = backupInfo.latestAgeDays != null && backupInfo.latestAgeDays > staleDays;
1216
+ findings.push({
1217
+ id: 'backup-freshness',
1218
+ severity: never ? 'warning' : tooOld ? 'warning' : 'info',
1219
+ category: '备份覆盖',
1220
+ title: never
1221
+ ? '没有找到任何加密备份文件'
1222
+ : tooOld
1223
+ ? '上次加密备份是 ' + backupInfo.latestAgeDays + ' 天前(超过 ' + staleDays + ' 天)'
1224
+ : '上次加密备份:' + backupInfo.latestHuman,
1225
+ detail: never
1226
+ ? '在 ' + (scanned.dir || '导出目录') + ' 下未找到 .zip 备份。易失数据(sessions/pluginData/pluginAssets/Mnemon)不在默认备份分区里,且本机从未启用过定时备份(backup-schedule.json 不存在)。'
1227
+ : '共 ' + scanned.backups.length + ' 份导出;最新一份 ' + backupInfo.latestHuman + '。默认备份不包含 sessions/pluginData/pluginAssets,真实保护靠这份手动导出。'
1228
+ + (tooOld ? '超过 ' + staleDays + ' 天未更新,期间新增的会话与知识不在任何备份里。' : ''),
1229
+ evidence: (scanned.dir || '')
1230
+ + (scanned.backups.length
1231
+ ? ' → ' + scanned.backups.slice(0, 5).map((b) => b.file).join('、')
1232
+ + (scanned.backups.length > 5 ? ' 等共 ' + scanned.backups.length + ' 份' : '')
1233
+ : ''),
1234
+ recommendation: never || tooOld
1235
+ ? '在插件面板的「加密备份」卡里预览后执行(口令只在本次请求内存在,不经会话记录);或在 GUI 里手动加密导出。'
1236
+ : '保持现状;下次会话量明显增长后再导一次。',
1237
+ data: {
1238
+ dir: scanned.dir,
1239
+ count: scanned.backups.length,
1240
+ countNote: scanned.backups.length > 5 ? 'evidence 只列前 5 份,data.backups 含全部' : null,
1241
+ latestAgeDays: backupInfo.latestAgeDays,
1242
+ staleDays,
1243
+ backups: scanned.backups.slice(0, 5),
1244
+ },
1245
+ });
1246
+ } catch (e) {
1247
+ errors.push({ step: 'backup-freshness', error: String((e && e.message) || e) });
1248
+ }
1249
+
1250
+ // ④ v0.7.6 config drift. Compare cordis.patch.yml / package.json digests
1251
+ // against the baseline recorded on the previous run. We never write the
1252
+ // config: the only side effect is the baseline file the host passes back in.
1253
+ let configNow = [];
1254
+ try {
1255
+ configNow = collectConfigFingerprint(paths, io, errors);
1256
+ const prev = Array.isArray(options.configBaseline) ? options.configBaseline : [];
1257
+ const prevKey = (x) => x.dir + '|' + x.file;
1258
+ const prevMap = new Map(prev.map((x) => [prevKey(x), x]));
1259
+ const changed = [];
1260
+ const added = [];
1261
+ for (const cur of configNow) {
1262
+ const old = prevMap.get(prevKey(cur));
1263
+ if (!old) { added.push(cur); continue; }
1264
+ if (old.digest !== cur.digest) {
1265
+ changed.push({
1266
+ file: cur.file,
1267
+ dir: cur.dir,
1268
+ bytes: cur.bytes,
1269
+ lines: cur.lines,
1270
+ fromBytes: old.bytes,
1271
+ fromLines: old.lines,
1272
+ deltaLines: (cur.lines || 0) - (old.lines || 0),
1273
+ });
1274
+ }
1275
+ }
1276
+ const shrunk = changed.filter((c) => c.deltaLines < -settingValue(settings, 'threshold.driftShrinkLines'));
1277
+ if (changed.length > 0) {
1278
+ const lines = changed.map((c) =>
1279
+ '· ' + c.file + ':' + (c.fromLines != null ? c.fromLines + ' → ' : '') + c.lines + ' 行'
1280
+ + (c.deltaLines ? '(' + (c.deltaLines > 0 ? '+' : '') + c.deltaLines + ')' : '')
1281
+ + ',' + mbLabel(c.bytes)).join('\n');
1282
+ findings.push({
1283
+ id: 'config-drift',
1284
+ severity: shrunk.length > 0 ? 'warning' : 'info',
1285
+ category: '配置完整性',
1286
+ title: shrunk.length > 0
1287
+ ? '配置文件明显变短了:' + shrunk.map((c) => c.file).join('、')
1288
+ : '配置自上次体检后有改动(' + changed.length + ' 个文件)',
1289
+ detail: lines
1290
+ + (shrunk.length > 0
1291
+ ? '\n行数大幅减少是恢复/重置流程(sanitizeProfile 写默认 stub)的典型痕迹,曾有过静默丢 providers / 模型 / 权限预设的报告。'
1292
+ : '\n如果是你自己改的,忽略即可;如果没改过,值得看一眼。'),
1293
+ evidence: '指纹:' + changed.map((c) => c.file + '=' + (c.lines || 0) + '行').join('、') + ';基线来自上次体检(本机记录)',
1294
+ recommendation: shrunk.length > 0
1295
+ ? '先备份当前 cordis.patch.yml(或找 .bak-<epoch> 兄弟文件),再对比差异;旧配置禁止整体回灌。管家只读不改 patch。'
1296
+ : '无需处理。若确认改动是有意的,下次体检会以新内容作为基线。',
1297
+ data: { changed, shrunk: shrunk.map((c) => c.file), added: added.map((a) => a.file) },
1298
+ });
1299
+ } else if (configNow.length > 0 && prev.length === 0) {
1300
+ findings.push({
1301
+ id: 'config-drift',
1302
+ severity: 'info', category: '配置完整性',
1303
+ title: '已记录配置指纹作为基线(首次)',
1304
+ detail: configNow.map((c) => '· ' + c.file + ':' + c.lines + ' 行,' + mbLabel(c.bytes)).join('\n')
1305
+ + '\n从下一次体检起,这两个文件一旦变化就会在这里提示;变短会升为 warning。',
1306
+ evidence: 'cordis.patch.yml / package.json 的 sha256 前 16 位',
1307
+ recommendation: '无需处理。这条用于日后发现「配置被悄悄换掉」。',
1308
+ data: { baseline: configNow, added: [], shrunk: [], changed: [] },
1309
+ });
1310
+ }
1311
+ } catch (e) {
1312
+ errors.push({ step: 'config-drift', error: String((e && e.message) || e) });
1313
+ }
1314
+
1315
+ // ③ v0.3.0 upgrade-compatibility check: engines.dsh declared by installed
1316
+ // bundles vs the actual host dsh-base version. This is the only half of pain
1317
+ // point ① nobody else covers (the host gate blocks NEW installs; it cannot
1318
+ // re-audit what is already installed after a host update).
1319
+ let compat = { hostVersion: null, entries: [] };
1320
+ try {
1321
+ // v0.4 hostVersion source chain: explicit option → newest crash log's
1322
+ // meta.app ("@deepseek-ai/dsh-desktop 0.2.2-rc.1" — the desktop app version
1323
+ // IS the host version) → null (只列声明不判等). @deepseek-ai/dsh-base is not
1324
+ // resolvable in the profile node_modules on this install (probed 2026-11-09,
1325
+ // MODULE_NOT_FOUND), so meta.app is the primary real source.
1326
+ let hostVersion = options.hostVersion || null;
1327
+ if (!hostVersion) {
1328
+ for (const c of crashes) {
1329
+ const app = c && c.meta && c.meta.app;
1330
+ if (typeof app !== 'string') continue;
1331
+ const m = /\s(\d+\.\d+\.\d+[^\s]*)/.exec(app);
1332
+ if (m) { hostVersion = m[1]; break; }
1333
+ }
1334
+ }
1335
+ compat = collectCompat(paths, io, hostVersion);
1336
+ const mismatched = compat.entries.filter((e) => e.state === 'mismatch');
1337
+ const unknown = compat.entries.filter((e) => e.state === 'unknown');
1338
+ if (!compat.hostVersion) {
1339
+ findings.push({
1340
+ id: 'compat-check', severity: 'info', category: '版本适配',
1341
+ title: '升级兼容体检:宿主版本未确定,只列声明',
1342
+ detail: `读到 ${compat.entries.length} 个包声明了 engines.dsh 门槛,但当前宿主版本无法确定(既没有显式传入,crash 日志 meta.app 也没有版本)。按红线不做猜测,只列声明不判等。`,
1343
+ evidence: '来源:profile node_modules 各包 package.json 的 engines.dsh;宿主版本源候选:options.hostVersion → crash meta.app',
1344
+ recommendation: '宿主升级后重新体检;若 crash 日志也没有版本行,可在体检时显式传 options.hostVersion。',
1345
+ data: { hostVersion: null, declared: compat.entries.length, entries: compat.entries.slice(0, 40) },
1346
+ });
1347
+ } else if (mismatched.length > 0) {
1348
+ findings.push({
1349
+ id: 'compat-check', severity: 'warning', category: '版本适配',
1350
+ title: `${mismatched.length} 个已装包的宿主版本门槛不满足当前宿主`,
1351
+ detail: '当前宿主 ' + compat.hostVersion + ',以下包声明的范围不含它——它们是「升级后最先出问题」的候选:\n'
1352
+ + mismatched.map((e) => `· ${e.name}@${e.version} 需要 ${e.range}`).join('\n')
1353
+ + (unknown.length ? `\n(另有 ${unknown.length} 个声明无法判定,未计入)` : ''),
1354
+ evidence: '判据:semver 范围本地实现(支持 >= <= > < ^ = 与 || 并集);数据源 profile node_modules/*/package.json engines.dsh',
1355
+ recommendation: '优先核对这些包的更新版本;宿主已装清单里市场不会替你重审旧包,这是 pre-flight 的存量半边。管家不改安装状态。',
1356
+ data: { hostVersion: compat.hostVersion, mismatched, unknown: unknown.length, declared: compat.entries.length },
1357
+ });
1358
+ } else {
1359
+ findings.push({
1360
+ id: 'compat-check', severity: 'info', category: '版本适配',
1361
+ title: `升级兼容体检通过:${compat.entries.length} 个声明门槛的包都适配宿主 ${compat.hostVersion}`,
1362
+ detail: unknown.length
1363
+ ? `其中 ${unknown.length} 个声明写法超出本地判据范围,标记为未判定而非通过。`
1364
+ : '所有已声明 engines.dsh 的包,其范围都包含当前宿主版本。',
1365
+ evidence: '判据:semver 范围本地实现;数据源 profile node_modules/*/package.json engines.dsh',
1366
+ recommendation: '宿主升级后重新体检即可复查存量适配。',
1367
+ data: { hostVersion: compat.hostVersion, declared: compat.entries.length, unknown: unknown.length },
1368
+ });
1369
+ }
1370
+ } catch (e) {
1371
+ errors.push({ step: 'compat-check', error: String(e && e.message || e) });
1372
+ }
1373
+
1374
+ // ⑨ v0.9.3 client 标识体检(boot 的第三道盲区)。dshmarket 的静态体检按
1375
+ // loader entry id / bundle 名统计,trial boot 回放的是 loader 组合树——两者
1376
+ // 都看不见渲染端的 client 工厂表。2026-11-09 那次「整个 Web UI 起不来」就是
1377
+ // 从这个缝里漏过去的。判据只看磁盘,不启动任何东西。
1378
+ let clientIdentity = { entries: [], sameReal: [], drifted: [], collisions: [], unreadable: 0, withoutId: 0, scanned: 0 };
1379
+ try {
1380
+ clientIdentity = collectClientIdentity(paths, io, errors);
1381
+ const realHits = clientIdentity.sameReal || [];
1382
+ const driftHits = clientIdentity.drifted || [];
1383
+ const collHits = clientIdentity.collisions || [];
1384
+ const total = (clientIdentity.entries || []).length;
1385
+ const blame = crashes.length > 0 ? (crashes[0].clientDuplicates || []) : [];
1386
+ if (realHits.length > 0 || driftHits.length > 0 || collHits.length > 0) {
1387
+ const lines = [];
1388
+ for (const hit of realHits) {
1389
+ lines.push('· 同一份代码被装成多个名字:' + hit.dirs.join(' 与 ') + ' 都指向 ' + hit.realpath);
1390
+ }
1391
+ for (const hit of driftHits) {
1392
+ lines.push('· ' + hit.dir + ':client.js 声明的 id 是 "' + hit.id + '",但包名是 "' + hit.name + '"');
1393
+ }
1394
+ for (const hit of collHits) {
1395
+ lines.push('· 两个包用了同一个 client id "' + hit.id + '":' + hit.names.join('、'));
1396
+ }
1397
+ findings.push({
1398
+ id: 'client-identity',
1399
+ severity: 'warning',
1400
+ category: '启动诊断',
1401
+ title: '发现 ' + (realHits.length + driftHits.length + collHits.length) + ' 处会让整个 Web UI 起不来的 client 标识冲突',
1402
+ detail: 'DSH 的渲染端按 `__ModuleLoader__.load({ id })` 里的 id 建工厂表,同一个 id 注册第二次会直接抛 '
1403
+ + '`client-modules: duplicate factory registration` 并让 boot 失败——一个重复 id,整个界面都打不开。\n'
1404
+ + lines.join('\n')
1405
+ + '\n这类状态不会被市场现有的体检抓到:静态体检按 loader entry id 与 bundle 名统计,trial boot 回放的也是 loader 层;'
1406
+ + 'client 工厂表在渲染端,两边都看不见。',
1407
+ evidence: '遍历 ' + total + ' 个含 dsh.client 的已装包,按 exports["./client"] 解析出入口,读其中 `__ModuleLoader__.load({ id })`,'
1408
+ + '并用 realpath 比对目录真身'
1409
+ + (blame.length > 0 ? ';最近一次崩溃日志也点名了重复 id:' + blame.join('、') : ''),
1410
+ recommendation: '按上面点名的路径清理:同一份代码只留一个 node_modules 名字(多出来的是旧改名残留,删链接即可);'
1411
+ + 'id 与包名不一致的,把 client.js 里的 id 改成包名。管家只读不代改——这类动作会动别的包,不属于授权执行面。',
1412
+ data: {
1413
+ checked: total,
1414
+ sameReal: realHits.slice(0, 10),
1415
+ drifted: driftHits.slice(0, 10),
1416
+ collisions: collHits.slice(0, 10),
1417
+ unreadable: clientIdentity.unreadable,
1418
+ withoutId: clientIdentity.withoutId,
1419
+ },
1420
+ });
1421
+ } else if (total > 0) {
1422
+ findings.push({
1423
+ id: 'client-identity',
1424
+ severity: 'info',
1425
+ category: '启动诊断',
1426
+ title: 'client 标识体检通过:' + total + ' 个已装客户端包的 id 互不冲突且与包名一致',
1427
+ detail: '核对了三件事:没有同一份代码被装成两个名字、每个客户端入口声明的 id 与包名一致、没有两个包抢同一个 id。'
1428
+ + (clientIdentity.withoutId > 0
1429
+ ? '\n(其中 ' + clientIdentity.withoutId + ' 个包没在 client.js 里声明 id,按宿主约定走包名,不计入冲突。)'
1430
+ : '')
1431
+ + (clientIdentity.unreadable > 0
1432
+ ? '\n(另有 ' + clientIdentity.unreadable + ' 个包的 client.js 读不到,这次没能核对。)'
1433
+ : ''),
1434
+ evidence: '数据源:profile node_modules 下 ' + total + ' 个含 dsh.client 的包,入口按 exports["./client"] → dsh.client → lib/client.js 依次解析;realpath 用于识别同一份代码的多个名字',
1435
+ recommendation: '无需处理。这类冲突只在改包名或重复安装时出现,平时安静是正确的。',
1436
+ data: {
1437
+ checked: total,
1438
+ unreadable: clientIdentity.unreadable,
1439
+ withoutId: clientIdentity.withoutId,
1440
+ },
1441
+ });
1442
+ }
1443
+ } catch (e) {
1444
+ errors.push({ step: 'client-identity', error: String((e && e.message) || e) });
1445
+ }
1446
+
1447
+ // ⑧ v0.9.1 模型健康:默认模型是你此刻真正在用的那一个,它挂了比任何配置问题
1448
+ // 都直接。判据来自 models.js(只探哨兵集、只标不判死)。这里只负责把结论
1449
+ // 变成一条 finding:没探过 -> 不报;探过且默认模型 ok -> info 一句;默认模型
1450
+ // 不是 ok -> warning,并给出可切换的替代。
1451
+ const health = options.modelHealth;
1452
+ // index.js 交给这里的是 { at, default, defaultVerdict, summary, suggest, ... },
1453
+ // 摊平的计数在 summary 里。曾经直接读 health.probed,两边形状对不上,
1454
+ // 结果这条 finding 在真机上从不出现——单测用扁平形状喂进去反而过了。
1455
+ const mh = health && typeof health === 'object' && health.summary ? health.summary : health;
1456
+ if (mh && typeof mh === 'object' && Number(mh.probed) > 0) {
1457
+ const dv = health.defaultVerdict || { state: 'unprobed' };
1458
+ try {
1459
+ if (dv.state === 'dead' || (dv.state === 'busy' && health.defaultBusyRounds >= 2)) {
1460
+ const alt = health.suggest;
1461
+ findings.push({
1462
+ id: 'model-health',
1463
+ severity: 'warning',
1464
+ category: '模型可用性',
1465
+ title: '默认模型这会儿答不上来:' + health.default.provider + ' / ' + health.default.model,
1466
+ detail: (dv.state === 'dead'
1467
+ ? '探测结果是「这个模型已不存在」(404/422),不是网络问题。'
1468
+ : '探测连续两轮都拿不到内容(配额/网络/模型侧故障)。')
1469
+ + '\n这一轮共探了 ' + mh.probed + ' 个模型:' + mh.ok + ' 个正常、' + mh.dead + ' 个明确不可用、' + mh.busy + ' 个此刻不行。'
1470
+ + (alt
1471
+ ? '\n可切的替代:' + alt.provider + ' / ' + alt.model + (alt.sameProvider ? '(同一个服务商,换过去代价最小)' : '(另一个服务商)') + '。'
1472
+ : '\n这一轮没找到可推荐的替代——所有探过的模型都没正常回复,先查网络或配额。'),
1473
+ evidence: '探测口径:问答式单轮探针,超时 ' + Math.round((health.timeoutMs || 45000) / 1000) + ' 秒;判死仅限 HTTP 404/422,其余归为「此刻不行」;结果缓存 ' + Math.round((health.ttlMs || 3600000) / 60000) + ' 分钟。',
1474
+ recommendation: alt
1475
+ ? '在面板里点「切到这个模型」——走 plan→确认→审计,只改默认模型选择,不动任何插件配置。'
1476
+ : '先在模型设置里确认这个服务商的密钥/配额,再重新体检。',
1477
+ data: {
1478
+ default: health.default, state: dv.state, note: dv.note || '',
1479
+ probed: mh.probed, ok: mh.ok, dead: mh.dead, busy: mh.busy,
1480
+ deadModels: mh.deadModels || [], suggest: alt || null, at: health.at,
1481
+ },
1482
+ });
1483
+ } else if (dv.state === 'busy') {
1484
+ // 只忙了一轮:还不足以说它坏了(配额/冷却会自动恢复),但也不能一个字都不报 ——
1485
+ // 那条路会让用户以为「模型巡检根本没在跑」。给它 info:看得见、不报警。
1486
+ findings.push({
1487
+ id: 'model-health',
1488
+ severity: 'info',
1489
+ category: '模型可用性',
1490
+ title: '默认模型这一轮没答上来(第 ' + (Number(health.defaultBusyRounds) || 1) + ' 轮):' + health.default.provider + ' / ' + health.default.model,
1491
+ detail: '探针没拿到内容,原因可能是配额、冷却或网络,不一定是模型坏了。'
1492
+ + '\n判据:只有 HTTP 404/422 才算「这个模型已不存在」;连续两轮都答不上来才会升成「严重」。'
1493
+ + (health.suggest ? '\n可以先切到 ' + health.suggest.provider + ' / ' + health.suggest.model + ',等它恢复再切回来。' : ''),
1494
+ evidence: health.at ? '最近一次巡检:' + health.at : '本轮巡检',
1495
+ recommendation: health.suggest
1496
+ ? '可以先点「切到这个模型」应急;不改也完全可以,下一轮巡检会再判一次。'
1497
+ : '不改也行,下一轮巡检会再判一次;连续两轮答不上来它才会变成严重。',
1498
+ data: {
1499
+ default: health.default, state: 'busy', ms: dv.ms,
1500
+ probed: mh.probed, ok: mh.ok, dead: mh.dead, busy: mh.busy,
1501
+ deadModels: mh.deadModels || [], suggest: health.suggest || null, at: health.at,
1502
+ },
1503
+ });
1504
+ } else if (dv.state === 'ok') {
1505
+ const suspect = (mh.providers || []).filter((p) => p && p.offlineTwoRounds);
1506
+ findings.push({
1507
+ id: 'model-health',
1508
+ severity: 'info',
1509
+ category: '模型可用性',
1510
+ title: '默认模型可用(' + (dv.ms != null ? dv.ms + ' ms' : '已探通') + ')',
1511
+ detail: '这一轮探了 ' + mh.probed + ' 个模型(默认模型 + 每个服务商前几个):'
1512
+ + mh.ok + ' 个正常、' + mh.dead + ' 个明确不可用、' + mh.busy + ' 个此刻不行。'
1513
+ + (suspect.length > 0
1514
+ ? '\n连续两轮都不可用的服务商:' + suspect.map((p) => p.provider).join('、') + '。'
1515
+ : '')
1516
+ + '\n「此刻不行」不算坏:配额与冷却会自动恢复,所以管家只标出来,不把它们从选择器里删。',
1517
+ evidence: health.at ? '最近一次巡检:' + health.at : '本轮巡检',
1518
+ recommendation: suspect.length > 0
1519
+ ? '那些服务商可能是密钥或配额问题;默认模型不受影响,可以不当急事。'
1520
+ : '无需处理。',
1521
+ data: {
1522
+ default: health.default, state: 'ok', ms: dv.ms,
1523
+ probed: mh.probed, ok: mh.ok, dead: mh.dead, busy: mh.busy,
1524
+ deadModels: mh.deadModels || [], offlineTwoRounds: suspect.map((p) => p.provider), at: health.at,
1525
+ },
1526
+ });
1527
+ }
1528
+ } catch (e) {
1529
+ errors.push({ step: 'model-health', error: String((e && e.message) || e) });
1530
+ }
1531
+ }
1532
+
1533
+ // ⑤ v0.7.7 confidence self-check. A report is only as good as what it managed
1534
+ // to read. This records, per collector, whether the source was actually
1535
+ // available — so "no problems found" can be distinguished from "I couldn't
1536
+ // look". The panel surfaces it as a banner when the coverage is thin; the
1537
+ // report never silently upgrades "unknown" to "fine".
1538
+ const coverage = {
1539
+ crashLogs: crashes.length > 0 ? 'data' : 'empty',
1540
+ patchConfig: (() => {
1541
+ const hit = configNow.find((c) => c.file === 'cordis.patch.yml');
1542
+ return hit ? 'data' : 'unavailable';
1543
+ })(),
1544
+ backupExports: !backupInfo.found ? 'unavailable' : backupInfo.backups.length > 0 ? 'data' : 'empty',
1545
+ workspaces: workspaceInfo.available ? (workspaceInfo.workspaces.length > 0 ? 'data' : 'empty') : 'unavailable',
1546
+ updates: updateInfo.available ? (updateInfo.checked > 0 ? 'data' : 'empty') : 'unavailable',
1547
+ compat: compat.entries.length > 0 ? 'data' : 'empty',
1548
+ clientIdentity: (clientIdentity.entries || []).length > 0
1549
+ ? 'data'
1550
+ : (clientIdentity.scanned > 0 ? 'empty' : 'unavailable'),
1551
+ };
1552
+ if (coverage.patchConfig === 'unavailable' && backupInfo.found) {
1553
+ // An empty profile dir is the only reason patch is unavailable; that is a
1554
+ // missing install, not a broken read. Keep it visible but not alarming.
1555
+ coverage.patchConfig = 'empty';
1556
+ }
1557
+ const unavailable = Object.entries(coverage).filter(([, v]) => v === 'unavailable').map(([k]) => k);
1558
+ const totalChecked = Object.values(coverage).filter((v) => v === 'data').length;
1559
+ coverage.unavailable = unavailable;
1560
+ coverage.sourcesWithData = totalChecked;
1561
+ coverage.sourcesTotal = Object.keys(coverage).filter((k) => k !== 'unavailable' && k !== 'sourcesWithData' && k !== 'sourcesTotal').length;
1562
+ try {
1563
+ if (unavailable.length > 0) {
1564
+ findings.push({
1565
+ id: 'confidence',
1566
+ severity: unavailable.length >= 3 ? 'warning' : 'info',
1567
+ category: '体检可信度',
1568
+ title: unavailable.length + ' 个数据源这次没读到,结论可能不完整',
1569
+ detail: '读不到:' + unavailable.join('、') + '。\n'
1570
+ + '这意味着对应体检项可能报「没发现问题」,但那不是「真的没问题」,是「没看见」。\n'
1571
+ + '其余 ' + totalChecked + ' 个数据源正常。',
1572
+ evidence: '采集告警 ' + errors.length + ' 条'
1573
+ + (errors.length
1574
+ ? ':' + errors.slice(0, 3).map((e) => e.step).join('、')
1575
+ + (errors.length > 3 ? ' 等 ' + errors.length + ' 条(完整见 data.errors)' : '')
1576
+ : ''),
1577
+ recommendation: '按对应项的取数路径排查(如市场缓存需先打开一次插件列表页)。管家宁可不报,也不会把「没读到」写成「合格」。',
1578
+ data: {
1579
+ coverage,
1580
+ errors: errors.map((e) => ({ step: e.step, error: String(e.error) })),
1581
+ },
1582
+ });
1583
+ } else if (errors.length > 0) {
1584
+ findings.push({
1585
+ id: 'confidence', severity: 'info', category: '体检可信度',
1586
+ title: '全部主要数据源都读到了(' + totalChecked + '/' + coverage.sourcesTotal + '),采集告警 ' + errors.length + ' 条',
1587
+ detail: '采集过程中有少量次要不完整,已在报告的 errors 里留档;不影响主要结论。',
1588
+ evidence: errors.slice(0, 5).map((e) => e.step + ': ' + e.error).join('\n')
1589
+ + (errors.length > 5 ? '\n… 另 ' + (errors.length - 5) + ' 条(完整见 data.errors)' : ''),
1590
+ recommendation: '无需处理。',
1591
+ data: {
1592
+ coverage,
1593
+ errors: errors.map((e) => ({ step: e.step, error: String(e.error) })),
1594
+ },
1595
+ });
1596
+ }
1597
+ } catch (e) {
1598
+ errors.push({ step: 'confidence', error: String((e && e.message) || e) });
1599
+ }
1600
+
1601
+ // ③ v0.3.1 knowledge recall: match current active findings against the
1602
+ // steward's own append-only knowledge log. When the same finding was seen
1603
+ // before, surface that prior record inline — "上次也是这样、当时记了什么".
1604
+ // This is the line between a measuring instrument and a caretaker. Read-only;
1605
+ // a missing/corrupt knowledge log simply yields no recalls (never degrades).
1606
+ const knowledgeLog = Array.isArray(options.knowledge) ? options.knowledge : [];
1607
+ const priorById = new Map();
1608
+ for (const k of knowledgeLog) {
1609
+ if (!k || !Array.isArray(k.findingIds)) continue;
1610
+ for (const id of k.findingIds) {
1611
+ if (typeof id !== 'string') continue;
1612
+ const prev = priorById.get(id);
1613
+ // Keep the MOST RECENT record per finding id (recall the latest lesson).
1614
+ if (!prev || String(k.at || '') > String(prev.at || '')) priorById.set(id, k);
1615
+ }
1616
+ }
1617
+ for (const f of findings) {
1618
+ const hit = priorById.get(f.id);
1619
+ if (hit && String(hit.at || '') < String(now.toISOString ? now.toISOString() : now)) {
1620
+ f.recall = {
1621
+ at: hit.at,
1622
+ headline: hit.headline,
1623
+ counts: hit.counts,
1624
+ };
1625
+ }
1626
+ }
1627
+
1628
+ // Mute (ack) layer: user-acknowledged findings keep their evidence but stop
1629
+ // counting toward risk level. Reversible via kind=unack. v0.7.7: an ack that
1630
+ // carried an expiresAt stops suppressing once it lapses — silence is a note
1631
+ // about "right now", not a permanent gag. Entries without expiresAt keep the
1632
+ // old permanent behaviour so pre-existing acks do not suddenly reappear.
1633
+ const ackedRaw = Array.isArray(options.acked) ? options.acked : [];
1634
+ const ackedIds = new Set(
1635
+ ackedRaw
1636
+ .filter((a) => {
1637
+ if (!a) return false;
1638
+ const exp = a && typeof a === 'object' ? a.expiresAt : null;
1639
+ if (!exp) return true;
1640
+ const t = Date.parse(exp);
1641
+ if (!isFinite(t)) return true;
1642
+ return t > now.getTime();
1643
+ })
1644
+ .map((a) => (a && typeof a === 'object' ? a.id : a))
1645
+ .filter((x) => typeof x === 'string'),
1646
+ );
1647
+ let mutedCount = 0;
1648
+ for (const f of findings) {
1649
+ f.muted = ackedIds.has(f.id);
1650
+ if (f.muted) mutedCount += 1;
1651
+ }
1652
+
1653
+ // Importance scoring (v0.3.2) — runs AFTER the mute layer so "actionable"
1654
+ // honours the user's ack. Four observable factors, each stated in
1655
+ // f.scoreReasons: severity base (60/40/20) ± freshness (this process
1656
+ // lifetime) + actionable (one-click authorized path exists) + magnitude
1657
+ // (how big the evidence is). Auditable number, never a black box.
1658
+ try {
1659
+ scoreFindings(findings, { bootedAt });
1660
+ } catch (e) {
1661
+ errors.push({ step: 'priority-score', error: String(e && e.message || e) });
1662
+ }
1663
+
1664
+ const counts = { critical: 0, warning: 0, info: 0, muted: mutedCount };
1665
+ for (const f of findings) if (!f.muted && counts[f.severity] != null) counts[f.severity] += 1;
1666
+ const level = counts.critical > 0 ? 'critical' : counts.warning > 0 ? 'warning' : 'ok';
1667
+ const headline = counts.critical > 0
1668
+ ? '发现启动崩溃记录,请优先看「启动诊断」'
1669
+ : counts.warning > 0
1670
+ ? '有需要留意的风险项'
1671
+ : '当前未发现异常,以下为常备建议';
1672
+
1673
+ return {
1674
+ generatedAt: now.toISOString ? now.toISOString() : String(now),
1675
+ host: { platform, arch: process.arch, node: process.version, dshHome: paths.dshHome },
1676
+ cFreeBytes,
1677
+ backup: backupInfo,
1678
+ config: configNow,
1679
+ workspace: workspaceInfo,
1680
+ updates: updateInfo,
1681
+ coverage,
1682
+ status: errors.length > 0 ? 'degraded' : 'ok',
1683
+ summary: { level, headline, counts },
1684
+ findings,
1685
+ limitations: [
1686
+ '只读:不删文件、不代跑提权命令、不自动拉起/重启、不改启动流程。',
1687
+ '不重复实现磁盘扫描 / 备份 / 归档 / 记忆,只指向现成能力。',
1688
+ '不 import 任何其它 DSH 包内部模块,避免版本漂移。',
1689
+ '崩溃检测是「启动后读日志」,不是实时守护;本进程内事件随宿主 fatal 消失。',
1690
+ '采集限流:日志 ≤ ' + limits.maxLogs + ' 份、单份 ≤ ' + limits.maxBytesPerLog + ' B(可在面板「设置」里调)。',
1691
+ ],
1692
+ errors,
1693
+ };
1694
+ }
1695
+
1696
+ export default collectReport;