@gehennawu/dsh-service 2.0.3 → 2.0.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.en.md +4 -5
- package/README.md +4 -5
- package/backup-routes.js +1 -1
- package/client.js +1 -1
- package/index.js +3 -20
- package/package.json +1 -2
- package/quota-routes.js +1 -1
- package/session-routes.js +1 -1
- package/skill-routes.js +1 -1
- package/subagent-routes.js +1 -1
- package/plugin-compat.js +0 -611
package/plugin-compat.js
DELETED
|
@@ -1,611 +0,0 @@
|
|
|
1
|
-
// Host deep module: plugin compatibility scan against DSH alpha breakage (v1.3).
|
|
2
|
-
//
|
|
3
|
-
// 为什么不用 peerDependencies 判定(用户点名否掉的方向):多数第三方插件根本不写 DSH peer
|
|
4
|
-
// 声明,写了也未必反映真实兼容性。真实信号是「插件代码/清单是否依赖了已被 DSH alpha 移除或
|
|
5
|
-
// 变更的接口」——破坏面清单来自 docs/research/ 的各版本 plugin-impact 报告(0.1.2-alpha.{1,2,3}、
|
|
6
|
-
// 0.1.6-alpha.2、0.1.7-alpha.1)与 AGENTS.md 已核实的拆包事实:
|
|
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
|
-
function isSemverIdentifier(value) {
|
|
54
|
-
if (value.length === 0) return false
|
|
55
|
-
return [...value].every((char) => (char >= '0' && char <= '9') || (char >= 'A' && char <= 'Z') || (char >= 'a' && char <= 'z') || char === '-')
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
function isNumericSemverIdentifier(value) {
|
|
59
|
-
return value.length > 0 && [...value].every((char) => char >= '0' && char <= '9')
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
export function parseSemver(value) {
|
|
63
|
-
if (typeof value !== 'string') return null
|
|
64
|
-
const trimmed = value.trim()
|
|
65
|
-
const buildParts = trimmed.split('+')
|
|
66
|
-
if (buildParts.length > 2 || (buildParts.length === 2 && !buildParts[1].split('.').every((part) => isSemverIdentifier(part)))) return null
|
|
67
|
-
const versionPart = buildParts[0]
|
|
68
|
-
const dashIndex = versionPart.indexOf('-')
|
|
69
|
-
const corePart = dashIndex === -1 ? versionPart : versionPart.slice(0, dashIndex)
|
|
70
|
-
const prereleasePart = dashIndex === -1 ? '' : versionPart.slice(dashIndex + 1)
|
|
71
|
-
if (dashIndex !== -1 && prereleasePart.length === 0) return null
|
|
72
|
-
const core = corePart.split('.')
|
|
73
|
-
if (core.length !== 3 || !core.every((part) => isNumericSemverIdentifier(part) && (part.length === 1 || !part.startsWith('0')))) return null
|
|
74
|
-
const prerelease = prereleasePart === '' ? [] : prereleasePart.split('.')
|
|
75
|
-
if (!prerelease.every((part) => isSemverIdentifier(part) && !(isNumericSemverIdentifier(part) && part.length > 1 && part.startsWith('0')))) return null
|
|
76
|
-
return { major: Number(core[0]), minor: Number(core[1]), patch: Number(core[2]), prerelease }
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
export function compareSemver(left, right) {
|
|
80
|
-
const a = parseSemver(left)
|
|
81
|
-
const b = parseSemver(right)
|
|
82
|
-
if (!a || !b) return 0
|
|
83
|
-
for (const key of ['major', 'minor', 'patch']) {
|
|
84
|
-
if (a[key] !== b[key]) return a[key] > b[key] ? 1 : -1
|
|
85
|
-
}
|
|
86
|
-
if (a.prerelease.length === 0 && b.prerelease.length === 0) return 0
|
|
87
|
-
if (a.prerelease.length === 0) return 1
|
|
88
|
-
if (b.prerelease.length === 0) return -1
|
|
89
|
-
const length = Math.max(a.prerelease.length, b.prerelease.length)
|
|
90
|
-
for (let index = 0; index < length; index += 1) {
|
|
91
|
-
if (index >= a.prerelease.length) return -1
|
|
92
|
-
if (index >= b.prerelease.length) return 1
|
|
93
|
-
const leftPart = a.prerelease[index]
|
|
94
|
-
const rightPart = b.prerelease[index]
|
|
95
|
-
if (leftPart === rightPart) continue
|
|
96
|
-
const leftNumeric = /^[0-9]+$/.test(leftPart)
|
|
97
|
-
const rightNumeric = /^[0-9]+$/.test(rightPart)
|
|
98
|
-
if (leftNumeric && rightNumeric) return Number(leftPart) > Number(rightPart) ? 1 : -1
|
|
99
|
-
if (leftNumeric !== rightNumeric) return leftNumeric ? -1 : 1
|
|
100
|
-
return leftPart > rightPart ? 1 : -1
|
|
101
|
-
}
|
|
102
|
-
return 0
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
/**
|
|
106
|
-
* 已核实破坏面清单(每条 = 一个已被 DSH alpha 移除/迁移的旧标识)。
|
|
107
|
-
* 客户端按 `plugin.compat.break.<id>` 取词典文案;layer 决定扫描目标(manifest=package.json
|
|
108
|
-
* 依赖键与 dsh 字段,code=入口文件非注释文本)。
|
|
109
|
-
*
|
|
110
|
-
* since: 该破坏面首次生效的 DSH 版本。
|
|
111
|
-
* kind: 破坏类型(package-removed / slot-retired / method-removed / hash-migrated / event-removed / attribute-removed)。
|
|
112
|
-
*
|
|
113
|
-
* `severity: 'info'`(可选,默认 warning)把该条降为提示档:命中的插件仍逐条列出,但走
|
|
114
|
-
* 蓝色提示行、不把检查项拉成 warning(也就不进概览可行动项)。适用于两类:旧标识仍被
|
|
115
|
-
* 注册但插件照常运行的退役接口(如为旧宿主保留的退役槽位注册);标识确已移除、但多数
|
|
116
|
-
* 引用形态只是静默退化而插件照常运行,文本扫描又分不清形态轻重的(如 settingsScope 的
|
|
117
|
-
* 回调形态引用——声明式 inject 才会挂起,见该条目注释)。
|
|
118
|
-
*/
|
|
119
|
-
export const COMPAT_BREAKS = Object.freeze([
|
|
120
|
-
{
|
|
121
|
-
id: 'client-runtime',
|
|
122
|
-
layer: 'manifest',
|
|
123
|
-
match: '@deepseek-ai/dsh-client-runtime',
|
|
124
|
-
since: '0.1.2-alpha.2',
|
|
125
|
-
kind: 'package-removed',
|
|
126
|
-
},
|
|
127
|
-
{
|
|
128
|
-
id: 'sqlite-persistence',
|
|
129
|
-
layer: 'manifest',
|
|
130
|
-
match: '@deepseek-ai/dsh-session-persistence-sqlite',
|
|
131
|
-
since: '0.1.2-alpha.3',
|
|
132
|
-
kind: 'package-removed',
|
|
133
|
-
},
|
|
134
|
-
{
|
|
135
|
-
id: 'code-runtime',
|
|
136
|
-
layer: 'manifest',
|
|
137
|
-
match: '@deepseek-ai/dsh-code-runtime',
|
|
138
|
-
since: '0.1.6-alpha.1',
|
|
139
|
-
kind: 'package-removed',
|
|
140
|
-
},
|
|
141
|
-
{
|
|
142
|
-
id: 'e2b-runtime',
|
|
143
|
-
layer: 'manifest',
|
|
144
|
-
match: '@deepseek-ai/dsh-e2b',
|
|
145
|
-
since: '0.1.6-alpha.1',
|
|
146
|
-
kind: 'package-removed',
|
|
147
|
-
},
|
|
148
|
-
{
|
|
149
|
-
id: 'session-start-event',
|
|
150
|
-
layer: 'code',
|
|
151
|
-
match: 'agent/session-start',
|
|
152
|
-
since: '0.1.6-alpha.1',
|
|
153
|
-
kind: 'event-removed',
|
|
154
|
-
},
|
|
155
|
-
{
|
|
156
|
-
id: 'chat-hash',
|
|
157
|
-
layer: 'code',
|
|
158
|
-
match: 'Md3f7G_',
|
|
159
|
-
since: '0.1.2-alpha.2',
|
|
160
|
-
kind: 'hash-migrated',
|
|
161
|
-
},
|
|
162
|
-
{
|
|
163
|
-
id: 'stats-hash',
|
|
164
|
-
layer: 'code',
|
|
165
|
-
match: 'FJxK0a_',
|
|
166
|
-
since: '0.1.2-alpha.2',
|
|
167
|
-
kind: 'hash-migrated',
|
|
168
|
-
},
|
|
169
|
-
{
|
|
170
|
-
id: 'time-hover-root',
|
|
171
|
-
layer: 'code',
|
|
172
|
-
match: 'data-time-hover-root',
|
|
173
|
-
since: '0.1.2-alpha.2',
|
|
174
|
-
kind: 'attribute-removed',
|
|
175
|
-
},
|
|
176
|
-
{
|
|
177
|
-
// 0.1.6-alpha.2 退役设置页槽位:第三方注册它则配置卡片在新插件页不再渲染。
|
|
178
|
-
// 不标 selfExempt:本插件自身也注册该槽位(老宿主仍需),且这条蓝色提示行正是给使用者
|
|
179
|
-
// 的解释——对自己的命中照常列出(研究 §10:dsh-v0.1.6-alpha.2-plugin-impact.md)。
|
|
180
|
-
// severity info:退役槽位只是配置入口搬迁——注入不生效、插件本体照常运行,既非代码错误
|
|
181
|
-
// 也非挂载风险,故走蓝色提示行且不把检查项拉成 warning(与「仅声明残留」同档)。
|
|
182
|
-
id: 'settings-plugin-item',
|
|
183
|
-
layer: 'code',
|
|
184
|
-
match: 'settings.plugin.item',
|
|
185
|
-
since: '0.1.6-alpha.2',
|
|
186
|
-
kind: 'slot-retired',
|
|
187
|
-
severity: 'info',
|
|
188
|
-
},
|
|
189
|
-
{
|
|
190
|
-
// 0.1.6-alpha.2 移除 ISessions 命令式打开方法:第三方调用它则详情页「打开」点击无响应
|
|
191
|
-
// (官方改由 ui-workspace 的 openSession 承载)。call: true = 方法名条目,真实引用以
|
|
192
|
-
// 调用括号结尾——`sessions.open(` 算引用,`sessions.openBar` 前缀误报与词典/提示提及
|
|
193
|
-
// 都不算。已知局限:可选用链 `sessions?.open` 不含该串,扫描不到(研究 §10 建议形态)。
|
|
194
|
-
id: 'sessions-open-method',
|
|
195
|
-
layer: 'code',
|
|
196
|
-
match: 'sessions.open',
|
|
197
|
-
call: true,
|
|
198
|
-
since: '0.1.6-alpha.2',
|
|
199
|
-
kind: 'method-removed',
|
|
200
|
-
},
|
|
201
|
-
{
|
|
202
|
-
// 0.1.7-alpha.1 移除客户端设置服务 settingsScope,官方配置面改由 configForms 承载
|
|
203
|
-
// (研究 §2:dsh-v0.1.7-alpha.1-plugin-impact.md)。实测修订(2026-09-23,dshmarket
|
|
204
|
-
// 1.57.0 真机实证):引用形态决定实际影响——声明式静态 inject / 无守卫的
|
|
205
|
-
// `ctx.settingsScope.*` 直读会让客户端激活挂起(本插件适配前的形态,P0 结论仍成立于
|
|
206
|
-
// 这两种形态);`ctx.inject(['settingsScope'], cb)` 回调形态只是回调不再执行,插件本体
|
|
207
|
-
// 照常激活(dshmarket 的市场主界面注册在回调之外,面板照常可用)。文本扫描分不清这些
|
|
208
|
-
// 形态,按较轻形态整体降为提示档(severity info):命中照常逐条蓝色列出,不再把检查项
|
|
209
|
-
// 拉成 warning;「DSH ≥ 0.1.7-alpha.1」徽标与文案仍引导作者按建议迁移。
|
|
210
|
-
// kind 用新值 service-removed:整只客户端服务被移除,不是单个方法或槽位。
|
|
211
|
-
// selfExempt:本插件设置门面是能力探测(`ctx.get('settingsScope')`,非依赖),对自己的
|
|
212
|
-
// 命中在两档都剔除(探测代码删除时应同步删豁免,而不是让豁免遮住真命中)。
|
|
213
|
-
id: 'settings-scope',
|
|
214
|
-
layer: 'code',
|
|
215
|
-
match: 'settingsScope',
|
|
216
|
-
since: '0.1.7-alpha.1',
|
|
217
|
-
kind: 'service-removed',
|
|
218
|
-
selfExempt: true,
|
|
219
|
-
severity: 'info',
|
|
220
|
-
},
|
|
221
|
-
{
|
|
222
|
-
// 0.1.7-alpha.1 宿主 settings 服务重构为 SettingsForms,移除旧 register(研究 §2.2 S2):
|
|
223
|
-
// 命名空间注册失效(异常被插件 catch 则静默),功能开关停留在默认值、无法持久化。
|
|
224
|
-
// call: true = 方法名条目(同 sessions-open-method)。已知局限:`settings?.register` 守卫
|
|
225
|
-
// 形态不含该串扫不到——守卫本身是正确形态,漏报可接受。
|
|
226
|
-
// selfExempt:本插件宿主半保留旧协议守卫分支(老宿主权威路径,新宿主走 Config 双源叠放)。
|
|
227
|
-
id: 'settings-register',
|
|
228
|
-
layer: 'code',
|
|
229
|
-
match: 'settings.register',
|
|
230
|
-
call: true,
|
|
231
|
-
since: '0.1.7-alpha.1',
|
|
232
|
-
kind: 'method-removed',
|
|
233
|
-
selfExempt: true,
|
|
234
|
-
},
|
|
235
|
-
{
|
|
236
|
-
// 同上:settings.get 一并移除(研究 §5.1 M1)——读宿主命名空间配置(如 llm-pi-ai 的
|
|
237
|
-
// 自定义渠道)拿到空值,功能静默退化。词形较通用(本地 Map/缓存对象的同形调用会误报),
|
|
238
|
-
// 但真实破坏面(配置读取退化)值得上报;误报由「DSH ≥ 0.1.7-alpha.1」徽标与文案引导
|
|
239
|
-
// 人工复核,与 sessions-open-method 的粗粒度取舍同类。
|
|
240
|
-
// selfExempt:本插件 readLlmProviders 的旧源读取分支(settings.get 存在才走)。
|
|
241
|
-
id: 'settings-get',
|
|
242
|
-
layer: 'code',
|
|
243
|
-
match: 'settings.get',
|
|
244
|
-
call: true,
|
|
245
|
-
since: '0.1.7-alpha.1',
|
|
246
|
-
kind: 'method-removed',
|
|
247
|
-
selfExempt: true,
|
|
248
|
-
},
|
|
249
|
-
])
|
|
250
|
-
|
|
251
|
-
/** 本插件包名:双版本兼容的运行时自证豁免只对 loader 里的这个条目生效。 */
|
|
252
|
-
const SELF_PACKAGE = '@gehennawu/dsh-service'
|
|
253
|
-
|
|
254
|
-
/**
|
|
255
|
-
* 警示档 selfExempt 条目(settings-scope / settings-register / settings-get):本插件自身的
|
|
256
|
-
* 能力探测与守卫调用是刻意保留的旧面接入,不是破坏面命中,collectPluginCompat 扫到
|
|
257
|
-
* SELF_PACKAGE 自己时从 hits(可能不兼容档)与 softHits(提示档,settings-scope 现为 info)
|
|
258
|
-
* 两档都剔除,避免诊断页把自己列成不兼容或「引用已退役接口」、检查项常挂 warning。
|
|
259
|
-
* settings-plugin-item 无 selfExempt——本插件自己的蓝色提示行正是给使用者的解释
|
|
260
|
-
* (2026-09-19 真机验证口径)。SCAN_CACHE 缓存原始扫描结果,豁免在收集层做,
|
|
261
|
-
* 纯函数 scanPluginCompatibility 保持无状态。
|
|
262
|
-
*/
|
|
263
|
-
const SELF_EXEMPT_HITS = new Set(
|
|
264
|
-
COMPAT_BREAKS.filter((b) => b.selfExempt === true).map((b) => b.id),
|
|
265
|
-
)
|
|
266
|
-
|
|
267
|
-
const MANIFEST_FIELDS = ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies']
|
|
268
|
-
|
|
269
|
-
/**
|
|
270
|
-
* 状态机扫描:normal 态检查命中;行注释/块注释整段跳过;字符串态内检查命中
|
|
271
|
-
* (className/选择器/querySelector 都以字符串出现),但字符串内的 CSS 块注释跳过
|
|
272
|
-
* ——插件自己的 mobile CSS 注释里就写过旧哈希,注释提及必须不计。
|
|
273
|
-
* @returns 命中的 break id 集合
|
|
274
|
-
*/
|
|
275
|
-
|
|
276
|
-
// 文档性后缀字符集:命中标识后紧跟这些字符的是「句子里的提及」而非代码引用——插件自己的
|
|
277
|
-
// 词典/提示文案会在解释检测项时写出旧标识(如「Md3f7G_,0.1.2-alpha.2 起漂移」),必须不
|
|
278
|
-
// 计为命中;真实引用(className 词干延续、引号/中括号收尾的完整标识)都不在此集。
|
|
279
|
-
const DOCUMENT_TRAIL = /[\u3000-\u303f\u4e00-\u9fff\uff00-\uffef\s,;:!?()]/
|
|
280
|
-
|
|
281
|
-
export function scanCodeHits(text, breaks) {
|
|
282
|
-
const hits = new Set()
|
|
283
|
-
// 判定一次前缀命中的「引用形态」:默认(文档性后缀规则)紧跟字符是文档字符 → 提及(false),
|
|
284
|
-
// 否则 → 引用;方法名条目(call: true)反其道——只有紧跟调用括号 `(` 才算引用(方法调用),
|
|
285
|
-
// 标识符后跟字母的前缀误报(`sessions.openBar`)与「句子提及(」都不算。
|
|
286
|
-
const inString = (mode) => mode !== 'normal' && mode !== 'line' && mode !== 'block'
|
|
287
|
-
const isReferenceAt = (text, i, b) => {
|
|
288
|
-
const match = b.match
|
|
289
|
-
if (!text.startsWith(match, i)) return false
|
|
290
|
-
const next = text[i + match.length]
|
|
291
|
-
if (b.call === true) return next === '('
|
|
292
|
-
if (next === undefined || DOCUMENT_TRAIL.test(next)) return false
|
|
293
|
-
return true
|
|
294
|
-
}
|
|
295
|
-
const tryHit = (text, i) => {
|
|
296
|
-
for (const b of breaks) if (!hits.has(b.id) && isReferenceAt(text, i, b)) hits.add(b.id)
|
|
297
|
-
}
|
|
298
|
-
const n = text.length
|
|
299
|
-
let i = 0
|
|
300
|
-
let mode = 'normal'
|
|
301
|
-
while (i < n) {
|
|
302
|
-
const ch = text[i]
|
|
303
|
-
if (mode === 'line') {
|
|
304
|
-
if (ch === '\n') mode = 'normal'
|
|
305
|
-
i += 1
|
|
306
|
-
continue
|
|
307
|
-
}
|
|
308
|
-
if (mode === 'block') {
|
|
309
|
-
if (ch === '*' && text[i + 1] === '/') {
|
|
310
|
-
mode = 'normal'
|
|
311
|
-
i += 2
|
|
312
|
-
} else i += 1
|
|
313
|
-
continue
|
|
314
|
-
}
|
|
315
|
-
if (inString(mode)) {
|
|
316
|
-
if (ch === '\\') {
|
|
317
|
-
i += 2
|
|
318
|
-
continue
|
|
319
|
-
}
|
|
320
|
-
if (ch === mode) {
|
|
321
|
-
mode = 'normal'
|
|
322
|
-
i += 1
|
|
323
|
-
continue
|
|
324
|
-
}
|
|
325
|
-
// 字符串内的 CSS 块注释(styleTag 文本等里的注释提及)
|
|
326
|
-
if (ch === '/' && text[i + 1] === '*') {
|
|
327
|
-
const end = text.indexOf('*/', i + 2)
|
|
328
|
-
if (end === -1) {
|
|
329
|
-
i = n
|
|
330
|
-
continue
|
|
331
|
-
}
|
|
332
|
-
i = end + 2
|
|
333
|
-
continue
|
|
334
|
-
}
|
|
335
|
-
tryHit(text, i)
|
|
336
|
-
i += 1
|
|
337
|
-
continue
|
|
338
|
-
}
|
|
339
|
-
if (ch === '/' && text[i + 1] === '/') {
|
|
340
|
-
mode = 'line'
|
|
341
|
-
i += 2
|
|
342
|
-
continue
|
|
343
|
-
}
|
|
344
|
-
if (ch === '/' && text[i + 1] === '*') {
|
|
345
|
-
mode = 'block'
|
|
346
|
-
i += 2
|
|
347
|
-
continue
|
|
348
|
-
}
|
|
349
|
-
if (ch === '"' || ch === "'" || ch === '`') {
|
|
350
|
-
mode = ch
|
|
351
|
-
i += 1
|
|
352
|
-
continue
|
|
353
|
-
}
|
|
354
|
-
tryHit(text, i)
|
|
355
|
-
i += 1
|
|
356
|
-
}
|
|
357
|
-
return hits
|
|
358
|
-
}
|
|
359
|
-
|
|
360
|
-
/** manifest 层命中:依赖键集合 + dsh 字段(dsh.client.inject 是插件浏览器供应商声明处)。 */
|
|
361
|
-
export function collectManifestHits(pkg, breaks) {
|
|
362
|
-
const hits = new Set()
|
|
363
|
-
const fragments = []
|
|
364
|
-
for (const field of MANIFEST_FIELDS) {
|
|
365
|
-
const obj = pkg[field]
|
|
366
|
-
if (obj !== null && typeof obj === 'object' && !Array.isArray(obj)) fragments.push(...Object.keys(obj))
|
|
367
|
-
}
|
|
368
|
-
if (pkg.dsh !== undefined) fragments.push(JSON.stringify(pkg.dsh))
|
|
369
|
-
const haystack = JSON.stringify(fragments)
|
|
370
|
-
for (const b of breaks) if (!hits.has(b.id) && haystack.includes(b.match)) hits.add(b.id)
|
|
371
|
-
return hits
|
|
372
|
-
}
|
|
373
|
-
|
|
374
|
-
/**
|
|
375
|
-
* 包入口文件解析:exports['./client'] / exports['.'] / main / index.js 兜底。
|
|
376
|
-
* 只接受包目录内的相对路径(拒绝绝对路径与 .. 段);目录形态追加 index.js。
|
|
377
|
-
*/
|
|
378
|
-
export function packageEntryFiles(pkgDir, pkg) {
|
|
379
|
-
const files = []
|
|
380
|
-
const push = (rel) => {
|
|
381
|
-
if (typeof rel !== 'string' || rel === '' || rel.startsWith('/') || rel.split('/').some((segment) => segment === '..')) return
|
|
382
|
-
let target = resolve(pkgDir, rel)
|
|
383
|
-
if (!target.startsWith(pkgDir)) return
|
|
384
|
-
try {
|
|
385
|
-
if (statSync(target).isDirectory()) target = join(target, 'index.js')
|
|
386
|
-
if (statSync(target).isFile()) files.push(target)
|
|
387
|
-
} catch (_) {}
|
|
388
|
-
}
|
|
389
|
-
const exportsField = pkg.exports
|
|
390
|
-
if (exportsField !== null && typeof exportsField === 'object' && !Array.isArray(exportsField)) {
|
|
391
|
-
const pick = (spec) => {
|
|
392
|
-
if (typeof spec === 'string') return spec
|
|
393
|
-
if (spec !== null && typeof spec === 'object') return spec.default ?? spec.import ?? spec.require
|
|
394
|
-
return undefined
|
|
395
|
-
}
|
|
396
|
-
push(pick(exportsField['./client']))
|
|
397
|
-
push(pick(exportsField['.']))
|
|
398
|
-
}
|
|
399
|
-
push(pkg.main)
|
|
400
|
-
if (files.length === 0) push('index.js')
|
|
401
|
-
return files
|
|
402
|
-
}
|
|
403
|
-
|
|
404
|
-
/** 进程级扫描缓存:moduleName → scanPluginCompatibility 结果。 */
|
|
405
|
-
const SCAN_CACHE = new Map()
|
|
406
|
-
|
|
407
|
-
/**
|
|
408
|
-
* 扫描单个插件包(纯函数,测试可注入 requireFn 与选项)。
|
|
409
|
-
*
|
|
410
|
-
* 输出分级(用户实测「声明了但没使用」后定稿——官方 client module loader 对 dsh.client.inject
|
|
411
|
-
* 里缺失的供应商是静默跳过(arriveGraphRow 对 graphRows.get() === undefined 直接 continue),
|
|
412
|
-
* 因此 manifest 命中的危害取决于插件代码是否真的引用了该标识):
|
|
413
|
-
* - hits:真引用(code 层命中,或 manifest 标识在入口代码中被引用)→ “可能不兼容”;
|
|
414
|
-
* - softHits:命中来自 `severity: 'info'` 的条目 → 退役接口提示(蓝色、不拉高检查项);
|
|
415
|
-
* - declaredOnly:manifest 声明了已移除标识、但入口代码零引用 → 无害残留,仅提醒作者清理;
|
|
416
|
-
* - unknown:包不可解析/入口缺失/超限。
|
|
417
|
-
*
|
|
418
|
-
* @param requireFn createRequire 替身(resolve `${pkg}/package.json`)
|
|
419
|
-
* @param moduleName loader 条目模块说明符
|
|
420
|
-
* @param options.maxFileBytes 单入口文件上限(默认 4 MiB)
|
|
421
|
-
* @returns { hits: string[], softHits: string[], declaredOnly: string[], unknown: string | null, files: number, bytes: number }
|
|
422
|
-
*/
|
|
423
|
-
export function scanPluginCompatibility(requireFn, moduleName, options = {}) {
|
|
424
|
-
const maxFileBytes = options.maxFileBytes ?? 4 * 1024 * 1024
|
|
425
|
-
const pkgName = packageNameOf(moduleName)
|
|
426
|
-
if (pkgName === null) return { hits: [], softHits: [], declaredOnly: [], unknown: 'unresolved', files: 0, bytes: 0 }
|
|
427
|
-
let pkgPath
|
|
428
|
-
try {
|
|
429
|
-
pkgPath = requireFn(`${pkgName}/package.json`)
|
|
430
|
-
} catch (_) {
|
|
431
|
-
// exports 字段未导出 './package.json' 的包:回退主入口(exports['.']/main)定位包根。
|
|
432
|
-
try {
|
|
433
|
-
pkgPath = join(dirname(requireFn(pkgName)), 'package.json')
|
|
434
|
-
} catch (_) {
|
|
435
|
-
return { hits: [], softHits: [], declaredOnly: [], unknown: 'unresolved', files: 0, bytes: 0 }
|
|
436
|
-
}
|
|
437
|
-
}
|
|
438
|
-
const pkgDir = dirname(pkgPath)
|
|
439
|
-
let pkg
|
|
440
|
-
try {
|
|
441
|
-
pkg = JSON.parse(readFileSync(pkgPath, 'utf8'))
|
|
442
|
-
} catch (_) {
|
|
443
|
-
return { hits: [], softHits: [], declaredOnly: [], unknown: 'unresolved', files: 0, bytes: 0 }
|
|
444
|
-
}
|
|
445
|
-
const manifestBreaks = COMPAT_BREAKS.filter((b) => b.layer === 'manifest')
|
|
446
|
-
const codeBreaks = COMPAT_BREAKS.filter((b) => b.layer === 'code')
|
|
447
|
-
// 提示档条目(severity info)单列:命中照旧逐条上报,只是不升级成「可能不兼容」。
|
|
448
|
-
const softIds = new Set(COMPAT_BREAKS.filter((b) => b.severity === 'info').map((b) => b.id))
|
|
449
|
-
const declaredOnly = [...collectManifestHits(pkg, manifestBreaks)]
|
|
450
|
-
const hits = []
|
|
451
|
-
const softHits = []
|
|
452
|
-
const record = (id) => {
|
|
453
|
-
const bucket = softIds.has(id) ? softHits : hits
|
|
454
|
-
if (bucket.indexOf(id) === -1) bucket.push(id)
|
|
455
|
-
}
|
|
456
|
-
const files = packageEntryFiles(pkgDir, pkg)
|
|
457
|
-
let bytes = 0
|
|
458
|
-
for (const file of files) {
|
|
459
|
-
let text
|
|
460
|
-
try {
|
|
461
|
-
const info = statSync(file)
|
|
462
|
-
if (info.size > maxFileBytes) return { hits, softHits, declaredOnly, unknown: 'too-large', files, bytes }
|
|
463
|
-
bytes += info.size
|
|
464
|
-
text = readFileSync(file, 'utf8')
|
|
465
|
-
} catch (_) {
|
|
466
|
-
continue
|
|
467
|
-
}
|
|
468
|
-
// manifest 层复核:供应商被真实 require/import 调用(调用形态,见 manifestCallRefs)才升级为
|
|
469
|
-
// 真引用;字符串化的 `require(\"...\")` 示例与文档提及留在 declaredOnly。
|
|
470
|
-
if (declaredOnly.length > 0) {
|
|
471
|
-
for (const id of manifestCallRefs(text, manifestBreaks)) {
|
|
472
|
-
record(id)
|
|
473
|
-
const at = declaredOnly.indexOf(id)
|
|
474
|
-
if (at !== -1) declaredOnly.splice(at, 1)
|
|
475
|
-
}
|
|
476
|
-
}
|
|
477
|
-
// code 层照旧:状态机剥注释 + 引用形态判定(className/选择器在字符串内也算引用)。
|
|
478
|
-
if (codeBreaks.some((b) => text.includes(b.match))) {
|
|
479
|
-
for (const id of scanCodeHits(text, codeBreaks)) record(id)
|
|
480
|
-
}
|
|
481
|
-
}
|
|
482
|
-
return {
|
|
483
|
-
hits,
|
|
484
|
-
softHits,
|
|
485
|
-
declaredOnly,
|
|
486
|
-
unknown: hits.length === 0 && softHits.length === 0 && declaredOnly.length === 0 && files.length === 0 ? 'missing-entry' : null,
|
|
487
|
-
files: files.length,
|
|
488
|
-
bytes,
|
|
489
|
-
}
|
|
490
|
-
}
|
|
491
|
-
|
|
492
|
-
function escapeRegex(text) {
|
|
493
|
-
return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
494
|
-
}
|
|
495
|
-
|
|
496
|
-
/**
|
|
497
|
-
* manifest 层供应商的「真实调用」复核:包名以 `require('…')` / `require("…")` /
|
|
498
|
-
* `import('…')` / `import "…"` / `from "…"` 形态出现才算代码引用。引号前必须不是反斜杠
|
|
499
|
-
* ——被字符串化的文档/迁移示例写的是 `require(\"…\")`(引号带转义,dsh-dream-skin 实证),
|
|
500
|
-
* 真调用不会加反斜杠。
|
|
501
|
-
* @returns 命中的 manifest break id 列表
|
|
502
|
-
*/
|
|
503
|
-
export function manifestCallRefs(text, manifestBreaks) {
|
|
504
|
-
const found = []
|
|
505
|
-
for (const b of manifestBreaks) {
|
|
506
|
-
const re = new RegExp(`(?:require\\s*\\(\\s*|\\bimport\\s*(?:\\(\\s*)?|\\bfrom\\s+|\\bimport\\s+)(?<!\\\\)['"]${escapeRegex(b.match)}['"]`)
|
|
507
|
-
if (re.test(text)) found.push(b.id)
|
|
508
|
-
}
|
|
509
|
-
return found
|
|
510
|
-
}
|
|
511
|
-
|
|
512
|
-
export function enrichBreak(breakId, dshVersion) {
|
|
513
|
-
const meta = COMPAT_BREAKS.find((b) => b.id === breakId)
|
|
514
|
-
if (!meta) return { id: breakId, since: 'unknown', kind: 'unknown', severity: 'warning', active: true }
|
|
515
|
-
const active = dshVersion && dshVersion !== 'unknown' ? compareSemver(dshVersion, meta.since) >= 0 : true
|
|
516
|
-
return {
|
|
517
|
-
id: meta.id,
|
|
518
|
-
since: meta.since,
|
|
519
|
-
kind: meta.kind,
|
|
520
|
-
severity: meta.severity || 'warning',
|
|
521
|
-
active,
|
|
522
|
-
}
|
|
523
|
-
}
|
|
524
|
-
|
|
525
|
-
/**
|
|
526
|
-
* 收集启用插件的兼容性扫描结果(顺序 = loader 条目序,稳定可测)。
|
|
527
|
-
* @param ctx 宿主插件上下文(ctx.get('loader'))
|
|
528
|
-
* @param options 透传 scanPluginCompatibility 选项(requireFn/maxFileBytes)与缓存开关(noCache)
|
|
529
|
-
* @returns { available, scanned, issues: [{moduleName, breaks[]}], soft: [{moduleName, breaks[]}], declaredOnly: [{moduleName, breaks[]}], unknown: [{moduleName, reason}] }
|
|
530
|
-
*/
|
|
531
|
-
export async function collectPluginCompat(ctx, options = {}) {
|
|
532
|
-
const loader = ctx.get('loader')
|
|
533
|
-
if (loader === undefined) return { available: false, scanned: 0, issues: [], soft: [], declaredOnly: [], unknown: [] }
|
|
534
|
-
// 真机 ctx 是服务代理:未提供服务的属性直读(ctx.dshVersion 之类)会抛
|
|
535
|
-
// cannot get property "<prop>" without inject,让整个检查被降级 unavailable。
|
|
536
|
-
// 本函数只允许 ctx.get('loader') 与 ctx.baseUrl(Context 实体字段,恒存在)。
|
|
537
|
-
let requireFn = options.requireFn
|
|
538
|
-
if (typeof requireFn !== 'function') {
|
|
539
|
-
const baseUrl = typeof loader.ctx?.baseUrl === 'string' && loader.ctx.baseUrl.length > 0
|
|
540
|
-
? loader.ctx.baseUrl
|
|
541
|
-
: typeof ctx.baseUrl === 'string' && ctx.baseUrl.length > 0
|
|
542
|
-
? ctx.baseUrl
|
|
543
|
-
: undefined
|
|
544
|
-
// 必须是 .resolve 而非直接 require:require 会加载求值模块,对 JSON 返回对象而非路径。
|
|
545
|
-
const baseRequire = baseUrl === undefined ? createRequire(import.meta.url) : createRequire(baseUrl)
|
|
546
|
-
requireFn = (specifier) => baseRequire.resolve(specifier)
|
|
547
|
-
}
|
|
548
|
-
const issues = []
|
|
549
|
-
const soft = []
|
|
550
|
-
const declaredOnly = []
|
|
551
|
-
const unknown = []
|
|
552
|
-
let scanned = 0
|
|
553
|
-
for (const entry of loader.entries()) {
|
|
554
|
-
if (entry?.options?.group === true) continue
|
|
555
|
-
const moduleName = entry?.options?.name
|
|
556
|
-
if (typeof entry?.id !== 'string' || typeof moduleName !== 'string') continue
|
|
557
|
-
if (entry.disabled === true) continue
|
|
558
|
-
// 官方内置插件跳过且不计 scanned:@deepseek-ai/* 随 DSH 发行版统一构建分发,
|
|
559
|
-
// 内部版本天生自洽,不是第三方扩展;且部分大体积内置组件(如 documentpreview 6.5MB)
|
|
560
|
-
// 会误触发单文件扫描上限。
|
|
561
|
-
if (options.includeOfficial !== true && moduleName.startsWith('@deepseek-ai/')) continue
|
|
562
|
-
// 无法形成合法包名的条目跳过且不计 scanned:`cordis:include` 这类 Cordis 内建声明名、
|
|
563
|
-
// 相对路径/`file:` 形态不是可扫描的 npm 插件(没有清单可查),不该渲染「未能扫描」提示。
|
|
564
|
-
if (packageNameOf(moduleName) === null) continue
|
|
565
|
-
scanned += 1
|
|
566
|
-
let result
|
|
567
|
-
if (options.noCache === true) {
|
|
568
|
-
result = scanPluginCompatibility(requireFn, moduleName, options)
|
|
569
|
-
} else {
|
|
570
|
-
const cached = SCAN_CACHE.get(moduleName)
|
|
571
|
-
if (cached !== undefined) result = cached
|
|
572
|
-
else {
|
|
573
|
-
result = scanPluginCompatibility(requireFn, moduleName, options)
|
|
574
|
-
SCAN_CACHE.set(moduleName, result)
|
|
575
|
-
}
|
|
576
|
-
}
|
|
577
|
-
if (result.unknown !== null) {
|
|
578
|
-
unknown.push({ moduleName, reason: result.unknown })
|
|
579
|
-
} else {
|
|
580
|
-
// 警示档 selfExempt 条目对本插件自己两档都不列(见 SELF_EXEMPT_HITS 注释);第三方同名命中不受影响。
|
|
581
|
-
const isSelf = moduleName === SELF_PACKAGE
|
|
582
|
-
const hits = isSelf ? result.hits.filter((id) => !SELF_EXEMPT_HITS.has(id)) : result.hits
|
|
583
|
-
const softHits = isSelf ? result.softHits.filter((id) => !SELF_EXEMPT_HITS.has(id)) : result.softHits
|
|
584
|
-
if (hits.length > 0) issues.push({ moduleName, breaks: hits })
|
|
585
|
-
if (softHits.length > 0) soft.push({ moduleName, breaks: softHits })
|
|
586
|
-
if (result.declaredOnly.length > 0) declaredOnly.push({ moduleName, breaks: result.declaredOnly })
|
|
587
|
-
}
|
|
588
|
-
}
|
|
589
|
-
return { available: true, scanned, issues, soft, declaredOnly, unknown }
|
|
590
|
-
}
|
|
591
|
-
|
|
592
|
-
/**
|
|
593
|
-
* 聚合为诊断检查项(追加在 checks 尾部)。detail 五段
|
|
594
|
-
* `scanned:broken:declaredOnly:unknown:soft`——前四段与旧口径逐字相同(旧客户端按四段读,
|
|
595
|
-
* 多出的第五段被忽略),soft 追加在尾部保证前后兼容;客户端解析。
|
|
596
|
-
* - broken>0 → warning(「可能不兼容」是风险提示,不是已损坏的 error);
|
|
597
|
-
* - 仅 soft(退役接口,插件照常运行)→ info:蓝色提示行,不拉高 overall、不进概览可行动项;
|
|
598
|
-
* - 仅 declaredOnly(声明残留、代码未引用,官方 loader fail-open 无害)与 unknown 不改变状态,
|
|
599
|
-
* 只在摘要提及。
|
|
600
|
-
*/
|
|
601
|
-
export function pluginCompatCheckItem(report) {
|
|
602
|
-
const soft = report.soft ?? []
|
|
603
|
-
const issues = report.issues ?? []
|
|
604
|
-
const declaredOnly = report.declaredOnly ?? []
|
|
605
|
-
const unknown = report.unknown ?? []
|
|
606
|
-
return {
|
|
607
|
-
id: 'plugin-compat',
|
|
608
|
-
status: issues.length > 0 ? 'warning' : soft.length > 0 ? 'info' : 'ok',
|
|
609
|
-
detail: `${report.scanned}:${issues.length}:${declaredOnly.length}:${unknown.length}:${soft.length}`,
|
|
610
|
-
}
|
|
611
|
-
}
|