dsh-request-doctor 0.11.0 → 0.11.1

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.
@@ -113,6 +113,7 @@ DSH 是一个以 Cordis 插件框架组织的智能体运行时。插件通过
113
113
  - FR-4.6 修复必须同时对齐插件自身 manifest 中的 DSH 依赖声明,因为 Loader 在组合前读取该文件。
114
114
  - FR-4.7 **不得绕过版本检查**:修复后的声明必须真实满足当前运行时。
115
115
  - FR-4.8 现行运行时不存在的符号**不得伪造**。必须为插件安装它期望的那份真实旧版本;旧版本也没有时如实报缺。
116
+ - FR-4.13 兼容环境装入真实旧版本后重载失败、且失败原因是缺少导出时,必须判定为「需要重启」,向用户给出明确提示,**不得**记为环境失败。
116
117
  - FR-4.12 **不得使用占位实现**。缺符号只有两种出路:装真实旧版本,或如实拒绝。
117
118
  - FR-4.9 环境建成后必须**逐条核对**插件所需符号是否真能提供;核对不通过不得计为已修复。
118
119
  - FR-4.10 核对无法进行时(目标文件不可读)不得判为失败,应单独记录为未核对。
@@ -60,8 +60,9 @@
60
60
  | `test/install-rescue.test.mjs` | 4 | 从失败里取出包名与版本,供授权使用 |
61
61
  | `test/install-in-place.test.mjs` | 2 | 已装着的包就地修复,不再重复安装 |
62
62
  | `test/client-half.test.mjs` | 5 | 客户端半:模块身份、注入声明、apply 注册、组件能渲染 |
63
- | `test/diagnostics.test.mjs` | 3 | 快照可选写入:不配置不写盘、突发合并、坏路径不致命 |
64
- | **合计** | **152** | **全部通过** |
63
+ | `test/diagnostics.test.mjs` | 3 | 快照可选写入:不配置不写盘、突发合并、坏路径不致命 |
64
+ | `test/restart-notice.test.mjs` | 3 | 缺导出判为需要重启;其它失败不误判;多次累积不覆盖 |
65
+ | **合计** | **155** | **全部通过** |
65
66
 
66
67
  执行结果:
67
68
 
@@ -106,6 +107,7 @@
106
107
  | FR-4.9 建完必须核对 | `verify-roundtrip.test.mjs` — 建完必能验过 | 通过 |
107
108
  | FR-4.10 不可核对不算失败 | `env-verify.test.mjs` — 目标不可读记为未核对 | 通过 |
108
109
  | FR-4.11 坏垫片能被发现 | `verify-roundtrip.test.mjs` — 手工破坏的垫片验不过 | 通过 |
110
+ | FR-4.13 需要重启要说明 | `restart-notice.test.mjs` — 缺导出归入重启提示 | 通过 |
109
111
  | FR-4.12 不用占位 | `compat-env.test.mjs` — 装真实旧版本;旧版本也没有则拒绝 | 通过 |
110
112
  | FR-5.1 安装前预置 | `install-support.test.mjs` — 预置后检查通过 | 通过 |
111
113
  | FR-5.2 失败回滚 | `install-support.test.mjs` — 目录恢复原状 | 通过 |
@@ -177,7 +179,7 @@
177
179
 
178
180
  ## 6 结论
179
181
 
180
- 全部 152 个自动化用例通过,需求覆盖矩阵无空缺,实机验证完成关键路径。质量度量全部满足限值(见《软件质量度量报告》)。判定:**通过**。
182
+ 全部 155 个自动化用例通过,需求覆盖矩阵无空缺,实机验证完成关键路径。质量度量全部满足限值(见《软件质量度量报告》)。判定:**通过**。
181
183
 
182
184
  ## 7 全量回归检验记录
183
185
 
@@ -209,4 +211,4 @@
209
211
 
210
212
  ### 7.2 分文件执行结果
211
213
 
212
- 29 个测试文件逐个单独执行,每个都有用例且全部通过,不存在因导入失败而静默跳过的文件。用例数分布:compat.test.mjs 17、plugin.test.mjs 15、compat-env.test.mjs 14、classify.test.mjs 13,其余按职能分散,合计 152。
214
+ 30 个测试文件逐个单独执行,每个都有用例且全部通过,不存在因导入失败而静默跳过的文件。用例数分布:compat.test.mjs 17、plugin.test.mjs 15、compat-env.test.mjs 14、classify.test.mjs 13,其余按职能分散,合计 152。
@@ -179,3 +179,19 @@ Plugin xxx@1.0.0 is incompatible with dsh 0.2.1-rc.3
179
179
  1. **同一处失败**同时只跑一个重试,不会越堆越多;
180
180
  2. 压缩或修历史**连续两轮没进展**就放弃,不会无限循环;
181
181
  3. 同名同参的工具调用**超过阈值**后不再重放,避免把宿主侧失效的循环检测放大。
182
+ ## 7 什么时候需要重启
183
+
184
+ 兼容环境有两种形态,后果不同:
185
+
186
+ - **薄垫片**:转发到现行实现,改完立刻有效,不用重启。
187
+ - **真实旧版本**:插件要用的导出在当前版本里已经被删掉了,插件会去装它声明的那个旧版本。文件虽然换好了,但当前进程多半已经缓存过那次失败的导入,**必须重启 DSH 才会重新解析**。
188
+
189
+ 第二种情况 `/doctor` 会直接列出来:
190
+
191
+ ```
192
+ 需要重启后生效:
193
+ - dsh-hyper-tools 已经装好需要的旧版本,但这个进程不会重新解析
194
+ offloadRequestImagesWithPolicy,重启 DSH 后即可生效
195
+ ```
196
+
197
+ 看到这条说明修复本身已经完成,缺的只是一次重启——不要重复触发修复,那不会加快任何事。
@@ -35,17 +35,17 @@
35
35
 
36
36
  | 度量项 | 实测值 | 限值 | 判定 |
37
37
  | --- | --- | --- | --- |
38
- | 文件数 | 26 | — | — |
39
- | 总行数 | 4831 | — | — |
40
- | 源代码行数 | 3527 | — | — |
41
- | 注释行数 | 862 | — | — |
42
- | **注释密度** | **24.44%** | ≥ 10% | 满足 |
43
- | **重复率** | **4.82%** | ≤ 5% | 满足 |
44
- | 函数数 | 361 | — | — |
38
+ | 文件数 | 27 | — | — |
39
+ | 总行数 | 4863 | — | — |
40
+ | 源代码行数 | 3546 | — | — |
41
+ | 注释行数 | 871 | — | — |
42
+ | **注释密度** | **24.56%** | ≥ 10% | 满足 |
43
+ | **重复率** | **4.85%** | ≤ 5% | 满足 |
44
+ | 函数数 | 363 | — | — |
45
45
  | 平均圈复杂度 | 3.91 | — | — |
46
46
  | **最大圈复杂度** | **10** | ≤ 10 | 满足 |
47
47
  | **最长函数** | **56 行** | ≤ 60 | 满足 |
48
- | **最长文件** | **496 行** | ≤ 500 | 满足 |
48
+ | **最长文件** | **500 行** | ≤ 500 | 满足 |
49
49
 
50
50
  结论:**全部度量项满足限值**。
51
51
 
