@luolingp/request-doctor 0.0.0-stage → 0.11.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.
- package/LICENSE +21 -0
- package/README.md +184 -3
- package/client.js +174 -0
- package/cordis.patch.yml +16 -0
- package/docs/00-/346/226/207/346/241/243/347/264/242/345/274/225.md +26 -0
- package/docs/01-/350/275/257/344/273/266/351/234/200/346/261/202/350/247/204/346/240/274/350/257/264/346/230/216.md +191 -0
- package/docs/02-/350/275/257/344/273/266/350/256/276/350/256/241/350/257/264/346/230/216.md +391 -0
- package/docs/03-/350/275/257/344/273/266/346/265/213/350/257/225/346/212/245/345/221/212.md +212 -0
- package/docs/04-/345/256/211/350/243/205/351/203/250/347/275/262/350/257/264/346/230/216.md +176 -0
- package/docs/05-/347/224/250/346/210/267/346/211/213/345/206/214.md +181 -0
- package/docs/06-/350/275/257/344/273/266/350/264/250/351/207/217/345/272/246/351/207/217/346/212/245/345/221/212.md +109 -0
- package/docs/quality-report.json +27 -0
- package/icon.svg +12 -0
- package/index.js +464 -0
- package/lib/arbitrate.js +45 -0
- package/lib/cache-store.js +79 -0
- package/lib/classify.js +389 -0
- package/lib/compat-env.js +490 -0
- package/lib/compat-repair.js +196 -0
- package/lib/compat.js +332 -0
- package/lib/diagnostics.js +77 -0
- package/lib/env-verify.js +160 -0
- package/lib/install-bundle.js +199 -0
- package/lib/install-rescue.js +161 -0
- package/lib/install-support.js +206 -0
- package/lib/model-repair.js +190 -0
- package/lib/package-fetch.js +181 -0
- package/lib/plugin-discovery.js +232 -0
- package/lib/repair-listeners.js +126 -0
- package/lib/retry-ledger.js +67 -0
- package/lib/runtime-probe.js +171 -0
- package/lib/semver.js +159 -0
- package/lib/sleep.js +18 -0
- package/lib/stat-cache.js +67 -0
- package/lib/symbol-alias.js +44 -0
- package/lib/timers.js +39 -0
- package/lib/version-repair.js +495 -0
- package/lib/version-search.js +44 -0
- package/locale/en.json +17 -0
- package/locale/zh.json +17 -0
- package/package.json +51 -3
package/lib/compat.js
ADDED
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
// 判断一个声明的 DSH 依赖与当前运行时对不上的插件,到底能不能真的跑起来。
|
|
2
|
+
//
|
|
3
|
+
// 版本不匹配只是"可能不能用"的说法,不是"一定不能用"的证据。这里查两件真正决定
|
|
4
|
+
// 兼容性的事:
|
|
5
|
+
//
|
|
6
|
+
// 1. 声明的要求离当前版本有多远。差一个预发布号和一个大版本完全不是一回事。
|
|
7
|
+
// 2. API 表面。插件从 DSH 包里导入的每个符号,在现行运行时里是否还导出。
|
|
8
|
+
// 对得上就说明能跑,缺一个就说明跑不了——这跟 manifest 怎么写没关系。
|
|
9
|
+
//
|
|
10
|
+
// 结论决定后续动作:compatible 才会被自动适配,其余一律只报告。
|
|
11
|
+
import { classifyDistance, satisfies } from './semver.js';
|
|
12
|
+
|
|
13
|
+
// 收集一个模块从 @deepseek-ai/* 具名导入的符号。
|
|
14
|
+
//
|
|
15
|
+
// 只认具名导入:这是插件和运行时之间的编译期契约。默认导入没有符号名可验,
|
|
16
|
+
// 只按"依赖了这个包"记账。
|
|
17
|
+
export function collectDshImports(source) {
|
|
18
|
+
const found = new Map();
|
|
19
|
+
const pattern = /\bimport\s+(?:([A-Za-z_$][\w$]*)\s*,?\s*)?(?:\{([^}]*)\}|\*\s+as\s+[A-Za-z_$][\w$]*)?\s*from\s*["'](@deepseek-ai\/[^"']+)["']/g;
|
|
20
|
+
let match;
|
|
21
|
+
while ((match = pattern.exec(source)) !== null) {
|
|
22
|
+
const [, defaultBinding, braces, packageName] = match;
|
|
23
|
+
const symbols = found.get(packageName) ?? [];
|
|
24
|
+
if (braces !== undefined && braces.trim() !== '') {
|
|
25
|
+
for (const part of braces.split(',')) {
|
|
26
|
+
const piece = part.trim();
|
|
27
|
+
if (piece === '') continue;
|
|
28
|
+
// import { original as local } 这种写法,运行时必须导出 original
|
|
29
|
+
const imported = /^([A-Za-z_$][\w$]*|"[^"]+"|\$)\s+as\s+[A-Za-z_$][\w$]*$/.exec(piece);
|
|
30
|
+
if (imported !== null) {
|
|
31
|
+
symbols.push(imported[1].replace(/^"|"$/g, ''));
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
const plain = /^([A-Za-z_$][\w$]*|\$)$/.exec(piece);
|
|
35
|
+
if (plain !== null) symbols.push(plain[1]);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
if (defaultBinding !== undefined) symbols.push('default');
|
|
39
|
+
found.set(packageName, [...new Set(symbols)]);
|
|
40
|
+
}
|
|
41
|
+
return found;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// 去掉子路径,留下裸包名。`@scope/pkg/sub` 取 `@scope/pkg`,`pkg/sub` 取 `pkg`。
|
|
45
|
+
//
|
|
46
|
+
// 插件经常从子路径导入(`pkg/lib/extra.js`)。子路径不是独立的包,凡是要按包处理的
|
|
47
|
+
// 地方都得先过这一道,否则会去一个不存在的位置找 package.json。
|
|
48
|
+
export function bareNameOf(specifier) {
|
|
49
|
+
const parts = String(specifier).split('/');
|
|
50
|
+
return parts[0].startsWith('@') ? parts.slice(0, 2).join('/') : parts[0];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// 这个缺失的符号能不能靠装旧版本解决。
|
|
54
|
+
//
|
|
55
|
+
// 依据只有一条:插件声明了对这个包的依赖范围,而范围指向的版本不是当前运行时。
|
|
56
|
+
// 那种情况旧版本还在仓库里,取下来就有的救。声明就是当前版本、或者压根没声明,
|
|
57
|
+
// 说明插件本来就不该指望旧东西,如实判为不兼容。
|
|
58
|
+
function canUseOlderVersion(entry, input) {
|
|
59
|
+
const packageName = entry.split(':')[0];
|
|
60
|
+
const declared = input.requirements?.[packageName];
|
|
61
|
+
if (typeof declared !== 'string') return false;
|
|
62
|
+
const pinned = /\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?/.exec(declared)?.[0];
|
|
63
|
+
if (pinned === undefined) return false;
|
|
64
|
+
return pinned !== input.runtimeVersion;
|
|
65
|
+
}
|
|
66
|
+
// 收集一个模块注册的 LLM provider 路由。
|
|
67
|
+
//
|
|
68
|
+
// 注册了别人已经在提供的 provider 时插件根本起不来(registerAdapter 抛
|
|
69
|
+
// DUPLICATE_ADAPTER),而这跟 manifest 里的 peer 范围毫无关系,所以审计必须单独查。
|
|
70
|
+
//
|
|
71
|
+
// 认得几种常见写法:registerAdapter(['a'], ...)、registerAdapter([PROVIDER], ...)
|
|
72
|
+
// (PROVIDER 是本地字符串常量),以及适配器描述对象的 providerId 字段。
|
|
73
|
+
export function collectProviderIds(source) {
|
|
74
|
+
const ids = new Set();
|
|
75
|
+
// 先把本地字符串常量收起来,这样 [PROVIDER] 才解得出来
|
|
76
|
+
const constants = new Map();
|
|
77
|
+
const constPattern = /\bconst\s+([A-Za-z_$][\w$]*)\s*=\s*["']([^"'\n]{1,80})["']/g;
|
|
78
|
+
let match;
|
|
79
|
+
while ((match = constPattern.exec(source)) !== null) constants.set(match[1], match[2]);
|
|
80
|
+
|
|
81
|
+
const resolveTerm = (raw) => {
|
|
82
|
+
const term = raw.trim();
|
|
83
|
+
if (term === '') return undefined;
|
|
84
|
+
const literal = /^["']([^"'\n]{1,80})["']$/.exec(term);
|
|
85
|
+
if (literal !== null) return literal[1];
|
|
86
|
+
const identifier = /^([A-Za-z_$][\w$]*)$/.exec(term);
|
|
87
|
+
if (identifier !== null) return constants.get(identifier[1]);
|
|
88
|
+
return undefined;
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
const callPattern = /registerAdapter\s*\(\s*\[([^\]]*)\]/g;
|
|
92
|
+
while ((match = callPattern.exec(source)) !== null) {
|
|
93
|
+
for (const part of match[1].split(',')) {
|
|
94
|
+
const id = resolveTerm(part);
|
|
95
|
+
if (id !== undefined) ids.add(id);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
const providerField = /\bproviderId\s*:\s*([^,\n}]+)/g;
|
|
99
|
+
while ((match = providerField.exec(source)) !== null) {
|
|
100
|
+
const id = resolveTerm(match[1]);
|
|
101
|
+
if (id !== undefined) ids.add(id);
|
|
102
|
+
}
|
|
103
|
+
return [...ids];
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// 插件是否通过 [variant.id] 这种变体对象注册路由。
|
|
107
|
+
//
|
|
108
|
+
// 打包的插件经常这么写,而 variant 来自另一个 chunk 里的共享表,所以这条语句本身
|
|
109
|
+
// 看不到具体 id。那就把这个插件模块图里所有 id 字面量都算进来——宁可多报,
|
|
110
|
+
// 因为其中任何一个都可能是真正注册的那条路由。
|
|
111
|
+
export function registersVariantRoute(source) {
|
|
112
|
+
return /registerAdapter\s*\(\s*\[\s*[A-Za-z_$][\w$]*\.id\s*\]/.test(source);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// 找出一个模块里的变体 id 字面量,用来还原 [variant.id] 那种路由。
|
|
116
|
+
//
|
|
117
|
+
// 靠"形状"认:一个 id 旁边同时有 displayName 和 region。同一份文件里往往还打包了
|
|
118
|
+
// 模型清单,那些条目没有 region,靠这一条就能排掉——否则结果会混进几十个模型 id,
|
|
119
|
+
// 把不相关的插件也判成冲突。
|
|
120
|
+
export function collectIdLiterals(source) {
|
|
121
|
+
const ids = new Set();
|
|
122
|
+
const pattern = /\bid\s*:\s*["']([^"'\n]{1,80})["'][\s\S]{0,600}?\bregion\s*:\s*["'][^"'\n]*["']/g;
|
|
123
|
+
let match;
|
|
124
|
+
while ((match = pattern.exec(source)) !== null) ids.add(match[1]);
|
|
125
|
+
return [...ids];
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// 找出 manifest 里声明了、但当前运行时不满足的那些 DSH 要求。
|
|
129
|
+
//
|
|
130
|
+
// 跟 DSH 自己的规则保持一致:有 engines.dsh 就只看它,否则每个 @deepseek-ai/dsh*
|
|
131
|
+
// peer 都算数。之所以自己算一遍,是因为 Loader 只报告它拒绝掉的包,已经加载着的
|
|
132
|
+
// 就算不匹配也不会出现在拒绝列表里。
|
|
133
|
+
export function unmetPeers(manifest, runtimeVersion) {
|
|
134
|
+
const unmet = {};
|
|
135
|
+
const engines = manifest?.engines;
|
|
136
|
+
if (engines !== null && typeof engines === 'object' && typeof engines.dsh === 'string') {
|
|
137
|
+
if (satisfies(runtimeVersion, engines.dsh) !== true) unmet['engines.dsh'] = engines.dsh;
|
|
138
|
+
return unmet;
|
|
139
|
+
}
|
|
140
|
+
const peers = manifest?.peerDependencies;
|
|
141
|
+
if (peers === null || typeof peers !== 'object' || Array.isArray(peers)) return unmet;
|
|
142
|
+
for (const [name, range] of Object.entries(peers)) {
|
|
143
|
+
// 只比运行时自己发布的那几个。schemastery 的 ^3.18.5、cordis 的 ^4.0.2
|
|
144
|
+
// 是它们自己的版本线,拿去跟 DSH 版本比毫无意义。
|
|
145
|
+
if (!/^@deepseek-ai\/dsh(?:[-/]|$)/.test(name) || typeof range !== 'string') continue;
|
|
146
|
+
const workspace = ['workspace:^', 'workspace:~', 'workspace:*'].includes(range);
|
|
147
|
+
if (satisfies(runtimeVersion, workspace ? runtimeVersion : range) !== true) unmet[name] = range;
|
|
148
|
+
}
|
|
149
|
+
return unmet;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
// 不执行被测包,只把它的导出名读出来。
|
|
154
|
+
//
|
|
155
|
+
// DSH 自带的包都是单文件 ESM 构建,最后一句就是 export { a, b as c, ... }。
|
|
156
|
+
// 解析这份清单就足够回答"运行时还导不导出插件要的东西",而完全不需要把包跑起来。
|
|
157
|
+
export function parseExportedNames(entryPath, readText) {
|
|
158
|
+
let source;
|
|
159
|
+
try {
|
|
160
|
+
source = readText(entryPath);
|
|
161
|
+
} catch {
|
|
162
|
+
return undefined;
|
|
163
|
+
}
|
|
164
|
+
const names = new Set();
|
|
165
|
+
for (const name of namedExports(source)) names.add(name);
|
|
166
|
+
for (const name of inlineExports(source)) names.add(name);
|
|
167
|
+
if (/\bexport\s+default\b/.test(source)) names.add('default');
|
|
168
|
+
return names.size === 0 ? undefined : [...names];
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// 从 export { a, b as c } 这种语句里读导出名。
|
|
172
|
+
function namedExports(source) {
|
|
173
|
+
const found = [];
|
|
174
|
+
const pattern = /\bexport\s*\{([^}]*)\}/g;
|
|
175
|
+
let match;
|
|
176
|
+
while ((match = pattern.exec(source)) !== null) {
|
|
177
|
+
for (const part of match[1].split(',')) {
|
|
178
|
+
const name = exportedNameOf(part.trim());
|
|
179
|
+
if (name !== undefined) found.push(name);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
return found;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// 把清单里的一项换算成导入方实际拿到的名字。
|
|
186
|
+
function exportedNameOf(piece) {
|
|
187
|
+
if (piece === '') return undefined;
|
|
188
|
+
// original as exported 时,别人用的是 exported 这个名字
|
|
189
|
+
const alias = /\bas\s+([A-Za-z_$][\w$]*)$/.exec(piece);
|
|
190
|
+
if (alias !== null) return alias[1];
|
|
191
|
+
const direct = /^([A-Za-z_$][\w$]*)$/.exec(piece);
|
|
192
|
+
return direct === null ? undefined : direct[1];
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// 从 export function / class / const / let / var 这种行内声明里读导出名。
|
|
196
|
+
function inlineExports(source) {
|
|
197
|
+
const pattern = /\bexport\s+(?:async\s+)?(?:function|class|const|let|var)\s+([A-Za-z_$][\w$]*)/g;
|
|
198
|
+
const found = [];
|
|
199
|
+
let match;
|
|
200
|
+
while ((match = pattern.exec(source)) !== null) found.push(match[1]);
|
|
201
|
+
return found;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// 逐条记下声明跟当前运行时差多远,取最坏的那一档。
|
|
205
|
+
//
|
|
206
|
+
// 单独拎出来是因为它只是一段记账:读声明、比距离、把结果攒进 reasons。
|
|
207
|
+
// 留在主流程里会把结论判断淹掉。
|
|
208
|
+
function describeDistances(input, reasons) {
|
|
209
|
+
const distances = {};
|
|
210
|
+
let worst = 'satisfied';
|
|
211
|
+
const order = ['satisfied', 'prerelease-delta', 'patch-delta', 'minor-delta', 'major-delta', 'unknown'];
|
|
212
|
+
for (const [requirement, declaredRange] of Object.entries(input.requirements ?? {})) {
|
|
213
|
+
const distance = classifyDistance(declaredRange, input.runtimeVersion);
|
|
214
|
+
distances[requirement] = distance;
|
|
215
|
+
if (order.indexOf(distance) > order.indexOf(worst)) worst = distance;
|
|
216
|
+
reasons.push(`${requirement} declares ${declaredRange}; running ${input.runtimeVersion} (${distance})`);
|
|
217
|
+
}
|
|
218
|
+
return { distances, worst };
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// 判断某个插件在版本对不上的情况下能否真的在当前运行时跑起来。
|
|
222
|
+
//
|
|
223
|
+
// 结论是要担责任的,不是盖章:compatible 要求版本距离够近**并且**插件导入的每个
|
|
224
|
+
// DSH 符号都还在。任何没被证实的都返回 review,调用方不许拿它去自动适配。
|
|
225
|
+
export async function auditPluginCompatibility(input) {
|
|
226
|
+
const reasons = [];
|
|
227
|
+
const missing = [];
|
|
228
|
+
const { distances, worst } = describeDistances(input, reasons);
|
|
229
|
+
|
|
230
|
+
// 别的已启用插件已经在提供的路由,不管版本范围多接近,激活时都会失败。
|
|
231
|
+
// 这是激活层面的问题,不是版本兼容问题,所以单独记一笔,不影响版本结论。
|
|
232
|
+
// 真正让插件跑起来的是兼容环境和路由仲裁这两件事。
|
|
233
|
+
const taken = new Set(input.takenProviders ?? []);
|
|
234
|
+
const conflicts = (input.providerIds ?? []).filter((id) => taken.has(id));
|
|
235
|
+
if (conflicts.length > 0) {
|
|
236
|
+
reasons.push(`it registers the LLM provider ${conflicts.join(', ')}, which another enabled plugin already serves (DUPLICATE_ADAPTER)`);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
if (worst === 'major-delta') {
|
|
240
|
+
// 跨大版本仍然要试。声明会被对齐到当前运行时,插件实际用的是这里的实现,
|
|
241
|
+
// 用到哪个符号、还在不在,下面的 API 表面检查会一并回答。
|
|
242
|
+
reasons.push('a major-version gap; the declaration will be realigned and the API surface checked');
|
|
243
|
+
}
|
|
244
|
+
// 版本距离比不出来时,只有两种情况才拦下来。
|
|
245
|
+
// 一是运行时版本本身就没拿到——那说明环境有问题,什么判断都不作数。
|
|
246
|
+
// 二是导入的符号对不上——那是真跑不了。
|
|
247
|
+
// 只是写法怪(workspace: 协议、tag 之类)导致比不了,不该一概拒绝。
|
|
248
|
+
if (worst === 'unknown') {
|
|
249
|
+
if (input.runtimeVersion === undefined || input.runtimeVersion === '') {
|
|
250
|
+
return {
|
|
251
|
+
verdict: 'review',
|
|
252
|
+
reasons: [...reasons, 'the running DSH version is unknown, so nothing can be verified'],
|
|
253
|
+
distances,
|
|
254
|
+
missing,
|
|
255
|
+
conflicts,
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
reasons.push('the declared range cannot be compared with the running version; relying on the import surface instead');
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// 光看距离不足以放行,API 表面也必须对得上。
|
|
262
|
+
// 只有 DSH 的包构成运行时契约。第三方依赖(schemastery、react 之类)
|
|
263
|
+
// 是插件自己的事,不去查。
|
|
264
|
+
const imports = input.imports instanceof Map ? input.imports : new Map(Object.entries(input.imports ?? {}));
|
|
265
|
+
const checked = [];
|
|
266
|
+
const unverified = [];
|
|
267
|
+
if (typeof input.exportsOf === 'function') {
|
|
268
|
+
for (const [packageName, symbols] of imports) {
|
|
269
|
+
if (!/^@deepseek-ai\/[a-z]/.test(packageName)) continue;
|
|
270
|
+
let names;
|
|
271
|
+
try {
|
|
272
|
+
names = await input.exportsOf(packageName);
|
|
273
|
+
} catch (error) {
|
|
274
|
+
reasons.push(`${packageName} could not be read: ${String(error)}`);
|
|
275
|
+
return { verdict: 'review', reasons, distances, missing, conflicts };
|
|
276
|
+
}
|
|
277
|
+
if (names === undefined) {
|
|
278
|
+
// 这里解析不到这个包,所以它的表面无从证实。
|
|
279
|
+
unverified.push(packageName);
|
|
280
|
+
continue;
|
|
281
|
+
}
|
|
282
|
+
checked.push(packageName);
|
|
283
|
+
for (const symbol of symbols ?? []) {
|
|
284
|
+
if (!names.includes(symbol)) missing.push(`${packageName}:${symbol}`);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
if (missing.length > 0) {
|
|
290
|
+
// 当前实现没有这些符号,但插件当初对着写的那个版本里可能有——
|
|
291
|
+
// 那个版本的代码还在仓库里,取下来装上就行。所以只有「连旧版本都无从获取」才算真不兼容:
|
|
292
|
+
// 插件没声明对应的依赖范围,或者声明就是当前版本。
|
|
293
|
+
const recoverable = missing.every((entry) => canUseOlderVersion(entry, input));
|
|
294
|
+
if (recoverable) {
|
|
295
|
+
return {
|
|
296
|
+
verdict: 'compatible',
|
|
297
|
+
reasons: [...reasons, `the running runtime lacks ${missing.join(', ')}; the plugin own older version will be installed instead`],
|
|
298
|
+
distances,
|
|
299
|
+
missing: [],
|
|
300
|
+
conflicts,
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
return {
|
|
304
|
+
verdict: 'incompatible',
|
|
305
|
+
reasons: [...reasons, `the running runtime no longer exports ${missing.join(', ')}`],
|
|
306
|
+
distances,
|
|
307
|
+
missing,
|
|
308
|
+
conflicts,
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
if (unverified.length > 0) {
|
|
312
|
+
return {
|
|
313
|
+
verdict: 'review',
|
|
314
|
+
reasons: [...reasons, `could not read the running ${unverified.join(', ')} to verify the plugin's imports`],
|
|
315
|
+
distances,
|
|
316
|
+
missing,
|
|
317
|
+
conflicts,
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
if (checked.length > 0) reasons.push(`API surface verified against ${[...new Set(checked)].join(', ')}`);
|
|
321
|
+
else if (imports.size > 0) reasons.push('the plugin imports no DSH package that could be verified');
|
|
322
|
+
|
|
323
|
+
// 声明距离只是提示。能不能跑由上面查过的导入表面决定——对齐声明是适配的一部分,
|
|
324
|
+
// 不是需要人来点头的事。
|
|
325
|
+
if (worst === 'unknown' && (input.runtimeVersion === undefined || input.runtimeVersion === '')) {
|
|
326
|
+
return { verdict: 'review', reasons, distances, missing, conflicts };
|
|
327
|
+
}
|
|
328
|
+
return { verdict: 'compatible', reasons, distances, missing, conflicts };
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// 顺手重导出,老代码里从 compat.js 拿这些函数的写法还能用。
|
|
332
|
+
export { classifyDistance, compareVersions, parseSemver, satisfies } from './semver.js';
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// 诊断记录。
|
|
2
|
+
//
|
|
3
|
+
// 管三件事:各项修复计数、最近若干条事件、可选的快照文件。单独拎出来是因为组合根要
|
|
4
|
+
// 关心的是"怎么修",不该顺带管状态怎么记。
|
|
5
|
+
import { writeFileSync } from 'node:fs';
|
|
6
|
+
|
|
7
|
+
// 最近事件留多少条给 /doctor 看
|
|
8
|
+
const MAX_LOG = 30;
|
|
9
|
+
|
|
10
|
+
// 突发重试时按这个间隔合并写盘
|
|
11
|
+
const WRITE_COALESCE_MS = 250;
|
|
12
|
+
|
|
13
|
+
export function createDiagnostics(input) {
|
|
14
|
+
const stats = {
|
|
15
|
+
startedAt: Date.now(),
|
|
16
|
+
modelRepairs: 0,
|
|
17
|
+
modelSkips: 0,
|
|
18
|
+
toolRepairs: 0,
|
|
19
|
+
connectionRepairs: 0,
|
|
20
|
+
versionRepairs: 0,
|
|
21
|
+
last: [],
|
|
22
|
+
};
|
|
23
|
+
const diagnostics = { versionScan: null };
|
|
24
|
+
let pending;
|
|
25
|
+
|
|
26
|
+
const snapshot = () => ({
|
|
27
|
+
pid: process.pid,
|
|
28
|
+
...stats,
|
|
29
|
+
diagnostics,
|
|
30
|
+
...(input.describe === undefined ? {} : { config: input.describe() }),
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
// 立刻写。诊断不是正确性数据,写失败就算了——磁盘满了也不该把请求修复带崩。
|
|
34
|
+
const writeNow = () => {
|
|
35
|
+
if (input.statusFile === '') return;
|
|
36
|
+
try {
|
|
37
|
+
writeFileSync(input.statusFile, `${JSON.stringify(snapshot(), null, 2)}\n`, 'utf8');
|
|
38
|
+
} catch {
|
|
39
|
+
// 只有诊断,丢了就丢了
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
// 合并写。record 每次修复都会调,而修复是会扎堆的——网络抖一下一秒能来好几次,
|
|
44
|
+
// 每次都同步写盘等于在 agent 最忙的时候卡住事件循环。
|
|
45
|
+
const scheduleWrite = () => {
|
|
46
|
+
if (input.statusFile === '' || pending !== undefined) return;
|
|
47
|
+
pending = setTimeout(() => {
|
|
48
|
+
pending = undefined;
|
|
49
|
+
writeNow();
|
|
50
|
+
}, WRITE_COALESCE_MS);
|
|
51
|
+
pending.unref?.();
|
|
52
|
+
input.trackTimer?.(pending);
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const record = (kind, detail) => {
|
|
56
|
+
stats.last.push({ at: new Date().toISOString(), kind, detail: String(detail).slice(0, 240) });
|
|
57
|
+
if (stats.last.length > MAX_LOG) stats.last.shift();
|
|
58
|
+
input.log('info', `request-doctor: ${kind}: ${detail}`);
|
|
59
|
+
scheduleWrite();
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
// 把扫描错误挂到当前这轮结果上,别让调用方去翻嵌套对象。
|
|
63
|
+
const scanError = (detail) => {
|
|
64
|
+
diagnostics.versionScan = { ...(diagnostics.versionScan ?? {}), ok: false, error: detail };
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
// 收尾时把攒着的写掉
|
|
68
|
+
const flush = () => {
|
|
69
|
+
if (pending !== undefined) {
|
|
70
|
+
clearTimeout(pending);
|
|
71
|
+
pending = undefined;
|
|
72
|
+
}
|
|
73
|
+
writeNow();
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
return { record, scheduleWrite, writeNow, flush, scanError, stats, diagnostics, snapshot };
|
|
77
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
// 验证兼容环境真的能把插件要的东西交出来。
|
|
2
|
+
//
|
|
3
|
+
// 建完环境就假定成功是不够的:垫片写错一个导出名、目标文件读不出来,插件在加载那一刻
|
|
4
|
+
// 就会崩,而修复流程还以为自己干得不错。这里把「插件要的每个符号,环境是否真能提供」
|
|
5
|
+
// 逐条核对一遍,让问题在修复阶段暴露,而不是甩给用户。
|
|
6
|
+
import { readFileSync, statSync } from 'node:fs';
|
|
7
|
+
import { join } from 'node:path';
|
|
8
|
+
import { fileURLToPath } from 'node:url';
|
|
9
|
+
import { bareNameOf, parseExportedNames } from './compat.js';
|
|
10
|
+
|
|
11
|
+
// 一个插件图里最多缓存这么多个包的结果。运行时包不小,每次扫描都重新解析太费。
|
|
12
|
+
const MAX_CACHE = 256;
|
|
13
|
+
|
|
14
|
+
// 缓存键带文件签名,改动自然失效。键算错不会导致误判——只是多解析一次。
|
|
15
|
+
const cache = new Map();
|
|
16
|
+
|
|
17
|
+
// 文件签名,读不到用固定占位(表示"读不到"本身也是一种稳定状态)。
|
|
18
|
+
function signatureOf(path) {
|
|
19
|
+
try {
|
|
20
|
+
const stat = statSync(path);
|
|
21
|
+
return `${stat.mtimeMs}:${stat.size}`;
|
|
22
|
+
} catch {
|
|
23
|
+
return 'unreadable';
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// 从垫片里取出 `export * from "..."` / `export { ... } from "..."` 指向的 URL。
|
|
28
|
+
//
|
|
29
|
+
// 这里保留 URL 而不是立刻转成路径:转路径在有些平台上会失败(比如没有盘符的 Windows
|
|
30
|
+
// 路径),那不代表垫片有问题,只说明这次核对不了。把"转换失败"和"没有目标"分开,
|
|
31
|
+
// 才不会把能正常跑的环境误判成坏的。
|
|
32
|
+
function targetUrlsOf(source) {
|
|
33
|
+
const urls = [];
|
|
34
|
+
const pattern = /\bfrom\s+"(file:[^"]+)"/g;
|
|
35
|
+
let match;
|
|
36
|
+
while ((match = pattern.exec(source)) !== null) urls.push(match[1]);
|
|
37
|
+
return urls;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// 读一个文件,读不到就返回 undefined。
|
|
41
|
+
function readText(path) {
|
|
42
|
+
try {
|
|
43
|
+
return readFileSync(path, 'utf8');
|
|
44
|
+
} catch {
|
|
45
|
+
return undefined;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
// 一个包的环境能否满足它需要的符号。
|
|
52
|
+
//
|
|
53
|
+
// 三种结果:provided(能提供)、missing(能读到目标但确实没有)、unverifiable(目标读不到,
|
|
54
|
+
// 无从判断)。最后一种不算失败——运行时包常常装在归档里,普通读文件读不到是正常的。
|
|
55
|
+
export function verifyPackage(pluginDir, name, symbols) {
|
|
56
|
+
const shimPath = join(pluginDir, 'node_modules', ...name.split('/'), 'index.js');
|
|
57
|
+
const shimSource = readText(shimPath);
|
|
58
|
+
if (shimSource === undefined) return { status: 'missing', detail: '没有垫片' };
|
|
59
|
+
|
|
60
|
+
// 垫片本身和它的目标都参与签名。这样运行时升级、垫片被改写都会自然失效,
|
|
61
|
+
// 而重复扫描同一个环境时不必再解析那个几百 KB 的运行时包。
|
|
62
|
+
const targets = targetUrlsOf(shimSource);
|
|
63
|
+
const targetPaths = targets.map((url) => {
|
|
64
|
+
try {
|
|
65
|
+
return fileURLToPath(url);
|
|
66
|
+
} catch {
|
|
67
|
+
return undefined;
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
const key = [shimPath, signatureOf(shimPath), ...targetPaths.map((path) => (path === undefined ? 'bad-url' : signatureOf(path)))].join('|');
|
|
71
|
+
|
|
72
|
+
const cached = cache.get(shimPath);
|
|
73
|
+
if (cached !== undefined && cached.key === key) return judge(cached.info, symbols);
|
|
74
|
+
|
|
75
|
+
const info = describeEnvironment(shimPath, shimSource, targetPaths);
|
|
76
|
+
if (cache.size >= MAX_CACHE && !cache.has(shimPath)) {
|
|
77
|
+
const oldest = cache.keys().next().value;
|
|
78
|
+
if (oldest !== undefined) cache.delete(oldest);
|
|
79
|
+
}
|
|
80
|
+
cache.set(shimPath, { key, info });
|
|
81
|
+
return judge(info, symbols);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// 把一个环境能提供的符号收齐。
|
|
85
|
+
//
|
|
86
|
+
// 有些包是 CommonJS 的(schemastery 就是),读不出 ESM 的导出清单。这时候退回按词边界
|
|
87
|
+
// 搜源码——建环境那一侧本来就是这么做的,两边必须一致,否则一边说齐了、另一边说缺了,
|
|
88
|
+
// 插件就会在「修好了」和「没修好」之间来回跳。
|
|
89
|
+
function describeEnvironment(shimPath, shimSource, targetPaths) {
|
|
90
|
+
const shimNames = parseExportedNames(shimPath, (source) => readFileSync(source, 'utf8')) ?? [];
|
|
91
|
+
const targetNames = [];
|
|
92
|
+
const needles = [];
|
|
93
|
+
let readableTargets = 0;
|
|
94
|
+
for (const path of targetPaths) {
|
|
95
|
+
if (path === undefined) continue;
|
|
96
|
+
const source = readText(path);
|
|
97
|
+
if (source === undefined) continue;
|
|
98
|
+
readableTargets += 1;
|
|
99
|
+
const names = parseExportedNames(path, (file) => readFileSync(file, 'utf8'));
|
|
100
|
+
// 空清单同样不可信:CommonJS 包读出来就是空的。
|
|
101
|
+
if (names === undefined || names.length === 0) needles.push(source);
|
|
102
|
+
else targetNames.push(...names);
|
|
103
|
+
}
|
|
104
|
+
return { shimNames, targetNames, needles, readableTargets, hasTargets: targetPaths.length > 0 };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// 一个符号在环境里到底有没有。名字对不上时退回搜源码。
|
|
108
|
+
function provides(info, symbol) {
|
|
109
|
+
if (info.shimNames.includes(symbol) || info.targetNames.includes(symbol)) return true;
|
|
110
|
+
const escaped = symbol.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
111
|
+
const pattern = new RegExp('\\b' + escaped + '\\b');
|
|
112
|
+
return info.needles.some((source) => pattern.test(source));
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// 拿插件要的符号去对照环境能提供的,给出结论。
|
|
116
|
+
function judge(info, symbols) {
|
|
117
|
+
const lacking = symbols.filter((symbol) => {
|
|
118
|
+
// default 必须由垫片自己写出来:`export *` 从不转发默认导出,
|
|
119
|
+
// 目标有 default 不等于插件拿得到。
|
|
120
|
+
if (symbol === 'default') return !info.shimNames.includes('default');
|
|
121
|
+
return !provides(info, symbol);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
if (lacking.length === 0) return { status: 'provided' };
|
|
125
|
+
// 垫片指着的东西一个都读不到,这个环境是坏的:插件加载时自己也要读那些文件,
|
|
126
|
+
// 读不到就会崩。所以这里不能当「无法核对」放过去,要如实算作问题。
|
|
127
|
+
if (info.readableTargets === 0 && info.hasTargets) {
|
|
128
|
+
return { status: 'missing', detail: '垫片指向的文件一个都读不到,环境不可用' };
|
|
129
|
+
}
|
|
130
|
+
return { status: 'missing', detail: `缺 ${lacking.join(', ')}` };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// 核对整个环境。
|
|
134
|
+
//
|
|
135
|
+
// @param {object} input
|
|
136
|
+
// @param {string} input.pluginDir - 插件目录。
|
|
137
|
+
// @param {Map<string,string[]>} input.imports - 插件导入的包与符号。
|
|
138
|
+
// @returns {{ok: boolean, problems: object[], unverified: object[]}}
|
|
139
|
+
export function verifyEnvironment(input) {
|
|
140
|
+
const problems = [];
|
|
141
|
+
const unverified = [];
|
|
142
|
+
// 同一个包可能既从包根、也从子路径被导入。垫片只有一个,所以要按裸包名归组,
|
|
143
|
+
// 把两处的符号合起来核对,否则子路径那条会被当成一个没有垫片的包。
|
|
144
|
+
const groups = new Map();
|
|
145
|
+
for (const [specifier, symbols] of input.imports ?? new Map()) {
|
|
146
|
+
if (!specifier.startsWith('@deepseek-ai/')) continue;
|
|
147
|
+
const name = bareNameOf(specifier);
|
|
148
|
+
if (groups.has(name) === false) groups.set(name, new Set());
|
|
149
|
+
const set = groups.get(name);
|
|
150
|
+
for (const symbol of symbols ?? []) set.add(symbol);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
for (const [name, symbols] of groups) {
|
|
154
|
+
if (symbols.size === 0) continue;
|
|
155
|
+
const result = verifyPackage(input.pluginDir, name, [...symbols]);
|
|
156
|
+
if (result.status === 'missing') problems.push({ name, detail: result.detail });
|
|
157
|
+
else if (result.status === 'unverifiable') unverified.push({ name, detail: result.detail });
|
|
158
|
+
}
|
|
159
|
+
return { ok: problems.length === 0, problems, unverified };
|
|
160
|
+
}
|