@gehennawu/dsh-service 1.1.2 → 1.3.0

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.
@@ -0,0 +1,374 @@
1
+ // Host deep module: plugin compatibility scan against DSH alpha breakage (v1.3).
2
+ //
3
+ // 为什么不用 peerDependencies 判定(用户点名否掉的方向):多数第三方插件根本不写 DSH peer
4
+ // 声明,写了也未必反映真实兼容性。真实信号是「插件代码/清单是否依赖了已被 DSH alpha 移除或
5
+ // 变更的接口」——破坏面清单来自 docs/research/dsh-v0.1.2-alpha.{1,2,3}-plugin-impact.md 与
6
+ // AGENTS.md 已核实的 alpha.2 拆包事实:
7
+ //
8
+ // manifest 层(package.json 的依赖键与 dsh.client.inject):
9
+ // - @deepseek-ai/dsh-client-runtime:alpha.2 起官方 web roster 移除该 client supplier,
10
+ // npm 无 0.1.2-alpha.* 发布物;插件声明它会使浏览器半等待不存在的供应商而无法挂载。
11
+ // - @deepseek-ai/dsh-session-persistence-sqlite:alpha.3 移除 SQLite Session persistence
12
+ // backend,官方只交付 JSONL provider;旧 SQLite 数据不会被自动转换。
13
+ // code 层(宿主/浏览器入口文件的非注释文本):
14
+ // - Md3f7G_:dsh-client-ui-conversation 旧 CSS 哈希前缀,alpha.2 聊天视图拆进
15
+ // dsh-client-ui-chat 后漂移至 EvIC1a_(词干不变,前缀更换)。
16
+ // - FJxK0a_:旧 StatsLine 哈希前缀,alpha.2 漂移至 -NDN2W_。
17
+ // - data-time-hover-root:alpha.2 删除的元素属性,由 data-turn-tail 取代。
18
+ //
19
+ // 破坏面清单是扩展点:每次 DSH alpha 发布后,把新「移除/迁移的旧标识」追加到 COMPAT_BREAKS
20
+ // 即可(清单条目 = 旧标识,从来不列新标识——检测的是「还依赖旧面」)。
21
+ //
22
+ // 扫描纪律:
23
+ // - 只扫启用条目(与插件体检口径一致:停用是用户选择,不算异常);
24
+ // - code 层用状态机扫描:注释(行/块/CSS 块)内的提及不算引用(我们的 mobile CSS 注释就
25
+ // 写过旧哈希,剥掉后自证零命中);字符串内的命中算引用(className/选择器/querySelector
26
+ // 都以字符串出现);
27
+ // - 单个入口文件 ≤ 4 MiB(注入可调),超限记 too-large 不误报也不拉满内存;
28
+ // - 结果按模块名进程级缓存(插件更新需重启 DSH 才生效,缓存安全),重复诊断零成本。
29
+ //
30
+ // 本模块只做数据收集与纯函数变换;无浏览器输入,moduleName 只来自宿主 loader。
31
+
32
+ import { readFileSync, statSync } from 'node:fs'
33
+ import { createRequire } from 'node:module'
34
+ import { dirname, join, resolve } from 'node:path'
35
+
36
+ /** 允许进入 require.resolve 的安全包名(拒绝 .. 逃逸、空串与非法字符)。 */
37
+ function packageNameOf(moduleName) {
38
+ if (typeof moduleName !== 'string' || moduleName.length === 0 || moduleName.length > 4096) return null
39
+ let pkg
40
+ if (moduleName.startsWith('@')) {
41
+ const matched = /^(@[^/]+)\/([^/]+)/.exec(moduleName)
42
+ if (matched === null) return null
43
+ pkg = `${matched[1]}/${matched[2]}`
44
+ } else {
45
+ const matched = /^([^/]+)/.exec(moduleName)
46
+ if (matched === null) return null
47
+ pkg = matched[1]
48
+ }
49
+ if (!/^@?[A-Za-z0-9][A-Za-z0-9._-]{0,255}$/.test(pkg) && !/^@[A-Za-z0-9][A-Za-z0-9._-]{0,255}\/[A-Za-z0-9][A-Za-z0-9._-]{0,255}$/.test(pkg)) return null
50
+ return pkg
51
+ }
52
+
53
+ /**
54
+ * 已核实破坏面清单(每条 = 一个已被 DSH alpha 移除/迁移的旧标识)。
55
+ * 客户端按 `plugin.compat.break.<id>` 取词典文案;layer 决定扫描目标(manifest=package.json
56
+ * 依赖键与 dsh 字段,code=入口文件非注释文本)。
57
+ */
58
+ export const COMPAT_BREAKS = Object.freeze([
59
+ {
60
+ id: 'client-runtime',
61
+ layer: 'manifest',
62
+ match: '@deepseek-ai/dsh-client-runtime',
63
+ },
64
+ {
65
+ id: 'sqlite-persistence',
66
+ layer: 'manifest',
67
+ match: '@deepseek-ai/dsh-session-persistence-sqlite',
68
+ },
69
+ {
70
+ id: 'chat-hash',
71
+ layer: 'code',
72
+ match: 'Md3f7G_',
73
+ },
74
+ {
75
+ id: 'stats-hash',
76
+ layer: 'code',
77
+ match: 'FJxK0a_',
78
+ },
79
+ {
80
+ id: 'time-hover-root',
81
+ layer: 'code',
82
+ match: 'data-time-hover-root',
83
+ },
84
+ ])
85
+
86
+ const MANIFEST_FIELDS = ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies']
87
+
88
+ /**
89
+ * 状态机扫描:normal 态检查命中;行注释/块注释整段跳过;字符串态内检查命中
90
+ * (className/选择器/querySelector 都以字符串出现),但字符串内的 CSS 块注释跳过
91
+ * ——插件自己的 mobile CSS 注释里就写过旧哈希,注释提及必须不计。
92
+ * @returns 命中的 break id 集合
93
+ */
94
+
95
+ // 文档性后缀字符集:命中标识后紧跟这些字符的是「句子里的提及」而非代码引用——插件自己的
96
+ // 词典/提示文案会在解释检测项时写出旧标识(如「Md3f7G_,0.1.2-alpha.2 起漂移」),必须不
97
+ // 计为命中;真实引用(className 词干延续、引号/中括号收尾的完整标识)都不在此集。
98
+ const DOCUMENT_TRAIL = /[\u3000-\u303f\u4e00-\u9fff\uff00-\uffef\s,;:!?()]/
99
+
100
+ export function scanCodeHits(text, breaks) {
101
+ const hits = new Set()
102
+ // 判定一次前缀命中的「引用形态」:紧跟字符是文档字符 → 提及(false),否则 → 引用。
103
+ const inString = (mode) => mode !== 'normal' && mode !== 'line' && mode !== 'block'
104
+ const isReferenceAt = (text, i, match) => {
105
+ if (!text.startsWith(match, i)) return false
106
+ const next = text[i + match.length]
107
+ if (next === undefined || DOCUMENT_TRAIL.test(next)) return false
108
+ return true
109
+ }
110
+ const tryHit = (text, i) => {
111
+ for (const b of breaks) if (!hits.has(b.id) && isReferenceAt(text, i, b.match)) hits.add(b.id)
112
+ }
113
+ const n = text.length
114
+ let i = 0
115
+ let mode = 'normal'
116
+ while (i < n) {
117
+ const ch = text[i]
118
+ if (mode === 'line') {
119
+ if (ch === '\n') mode = 'normal'
120
+ i += 1
121
+ continue
122
+ }
123
+ if (mode === 'block') {
124
+ if (ch === '*' && text[i + 1] === '/') {
125
+ mode = 'normal'
126
+ i += 2
127
+ } else i += 1
128
+ continue
129
+ }
130
+ if (inString(mode)) {
131
+ if (ch === '\\') {
132
+ i += 2
133
+ continue
134
+ }
135
+ if (ch === mode) {
136
+ mode = 'normal'
137
+ i += 1
138
+ continue
139
+ }
140
+ // 字符串内的 CSS 块注释(styleTag 文本等里的注释提及)
141
+ if (ch === '/' && text[i + 1] === '*') {
142
+ const end = text.indexOf('*/', i + 2)
143
+ if (end === -1) {
144
+ i = n
145
+ continue
146
+ }
147
+ i = end + 2
148
+ continue
149
+ }
150
+ tryHit(text, i)
151
+ i += 1
152
+ continue
153
+ }
154
+ if (ch === '/' && text[i + 1] === '/') {
155
+ mode = 'line'
156
+ i += 2
157
+ continue
158
+ }
159
+ if (ch === '/' && text[i + 1] === '*') {
160
+ mode = 'block'
161
+ i += 2
162
+ continue
163
+ }
164
+ if (ch === '"' || ch === "'" || ch === '`') {
165
+ mode = ch
166
+ i += 1
167
+ continue
168
+ }
169
+ tryHit(text, i)
170
+ i += 1
171
+ }
172
+ return hits
173
+ }
174
+
175
+ /** manifest 层命中:依赖键集合 + dsh 字段(dsh.client.inject 是插件浏览器供应商声明处)。 */
176
+ export function collectManifestHits(pkg, breaks) {
177
+ const hits = new Set()
178
+ const fragments = []
179
+ for (const field of MANIFEST_FIELDS) {
180
+ const obj = pkg[field]
181
+ if (obj !== null && typeof obj === 'object' && !Array.isArray(obj)) fragments.push(...Object.keys(obj))
182
+ }
183
+ if (pkg.dsh !== undefined) fragments.push(JSON.stringify(pkg.dsh))
184
+ const haystack = JSON.stringify(fragments)
185
+ for (const b of breaks) if (!hits.has(b.id) && haystack.includes(b.match)) hits.add(b.id)
186
+ return hits
187
+ }
188
+
189
+ /**
190
+ * 包入口文件解析:exports['./client'] / exports['.'] / main / index.js 兜底。
191
+ * 只接受包目录内的相对路径(拒绝绝对路径与 .. 段);目录形态追加 index.js。
192
+ */
193
+ export function packageEntryFiles(pkgDir, pkg) {
194
+ const files = []
195
+ const push = (rel) => {
196
+ if (typeof rel !== 'string' || rel === '' || rel.startsWith('/') || rel.split('/').some((segment) => segment === '..')) return
197
+ let target = resolve(pkgDir, rel)
198
+ if (!target.startsWith(pkgDir)) return
199
+ try {
200
+ if (statSync(target).isDirectory()) target = join(target, 'index.js')
201
+ if (statSync(target).isFile()) files.push(target)
202
+ } catch (_) {}
203
+ }
204
+ const exportsField = pkg.exports
205
+ if (exportsField !== null && typeof exportsField === 'object' && !Array.isArray(exportsField)) {
206
+ const pick = (spec) => {
207
+ if (typeof spec === 'string') return spec
208
+ if (spec !== null && typeof spec === 'object') return spec.default ?? spec.import ?? spec.require
209
+ return undefined
210
+ }
211
+ push(pick(exportsField['./client']))
212
+ push(pick(exportsField['.']))
213
+ }
214
+ push(pkg.main)
215
+ if (files.length === 0) push('index.js')
216
+ return files
217
+ }
218
+
219
+ /** 进程级扫描缓存:moduleName → scanPluginCompatibility 结果。 */
220
+ const SCAN_CACHE = new Map()
221
+
222
+ /**
223
+ * 扫描单个插件包(纯函数,测试可注入 requireFn 与选项)。
224
+ *
225
+ * 输出分级(用户实测「声明了但没使用」后定稿——官方 client module loader 对 dsh.client.inject
226
+ * 里缺失的供应商是静默跳过(arriveGraphRow 对 graphRows.get() === undefined 直接 continue),
227
+ * 因此 manifest 命中的危害取决于插件代码是否真的引用了该标识):
228
+ * - hits:真引用(code 层命中,或 manifest 标识在入口代码中被引用)→ “可能不兼容”;
229
+ * - declaredOnly:manifest 声明了已移除标识、但入口代码零引用 → 无害残留,仅提醒作者清理;
230
+ * - unknown:包不可解析/入口缺失/超限。
231
+ *
232
+ * @param requireFn createRequire 替身(resolve `${pkg}/package.json`)
233
+ * @param moduleName loader 条目模块说明符
234
+ * @param options.maxFileBytes 单入口文件上限(默认 4 MiB)
235
+ * @returns { hits: string[], declaredOnly: string[], unknown: string | null, files: number, bytes: number }
236
+ */
237
+ export function scanPluginCompatibility(requireFn, moduleName, options = {}) {
238
+ const maxFileBytes = options.maxFileBytes ?? 4 * 1024 * 1024
239
+ const pkgName = packageNameOf(moduleName)
240
+ if (pkgName === null) return { hits: [], declaredOnly: [], unknown: 'unresolved', files: 0, bytes: 0 }
241
+ let pkgPath
242
+ try {
243
+ pkgPath = requireFn(`${pkgName}/package.json`)
244
+ } catch (_) {
245
+ // exports 字段未导出 './package.json' 的包:回退主入口(exports['.']/main)定位包根。
246
+ try {
247
+ pkgPath = join(dirname(requireFn(pkgName)), 'package.json')
248
+ } catch (_) {
249
+ return { hits: [], declaredOnly: [], unknown: 'unresolved', files: 0, bytes: 0 }
250
+ }
251
+ }
252
+ const pkgDir = dirname(pkgPath)
253
+ let pkg
254
+ try {
255
+ pkg = JSON.parse(readFileSync(pkgPath, 'utf8'))
256
+ } catch (_) {
257
+ return { hits: [], declaredOnly: [], unknown: 'unresolved', files: 0, bytes: 0 }
258
+ }
259
+ const manifestBreaks = COMPAT_BREAKS.filter((b) => b.layer === 'manifest')
260
+ const codeBreaks = COMPAT_BREAKS.filter((b) => b.layer === 'code')
261
+ const declaredOnly = [...collectManifestHits(pkg, manifestBreaks)]
262
+ const hits = []
263
+ const files = packageEntryFiles(pkgDir, pkg)
264
+ let bytes = 0
265
+ for (const file of files) {
266
+ let text
267
+ try {
268
+ const info = statSync(file)
269
+ if (info.size > maxFileBytes) return { hits, declaredOnly, unknown: 'too-large', files, bytes }
270
+ bytes += info.size
271
+ text = readFileSync(file, 'utf8')
272
+ } catch (_) {
273
+ continue
274
+ }
275
+ // manifest 层复核:供应商被真实 require/import 调用(调用形态,见 manifestCallRefs)才升级为
276
+ // 真引用;字符串化的 `require(\"...\")` 示例与文档提及留在 declaredOnly。
277
+ if (declaredOnly.length > 0) {
278
+ for (const id of manifestCallRefs(text, manifestBreaks)) {
279
+ if (hits.indexOf(id) === -1) hits.push(id)
280
+ const at = declaredOnly.indexOf(id)
281
+ if (at !== -1) declaredOnly.splice(at, 1)
282
+ }
283
+ }
284
+ // code 层照旧:状态机剥注释 + 引用形态判定(className/选择器在字符串内也算引用)。
285
+ if (codeBreaks.some((b) => text.includes(b.match))) {
286
+ for (const id of scanCodeHits(text, codeBreaks)) if (hits.indexOf(id) === -1) hits.push(id)
287
+ }
288
+ }
289
+ return { hits, declaredOnly, unknown: hits.length === 0 && declaredOnly.length === 0 && files.length === 0 ? 'missing-entry' : null, files: files.length, bytes }
290
+ }
291
+
292
+ function escapeRegex(text) {
293
+ return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
294
+ }
295
+
296
+ /**
297
+ * manifest 层供应商的「真实调用」复核:包名以 `require('…')` / `require("…")` /
298
+ * `import('…')` / `import "…"` / `from "…"` 形态出现才算代码引用。引号前必须不是反斜杠
299
+ * ——被字符串化的文档/迁移示例写的是 `require(\"…\")`(引号带转义,dsh-dream-skin 实证),
300
+ * 真调用不会加反斜杠。
301
+ * @returns 命中的 manifest break id 列表
302
+ */
303
+ export function manifestCallRefs(text, manifestBreaks) {
304
+ const found = []
305
+ for (const b of manifestBreaks) {
306
+ const re = new RegExp(`(?:require\\s*\\(\\s*|\\bimport\\s*(?:\\(\\s*)?|\\bfrom\\s+|\\bimport\\s+)(?<!\\\\)['"]${escapeRegex(b.match)}['"]`)
307
+ if (re.test(text)) found.push(b.id)
308
+ }
309
+ return found
310
+ }
311
+
312
+ /**
313
+ * 收集启用插件的兼容性扫描结果(顺序 = loader 条目序,稳定可测)。
314
+ * @param ctx 宿主插件上下文(ctx.get('loader'))
315
+ * @param options 透传 scanPluginCompatibility 选项(requireFn/maxFileBytes)与缓存开关(noCache)
316
+ * @returns { available, scanned, issues: [{moduleName, breaks[]}], declaredOnly: [{moduleName, breaks[]}], unknown: [{moduleName, reason}] }
317
+ */
318
+ export async function collectPluginCompat(ctx, options = {}) {
319
+ const loader = ctx.get('loader')
320
+ if (loader === undefined) return { available: false, scanned: 0, issues: [], declaredOnly: [], unknown: [] }
321
+ let requireFn = options.requireFn
322
+ if (typeof requireFn !== 'function') {
323
+ const baseUrl = typeof loader.ctx?.baseUrl === 'string' && loader.ctx.baseUrl.length > 0
324
+ ? loader.ctx.baseUrl
325
+ : typeof ctx.baseUrl === 'string' && ctx.baseUrl.length > 0
326
+ ? ctx.baseUrl
327
+ : undefined
328
+ // 必须是 .resolve 而非直接 require:require 会加载求值模块,对 JSON 返回对象而非路径。
329
+ const baseRequire = baseUrl === undefined ? createRequire(import.meta.url) : createRequire(baseUrl)
330
+ requireFn = (specifier) => baseRequire.resolve(specifier)
331
+ }
332
+ const issues = []
333
+ const declaredOnly = []
334
+ const unknown = []
335
+ let scanned = 0
336
+ for (const entry of loader.entries()) {
337
+ if (entry?.options?.group === true) continue
338
+ const moduleName = entry?.options?.name
339
+ if (typeof entry?.id !== 'string' || typeof moduleName !== 'string') continue
340
+ if (entry.disabled === true) continue
341
+ scanned += 1
342
+ let result
343
+ if (options.noCache === true) {
344
+ result = scanPluginCompatibility(requireFn, moduleName, options)
345
+ } else {
346
+ const cached = SCAN_CACHE.get(moduleName)
347
+ if (cached !== undefined) result = cached
348
+ else {
349
+ result = scanPluginCompatibility(requireFn, moduleName, options)
350
+ SCAN_CACHE.set(moduleName, result)
351
+ }
352
+ }
353
+ if (result.unknown !== null) {
354
+ unknown.push({ moduleName, reason: result.unknown })
355
+ } else {
356
+ if (result.hits.length > 0) issues.push({ moduleName, breaks: result.hits })
357
+ if (result.declaredOnly.length > 0) declaredOnly.push({ moduleName, breaks: result.declaredOnly })
358
+ }
359
+ }
360
+ return { available: true, scanned, issues, declaredOnly, unknown }
361
+ }
362
+
363
+ /**
364
+ * 聚合为诊断检查项(追加在 checks 尾部)。detail 四段 `scanned:broken:declaredOnly:unknown`,
365
+ * 客户端解析;broken>0 → warning(「可能不兼容」是风险提示,不是已损坏的 error);
366
+ * 仅 declaredOnly(声明残留、代码未引用,官方 loader fail-open 无害)不改变状态,只在摘要提及。
367
+ */
368
+ export function pluginCompatCheckItem(report) {
369
+ return {
370
+ id: 'plugin-compat',
371
+ status: report.issues.length > 0 ? 'warning' : 'ok',
372
+ detail: `${report.scanned}:${report.issues.length}:${report.declaredOnly.length}:${report.unknown.length}`,
373
+ }
374
+ }
@@ -0,0 +1,138 @@
1
+ // Host deep module: plugin health check for the diagnostics feature (v1.3).
2
+ //
3
+ // 数据源(v1.3 真机探针核实,详见 KNOWLEDGE.md):
4
+ // - `ctx.get('loader').entries()` 返回全部已注册 Loader 条目(官方 @deepseek-ai/* 与第三方
5
+ // 自定义插件同列;group 条目是文件夹,跳过)。每条目:id / options.name(模块说明符)/
6
+ // options.group / disabled(含祖先组禁用)/ fiber(启用时存在)。
7
+ // - `fiber.state`:0 pending(等待依赖)/ 1 loading / 2 active / 3 failed / 4 disposed /
8
+ // 5 unloading;`fiber.inject` = 声明依赖的服务名 Dict;`fiber.store` = 已满足依赖的 Impl
9
+ // 快照(ACTIVE 时含全部 inject 键,缺失键即依赖未就绪);`fiber._error` = fail 原因。
10
+ //
11
+ // 口径(用户点名:只要「检查是否有插件异常」;官方设置页已有完整插件清单与开关,
12
+ // 本功能不做重复清单):
13
+ // - 停用条目(disabled,无论内置还是自定义)一律不算异常、不进任何统计文案;
14
+ // - failed → error;pending(等待依赖)/ loading / unloading → warning;
15
+ // - 只把「异常」条目下发(issues),正常插件客户端不渲染,也不需要版本/归属等元数据。
16
+ //
17
+ // 本模块只做数据收集与纯函数变换:不注册任何服务/事件,生命周期全部由 index.js 的
18
+ // RPC 装配层托管;无浏览器输入,entryId 只接受宿主 loader 列出的条目。
19
+
20
+ /** Cordis FiberState 数值常量(跨包 const enum 的运行时镜像;真机核实与 provider 一致)。 */
21
+ export const PLUGIN_FIBER_STATE = Object.freeze({
22
+ PENDING: 0,
23
+ LOADING: 1,
24
+ ACTIVE: 2,
25
+ FAILED: 3,
26
+ DISPOSED: 4,
27
+ UNLOADING: 5,
28
+ })
29
+
30
+ /** 异常相位 → 检查项等级:失败是真故障(error),依赖未就绪/加载中按 warning 提示。 */
31
+ export function issueLevelOf(phase) {
32
+ switch (phase) {
33
+ case 'failed': return 'error'
34
+ case 'pending':
35
+ case 'loading':
36
+ case 'unloading': return 'warning'
37
+ default: return null
38
+ }
39
+ }
40
+
41
+ /** fiber.state 数值 → 相位串;未知/无 fiber 一律按加载中(瞬态/声明未挂载)。 */
42
+ function phaseOfState(state) {
43
+ switch (state) {
44
+ case PLUGIN_FIBER_STATE.PENDING: return 'pending'
45
+ case PLUGIN_FIBER_STATE.LOADING: return 'loading'
46
+ case PLUGIN_FIBER_STATE.ACTIVE: return 'active'
47
+ case PLUGIN_FIBER_STATE.FAILED: return 'failed'
48
+ case PLUGIN_FIBER_STATE.UNLOADING: return 'unloading'
49
+ default: return 'loading'
50
+ }
51
+ }
52
+
53
+ /**
54
+ * 收集插件健康体检结果。
55
+ *
56
+ * @param ctx 宿主插件上下文(供 `ctx.get('loader')`)
57
+ * @returns {available: boolean, total: number, issues: PluginIssue[]}——loader 缺席时
58
+ * available=false(调用方降级为 info 检查项,不含任何插件数据)。
59
+ */
60
+ export async function collectPluginHealth(ctx) {
61
+ const loader = ctx.get('loader')
62
+ if (loader === undefined) return { available: false, total: 0, issues: [] }
63
+ const issues = []
64
+ let total = 0
65
+ for (const entry of loader.entries()) {
66
+ if (entry?.options?.group === true) continue
67
+ const moduleName = entry?.options?.name
68
+ if (typeof entry?.id !== 'string' || typeof moduleName !== 'string') continue
69
+ // 停用是用户的显式选择(官方插件页的开关),无论内置还是自定义都不算异常。
70
+ if (entry.disabled === true) continue
71
+ total += 1
72
+ const fiber = entry.fiber
73
+ const phase = fiber === undefined ? 'loading' : phaseOfState(fiber.state)
74
+ if (phase === 'active') continue
75
+ const error = phase === 'failed' && fiber?._error != null
76
+ ? String(fiber._error?.message || fiber._error)
77
+ : null
78
+ const missingDeps = phase === 'pending'
79
+ ? Object.keys(fiber.inject || {}).filter((key) => !(fiber.store !== undefined && fiber.store[key]))
80
+ : []
81
+ issues.push({
82
+ entryId: entry.id,
83
+ moduleName,
84
+ phase,
85
+ ...(error === null ? {} : { error }),
86
+ ...(missingDeps.length > 0 ? { missingDeps } : {}),
87
+ })
88
+ }
89
+ return { available: true, total, issues }
90
+ }
91
+
92
+ /**
93
+ * 聚合为一个诊断检查项(追加在 checks 尾部)。detail 三段 `total:failed:pending`:
94
+ * 客户端用它渲染「共 N 个插件,全部正常」或异常摘要;停用插件完全不进统计
95
+ * (failed 是 error,其余异常相位归 pending 桶按 warning 处理)。
96
+ */
97
+ export function pluginCheckItem(report) {
98
+ const failed = report.issues.filter((issue) => issue.phase === 'failed').length
99
+ const pending = report.issues.filter((issue) => issue.phase !== 'failed').length
100
+ return {
101
+ id: 'plugins',
102
+ status: failed > 0 ? 'error' : pending > 0 ? 'warning' : 'ok',
103
+ detail: `${report.total}:${failed}:${pending}`,
104
+ }
105
+ }
106
+
107
+ /**
108
+ * 重新加载一个 failed 插件的 fiber(唯一的修复动作——只对已失败条目生效,绝不打扰
109
+ * 正在运行/等待依赖的插件)。entryId 只接受宿主 loader 实际列出的条目 id;其余一律
110
+ * 稳定错误码拒绝。
111
+ *
112
+ * @returns {ok: true} 或 {ok: false, code, error?};code ∈
113
+ * unknown-plugin / loader-unavailable / plugin-disabled / not-failed / restart-failed。
114
+ */
115
+ export async function restartPluginEntry(ctx, entryId) {
116
+ if (typeof entryId !== 'string' || entryId.length === 0 || entryId.length > 256) return { ok: false, code: 'unknown-plugin' }
117
+ const loader = ctx.get('loader')
118
+ if (loader === undefined) return { ok: false, code: 'loader-unavailable' }
119
+ let target
120
+ for (const entry of loader.entries()) {
121
+ if (entry?.options?.group === true) continue
122
+ if (entry.id === entryId) {
123
+ target = entry
124
+ break
125
+ }
126
+ }
127
+ if (target === undefined) return { ok: false, code: 'unknown-plugin' }
128
+ if (target.disabled) return { ok: false, code: 'plugin-disabled' }
129
+ const fiber = target.fiber
130
+ if (fiber === undefined || fiber.state !== PLUGIN_FIBER_STATE.FAILED) return { ok: false, code: 'not-failed' }
131
+ try {
132
+ await fiber.restart()
133
+ } catch (error) {
134
+ // 重启后仍失败;错误原文交给客户端透出,等待下一次「重新诊断」落定新状态。
135
+ return { ok: false, code: 'restart-failed', error: String(error?.message || error) }
136
+ }
137
+ return { ok: true }
138
+ }