@@ -1,20 +1,20 @@
1
1
  {
2
2
  "standard": "GB/T 45717-2025 信息技术 软件测量 软件质量测量 自动化的源代码质量测度",
3
- "generatedAt": "2026-10-28T11:33:25.565Z",
3
+ "generatedAt": "2026-10-29T12:02:20.522Z",
4
4
  "scope": {
5
- "files": 26,
6
- "totalLines": 4831,
7
- "sourceLines": 3527,
8
- "commentLines": 862
5
+ "files": 27,
6
+ "totalLines": 4863,
7
+ "sourceLines": 3546,
8
+ "commentLines": 871
9
9
  },
10
10
  "measures": {
11
- "commentDensity": 24.44,
12
- "duplicationPercent": 4.82,
13
- "functionCount": 361,
11
+ "commentDensity": 24.56,
12
+ "duplicationPercent": 4.85,
13
+ "functionCount": 363,
14
14
  "maxComplexity": 10,
15
15
  "maxFunctionLines": 59,
16
- "maxFileLines": 496,
17
- "averageComplexity": 3.86
16
+ "maxFileLines": 500,
17
+ "averageComplexity": 3.87
18
18
  },
19
19
  "limits": {
20
20
  "maxComplexity": 10,
package/index.js CHANGED
@@ -460,5 +460,12 @@ async function summarizeRepairs(deps) {
460
460
  } else if (scan?.error !== undefined) {
461
461
  lines.push('', `最近一轮扫描出错:${scan.error}`);
462
462
  }
463
+
464
+ // 有插件已经修好但必须重启才生效:说清楚,别让人以为修复失败。
465
+ const waiting = deps.diagnostics.diagnostics.restartRequired ?? [];
466
+ if (waiting.length > 0) {
467
+ lines.push('', '需要重启后生效:');
468
+ for (const item of waiting) lines.push(`- ${item.hint}`);
469
+ }
463
470
  return { kind: 'success', text: lines.join('\n') };
464
471
  }
@@ -0,0 +1,21 @@
1
+ // 「需要重启」的判定。
2
+ //
3
+ // 兼容环境的两种形态后果不同:
4
+ // - 薄垫片:转发到现行实现,改完立刻有效;
5
+ // - 真实旧版本:文件已经换好,但这个进程多半缓存过那次失败的导入,ESM 不会重新解析。
6
+ //
7
+ // 第二种只能重启,所以要明确告诉用户,而不是反复重试或报成环境失败。
8
+
9
+ const MISSING_EXPORT = /does not provide an export named '([^']+)'/;
10
+
11
+ // 从失败明细里认出「缺导出」这种情况,并把它记成一条待重启提示。
12
+ export function noteRestart(deps, name, detail) {
13
+ const missing = MISSING_EXPORT.exec(detail);
14
+ if (missing === null) return false;
15
+ const symbol = missing[1];
16
+ const hint = `${name} 已经装好需要的旧版本,但这个进程不会重新解析 ${symbol},重启 DSH 后即可生效`;
17
+ const box = deps.diagnostics.diagnostics;
18
+ box.restartRequired = [...(box.restartRequired ?? []), { name, symbol, hint }];
19
+ deps.record('restart-required', hint);
20
+ return true;
21
+ }
@@ -1,495 +1,499 @@
1
- // 插件与 DSH 的版本修复。
2
- //
3
- // 找出所有声明了当前运行时不满足的依赖的插件,逐个审计,把能证明兼容的那些交给兼容环境。
4
- // 审计是前置条件:插件要用的符号已经不存在了,就绝不硬上。
5
- import { existsSync, readFileSync, readdirSync } from 'node:fs';
6
- import { dirname, join } from 'node:path';
7
- import { unmetPeers } from './compat.js';
8
- import { auditInstalledPlugin, entryPathOf, installedEntryPath, packageDir } from './compat-repair.js';
9
- import { alignManifest, installCompatEnvironment } from './compat-env.js';
10
- import { describeContention } from './arbitrate.js';
11
- import { clearStagedPreparation, prepareForInstall, readStagedPreparation } from './install-support.js';
12
- import { verifyEnvironment } from './env-verify.js';
13
- import { fetchPackage, listVersions } from './package-fetch.js';
14
- import { orderCandidates } from './version-search.js';
15
- import { pruneCache, touchAndReturn } from './cache-store.js';
16
-
17
- export { pruneCache };
18
- import { collectMismatched } from './plugin-discovery.js';
19
-
20
- // 激活过程中重排会报这个错
21
- const NESTED_HMR = /transactions cannot be nested/i;
22
-
23
- // 造一个版本修复器。
24
- export function createVersionRepair(input) {
25
- const { ctx, probe, diagnostics, config } = input;
26
- const { record, stats } = diagnostics;
27
- const state = { busy: false };
28
-
29
- // 拿插件管理器服务,没有就返回 undefined。
30
- const managerOf = () => probe.get('pluginManager');
31
-
32
- const deps = { ctx, probe, diagnostics, config, record, stats, warn: input.warn, later: input.later, maxHmrRetries: input.maxHmrRetries, state, scanError: diagnostics.scanError };
33
-
34
- return {
35
- run: (reason, manager, attempt) => run(reason, manager ?? managerOf(), attempt ?? 0, deps),
36
- isBusy: () => state.busy,
37
- };
38
- }
39
-
40
- // 把每个不匹配的插件都审一遍。
41
- //
42
- // 单个插件读不出来或者 manifest 坏了,不能把整轮扫描带停,所以每个都单独兜住。
43
- async function auditAll(bundles, facts, plugins, probe) {
44
- const taken = probe.activeProviders() ?? [];
45
- const audited = [];
46
- for (const issue of await collectMismatched(bundles, facts, plugins, probe)) {
47
- // One plugin's unreadable package or malformed manifest must not abort the
48
- // scan for every other plugin, so each audit is contained.
49
- audited.push(await auditOne(issue, facts, probe, taken));
50
- }
51
- return audited;
52
- }
53
-
54
- // 审一个候选。抛出来的异常折算成 review,不往上冒。
55
- async function auditOne(issue, facts, probe, taken) {
56
- const base = {
57
- name: issue.name,
58
- version: issue.version,
59
- runtimeVersion: issue.runtimeVersion ?? facts.runtimeVersion,
60
- requirements: issue.requirements ?? {},
61
- deferred: issue.deferred === true,
62
- needsEnvironment: issue.needsEnvironment === true,
63
- };
64
- try {
65
- const manifestPath = facts.profileDir === undefined
66
- ? undefined
67
- : join(packageDir(facts.profileDir, issue.name) ?? '', 'package.json');
68
- const result = await auditInstalledPlugin({
69
- profileDir: facts.profileDir,
70
- name: issue.name,
71
- runtimeVersion: base.runtimeVersion,
72
- requirements: base.requirements,
73
- manifest: manifestPath === undefined ? undefined : probe.readManifestCached(manifestPath),
74
- runtimeEntryOf: probe.runtimeEntryOf(facts),
75
- takenProviders: taken,
76
- assumeCompatible: base.deferred === true || base.needsEnvironment === true,
77
- });
78
- return {
79
- ...base,
80
- verdict: result.verdict,
81
- reasons: result.reasons,
82
- missing: result.missing,
83
- imports: result.imports,
84
- importMap: result.importMap,
85
- providers: result.providers,
86
- conflicts: result.conflicts ?? [],
87
- };
88
- } catch (error) {
89
- return {
90
- ...base,
91
- verdict: 'review',
92
- reasons: [`audit failed: ${String(error)}`],
93
- missing: [],
94
- imports: [],
95
- importMap: new Map(),
96
- providers: [],
97
- conflicts: [],
98
- };
99
- }
100
- }
101
- // 已启用插件的花名册,附上每个实际在服务的路由。
102
- //
103
- // 源码分析会多算,所以要跟运行时注册表求交集:某个地区没启用,不代表它在抢路由。
104
- function providerRoster(facts, plugins, deps) {
105
- const { probe, warn } = deps;
106
- const roster = [];
107
- const live = probe.activeProviders();
108
- for (const entry of plugins ?? []) {
109
- if (entry?.enabled !== true) continue;
110
- const entryPath = facts.profileDir === undefined ? undefined : installedEntryPath(facts.profileDir, entry.moduleName);
111
- if (entryPath === undefined) continue;
112
- try {
113
- const declared = probe.factsOf(dirname(entryPath), entryPath, facts.runtimeVersion).providers;
114
- roster.push({
115
- name: entry.moduleName,
116
- version: entry.version ?? '0.0.0',
117
- active: entry.fiberPhase === 'active',
118
- providers: live === undefined ? declared : declared.filter((id) => live.includes(id)),
119
- });
120
- } catch (error) {
121
- // A plugin that cannot be read contributes no claim, but the reason is
122
- // worth one line: a broken install otherwise looks like a clean scan.
123
- warn(`request-doctor: could not analyse ${entry.moduleName}: ${String(error)}`);
124
- }
125
- }
126
- return roster;
127
- };
128
-
129
- // 本进程里已经确认「所有版本都凑不齐」的组合,就别再搜第二遍了。
130
- //
131
- // 不记这一笔的话,每次扫描都会把注册表和下载重走一遍——明明上一次已经证明没有。
132
- // 进程重启会清空,所以新发布的版本自然会被重新看到。
133
- const exhaustedSearches = new Set();
134
-
135
- // 把缺符号的那些包的真实旧版本取回来。
136
- //
137
- // 兼容模式的关键就在这里:插件当初是对着某个版本写的,那个版本的代码还在仓库里,
138
- // 取下来装上就行。这样跑的是它认识的那份实现,不是转发到今天的替身。
139
- async function fetchNeededVersions(item, facts, deps) {
140
- const map = new Map();
141
- const probeResult = installCompatEnvironment({
142
- pluginDir: facts.profileDir ?? '',
143
- profileDir: facts.profileDir,
144
- ...commonEnvironmentFields(item),
145
- runtimeVersion: item.runtimeVersion,
146
- resolveDir: deps.probe.runtimeDirOf(facts),
147
- // 只问一句,别真写:这一调用如果落盘,会把 profile 里的真实包盖成垫片。
148
- dryRun: true,
149
- });
150
- if (probeResult.status !== 'needs-old-version') return map;
151
-
152
-
153
- for (const need of probeResult.needed) {
154
- const found = await findVersionWithSymbols(item, facts, need, deps);
155
- if (found !== undefined) map.set(need.name, found);
156
- }
157
- return map;
158
- }
159
-
160
- // 找一个能凑齐符号的版本。
161
- //
162
- // 钉住的那个版本未必有插件要的东西——它声明的范围可能覆盖好几个版本,而符号只在其中
163
- // 某一个里。既然旧代码都还在仓库里,就按顺序试,第一个凑齐的就是答案。
164
- async function findVersionWithSymbols(item, facts, need, deps) {
165
- const key = `${need.name}|${(need.symbols ?? []).join(',')}`;
166
- if (exhaustedSearches.has(key)) return undefined;
167
-
168
- const versions = await listVersions(need.name);
169
- const candidates = orderCandidates(versions, item.requirements?.[need.name], need.version);
170
- if (candidates.length === 0) candidates.push(need.version);
171
-
172
- for (const version of candidates) {
173
- const dir = await cachePackage(need.name, version, facts.profileDir, deps);
174
- if (dir === undefined) continue;
175
- // 只问这个版本能不能凑齐,别真装。
176
- const trial = installCompatEnvironment({
177
- pluginDir: '',
178
- profileDir: facts.profileDir,
179
- ...environmentInput(item, facts, deps),
180
- oldVersionDirs: new Map([[need.name, { dir, version }]]),
181
- dryRun: true,
182
- });
183
- if (trial.status === 'installable') {
184
- return { dir, version, closure: await resolveClosure(need.name, version, facts.profileDir, deps) };
185
- }
186
- deps.record('version-skipped', `${need.name}@${version} 凑不齐,试下一个`);
187
- }
188
- exhaustedSearches.add(key);
189
- return undefined;
190
- }
191
-
192
- // 把每个包各自的闭包合起来。同一个包出现在多个闭包里时,以先来的为准——
193
- // 它们通常来自同一批发布,版本一致;真不一致的情况下,冲突本身就该由插件作者解决。
194
- function mergeClosures(oldVersionDirs) {
195
- const merged = new Map();
196
- for (const entry of oldVersionDirs?.values() ?? []) {
197
- for (const [name, value] of entry.closure ?? new Map()) {
198
- if (!merged.has(name)) merged.set(name, value);
199
- }
200
- }
201
- return merged;
202
- }
203
- // 每处都一样的两个字段:插件声明的范围和它实际导入的符号。
204
- function commonEnvironmentFields(item) {
205
- return {
206
- requirements: item.requirements ?? {},
207
- imports: item.importMap ?? new Map(),
208
- };
209
- }
210
-
211
- // 建兼容环境要的那几个参数,每处都一样,拼一次就够。
212
- function environmentInput(item, facts, deps) {
213
- return {
214
- profileDir: facts.profileDir,
215
- ...commonEnvironmentFields(item),
216
- runtimeVersion: item.runtimeVersion,
217
- resolveDir: deps.probe.runtimeDirOf(facts),
218
- };
219
- }
220
-
221
- // 一次闭包最多装这么多个包。旧版本的依赖通常几个,多了说明依赖树异常,该停下来。
222
- const MAX_CLOSURE = 16;
223
-
224
- // 从一个版本范围里取出具体版本号。取不到就跳过——猜一个版本去装还不如不装。
225
- function exactVersionOf(range) {
226
- if (typeof range !== 'string') return undefined;
227
- return /\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?/.exec(range)?.[0];
228
- }
229
-
230
- // 把旧版本自己的运行时依赖也取回来。
231
- //
232
- // 这一步不能省:DSH 的包彼此依赖,而且钉的是具体版本。只装它自己一个,加载时
233
- // 它 import 的兄弟包要么根本找不到,要么拿到新版本——两种都不是插件当初的环境。
234
- export async function resolveClosure(root, version, profileDir, deps) {
235
- const closure = new Map();
236
- const seen = new Set();
237
- const queue = [{ name: root, version }];
238
-
239
- while (queue.length > 0 && closure.size < MAX_CLOSURE) {
240
- const next = queue.shift();
241
- if (next.name === undefined || seen.has(next.name)) continue;
242
- seen.add(next.name);
243
-
244
- const dir = await cachePackage(next.name, next.version, profileDir, deps);
245
- if (dir === undefined) continue;
246
- closure.set(next.name, { dir, version: next.version });
247
- for (const dependency of runtimeDependenciesOf(dir)) {
248
- if (seen.has(dependency.name)) continue;
249
- queue.push(dependency);
250
- }
251
- }
252
- return closure;
253
- }
254
-
255
-
256
- // 读出一个包自己依赖的运行时包,版本按声明钉死。
257
- //
258
- // 只关心 @deepseek-ai 这一谱系:第三方库的版本线跟运行时不是一回事,交给正常的
259
- // 模块解析去处理。取不到具体版本的一律跳过——猜一个版本去装,还不如不装。
260
- function runtimeDependenciesOf(dir) {
261
- let manifest;
262
- try {
263
- manifest = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8'));
264
- } catch {
265
- return [];
266
- }
267
- const found = [];
268
- for (const [name, range] of Object.entries(manifest.dependencies ?? {})) {
269
- if (!name.startsWith('@deepseek-ai/')) continue;
270
- const version = exactVersionOf(range);
271
- if (version !== undefined) found.push({ name, version });
272
- }
273
- return found;
274
- }
275
-
276
- // 取一个包到本地缓存目录,已经缓存过就直接用。
277
- export async function cachePackage(name, version, profileDir, deps) {
278
- if (typeof profileDir !== 'string' || profileDir === '') return undefined;
279
- const dir = join(profileDir, '.request-doctor-cache', ...`${name}@${version}`.split('/'));
280
- try {
281
- const manifest = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8'));
282
- if (manifest.version === version) return touchAndReturn(dir);
283
- } catch {
284
- // 没缓存,去取
285
- }
286
- const result = await fetchPackage(name, version, dir);
287
- if (!result.ok) {
288
- deps.record('fetch-failed', `${name}@${version} 取不回来:${result.error ?? ''}`);
289
- return undefined;
290
- }
291
- deps.record('fetch', `${name}@${version} 已取回并缓存`);
292
- return touchAndReturn(dir);
293
- }
294
-
295
-
296
- // 给插件配上它当初的那个环境。
297
- //
298
- // 插件目录里的嵌套 node_modules 会遮住 profile 里的新包,每个垫片又转发现行实现,
299
- // 于是插件看到的是自己期望的版本,跑的是当前代码。manifest 也得跟着对齐,
300
- // 因为 DSH 在任何导入发生之前就要读它。
301
- async function adapt(item, dir, facts, deps) {
302
- const { probe, diagnostics, record, stats, warn, scanError } = deps;
303
- const packageVersion = `${item.name}@${item.version}`;
304
- // 缺符号时先把插件期望的真实旧版本取回来,再建环境。
305
- const oldVersionDirs = await fetchNeededVersions(item, facts, deps);
306
- const result = installCompatEnvironment({
307
- pluginDir: dir,
308
- profileDir: facts.profileDir,
309
- ...commonEnvironmentFields(item),
310
- runtimeVersion: item.runtimeVersion,
311
- resolveDir: probe.runtimeDirOf(facts),
312
- oldVersionDirs,
313
- closure: mergeClosures(oldVersionDirs),
314
- });
315
-
316
-
317
- if (result.status === 'refused') {
318
- deps.scanError(`${packageVersion}: ${result.error}`);
319
- record('compat-refused', `${packageVersion} cannot run here: ${result.error}`);
320
- return false;
321
- }
322
- if (result.status === 'failed') {
323
- deps.scanError(`${packageVersion}: ${result.error ?? 'unknown failure'}`);
324
- warn(`request-doctor: compatibility environment failed for ${packageVersion}: ${result.error ?? ''}`);
325
- return false;
326
- }
327
-
328
- // 建好了不等于能用。逐条核对插件要的每个符号环境是否真能给出来,
329
- // 有问题在这里就暴露,而不是等插件加载那一刻崩给用户看。
330
- const verdict = verifyEnvironment({ pluginDir: dir, imports: item.importMap ?? new Map() });
331
- if (!verdict.ok) {
332
- const detail = verdict.problems.map((problem) => `${problem.name} ${problem.detail}`).join('; ');
333
- deps.scanError(`${packageVersion}: 环境核对未通过:${detail}`);
334
- record('compat-unverified', `${packageVersion} 的环境核对未通过:${detail}`);
335
- return false;
336
- }
337
- // 运行时包装在归档里时核对不了,不算失败,但值得记一笔。
338
- for (const note of verdict.unverified) {
339
- record('compat-unchecked', `${packageVersion}: ${note.name} ${note.detail}`);
340
- }
341
-
342
- // 环境没变化也要对齐声明。
343
- //
344
- // 有些插件一个运行时包都不 import——皮肤、纯客户端界面就是这种。它们没有垫片要建,
345
- // 环境自然是「没变化」,但 manifest 里照样声明了运行时版本。跳过对齐的话,
346
- // 这类插件永远过不了管理器的检查,也永远显示不出来。
347
- // 有垫片的包按运行时版本声明;只声明、没导入的 DSH 包也要一起对齐——
348
- // 管理器的检查看的就是这份 manifest,漏一个它就不放行。
349
- const served = Object.fromEntries([
350
- ...(result.packages ?? []).map((pkg) => [pkg, item.runtimeVersion]),
351
- ...Object.keys(item.requirements ?? {}).map((pkg) => [pkg, item.runtimeVersion]),
352
- ]);
353
- const aligned = alignManifest({ pluginDir: dir, versions: served, runtimeVersion: item.runtimeVersion });
354
- if (aligned.status === 'failed') {
355
- deps.scanError(`${packageVersion}: ${aligned.error}`);
356
- warn(`request-doctor: could not align ${packageVersion}: ${aligned.error}`);
357
- return false;
358
- }
359
- if (result.status === 'unchanged' && aligned.status !== 'aligned') return true;
360
- // 装的时候提前备好的环境,到这里已经落定,临时标记没用了。
361
- if (item.deferred === true) clearStagedPreparation(dir);
362
- stats.versionRepairs += 1;
363
- record('compat', `${packageVersion} given a ${item.runtimeVersion} compatibility environment for ${(result.packages ?? []).join(', ') || 'no shims'}; declared versions aligned`);
364
- return true;
365
- };
366
-
367
- // 审一遍所有插件,把结论写进诊断,并返回分了组的清单。
368
- //
369
- // 冲突只报告不动手:两个插件抢同一个 provider 时,谁该让位是人的判断,
370
- // 插件贸然停用一个,用户看到的就是「莫名其妙少了功能」。
371
- async function collectAudit(reason, attempt, bundles, facts, plugins, deps) {
372
- const { diagnostics, probe, record } = deps;
373
- const audited = await auditAll(bundles, facts, plugins, probe);
374
- const roster = audited.length === 0 ? [] : providerRoster(facts, plugins, deps);
375
-
376
- for (const entry of audited) {
377
- entry.contention = describeContention({
378
- name: entry.name,
379
- providers: entry.providers ?? [],
380
- conflicts: entry.conflicts ?? [],
381
- roster,
382
- });
383
- if (entry.contention.contested.length > 0) {
384
- record('route-report', `${entry.name}@${entry.version} shares ${entry.contention.contested.join(', ')}`);
385
- }
386
- }
387
-
388
-
389
- const approved = audited.filter((entry) => entry.verdict === 'compatible');
390
- const rejected = audited.filter((entry) => entry.verdict !== 'compatible');
391
- diagnostics.diagnostics.versionScan = {
392
- reason,
393
- at: new Date().toISOString(),
394
- ok: true,
395
- attempt,
396
- bundles: bundles.length,
397
- audited,
398
- approved: approved.map((entry) => `${entry.name}@${entry.version}`),
399
- rejected: rejected.map((entry) => ({ name: entry.name, verdict: entry.verdict, reasons: entry.reasons })),
400
- };
401
- for (const entry of rejected) {
402
- record('version-review', `${entry.name}@${entry.version} not approved (${entry.verdict}): ${entry.reasons.slice(-1)[0] ?? ''}`);
403
- }
404
- return { approved, rejected };
405
- }
406
-
407
-
408
- // 重载刚适配好的插件。
409
- //
410
- // Loader 是在组合期检查 bundle 的,所以改过 manifest 之后必须重新组合才生效。
411
- // 只碰这次真正适配过的插件,别人的状态一概不动。
412
- async function reloadAdapted(manager, fixed, deps) {
413
- let retry = false;
414
- for (const packageVersion of fixed) {
415
- const name = packageVersion.slice(0, packageVersion.lastIndexOf('@'));
416
- if (name === '') continue;
417
- try {
418
- const applied = await manager.setBundleEnabled(name, true);
419
- if (applied?.application === 'failed') {
420
- const detail = JSON.stringify(applied.error ?? applied);
421
- deps.scanError(`${name}: ${detail}`);
422
- if (NESTED_HMR.test(detail)) retry = true;
423
- }
424
- } catch (error) {
425
- const detail = String(error);
426
- deps.scanError(`${name}: ${detail}`);
427
- if (NESTED_HMR.test(detail)) retry = true;
428
- }
429
- }
430
- return retry;
431
- }
432
-
433
-
434
- // 跑一轮修复。
435
- async function run(reason, manager, attempt, deps) {
436
- if (deps === undefined) throw new Error('NO_DEPS:' + new Error().stack);
437
- const { diagnostics, config, record, later, scanError } = deps;
438
- const { probe, stats, warn } = deps;
439
- if (manager === undefined || manager === null) {
440
- diagnostics.diagnostics.versionScan = { reason, at: new Date().toISOString(), ok: false, error: 'pluginManager service is unavailable' };
441
- diagnostics.scheduleWrite();
442
- return [];
443
- }
444
- if (!config().autoVersionRepair || deps.state.busy) return [];
445
- deps.state.busy = true;
446
- try {
447
- const bundles = await manager.listBundles();
448
- const facts = probe.runtimeFacts();
449
- // One roster lookup serves both the mismatch scan and the route analysis.
450
- let plugins;
451
- try {
452
- plugins = await manager.listPlugins?.();
453
- } catch {
454
- plugins = undefined;
455
- }
456
- const { approved, rejected } = await collectAudit(reason, attempt, bundles, facts, plugins, deps);
457
-
458
- const fixed = [];
459
- // Every compatible plugin is adapted, not just the first few: a profile can
460
- // carry a dozen mismatched plugins. The cap is a per-scan safety valve, and
461
- // `0` means no limit.
462
- const budget = config().maxVersionRepairs === 0 ? approved.length : config().maxVersionRepairs;
463
- for (const item of approved.slice(0, budget)) {
464
- const dir = facts.profileDir === undefined ? undefined : packageDir(facts.profileDir, item.name);
465
- if (dir === undefined) {
466
- deps.scanError(`${item.name}@${item.version}: installed directory not found`);
467
- continue;
468
- }
469
- if (await adapt(item, dir, facts, deps)) fixed.push(item.name + "@" + item.version);
470
- }
471
-
472
- const retry = await reloadAdapted(manager, fixed, deps);
473
-
474
- // 扫完顺手清一下缓存。版本搜索会把试过的版本都下载下来,不清理只会越长越大。
475
- const pruned = pruneCache(facts.profileDir);
476
- if (pruned > 0) record('cache-pruned', `清掉 ${pruned} 个冷缓存`);
477
-
478
-
479
- diagnostics.scheduleWrite();
480
- if (retry && attempt < 6) {
481
- const waitMs = Math.min(2000 * 2 ** attempt, 60000);
482
- later(() => {
483
- run(`${reason}-retry`, manager, attempt + 1).catch(() => {});
484
- }, waitMs);
485
- }
486
- return fixed;
487
- } catch (error) {
488
- diagnostics.diagnostics.versionScan = { reason, at: new Date().toISOString(), ok: false, error: String(error) };
489
- diagnostics.scheduleWrite();
490
- warn(`request-doctor: version scan failed: ${String(error)}`);
491
- return [];
492
- } finally {
493
- deps.state.busy = false;
494
- }
495
- };
1
+ // 插件与 DSH 的版本修复。
2
+ //
3
+ // 找出所有声明了当前运行时不满足的依赖的插件,逐个审计,把能证明兼容的那些交给兼容环境。
4
+ // 审计是前置条件:插件要用的符号已经不存在了,就绝不硬上。
5
+ import { existsSync, readFileSync, readdirSync } from 'node:fs';
6
+ import { dirname, join } from 'node:path';
7
+ import { noteRestart } from './restart-notice.js';
8
+ import { unmetPeers } from './compat.js';
9
+ import { auditInstalledPlugin, entryPathOf, installedEntryPath, packageDir } from './compat-repair.js';
10
+ import { alignManifest, installCompatEnvironment } from './compat-env.js';
11
+ import { describeContention } from './arbitrate.js';
12
+ import { clearStagedPreparation, prepareForInstall, readStagedPreparation } from './install-support.js';
13
+ import { verifyEnvironment } from './env-verify.js';
14
+ import { fetchPackage, listVersions } from './package-fetch.js';
15
+ import { orderCandidates } from './version-search.js';
16
+ import { pruneCache, touchAndReturn } from './cache-store.js';
17
+
18
+ export { pruneCache };
19
+ import { collectMismatched } from './plugin-discovery.js';
20
+
21
+ // 激活过程中重排会报这个错
22
+ const NESTED_HMR = /transactions cannot be nested/i;
23
+
24
+ // 造一个版本修复器。
25
+ export function createVersionRepair(input) {
26
+ const { ctx, probe, diagnostics, config } = input;
27
+ const { record, stats } = diagnostics;
28
+ const state = { busy: false };
29
+
30
+ // 拿插件管理器服务,没有就返回 undefined。
31
+ const managerOf = () => probe.get('pluginManager');
32
+
33
+ const deps = { ctx, probe, diagnostics, config, record, stats, warn: input.warn, later: input.later, maxHmrRetries: input.maxHmrRetries, state, scanError: diagnostics.scanError };
34
+
35
+ return {
36
+ run: (reason, manager, attempt) => run(reason, manager ?? managerOf(), attempt ?? 0, deps),
37
+ isBusy: () => state.busy,
38
+ };
39
+ }
40
+
41
+ // 把每个不匹配的插件都审一遍。
42
+ //
43
+ // 单个插件读不出来或者 manifest 坏了,不能把整轮扫描带停,所以每个都单独兜住。
44
+ async function auditAll(bundles, facts, plugins, probe) {
45
+ const taken = probe.activeProviders() ?? [];
46
+ const audited = [];
47
+ for (const issue of await collectMismatched(bundles, facts, plugins, probe)) {
48
+ // One plugin's unreadable package or malformed manifest must not abort the
49
+ // scan for every other plugin, so each audit is contained.
50
+ audited.push(await auditOne(issue, facts, probe, taken));
51
+ }
52
+ return audited;
53
+ }
54
+
55
+ // 审一个候选。抛出来的异常折算成 review,不往上冒。
56
+ async function auditOne(issue, facts, probe, taken) {
57
+ const base = {
58
+ name: issue.name,
59
+ version: issue.version,
60
+ runtimeVersion: issue.runtimeVersion ?? facts.runtimeVersion,
61
+ requirements: issue.requirements ?? {},
62
+ deferred: issue.deferred === true,
63
+ needsEnvironment: issue.needsEnvironment === true,
64
+ };
65
+ try {
66
+ const manifestPath = facts.profileDir === undefined
67
+ ? undefined
68
+ : join(packageDir(facts.profileDir, issue.name) ?? '', 'package.json');
69
+ const result = await auditInstalledPlugin({
70
+ profileDir: facts.profileDir,
71
+ name: issue.name,
72
+ runtimeVersion: base.runtimeVersion,
73
+ requirements: base.requirements,
74
+ manifest: manifestPath === undefined ? undefined : probe.readManifestCached(manifestPath),
75
+ runtimeEntryOf: probe.runtimeEntryOf(facts),
76
+ takenProviders: taken,
77
+ assumeCompatible: base.deferred === true || base.needsEnvironment === true,
78
+ });
79
+ return {
80
+ ...base,
81
+ verdict: result.verdict,
82
+ reasons: result.reasons,
83
+ missing: result.missing,
84
+ imports: result.imports,
85
+ importMap: result.importMap,
86
+ providers: result.providers,
87
+ conflicts: result.conflicts ?? [],
88
+ };
89
+ } catch (error) {
90
+ return {
91
+ ...base,
92
+ verdict: 'review',
93
+ reasons: [`audit failed: ${String(error)}`],
94
+ missing: [],
95
+ imports: [],
96
+ importMap: new Map(),
97
+ providers: [],
98
+ conflicts: [],
99
+ };
100
+ }
101
+ }
102
+ // 已启用插件的花名册,附上每个实际在服务的路由。
103
+ //
104
+ // 源码分析会多算,所以要跟运行时注册表求交集:某个地区没启用,不代表它在抢路由。
105
+ function providerRoster(facts, plugins, deps) {
106
+ const { probe, warn } = deps;
107
+ const roster = [];
108
+ const live = probe.activeProviders();
109
+ for (const entry of plugins ?? []) {
110
+ if (entry?.enabled !== true) continue;
111
+ const entryPath = facts.profileDir === undefined ? undefined : installedEntryPath(facts.profileDir, entry.moduleName);
112
+ if (entryPath === undefined) continue;
113
+ try {
114
+ const declared = probe.factsOf(dirname(entryPath), entryPath, facts.runtimeVersion).providers;
115
+ roster.push({
116
+ name: entry.moduleName,
117
+ version: entry.version ?? '0.0.0',
118
+ active: entry.fiberPhase === 'active',
119
+ providers: live === undefined ? declared : declared.filter((id) => live.includes(id)),
120
+ });
121
+ } catch (error) {
122
+ // A plugin that cannot be read contributes no claim, but the reason is
123
+ // worth one line: a broken install otherwise looks like a clean scan.
124
+ warn(`request-doctor: could not analyse ${entry.moduleName}: ${String(error)}`);
125
+ }
126
+ }
127
+ return roster;
128
+ };
129
+
130
+ // 本进程里已经确认「所有版本都凑不齐」的组合,就别再搜第二遍了。
131
+ //
132
+ // 不记这一笔的话,每次扫描都会把注册表和下载重走一遍——明明上一次已经证明没有。
133
+ // 进程重启会清空,所以新发布的版本自然会被重新看到。
134
+ const exhaustedSearches = new Set();
135
+
136
+ // 把缺符号的那些包的真实旧版本取回来。
137
+ //
138
+ // 兼容模式的关键就在这里:插件当初是对着某个版本写的,那个版本的代码还在仓库里,
139
+ // 取下来装上就行。这样跑的是它认识的那份实现,不是转发到今天的替身。
140
+ async function fetchNeededVersions(item, facts, deps) {
141
+ const map = new Map();
142
+ const probeResult = installCompatEnvironment({
143
+ pluginDir: facts.profileDir ?? '',
144
+ profileDir: facts.profileDir,
145
+ ...commonEnvironmentFields(item),
146
+ runtimeVersion: item.runtimeVersion,
147
+ resolveDir: deps.probe.runtimeDirOf(facts),
148
+ // 只问一句,别真写:这一调用如果落盘,会把 profile 里的真实包盖成垫片。
149
+ dryRun: true,
150
+ });
151
+ if (probeResult.status !== 'needs-old-version') return map;
152
+
153
+
154
+ for (const need of probeResult.needed) {
155
+ const found = await findVersionWithSymbols(item, facts, need, deps);
156
+ if (found !== undefined) map.set(need.name, found);
157
+ }
158
+ return map;
159
+ }
160
+
161
+ // 找一个能凑齐符号的版本。
162
+ //
163
+ // 钉住的那个版本未必有插件要的东西——它声明的范围可能覆盖好几个版本,而符号只在其中
164
+ // 某一个里。既然旧代码都还在仓库里,就按顺序试,第一个凑齐的就是答案。
165
+ async function findVersionWithSymbols(item, facts, need, deps) {
166
+ const key = `${need.name}|${(need.symbols ?? []).join(',')}`;
167
+ if (exhaustedSearches.has(key)) return undefined;
168
+
169
+ const versions = await listVersions(need.name);
170
+ const candidates = orderCandidates(versions, item.requirements?.[need.name], need.version);
171
+ if (candidates.length === 0) candidates.push(need.version);
172
+
173
+ for (const version of candidates) {
174
+ const dir = await cachePackage(need.name, version, facts.profileDir, deps);
175
+ if (dir === undefined) continue;
176
+ // 只问这个版本能不能凑齐,别真装。
177
+ const trial = installCompatEnvironment({
178
+ pluginDir: '',
179
+ profileDir: facts.profileDir,
180
+ ...environmentInput(item, facts, deps),
181
+ oldVersionDirs: new Map([[need.name, { dir, version }]]),
182
+ dryRun: true,
183
+ });
184
+ if (trial.status === 'installable') {
185
+ return { dir, version, closure: await resolveClosure(need.name, version, facts.profileDir, deps) };
186
+ }
187
+ deps.record('version-skipped', `${need.name}@${version} 凑不齐,试下一个`);
188
+ }
189
+ exhaustedSearches.add(key);
190
+ return undefined;
191
+ }
192
+
193
+ // 把每个包各自的闭包合起来。同一个包出现在多个闭包里时,以先来的为准——
194
+ // 它们通常来自同一批发布,版本一致;真不一致的情况下,冲突本身就该由插件作者解决。
195
+ function mergeClosures(oldVersionDirs) {
196
+ const merged = new Map();
197
+ for (const entry of oldVersionDirs?.values() ?? []) {
198
+ for (const [name, value] of entry.closure ?? new Map()) {
199
+ if (!merged.has(name)) merged.set(name, value);
200
+ }
201
+ }
202
+ return merged;
203
+ }
204
+ // 每处都一样的两个字段:插件声明的范围和它实际导入的符号。
205
+ function commonEnvironmentFields(item) {
206
+ return {
207
+ requirements: item.requirements ?? {},
208
+ imports: item.importMap ?? new Map(),
209
+ };
210
+ }
211
+
212
+ // 建兼容环境要的那几个参数,每处都一样,拼一次就够。
213
+ function environmentInput(item, facts, deps) {
214
+ return {
215
+ profileDir: facts.profileDir,
216
+ ...commonEnvironmentFields(item),
217
+ runtimeVersion: item.runtimeVersion,
218
+ resolveDir: deps.probe.runtimeDirOf(facts),
219
+ };
220
+ }
221
+
222
+ // 一次闭包最多装这么多个包。旧版本的依赖通常几个,多了说明依赖树异常,该停下来。
223
+ const MAX_CLOSURE = 16;
224
+
225
+ // 从一个版本范围里取出具体版本号。取不到就跳过——猜一个版本去装还不如不装。
226
+ function exactVersionOf(range) {
227
+ if (typeof range !== 'string') return undefined;
228
+ return /\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?/.exec(range)?.[0];
229
+ }
230
+
231
+ // 把旧版本自己的运行时依赖也取回来。
232
+ //
233
+ // 这一步不能省:DSH 的包彼此依赖,而且钉的是具体版本。只装它自己一个,加载时
234
+ // 它 import 的兄弟包要么根本找不到,要么拿到新版本——两种都不是插件当初的环境。
235
+ export async function resolveClosure(root, version, profileDir, deps) {
236
+ const closure = new Map();
237
+ const seen = new Set();
238
+ const queue = [{ name: root, version }];
239
+
240
+ while (queue.length > 0 && closure.size < MAX_CLOSURE) {
241
+ const next = queue.shift();
242
+ if (next.name === undefined || seen.has(next.name)) continue;
243
+ seen.add(next.name);
244
+
245
+ const dir = await cachePackage(next.name, next.version, profileDir, deps);
246
+ if (dir === undefined) continue;
247
+ closure.set(next.name, { dir, version: next.version });
248
+ for (const dependency of runtimeDependenciesOf(dir)) {
249
+ if (seen.has(dependency.name)) continue;
250
+ queue.push(dependency);
251
+ }
252
+ }
253
+ return closure;
254
+ }
255
+
256
+
257
+ // 读出一个包自己依赖的运行时包,版本按声明钉死。
258
+ //
259
+ // 只关心 @deepseek-ai 这一谱系:第三方库的版本线跟运行时不是一回事,交给正常的
260
+ // 模块解析去处理。取不到具体版本的一律跳过——猜一个版本去装,还不如不装。
261
+ function runtimeDependenciesOf(dir) {
262
+ let manifest;
263
+ try {
264
+ manifest = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8'));
265
+ } catch {
266
+ return [];
267
+ }
268
+ const found = [];
269
+ for (const [name, range] of Object.entries(manifest.dependencies ?? {})) {
270
+ if (!name.startsWith('@deepseek-ai/')) continue;
271
+ const version = exactVersionOf(range);
272
+ if (version !== undefined) found.push({ name, version });
273
+ }
274
+ return found;
275
+ }
276
+
277
+ // 取一个包到本地缓存目录,已经缓存过就直接用。
278
+ export async function cachePackage(name, version, profileDir, deps) {
279
+ if (typeof profileDir !== 'string' || profileDir === '') return undefined;
280
+ const dir = join(profileDir, '.request-doctor-cache', ...`${name}@${version}`.split('/'));
281
+ try {
282
+ const manifest = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8'));
283
+ if (manifest.version === version) return touchAndReturn(dir);
284
+ } catch {
285
+ // 没缓存,去取
286
+ }
287
+ const result = await fetchPackage(name, version, dir);
288
+ if (!result.ok) {
289
+ deps.record('fetch-failed', `${name}@${version} 取不回来:${result.error ?? ''}`);
290
+ return undefined;
291
+ }
292
+ deps.record('fetch', `${name}@${version} 已取回并缓存`);
293
+ return touchAndReturn(dir);
294
+ }
295
+
296
+
297
+ // 给插件配上它当初的那个环境。
298
+ //
299
+ // 插件目录里的嵌套 node_modules 会遮住 profile 里的新包,每个垫片又转发现行实现,
300
+ // 于是插件看到的是自己期望的版本,跑的是当前代码。manifest 也得跟着对齐,
301
+ // 因为 DSH 在任何导入发生之前就要读它。
302
+ async function adapt(item, dir, facts, deps) {
303
+ const { probe, diagnostics, record, stats, warn, scanError } = deps;
304
+ const packageVersion = `${item.name}@${item.version}`;
305
+ // 缺符号时先把插件期望的真实旧版本取回来,再建环境。
306
+ const oldVersionDirs = await fetchNeededVersions(item, facts, deps);
307
+ const result = installCompatEnvironment({
308
+ pluginDir: dir,
309
+ profileDir: facts.profileDir,
310
+ ...commonEnvironmentFields(item),
311
+ runtimeVersion: item.runtimeVersion,
312
+ resolveDir: probe.runtimeDirOf(facts),
313
+ oldVersionDirs,
314
+ closure: mergeClosures(oldVersionDirs),
315
+ });
316
+
317
+
318
+ if (result.status === 'refused') {
319
+ deps.scanError(`${packageVersion}: ${result.error}`);
320
+ record('compat-refused', `${packageVersion} cannot run here: ${result.error}`);
321
+ return false;
322
+ }
323
+ if (result.status === 'failed') {
324
+ deps.scanError(`${packageVersion}: ${result.error ?? 'unknown failure'}`);
325
+ warn(`request-doctor: compatibility environment failed for ${packageVersion}: ${result.error ?? ''}`);
326
+ return false;
327
+ }
328
+
329
+ // 建好了不等于能用。逐条核对插件要的每个符号环境是否真能给出来,
330
+ // 有问题在这里就暴露,而不是等插件加载那一刻崩给用户看。
331
+ const verdict = verifyEnvironment({ pluginDir: dir, imports: item.importMap ?? new Map() });
332
+ if (!verdict.ok) {
333
+ const detail = verdict.problems.map((problem) => `${problem.name} ${problem.detail}`).join('; ');
334
+ deps.scanError(`${packageVersion}: 环境核对未通过:${detail}`);
335
+ record('compat-unverified', `${packageVersion} 的环境核对未通过:${detail}`);
336
+ return false;
337
+ }
338
+ // 运行时包装在归档里时核对不了,不算失败,但值得记一笔。
339
+ for (const note of verdict.unverified) {
340
+ record('compat-unchecked', `${packageVersion}: ${note.name} ${note.detail}`);
341
+ }
342
+
343
+ // 环境没变化也要对齐声明。
344
+ //
345
+ // 有些插件一个运行时包都不 import——皮肤、纯客户端界面就是这种。它们没有垫片要建,
346
+ // 环境自然是「没变化」,但 manifest 里照样声明了运行时版本。跳过对齐的话,
347
+ // 这类插件永远过不了管理器的检查,也永远显示不出来。
348
+ // 有垫片的包按运行时版本声明;只声明、没导入的 DSH 包也要一起对齐——
349
+ // 管理器的检查看的就是这份 manifest,漏一个它就不放行。
350
+ const served = Object.fromEntries([
351
+ ...(result.packages ?? []).map((pkg) => [pkg, item.runtimeVersion]),
352
+ ...Object.keys(item.requirements ?? {}).map((pkg) => [pkg, item.runtimeVersion]),
353
+ ]);
354
+ const aligned = alignManifest({ pluginDir: dir, versions: served, runtimeVersion: item.runtimeVersion });
355
+ if (aligned.status === 'failed') {
356
+ deps.scanError(`${packageVersion}: ${aligned.error}`);
357
+ warn(`request-doctor: could not align ${packageVersion}: ${aligned.error}`);
358
+ return false;
359
+ }
360
+ if (result.status === 'unchanged' && aligned.status !== 'aligned') return true;
361
+ // 装的时候提前备好的环境,到这里已经落定,临时标记没用了。
362
+ if (item.deferred === true) clearStagedPreparation(dir);
363
+ stats.versionRepairs += 1;
364
+ record('compat', `${packageVersion} given a ${item.runtimeVersion} compatibility environment for ${(result.packages ?? []).join(', ') || 'no shims'}; declared versions aligned`);
365
+ return true;
366
+ };
367
+
368
+ // 审一遍所有插件,把结论写进诊断,并返回分了组的清单。
369
+ //
370
+ // 冲突只报告不动手:两个插件抢同一个 provider 时,谁该让位是人的判断,
371
+ // 插件贸然停用一个,用户看到的就是「莫名其妙少了功能」。
372
+ async function collectAudit(reason, attempt, bundles, facts, plugins, deps) {
373
+ const { diagnostics, probe, record } = deps;
374
+ const audited = await auditAll(bundles, facts, plugins, probe);
375
+ const roster = audited.length === 0 ? [] : providerRoster(facts, plugins, deps);
376
+
377
+ for (const entry of audited) {
378
+ entry.contention = describeContention({
379
+ name: entry.name,
380
+ providers: entry.providers ?? [],
381
+ conflicts: entry.conflicts ?? [],
382
+ roster,
383
+ });
384
+ if (entry.contention.contested.length > 0) {
385
+ record('route-report', `${entry.name}@${entry.version} shares ${entry.contention.contested.join(', ')}`);
386
+ }
387
+ }
388
+
389
+
390
+ const approved = audited.filter((entry) => entry.verdict === 'compatible');
391
+ const rejected = audited.filter((entry) => entry.verdict !== 'compatible');
392
+ diagnostics.diagnostics.versionScan = {
393
+ reason,
394
+ at: new Date().toISOString(),
395
+ ok: true,
396
+ attempt,
397
+ bundles: bundles.length,
398
+ audited,
399
+ approved: approved.map((entry) => `${entry.name}@${entry.version}`),
400
+ rejected: rejected.map((entry) => ({ name: entry.name, verdict: entry.verdict, reasons: entry.reasons })),
401
+ };
402
+ for (const entry of rejected) {
403
+ record('version-review', `${entry.name}@${entry.version} not approved (${entry.verdict}): ${entry.reasons.slice(-1)[0] ?? ''}`);
404
+ }
405
+ return { approved, rejected };
406
+ }
407
+
408
+
409
+ // 重载刚适配好的插件。
410
+ //
411
+ // Loader 是在组合期检查 bundle 的,所以改过 manifest 之后必须重新组合才生效。
412
+ // 只碰这次真正适配过的插件,别人的状态一概不动。
413
+ async function reloadAdapted(manager, fixed, deps) {
414
+ let retry = false;
415
+ for (const packageVersion of fixed) {
416
+ const name = packageVersion.slice(0, packageVersion.lastIndexOf('@'));
417
+ if (name === '') continue;
418
+ try {
419
+ const applied = await manager.setBundleEnabled(name, true);
420
+ if (applied?.application === 'failed') {
421
+ const detail = JSON.stringify(applied.error ?? applied);
422
+ if (noteRestart(deps, name, detail) === true) continue;
423
+ deps.scanError(`${name}: ${detail}`);
424
+ if (NESTED_HMR.test(detail)) retry = true;
425
+ }
426
+ } catch (error) {
427
+ const detail = String(error);
428
+ if (noteRestart(deps, name, detail) === true) continue;
429
+ deps.scanError(`${name}: ${detail}`);
430
+ if (NESTED_HMR.test(detail)) retry = true;
431
+ }
432
+ }
433
+ return retry;
434
+ }
435
+
436
+
437
+
438
+ // 跑一轮修复。
439
+ async function run(reason, manager, attempt, deps) {
440
+ if (deps === undefined) throw new Error('NO_DEPS:' + new Error().stack);
441
+ const { diagnostics, config, record, later, scanError } = deps;
442
+ const { probe, stats, warn } = deps;
443
+ if (manager === undefined || manager === null) {
444
+ diagnostics.diagnostics.versionScan = { reason, at: new Date().toISOString(), ok: false, error: 'pluginManager service is unavailable' };
445
+ diagnostics.scheduleWrite();
446
+ return [];
447
+ }
448
+ if (!config().autoVersionRepair || deps.state.busy) return [];
449
+ deps.state.busy = true;
450
+ try {
451
+ const bundles = await manager.listBundles();
452
+ const facts = probe.runtimeFacts();
453
+ // One roster lookup serves both the mismatch scan and the route analysis.
454
+ let plugins;
455
+ try {
456
+ plugins = await manager.listPlugins?.();
457
+ } catch {
458
+ plugins = undefined;
459
+ }
460
+ const { approved, rejected } = await collectAudit(reason, attempt, bundles, facts, plugins, deps);
461
+
462
+ const fixed = [];
463
+ // Every compatible plugin is adapted, not just the first few: a profile can
464
+ // carry a dozen mismatched plugins. The cap is a per-scan safety valve, and
465
+ // `0` means no limit.
466
+ const budget = config().maxVersionRepairs === 0 ? approved.length : config().maxVersionRepairs;
467
+ for (const item of approved.slice(0, budget)) {
468
+ const dir = facts.profileDir === undefined ? undefined : packageDir(facts.profileDir, item.name);
469
+ if (dir === undefined) {
470
+ deps.scanError(`${item.name}@${item.version}: installed directory not found`);
471
+ continue;
472
+ }
473
+ if (await adapt(item, dir, facts, deps)) fixed.push(item.name + "@" + item.version);
474
+ }
475
+
476
+ const retry = await reloadAdapted(manager, fixed, deps);
477
+
478
+ // 扫完顺手清一下缓存。版本搜索会把试过的版本都下载下来,不清理只会越长越大。
479
+ const pruned = pruneCache(facts.profileDir);
480
+ if (pruned > 0) record('cache-pruned', `清掉 ${pruned} 个冷缓存`);
481
+
482
+
483
+ diagnostics.scheduleWrite();
484
+ if (retry && attempt < 6) {
485
+ const waitMs = Math.min(2000 * 2 ** attempt, 60000);
486
+ later(() => {
487
+ run(`${reason}-retry`, manager, attempt + 1).catch(() => {});
488
+ }, waitMs);
489
+ }
490
+ return fixed;
491
+ } catch (error) {
492
+ diagnostics.diagnostics.versionScan = { reason, at: new Date().toISOString(), ok: false, error: String(error) };
493
+ diagnostics.scheduleWrite();
494
+ warn(`request-doctor: version scan failed: ${String(error)}`);
495
+ return [];
496
+ } finally {
497
+ deps.state.busy = false;
498
+ }
499
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-request-doctor",
3
- "version": "0.11.0",
3
+ "version": "0.11.1",
4
4
  "description": "自动修复客户端引起的请求失败,并让版本不匹配的 DSH 插件能装上、能跑起来。",
5
5
  "keywords": [
6
6
  "dsh-plugin",