dsh-plugin-manager-companion 0.1.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.en.md +144 -0
- package/README.md +142 -0
- package/cordis.patch.yml +9 -0
- package/dist/about.d.ts +77 -0
- package/dist/about.js +179 -0
- package/dist/cli.d.ts +226 -0
- package/dist/cli.js +856 -0
- package/dist/client/AboutPage.d.ts +75 -0
- package/dist/client/ConsolePage.d.ts +79 -0
- package/dist/client/KindsPage.d.ts +21 -0
- package/dist/client/MarketplacePage.d.ts +36 -0
- package/dist/client/OfficialSlots.d.ts +35 -0
- package/dist/client/UpgradeRow.d.ts +108 -0
- package/dist/client/index.d.ts +26 -0
- package/dist/client/locales.d.ts +475 -0
- package/dist/client/pmSelect.d.ts +38 -0
- package/dist/client/shared.d.ts +928 -0
- package/dist/client/upgradeView.d.ts +278 -0
- package/dist/client/wire.d.ts +401 -0
- package/dist/client.js +9194 -0
- package/dist/diagnostics.d.ts +332 -0
- package/dist/diagnostics.js +2631 -0
- package/dist/envManager.d.ts +1047 -0
- package/dist/envManager.js +3214 -0
- package/dist/fix.d.ts +60 -0
- package/dist/fix.js +168 -0
- package/dist/guard.d.ts +133 -0
- package/dist/guard.js +232 -0
- package/dist/index.d.ts +121 -0
- package/dist/index.js +1150 -0
- package/dist/installSession.d.ts +111 -0
- package/dist/installSession.js +150 -0
- package/dist/kinds.d.ts +464 -0
- package/dist/kinds.js +1029 -0
- package/dist/marketView.d.ts +261 -0
- package/dist/marketView.js +406 -0
- package/dist/marketplace.d.ts +248 -0
- package/dist/marketplace.js +500 -0
- package/dist/match.d.ts +67 -0
- package/dist/match.js +203 -0
- package/dist/net.d.ts +108 -0
- package/dist/net.js +163 -0
- package/dist/official.d.ts +145 -0
- package/dist/official.js +205 -0
- package/dist/paths.d.ts +108 -0
- package/dist/paths.js +236 -0
- package/dist/presets.d.ts +299 -0
- package/dist/presets.js +578 -0
- package/dist/qualityGate.d.ts +66 -0
- package/dist/qualityGate.js +247 -0
- package/dist/rank.d.ts +88 -0
- package/dist/rank.js +164 -0
- package/dist/registry.d.ts +295 -0
- package/dist/registry.js +686 -0
- package/dist/rest.d.ts +122 -0
- package/dist/rest.js +219 -0
- package/dist/scan.d.ts +134 -0
- package/dist/scan.js +396 -0
- package/dist/settings.d.ts +447 -0
- package/dist/settings.js +263 -0
- package/dist/tags.d.ts +119 -0
- package/dist/tags.js +166 -0
- package/dist/tools.d.ts +131 -0
- package/dist/tools.js +377 -0
- package/dist/types.d.ts +651 -0
- package/dist/types.js +13 -0
- package/dist/upgrade.d.ts +428 -0
- package/dist/upgrade.js +1100 -0
- package/dist/upgradeView.d.ts +313 -0
- package/dist/upgradeView.js +273 -0
- package/docs/images/readme/01-console-health.png +0 -0
- package/docs/images/readme/02-console-envs.png +0 -0
- package/docs/images/readme/03-marketplace.png +0 -0
- package/docs/images/readme/04-official-plugin-page.png +0 -0
- package/package.json +104 -0
|
@@ -0,0 +1,2631 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 诊断引擎 — 对本地 DSH 环境做五层只读分析(环境控制台的核心)。
|
|
3
|
+
*
|
|
4
|
+
* 归属:A 类·重写(旧 src/analyze.ts 只作**检测清单**意图参考,未复制任何代码)。
|
|
5
|
+
* 旧实现参考:dsh-web-plugin-manager/src/analyze.ts(检测哪些问题)、
|
|
6
|
+
* src/installFlow.ts#qualityIssues(安装前质量门的检查面)。
|
|
7
|
+
* 旧扫描器是"正则 + 手工剥注释":被注释掉的 new Service(ctx, 'x') 会被算成真实注册(误报),
|
|
8
|
+
* 字符串里的 from 'x' 会被算成 import,正则字面量里的双斜杠会把整行代码当注释吞掉(漏报)。
|
|
9
|
+
* 本模块改为**逐字符词法扫描**:只在代码位置识别 import/export/require 关键字与调用实参,
|
|
10
|
+
* 注释与字符串字面量在词法阶段就被跳过,行号由扫描器直接给出,不做事后回找。
|
|
11
|
+
* 官方复用:@deepseek-ai/dsh-app-boot 的 loadProfileDirectory / composeEntries
|
|
12
|
+
* (patch 层栈与合并语义的唯一权威口径)、@deepseek-ai/dsh-host-plugin-inventory
|
|
13
|
+
* (运行时 fiber 事实,经 official.ts 的 readRuntimeInventory 降级封装)、
|
|
14
|
+
* Node 的 createRequire 真实解析(只用于判定"能解析";判定"不能解析"走文件与 exports 探测)。
|
|
15
|
+
*
|
|
16
|
+
* 解析根(2026-09-19 修复的误报根因):一个 profile 只装自己的依赖,官方包(@deepseek-ai/*)
|
|
17
|
+
* 与 bundle 本体都不在 profile 的 node_modules 里,它们由**安装锚点**提供。只按
|
|
18
|
+
* profile 目录解析会把官方包的每一行都判成孤儿(实测干净环境 163 条 orphan-row 全部是误报)。
|
|
19
|
+
* 因此解析根纳入 ctx.get('profileContext').installAnchor,口径与官方 profile.ts 的
|
|
20
|
+
* packageDirFromAnchor 一致:createRequire(anchor).resolve.paths(name),先 realpath 锚点
|
|
21
|
+
* (pnpm 的 bin shim 路径不是安装目录的兄弟目录,不 realpath 会一个包都解析不到)。
|
|
22
|
+
* 锚点拿不到时退回 profile 解析并在 skipped 里如实说明,绝不静默当成"包不存在"。
|
|
23
|
+
* 前提检查:旧实现的前提是"官方只有只读清单,所以诊断必须自己扫 patch 文本、自己猜绑定关系"。
|
|
24
|
+
* 0.1.6 之后该前提消失:官方提供 composeEntries(与 boot 同一次 applyEntryPatches 调用)与
|
|
25
|
+
* readPluginInventory。自己再实现一遍 patch 合并只会与官方漂移,因此事实来源是
|
|
26
|
+
* **官方优先、静态兜底、缺失记 skipped**,绝不假装健康。
|
|
27
|
+
*
|
|
28
|
+
* 五层,逐层独立可跳过(能力缺失时写入 report.skipped 并给原因):
|
|
29
|
+
* L1 dependency — import 图 × package.json 声明 × loader 提供项
|
|
30
|
+
* L2 composition — patch 层栈的行 id / 禁用 / 孤立行(只读,保留原始行号)
|
|
31
|
+
* L3 runtime — loader fiber 相位 + 源码注册名冲突
|
|
32
|
+
* L4 consistency — 官方 inventory × 本地 manifest
|
|
33
|
+
* L5 ecosystem — 市场索引(默认关闭;本模块只留最小骨架,索引归市场模块)
|
|
34
|
+
*
|
|
35
|
+
* 本模块不写任何文件、不调 pnpm、不注册服务。
|
|
36
|
+
*/
|
|
37
|
+
import { existsSync, readFileSync, readdirSync, realpathSync, statSync } from 'node:fs';
|
|
38
|
+
import { createRequire, isBuiltin } from 'node:module';
|
|
39
|
+
import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
40
|
+
import { fileURLToPath } from 'node:url';
|
|
41
|
+
import { profilesRoot, readEnvironmentManifest } from "./paths.js";
|
|
42
|
+
import { readRuntimeInventory } from "./official.js";
|
|
43
|
+
/** 传给官方装载函数(loadProfileDirectory / loadOptionalPatches)的诊断前缀。 */
|
|
44
|
+
const DIAG_BIN = 'dsh-plugin-manager-companion';
|
|
45
|
+
/** 五层的固定顺序:只在"环境级能力缺失废掉全部五层"这种跳过里用得到。 */
|
|
46
|
+
const ALL_LAYERS = ['dependency', 'composition', 'runtime', 'consistency', 'ecosystem'];
|
|
47
|
+
/** 单包源码扫描的硬上限(质量门要覆盖整条加载链,所以给得宽)。超出即标注 truncated。 */
|
|
48
|
+
const SCAN_MAX_FILES_PER_PACKAGE = 400;
|
|
49
|
+
/** 诊断时每个包的扫描预算(比硬上限小:诊断要扫很多包,不能把响应拖到不可用)。 */
|
|
50
|
+
const L1_PACKAGE_FILE_BUDGET = 60;
|
|
51
|
+
/** 相对 import 的 BFS 深度上限,防止畸形依赖图把扫描拖死。 */
|
|
52
|
+
const SCAN_MAX_DEPTH = 10;
|
|
53
|
+
/** 一次诊断里所有包扫描共享的文件预算,防止几十个包把响应拖到不可用。 */
|
|
54
|
+
const SCAN_FILE_BUDGET = 1200;
|
|
55
|
+
/** 依赖闭包 BFS(只读 manifest)的包数上限。 */
|
|
56
|
+
const CLOSURE_MAX_PACKAGES = 400;
|
|
57
|
+
/**
|
|
58
|
+
* 安装锚点 → Node 解析根(每个锚点只算一次;诊断会解析上百个说明符)。
|
|
59
|
+
*
|
|
60
|
+
* 锚点拿不到的包**不进**这张表,调用方据此区分"没有锚点"与"锚点里没有这个包"。
|
|
61
|
+
*/
|
|
62
|
+
const installPathCache = new Map();
|
|
63
|
+
/**
|
|
64
|
+
* 官方包里允许作为**普通 dependencies** 声明的那几个。
|
|
65
|
+
*
|
|
66
|
+
* 理由(沿用前身仓库审计结论,此处只记录事实):这两个包的模块身份不敏感——
|
|
67
|
+
* schemastery 的校验错误走 Symbol.for('ValidationError') 全局符号,schema 本身是纯闭包;
|
|
68
|
+
* cosmokit 是无模块级可变状态的工具集。其余 @deepseek-ai/* 一旦以普通依赖落进 profile,
|
|
69
|
+
* pnpm 会装出第二份拷贝,loader 从 profile 目录解析官方行时就会用到它,
|
|
70
|
+
* 唯一符号与类身份随之分裂,运行时报 "Cannot read properties of undefined" 这类错。
|
|
71
|
+
*/
|
|
72
|
+
export const OFFICIAL_DEP_ALLOWED = new Set([
|
|
73
|
+
'@deepseek-ai/schemastery',
|
|
74
|
+
'@deepseek-ai/cosmokit',
|
|
75
|
+
]);
|
|
76
|
+
/** loader / 平台直接提供、无需声明的精确说明符(含其子路径)。 */
|
|
77
|
+
const LOADER_PROVIDED_EXACT = [
|
|
78
|
+
'cordis',
|
|
79
|
+
'@deepseek-ai/cordis',
|
|
80
|
+
'react',
|
|
81
|
+
'react-dom',
|
|
82
|
+
'react/jsx-runtime',
|
|
83
|
+
'react-dom/client',
|
|
84
|
+
'@deepseek-ai/dsh-client-web-react',
|
|
85
|
+
];
|
|
86
|
+
/** 按前缀族提供的说明符:客户端平台表与 cordis 插件族整族由平台提供。 */
|
|
87
|
+
const LOADER_PROVIDED_PREFIX = [
|
|
88
|
+
'@deepseek-ai/dsh-client-',
|
|
89
|
+
'@deepseek-ai/cordis-plugin-',
|
|
90
|
+
];
|
|
91
|
+
/**
|
|
92
|
+
* 说明符是否由 loader / 平台提供(不需要在包里声明)。
|
|
93
|
+
* @param spec - 裸说明符。
|
|
94
|
+
* @returns 是否由平台提供。
|
|
95
|
+
*/
|
|
96
|
+
export function isLoaderProvided(spec) {
|
|
97
|
+
for (const name of LOADER_PROVIDED_EXACT) {
|
|
98
|
+
if (spec === name || spec.startsWith(name + '/'))
|
|
99
|
+
return true;
|
|
100
|
+
}
|
|
101
|
+
return LOADER_PROVIDED_PREFIX.some(prefix => spec.startsWith(prefix));
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* 对一个环境做完整诊断。
|
|
105
|
+
*
|
|
106
|
+
* 五层依次执行,任何一层失败只影响它自己:该层的失败原因写进 report.skipped,
|
|
107
|
+
* 其余层照常产出。函数本身不抛错——诊断工具把异常抛给 UI 只能得到白屏。
|
|
108
|
+
*
|
|
109
|
+
* @param ctx - host 上下文;只用 ctx.get()(官方能力探测)与 ctx.logger。
|
|
110
|
+
* @param env - 被诊断环境(EnvironmentInfo,来自环境列表)。
|
|
111
|
+
* @param config - 本插件配置;同时接受 CompanionConfig 与单独的 diagnostics 段。
|
|
112
|
+
* @returns 报告:分层问题数 + 按 code/作用域聚合的组 + 逐条问题清单 + 跳过项。
|
|
113
|
+
*/
|
|
114
|
+
export async function analyzeEnvironment(ctx, env, config) {
|
|
115
|
+
const diagnostics = normalizeDiagnosticsConfig(config);
|
|
116
|
+
const issues = [];
|
|
117
|
+
const skipped = [];
|
|
118
|
+
const usedIds = new Set();
|
|
119
|
+
if (!existsSync(env.dir)) {
|
|
120
|
+
// 环境目录不存在:五层都没有输入(不是"查过且没问题",每层都必须显示成没查)。
|
|
121
|
+
skipped.push({
|
|
122
|
+
check: 'environment-dir',
|
|
123
|
+
layers: [...ALL_LAYERS],
|
|
124
|
+
reason: '环境目录不存在:' + env.dir,
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
// 解析根要安装锚点:调用方透传优先,其次问官方 profileContext(launcher 一定会提供它)。
|
|
128
|
+
// 锚点的语义是"当前进程所属的 dsh 安装"(官方 ProfileContext 同理):它的模块根排在
|
|
129
|
+
// profile 自己的 node_modules **之后**,所以诊断别的 profile 时,那个 profile 装了什么
|
|
130
|
+
// 仍然优先命中,锚点只补上安装侧提供的那部分(官方包、bundle 本体)。
|
|
131
|
+
const installAnchor = env.installAnchor ?? readInstallAnchor(ctx);
|
|
132
|
+
const facts = collectStaticFacts({ ...env, installAnchor }, diagnostics, skipped);
|
|
133
|
+
const composition = await readComposition(ctx, env, facts);
|
|
134
|
+
skipped.push(...composition.skips);
|
|
135
|
+
let runtime;
|
|
136
|
+
try {
|
|
137
|
+
runtime = await readRuntimeInventory(ctx);
|
|
138
|
+
}
|
|
139
|
+
catch (error) {
|
|
140
|
+
runtime = { entries: [], agentPresets: undefined, source: 'unavailable', reason: messageOf(error) };
|
|
141
|
+
}
|
|
142
|
+
if (runtime.source === 'unavailable') {
|
|
143
|
+
// 没有 Loader:runtime 与 consistency 两层都真的没跑(两层的实现都按 source==='unavailable' 早退)。
|
|
144
|
+
// 只标一层会把另一层画成 0 = "查过且没问题",那正是这个字段要防的误读。
|
|
145
|
+
skipped.push({
|
|
146
|
+
check: 'runtime-inventory',
|
|
147
|
+
layers: ['runtime', 'consistency'],
|
|
148
|
+
reason: '运行时事实不可用:' + (runtime.reason ?? '原因未知'),
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
const layers = [
|
|
152
|
+
{ layer: 'dependency', run: () => dependencyLayer(env, facts, composition) },
|
|
153
|
+
{ layer: 'composition', run: () => compositionLayer(env, facts, composition, skipped) },
|
|
154
|
+
{ layer: 'runtime', run: () => runtimeLayer(env, facts, composition, runtime) },
|
|
155
|
+
{ layer: 'consistency', run: () => consistencyLayer(env, facts, composition, runtime) },
|
|
156
|
+
{ layer: 'ecosystem', run: () => ecosystemLayer() },
|
|
157
|
+
];
|
|
158
|
+
for (const entry of layers) {
|
|
159
|
+
if (!layerEnabled(diagnostics, entry.layer)) {
|
|
160
|
+
skipped.push({
|
|
161
|
+
check: entry.layer + '-layer',
|
|
162
|
+
// 整层没查:带上层归属,UI 才能把这一格画成「没查」而不是 0。
|
|
163
|
+
layers: [entry.layer],
|
|
164
|
+
reason: '配置里关闭了该层(settings.diagnostics.' + entry.layer + ')',
|
|
165
|
+
});
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
try {
|
|
169
|
+
issues.push(...entry.run());
|
|
170
|
+
}
|
|
171
|
+
catch (error) {
|
|
172
|
+
// 单层内部异常:如实登记为"这次没查成",不让其余层的结论被吞掉。
|
|
173
|
+
// 这同样是整层没查:layers 必须设上,否则 UI 会把它画成 0(=查过且没问题)。
|
|
174
|
+
// 抛错的是这一层,就只标这一层——不替别的层下结论。
|
|
175
|
+
skipped.push({
|
|
176
|
+
check: entry.layer + '-layer',
|
|
177
|
+
layers: [entry.layer],
|
|
178
|
+
reason: '该层执行失败:' + messageOf(error),
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
// 启动阻断根因(从官方异常文本解析出来的一等公民)并入报告:
|
|
183
|
+
// 它们与其余发现一起进计数、一起进分组,UI 不必再去 skip.reason 里翻长文本。
|
|
184
|
+
// 去重:重复 id / 孤立行在纯文本路径下已经产出更细的 duplicate-row-id / orphan-row(带文件与行号),
|
|
185
|
+
// 这里就不再把同一条事实报第二遍;没有更细版本时才由启动阻断自己出场。
|
|
186
|
+
const reportedIds = new Set(issues.map(issue => issue.id));
|
|
187
|
+
const coveredByRawCheck = (boot) => {
|
|
188
|
+
const id = String(boot.subjects[0] ?? '');
|
|
189
|
+
return boot.code === 'boot-blocker-row'
|
|
190
|
+
? issues.some(issue => issue.code === 'duplicate-row-id' && issue.subjects.includes(id))
|
|
191
|
+
: false;
|
|
192
|
+
};
|
|
193
|
+
for (const boot of composition.bootIssues) {
|
|
194
|
+
if (reportedIds.has(boot.id) || coveredByRawCheck(boot))
|
|
195
|
+
continue;
|
|
196
|
+
reportedIds.add(boot.id);
|
|
197
|
+
issues.push(boot);
|
|
198
|
+
}
|
|
199
|
+
if (diagnostics.ecosystem && issues.every(issue => issue.layer !== 'ecosystem')) {
|
|
200
|
+
skipped.push({
|
|
201
|
+
check: 'ecosystem-index',
|
|
202
|
+
reason: '这一层当前没有可判定的事实:市场索引数据未接入。',
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
// 噪声治理:同一 code 的发现按「作用域 + 严重级别」归组,让 163 条同类命中也能被人读懂。
|
|
206
|
+
// 逐条发现一条不少(证据与行号仍在 issues 里),groups 只提供可折叠的计数与来源说明。
|
|
207
|
+
const withIds = issues.map(issue => ({ ...issue, id: uniqueId(issue.id, usedIds) }));
|
|
208
|
+
const report = {
|
|
209
|
+
environment: env.name,
|
|
210
|
+
generatedAt: new Date().toISOString(),
|
|
211
|
+
counts: countByLayer(issues),
|
|
212
|
+
issues: withIds,
|
|
213
|
+
groups: buildGroups(withIds),
|
|
214
|
+
skipped,
|
|
215
|
+
};
|
|
216
|
+
return report;
|
|
217
|
+
}
|
|
218
|
+
/**
|
|
219
|
+
* 组键:层级 + 类别 + 严重级别 + 作用域(同一个 code 落在不同包里是不同的问题)。
|
|
220
|
+
* @param issue - 一条发现。
|
|
221
|
+
* @returns 稳定组键。
|
|
222
|
+
*/
|
|
223
|
+
export function groupKeyOf(issue) {
|
|
224
|
+
const scope = issue.scope ?? '(no-scope)';
|
|
225
|
+
return issue.layer + ':' + issue.code + ':' + issue.severity + ':' + scope;
|
|
226
|
+
}
|
|
227
|
+
/**
|
|
228
|
+
* 按组键聚合发现;组按问题数降序、同数按组键升序,结果确定性可测。
|
|
229
|
+
* @param issues - 逐条发现(在报告里已带唯一 id)。
|
|
230
|
+
* @returns 组列表;每组给出计数、归属包计数与样例标题。
|
|
231
|
+
*/
|
|
232
|
+
export function buildGroups(issues) {
|
|
233
|
+
const buckets = new Map();
|
|
234
|
+
for (const issue of issues) {
|
|
235
|
+
const key = groupKeyOf(issue);
|
|
236
|
+
const list = buckets.get(key) ?? [];
|
|
237
|
+
list.push(issue);
|
|
238
|
+
buckets.set(key, list);
|
|
239
|
+
}
|
|
240
|
+
const groups = [];
|
|
241
|
+
for (const [key, list] of buckets) {
|
|
242
|
+
const first = list[0];
|
|
243
|
+
if (first === undefined)
|
|
244
|
+
continue;
|
|
245
|
+
const counters = new Map();
|
|
246
|
+
for (const issue of list) {
|
|
247
|
+
const scope = issue.scope;
|
|
248
|
+
if (scope === undefined)
|
|
249
|
+
continue;
|
|
250
|
+
counters.set(scope, (counters.get(scope) ?? 0) + 1);
|
|
251
|
+
}
|
|
252
|
+
groups.push({
|
|
253
|
+
key,
|
|
254
|
+
layer: first.layer,
|
|
255
|
+
code: first.code,
|
|
256
|
+
severity: first.severity,
|
|
257
|
+
count: list.length,
|
|
258
|
+
scopes: [...counters].map(([scope, count]) => ({ scope, count })),
|
|
259
|
+
subjects: [...new Set(list.flatMap(issue => issue.subjects))].slice(0, 50),
|
|
260
|
+
...(first.title.length > 0 ? { exampleTitle: first.title } : {}),
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
groups.sort((left, right) => right.count - left.count || left.key.localeCompare(right.key));
|
|
264
|
+
return groups;
|
|
265
|
+
}
|
|
266
|
+
/**
|
|
267
|
+
* 官方 ProfileContext 的安装锚点(launcher 之外的上下文没有它)。
|
|
268
|
+
* @param ctx - host 上下文。
|
|
269
|
+
* @returns package.json 绝对路径;拿不到时 undefined。
|
|
270
|
+
*/
|
|
271
|
+
export function readInstallAnchor(ctx) {
|
|
272
|
+
try {
|
|
273
|
+
const profileContext = ctx.get('profileContext');
|
|
274
|
+
const anchor = profileContext?.installAnchor;
|
|
275
|
+
return typeof anchor === 'string' && anchor.length > 0 ? anchor : undefined;
|
|
276
|
+
}
|
|
277
|
+
catch {
|
|
278
|
+
// ctx.get 在服务缺失时可能抛错:拿不到锚点按 undefined 处理,由调用方记 skipped。
|
|
279
|
+
return undefined;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
/**
|
|
283
|
+
* 这份 manifest 里有哪些派生字段读不出来(按字段表达,闭集见 types.ts 的 ManifestField)。
|
|
284
|
+
*
|
|
285
|
+
* 存在的理由:官方若改了 dsh.profile.bundles 的名字/位置,或把 dependencies 写成数组
|
|
286
|
+
* (Object.keys(['a']) === ['0'],会读出一个名叫 "0" 的依赖),读取器读出来都是空值。
|
|
287
|
+
* 若不作声,诊断会把"我不知道"说成"这个环境没有层栈 / 没有依赖"——典型的假阴性。
|
|
288
|
+
* 放在组合层:受影响的正是"层栈相关"的结论(bundle 声明、层栈与运行时对照),
|
|
289
|
+
* 依赖层与运行时层照常出结论,不会因此被连带跳过。
|
|
290
|
+
*
|
|
291
|
+
* @param facts - 静态事实(含 manifest)。
|
|
292
|
+
* @returns 一条跳过项;manifest 全部读得出来时 undefined。
|
|
293
|
+
*/
|
|
294
|
+
function manifestUnknownSkip(facts) {
|
|
295
|
+
const manifest = facts.manifest;
|
|
296
|
+
const fields = manifest.unknownFields ?? [];
|
|
297
|
+
if (fields.length === 0)
|
|
298
|
+
return undefined;
|
|
299
|
+
return {
|
|
300
|
+
check: 'manifest-unknown',
|
|
301
|
+
reason: '这个环境的 package.json 有读不出来的字段(' + fields.join('、') + '):'
|
|
302
|
+
+ (manifest.unknownReason ?? '原因未知')
|
|
303
|
+
+ '。与这些字段相关的结论本次没有判断。'
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
/** 该层是否被配置启用。 */
|
|
307
|
+
function layerEnabled(config, layer) {
|
|
308
|
+
return config[layer] !== false;
|
|
309
|
+
}
|
|
310
|
+
/** 把 CompanionConfig 与单独的 DiagnosticsConfig 归一为同一份诊断配置。 */
|
|
311
|
+
function normalizeDiagnosticsConfig(config) {
|
|
312
|
+
return config.diagnostics ?? config;
|
|
313
|
+
}
|
|
314
|
+
/** 分层计数。 */
|
|
315
|
+
function countByLayer(issues) {
|
|
316
|
+
const counts = {
|
|
317
|
+
dependency: 0, composition: 0, runtime: 0, consistency: 0, ecosystem: 0,
|
|
318
|
+
};
|
|
319
|
+
for (const issue of issues)
|
|
320
|
+
counts[issue.layer] += 1;
|
|
321
|
+
return counts;
|
|
322
|
+
}
|
|
323
|
+
/** 报告内唯一 id:同码同主体的发现按出现顺序加后缀。 */
|
|
324
|
+
function uniqueId(candidate, used) {
|
|
325
|
+
if (!used.has(candidate)) {
|
|
326
|
+
used.add(candidate);
|
|
327
|
+
return candidate;
|
|
328
|
+
}
|
|
329
|
+
let index = 2;
|
|
330
|
+
while (used.has(candidate + '#' + index))
|
|
331
|
+
index += 1;
|
|
332
|
+
const id = candidate + '#' + index;
|
|
333
|
+
used.add(id);
|
|
334
|
+
return id;
|
|
335
|
+
}
|
|
336
|
+
/** 收集静态事实:manifest、两层 node_modules、包扫描、依赖边。 */
|
|
337
|
+
function collectStaticFacts(env, config, skipped) {
|
|
338
|
+
const envDir = env.dir;
|
|
339
|
+
const manifest = readEnvironmentManifest(envDir);
|
|
340
|
+
const profileModules = join(envDir, 'node_modules');
|
|
341
|
+
const fallbackModules = join(dirname(resolve(envDir)), 'node_modules');
|
|
342
|
+
const profileNames = listInstalledNames(profileModules);
|
|
343
|
+
const fallbackNames = fallbackModules === profileModules
|
|
344
|
+
? new Set()
|
|
345
|
+
: listInstalledNames(fallbackModules);
|
|
346
|
+
// 解析根 = profile 层 + 共享兜底层 + **安装锚点**(官方包由安装侧提供,profile 里没有它们)。
|
|
347
|
+
const installRoots = installAnchorRoots(env.installAnchor);
|
|
348
|
+
if (installRoots === null) {
|
|
349
|
+
skipped.push({
|
|
350
|
+
check: 'install-anchor',
|
|
351
|
+
reason: env.installAnchor === undefined
|
|
352
|
+
? '没有安装锚点:本次只按 profile 与共享兜底层解析,'
|
|
353
|
+
+ '由安装侧提供的官方包会被判成不存在。'
|
|
354
|
+
: '启动这个环境的 dsh 安装锚点读不到或解析不出模块根(' + env.installAnchor + '):'
|
|
355
|
+
+ '本次只按 profile 与共享兜底层解析,由安装侧提供的官方包会被判成不存在。',
|
|
356
|
+
});
|
|
357
|
+
}
|
|
358
|
+
const roots = moduleRoots(envDir, env.installAnchor);
|
|
359
|
+
const declared = [...new Set([...manifest.dependencies, ...manifest.bundles])];
|
|
360
|
+
const packageDirs = new Map();
|
|
361
|
+
const manifests = new Map();
|
|
362
|
+
for (const name of declared) {
|
|
363
|
+
const dir = findInstalledDir(roots, name);
|
|
364
|
+
if (dir === undefined)
|
|
365
|
+
continue;
|
|
366
|
+
packageDirs.set(name, dir);
|
|
367
|
+
const raw = readJsonFile(join(dir, 'package.json'));
|
|
368
|
+
if (raw !== undefined)
|
|
369
|
+
manifests.set(name, raw);
|
|
370
|
+
}
|
|
371
|
+
const scans = new Map();
|
|
372
|
+
const unscanned = [];
|
|
373
|
+
// 源码扫描只服务这三层;三层都关掉时不做任何文件读取(省一次无意义的全量扫描)。
|
|
374
|
+
const scanning = config.dependency || config.runtime || config.consistency;
|
|
375
|
+
let budget = SCAN_FILE_BUDGET;
|
|
376
|
+
for (const [name, dir] of scanning ? packageDirs : []) {
|
|
377
|
+
// 官方本体(非 profile 本地拷贝)由安装方维护,逐文件扫描它们的成本远大于收益:
|
|
378
|
+
// 只扫 profile 自己装进来的包,以及官方 scope 的 profile 本地拷贝(重复安装已在 L1 报出)。
|
|
379
|
+
if (name.startsWith('@deepseek-ai/') && !profileNames.has(name))
|
|
380
|
+
continue;
|
|
381
|
+
const scan = scanPackage(dir, manifests.get(name) ?? {}, Math.min(budget, L1_PACKAGE_FILE_BUDGET));
|
|
382
|
+
budget -= scan.filesScanned;
|
|
383
|
+
scans.set(name, scan);
|
|
384
|
+
if (budget <= 0) {
|
|
385
|
+
unscanned.push(...[...packageDirs.keys()].filter(candidate => !scans.has(candidate)));
|
|
386
|
+
break;
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
if (unscanned.length > 0) {
|
|
390
|
+
skipped.push({
|
|
391
|
+
check: 'dependency-scan',
|
|
392
|
+
reason: '源码扫描预算(共 ' + SCAN_FILE_BUDGET + ' 个文件)用尽,以下包只做了 manifest 级检查:'
|
|
393
|
+
+ unscanned.join('、'),
|
|
394
|
+
});
|
|
395
|
+
}
|
|
396
|
+
const edges = [];
|
|
397
|
+
for (const [from, scan] of scans) {
|
|
398
|
+
for (const hit of scan.imports) {
|
|
399
|
+
const provider = providerOf(hit.spec, packageDirs);
|
|
400
|
+
if (provider === undefined || provider === from)
|
|
401
|
+
continue;
|
|
402
|
+
edges.push({ from, to: provider, spec: hit.spec, file: hit.file, line: hit.line });
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
return {
|
|
406
|
+
envDir, manifest, profileModules, fallbackModules, profileNames, fallbackNames,
|
|
407
|
+
installAnchor: env.installAnchor, installRoots,
|
|
408
|
+
packageDirs, scans, manifests, edges, unscanned,
|
|
409
|
+
};
|
|
410
|
+
}
|
|
411
|
+
// ── L1 依赖层 ───────────────────────────────────────────────────────────
|
|
412
|
+
/** L1:import 图 × 声明 × loader 提供项。 */
|
|
413
|
+
function dependencyLayer(env, facts, composition) {
|
|
414
|
+
const issues = [];
|
|
415
|
+
const envDir = facts.envDir;
|
|
416
|
+
const manifestPath = join(envDir, 'package.json');
|
|
417
|
+
const manifestText = readTextFile(manifestPath);
|
|
418
|
+
if (facts.manifest.broken !== undefined) {
|
|
419
|
+
issues.push(makeIssue({
|
|
420
|
+
layer: 'dependency',
|
|
421
|
+
severity: 'report-only',
|
|
422
|
+
code: 'broken-manifest',
|
|
423
|
+
title: '环境 manifest 无法解析',
|
|
424
|
+
detail: '该环境的 package.json 读取失败(' + facts.manifest.broken + '),'
|
|
425
|
+
+ '依赖声明全部读不到:以它启动的 profile 会在 boot 阶段直接失败。',
|
|
426
|
+
subjects: [env.name],
|
|
427
|
+
scope: env.name.length > 0 ? env.name : undefined,
|
|
428
|
+
evidence: [{ kind: 'file', at: 'package.json', note: '解析失败的 manifest' }],
|
|
429
|
+
id: 'broken-manifest',
|
|
430
|
+
}));
|
|
431
|
+
}
|
|
432
|
+
// official-duplicate:官方包同时存在于 profile 层与共享兜底层。
|
|
433
|
+
for (const name of facts.profileNames) {
|
|
434
|
+
if (!name.startsWith('@deepseek-ai/'))
|
|
435
|
+
continue;
|
|
436
|
+
if (!facts.fallbackNames.has(name))
|
|
437
|
+
continue;
|
|
438
|
+
if (OFFICIAL_DEP_ALLOWED.has(name))
|
|
439
|
+
continue;
|
|
440
|
+
const owners = [
|
|
441
|
+
...(declaresRegularDependency(facts.manifest.raw, name)
|
|
442
|
+
? [env.name + '(环境自身的 package.json)']
|
|
443
|
+
: []),
|
|
444
|
+
...[...facts.manifests.entries()]
|
|
445
|
+
.filter(([, manifest]) => declaresRegularDependency(manifest, name))
|
|
446
|
+
.map(([owner]) => owner),
|
|
447
|
+
];
|
|
448
|
+
issues.push(makeIssue({
|
|
449
|
+
layer: 'dependency',
|
|
450
|
+
severity: 'safe-fix',
|
|
451
|
+
code: 'official-duplicate',
|
|
452
|
+
title: '官方包 ' + name + ' 在 profile 里多出一份拷贝',
|
|
453
|
+
detail: name + ' 同时存在于 ' + facts.profileModules + ' 与 ' + facts.fallbackModules + '(安装兜底层)。'
|
|
454
|
+
+ (owners.length > 0 ? '它被 ' + owners.join('、') + ' 声明为普通依赖,pnpm 因此装出了第二份。' : '')
|
|
455
|
+
+ 'loader 从 profile 目录解析官方行时会用到这一份,模块身份(唯一符号与类)与安装侧分裂,'
|
|
456
|
+
+ '运行时报 "Cannot read properties of undefined" 这类错。正确做法是改成 peerDependencies'
|
|
457
|
+
+ '(peer 由共享兜底层满足),并删掉 profile 里的这份拷贝。',
|
|
458
|
+
subjects: [name, ...owners],
|
|
459
|
+
scope: name,
|
|
460
|
+
evidence: [
|
|
461
|
+
{
|
|
462
|
+
kind: 'file',
|
|
463
|
+
at: relativeTo(envDir, join(facts.profileModules, name, 'package.json')),
|
|
464
|
+
note: 'profile 层的拷贝',
|
|
465
|
+
},
|
|
466
|
+
{
|
|
467
|
+
kind: 'file',
|
|
468
|
+
at: relativeTo(envDir, join(facts.fallbackModules, name, 'package.json')),
|
|
469
|
+
note: '共享兜底层的同一包',
|
|
470
|
+
},
|
|
471
|
+
],
|
|
472
|
+
fix: {
|
|
473
|
+
action: 'remove-official-copy',
|
|
474
|
+
target: name,
|
|
475
|
+
summary: '从 profile 移除重复的官方包拷贝 ' + name + ',并把声明改为 peerDependencies',
|
|
476
|
+
},
|
|
477
|
+
id: 'official-duplicate:' + name,
|
|
478
|
+
}));
|
|
479
|
+
}
|
|
480
|
+
for (const name of [...new Set([...facts.manifest.dependencies, ...facts.manifest.bundles])]) {
|
|
481
|
+
const dir = facts.packageDirs.get(name);
|
|
482
|
+
if (dir === undefined) {
|
|
483
|
+
const line = manifestText === undefined ? undefined : jsonKeyLine(manifestText, 'dependencies', name);
|
|
484
|
+
issues.push(makeIssue({
|
|
485
|
+
layer: 'dependency',
|
|
486
|
+
severity: 'confirm-fix',
|
|
487
|
+
code: 'undeclared-dependency',
|
|
488
|
+
title: '声明了但没装:' + name,
|
|
489
|
+
detail: 'package.json 声明了 ' + name + ',但 profile 的 node_modules 与共享兜底层都找不到它。'
|
|
490
|
+
+ '任何 import 它的行都会在启动时 ERR_MODULE_NOT_FOUND,并带走整个 profile。',
|
|
491
|
+
subjects: [name],
|
|
492
|
+
scope: name,
|
|
493
|
+
evidence: [{
|
|
494
|
+
kind: 'file',
|
|
495
|
+
at: line === undefined ? 'package.json' : 'package.json:' + line,
|
|
496
|
+
note: '声明位置',
|
|
497
|
+
}],
|
|
498
|
+
fix: {
|
|
499
|
+
action: 'install-dependency',
|
|
500
|
+
target: name,
|
|
501
|
+
summary: '重新安装 ' + name + '(走官方安装通道),或从 package.json 删掉这条声明',
|
|
502
|
+
},
|
|
503
|
+
id: 'undeclared-dependency:' + name,
|
|
504
|
+
}));
|
|
505
|
+
continue;
|
|
506
|
+
}
|
|
507
|
+
const scan = facts.scans.get(name);
|
|
508
|
+
if (scan === undefined || scan.reason !== undefined)
|
|
509
|
+
continue;
|
|
510
|
+
const manifest = facts.manifests.get(name) ?? {};
|
|
511
|
+
for (const hit of scan.imports) {
|
|
512
|
+
const spec = hit.spec;
|
|
513
|
+
if (isBuiltin(spec) || isLoaderProvided(spec) || spec.startsWith('cordis:'))
|
|
514
|
+
continue;
|
|
515
|
+
if (declaresSpecifier(manifest, spec, name))
|
|
516
|
+
continue;
|
|
517
|
+
const provider = providerOf(spec, facts.packageDirs);
|
|
518
|
+
const at = relativeTo(envDir, hit.file) + ':' + hit.line;
|
|
519
|
+
if (provider === undefined) {
|
|
520
|
+
issues.push(makeIssue({
|
|
521
|
+
layer: 'dependency',
|
|
522
|
+
severity: 'confirm-fix',
|
|
523
|
+
code: 'missing-import',
|
|
524
|
+
title: name + ' 导入了未声明的 ' + spec + ',且环境里没有提供者',
|
|
525
|
+
detail: name + ' 在 ' + at + ' 导入 ' + spec + ',但它既没有在该包的 dependencies/peerDependencies 里声明,'
|
|
526
|
+
+ 'profile 里也没有任何包提供它。这一行在挂载时必然 ERR_MODULE_NOT_FOUND。'
|
|
527
|
+
+ unmountedNote(composition, name),
|
|
528
|
+
subjects: [name, spec],
|
|
529
|
+
scope: name,
|
|
530
|
+
evidence: [{ kind: 'file', at, note: '缺失的 import 位置' }],
|
|
531
|
+
fix: {
|
|
532
|
+
action: 'install-provider',
|
|
533
|
+
target: spec,
|
|
534
|
+
summary: '安装并提供 ' + spec + ',或移除 ' + at + ' 的这处 import',
|
|
535
|
+
},
|
|
536
|
+
id: 'missing-import:' + name + '>' + spec,
|
|
537
|
+
}));
|
|
538
|
+
}
|
|
539
|
+
else {
|
|
540
|
+
issues.push(makeIssue({
|
|
541
|
+
layer: 'dependency',
|
|
542
|
+
severity: 'report-only',
|
|
543
|
+
code: 'implicit-dependency',
|
|
544
|
+
title: name + ' 使用了未声明的 ' + spec,
|
|
545
|
+
detail: name + ' 在 ' + at + ' 导入 ' + spec + ':它没有声明该依赖,但 ' + provider
|
|
546
|
+
+ ' 恰好装在环境里(传递依赖或 hoisted 布局)。这种"能跑但没写清楚"的依赖会在依赖树变化时突然炸掉。',
|
|
547
|
+
subjects: [name, spec, provider],
|
|
548
|
+
scope: name,
|
|
549
|
+
evidence: [{ kind: 'file', at, note: '未声明的 import 位置' }],
|
|
550
|
+
id: 'implicit-dependency:' + name + '>' + spec,
|
|
551
|
+
}));
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
issues.push(...peerIssues(name, manifest, facts));
|
|
555
|
+
}
|
|
556
|
+
return issues;
|
|
557
|
+
}
|
|
558
|
+
/** 一个包的 peerDependencies 与本环境实际安装版本的对照。 */
|
|
559
|
+
function peerIssues(name, manifest, facts) {
|
|
560
|
+
const peers = asRecord(manifest['peerDependencies']);
|
|
561
|
+
if (peers === undefined)
|
|
562
|
+
return [];
|
|
563
|
+
const issues = [];
|
|
564
|
+
const dir = facts.packageDirs.get(name);
|
|
565
|
+
const manifestPath = dir === undefined ? name : relativeTo(facts.envDir, join(dir, 'package.json'));
|
|
566
|
+
const text = dir === undefined ? undefined : readTextFile(join(dir, 'package.json'));
|
|
567
|
+
for (const [peer, range] of Object.entries(peers)) {
|
|
568
|
+
if (typeof range !== 'string')
|
|
569
|
+
continue;
|
|
570
|
+
const line = text === undefined ? undefined : jsonKeyLine(text, 'peerDependencies', peer);
|
|
571
|
+
const at = line === undefined ? manifestPath : manifestPath + ':' + line;
|
|
572
|
+
const peerDir = findInstalledDir(moduleRoots(facts.envDir, facts.installAnchor), peer);
|
|
573
|
+
if (peerDir === undefined) {
|
|
574
|
+
issues.push(makeIssue({
|
|
575
|
+
layer: 'dependency',
|
|
576
|
+
severity: 'report-only',
|
|
577
|
+
code: 'missing-peer',
|
|
578
|
+
title: name + ' 的 peer ' + peer + ' 未安装',
|
|
579
|
+
detail: name + ' 声明 peerDependencies.' + peer + ',但环境里找不到它。'
|
|
580
|
+
+ 'peer 由共享兜底层满足,缺失时该包的运行期契约无法保证。',
|
|
581
|
+
subjects: [name, peer],
|
|
582
|
+
scope: name,
|
|
583
|
+
evidence: [{ kind: 'file', at, note: 'peer 声明位置' }],
|
|
584
|
+
id: 'missing-peer:' + name + '>' + peer,
|
|
585
|
+
}));
|
|
586
|
+
continue;
|
|
587
|
+
}
|
|
588
|
+
const peerManifest = readJsonFile(join(peerDir, 'package.json'));
|
|
589
|
+
const version = typeof peerManifest?.['version'] === 'string' ? peerManifest['version'] : undefined;
|
|
590
|
+
if (version === undefined)
|
|
591
|
+
continue;
|
|
592
|
+
if (satisfiesRange(version, range))
|
|
593
|
+
continue;
|
|
594
|
+
issues.push(makeIssue({
|
|
595
|
+
layer: 'dependency',
|
|
596
|
+
severity: 'report-only',
|
|
597
|
+
code: 'peer-mismatch',
|
|
598
|
+
title: name + ' 要求 ' + peer + ' ' + range + ',实际装的 ' + version + ' 不满足',
|
|
599
|
+
detail: 'peer 版本要求不满足:' + peer + ' 以插件传入的实例为准(单例、序列化与协议假设都建立在它上面),'
|
|
600
|
+
+ '出错点会离这里很远。这条只报告,不自动处理。',
|
|
601
|
+
subjects: [name, peer],
|
|
602
|
+
scope: name,
|
|
603
|
+
evidence: [{ kind: 'file', at, note: 'peer 声明的来源包' }],
|
|
604
|
+
id: 'peer-mismatch:' + name + '>' + peer,
|
|
605
|
+
}));
|
|
606
|
+
}
|
|
607
|
+
return issues;
|
|
608
|
+
}
|
|
609
|
+
/** 该包当前有没有启用中的 loader 行(用于降低"未挂载包"结论的紧迫度)。 */
|
|
610
|
+
function unmountedNote(composition, packageName) {
|
|
611
|
+
if (composition.rows.length === 0)
|
|
612
|
+
return '';
|
|
613
|
+
const rows = composition.rows.filter(row => packageNameOf(row.name) === packageName);
|
|
614
|
+
if (rows.length === 0)
|
|
615
|
+
return '(该包当前没有任何 loader 行,问题会在它被挂载后才暴露。)';
|
|
616
|
+
if (rows.every(row => !row.enabled))
|
|
617
|
+
return '(该包当前的行都被禁用,问题会在重新启用后暴露。)';
|
|
618
|
+
return '';
|
|
619
|
+
}
|
|
620
|
+
/**
|
|
621
|
+
* 从官方异常的原始文本里解析启动阻断根因(认不出就返回 undefined,不猜)。
|
|
622
|
+
*
|
|
623
|
+
* @param error - loadProfileDirectory / composeEntries 抛出的异常。
|
|
624
|
+
* @param env - 被诊断环境。
|
|
625
|
+
* @param anchorConsulted - 本次解析是否真的用过安装锚点(决定"解析不到"能不能断言致命)。
|
|
626
|
+
* @returns 阻断根因;异常文本不是这两种时 undefined。
|
|
627
|
+
*/
|
|
628
|
+
function bootBlockerFromError(error, env, anchorConsulted) {
|
|
629
|
+
const message = messageOf(error);
|
|
630
|
+
const unresolved = /cannot resolve profile bundle\s+("[^"]+"|'[^']+')/.exec(message);
|
|
631
|
+
const malformed = /profile bundle\s+("[^"]+"|'[^']+')\s+declares no dsh\.bundle/.exec(message);
|
|
632
|
+
const restarted = '装好(或修好这一项)后重启该环境才会生效。';
|
|
633
|
+
const unsure = '注意:本次解析没有用到安装锚点(拿不到或没给),所以本条只说明"这个 bundles 项装不上",'
|
|
634
|
+
+ '不能据此断言这次启动一定失败——环境可能由别的 dsh 安装提供该包。';
|
|
635
|
+
if (unresolved !== null) {
|
|
636
|
+
const name = stripQuotes(unresolved[1] ?? '');
|
|
637
|
+
return {
|
|
638
|
+
code: 'unresolvable-bundle',
|
|
639
|
+
severity: anchorConsulted ? 'confirm-fix' : 'report-only',
|
|
640
|
+
title: '组合层启动被阻断:bundle ' + name + ' 解析不到',
|
|
641
|
+
subjects: [name, env.name],
|
|
642
|
+
evidence: [{ kind: 'official', at: 'loadProfileDirectory(env.dir)', note: truncatedNote(message) }],
|
|
643
|
+
detail: 'dsh.profile.bundles 里的 ' + name + ' 既不在这个 profile 的 node_modules,也不在 dsh 安装的模块兜底层:'
|
|
644
|
+
+ '启动时抛 cannot resolve profile bundle,整个 profile 起不来。'
|
|
645
|
+
+ '处置:装回这个 bundle,或从 ' + absoluteManifestPath(env) + ' 的 dsh.profile.bundles 里删掉这一项;' + restarted
|
|
646
|
+
+ (anchorConsulted ? '' : unsure),
|
|
647
|
+
operation: '在 ' + absoluteManifestPath(env) + ' 的 dsh.profile.bundles 里装回 ' + name
|
|
648
|
+
+ '(或删掉这一项),然后重启该环境',
|
|
649
|
+
id: 'unresolvable-bundle:' + name,
|
|
650
|
+
};
|
|
651
|
+
}
|
|
652
|
+
if (malformed !== null) {
|
|
653
|
+
const name = stripQuotes(malformed[1] ?? '');
|
|
654
|
+
return {
|
|
655
|
+
code: 'invalid-bundle',
|
|
656
|
+
severity: 'confirm-fix',
|
|
657
|
+
title: '组合层启动被阻断:bundle ' + name + ' 没有声明 patch 层',
|
|
658
|
+
subjects: [name, env.name],
|
|
659
|
+
evidence: [{ kind: 'official', at: 'loadProfileDirectory(env.dir)', note: truncatedNote(message) }],
|
|
660
|
+
detail: 'dsh.profile.bundles 里的 ' + name + ' 的 package.json 没有 dsh.bundle.patch:'
|
|
661
|
+
+ '启动时抛 declares no dsh.bundle,整个 profile 起不来。'
|
|
662
|
+
+ '处置:把 ' + absoluteManifestPath(env) + ' 的 dsh.profile.bundles 里这一项删掉,'
|
|
663
|
+
+ '或改用真正带 bundle 声明的包;' + restarted,
|
|
664
|
+
operation: '从 ' + absoluteManifestPath(env) + ' 的 dsh.profile.bundles 里删掉 ' + name + ',然后重启该环境',
|
|
665
|
+
id: 'invalid-bundle:' + name,
|
|
666
|
+
};
|
|
667
|
+
}
|
|
668
|
+
return undefined;
|
|
669
|
+
}
|
|
670
|
+
/** 去掉异常文本里包裹包名的引号。 */
|
|
671
|
+
function stripQuotes(value) {
|
|
672
|
+
const trimmed = value.trim();
|
|
673
|
+
if (trimmed.length >= 2) {
|
|
674
|
+
const first = trimmed[0];
|
|
675
|
+
const last = trimmed[trimmed.length - 1];
|
|
676
|
+
if ((first === '"' && last === '"') || (first === "'" && last === "'"))
|
|
677
|
+
return trimmed.slice(1, -1);
|
|
678
|
+
}
|
|
679
|
+
return trimmed;
|
|
680
|
+
}
|
|
681
|
+
/** 环境 manifest 的绝对路径(证据里说明"改哪个文件")。 */
|
|
682
|
+
function absoluteManifestPath(env) {
|
|
683
|
+
return join(env.dir, 'package.json');
|
|
684
|
+
}
|
|
685
|
+
/** 官方异常文本放进证据 note 时截断(证据要能读,不是把异常整段塞进去)。 */
|
|
686
|
+
function truncatedNote(message) {
|
|
687
|
+
const flat = message.replace(/\s+/g, ' ').trim();
|
|
688
|
+
return flat.length > 240 ? flat.slice(0, 237) + '…' : flat;
|
|
689
|
+
}
|
|
690
|
+
/**
|
|
691
|
+
* 读组合层输入。
|
|
692
|
+
*
|
|
693
|
+
* 两个来源,各司其职:
|
|
694
|
+
* 1. 官方 app-boot 的 loadProfileDirectory + composeEntries —— **有效**行集合与启停状态
|
|
695
|
+
* (最后一层写入胜出),这是 boot 真正挂载的东西,绝不由本模块重算;
|
|
696
|
+
* 2. 直接读原始 patch 文本 —— 只为拿行号(官方返回值不带位置)与"行有没有写显式 id"。
|
|
697
|
+
* 官方不可用时降级:纯文本能做的检查(重复 id、无 id、孤立行)照做,
|
|
698
|
+
* 依赖有效禁用状态的检查记 skipped。
|
|
699
|
+
*/
|
|
700
|
+
async function readComposition(ctx, env, facts) {
|
|
701
|
+
const skips = [];
|
|
702
|
+
const patchPath = join(env.dir, 'cordis.patch.yml');
|
|
703
|
+
// 锚点只有一份来源:facts.installAnchor(analyzeEnvironment 已经按"调用方透传优先、其次官方
|
|
704
|
+
// profileContext"算好了)。这里若自己再算一份,两处口径一旦不一致,"锚点是否参与过解析"
|
|
705
|
+
// 这个判据就会失真,把致命判成不确定。
|
|
706
|
+
const installAnchor = facts.installAnchor;
|
|
707
|
+
const rawRows = [];
|
|
708
|
+
const bootIssues = [];
|
|
709
|
+
let layers;
|
|
710
|
+
let rows;
|
|
711
|
+
try {
|
|
712
|
+
const boot = await import('@deepseek-ai/dsh-app-boot');
|
|
713
|
+
const profile = boot.loadProfileDirectory(DIAG_BIN, env.dir, installAnchor ?? join(env.dir, 'package.json'), { userLayer: true });
|
|
714
|
+
layers = [
|
|
715
|
+
...profile.layers.map(layer => ({ path: layer.patchPath, patches: layer.patches })),
|
|
716
|
+
...(profile.patches.length > 0
|
|
717
|
+
? [{ path: profile.patchPath, patches: profile.patches }]
|
|
718
|
+
: []),
|
|
719
|
+
];
|
|
720
|
+
rows = boot.composeEntries([
|
|
721
|
+
...profile.layers.map(layer => layer.patches),
|
|
722
|
+
...(profile.patches.length > 0 ? [profile.patches] : []),
|
|
723
|
+
])
|
|
724
|
+
.filter(entry => entry.group !== true)
|
|
725
|
+
.map(entry => ({
|
|
726
|
+
id: String(entry.id ?? ''),
|
|
727
|
+
name: String(entry.name ?? ''),
|
|
728
|
+
...enablementOf(entry.disabled),
|
|
729
|
+
}));
|
|
730
|
+
}
|
|
731
|
+
catch (error) {
|
|
732
|
+
// 异常文本里可能就写着启动阻断的根因:解析出来成为一等公民 issue,而不是只留一段 skip.reason。
|
|
733
|
+
// anchorConsulted 的判据:本次解析真的用到了安装锚点(没用到时不能断言"这次启动一定失败")。
|
|
734
|
+
const anchorConsulted = facts.installRoots !== null && installAnchor !== undefined;
|
|
735
|
+
const blocker = bootBlockerFromError(error, env, anchorConsulted);
|
|
736
|
+
if (blocker !== undefined)
|
|
737
|
+
bootIssues.push(toBootIssue(blocker));
|
|
738
|
+
skips.push({
|
|
739
|
+
check: 'composition-official',
|
|
740
|
+
// 官方合成抛错 = 组合层这一次真的没查成(纯文本检查只是兜底的一部分),
|
|
741
|
+
// 层计数格必须显示"没查"而不是 0——不然与"查过且干净"长得一样。
|
|
742
|
+
layers: ['composition'],
|
|
743
|
+
reason: '官方组合结果不可用,只做纯文本检查,依赖禁用状态没有检查:' + messageOf(error),
|
|
744
|
+
});
|
|
745
|
+
}
|
|
746
|
+
const rawPaths = new Set([patchPath, ...(layers ?? []).map(layer => layer.path)]);
|
|
747
|
+
if (layers === undefined) {
|
|
748
|
+
for (const path of bundlePatchFiles(facts, env.dir))
|
|
749
|
+
rawPaths.add(path);
|
|
750
|
+
}
|
|
751
|
+
for (const path of rawPaths) {
|
|
752
|
+
if (!existsSync(path))
|
|
753
|
+
continue;
|
|
754
|
+
rawRows.push(...locatePatchRows(path));
|
|
755
|
+
}
|
|
756
|
+
if (rows !== undefined) {
|
|
757
|
+
// 合成路径同样可能带出启动阻断(同一 insert 列表内重复 id)。
|
|
758
|
+
const composedBlocker = duplicateIdBootIssue(rows, env);
|
|
759
|
+
if (composedBlocker !== undefined)
|
|
760
|
+
bootIssues.push(composedBlocker);
|
|
761
|
+
}
|
|
762
|
+
return { rows: rows ?? [], rawRows, skips, bootIssues, effectiveState: rows !== undefined };
|
|
763
|
+
}
|
|
764
|
+
/** 把官方 disabled 值翻译成启停状态;条件表达式静态不可判定。 */
|
|
765
|
+
function enablementOf(disabled) {
|
|
766
|
+
if (disabled === true)
|
|
767
|
+
return { enabled: false, conditional: false };
|
|
768
|
+
if (isJsExpression(disabled))
|
|
769
|
+
return { enabled: true, conditional: true };
|
|
770
|
+
return { enabled: true, conditional: false };
|
|
771
|
+
}
|
|
772
|
+
/** 官方 yaml 的条件节点(结构化克隆后仍是带 __jsExpr 的普通对象)。 */
|
|
773
|
+
function isJsExpression(value) {
|
|
774
|
+
return typeof value === 'object' && value !== null && '__jsExpr' in value;
|
|
775
|
+
}
|
|
776
|
+
/** 从 profile 的 bundles 列表解析每个 bundle 的 patch 文件路径(只读兜底路径)。 */
|
|
777
|
+
function bundlePatchFiles(facts, envDir) {
|
|
778
|
+
const files = [];
|
|
779
|
+
for (const bundle of facts.manifest.bundles) {
|
|
780
|
+
const dir = facts.packageDirs.get(bundle)
|
|
781
|
+
?? findInstalledDir(moduleRoots(envDir, facts.installAnchor), bundle);
|
|
782
|
+
if (dir === undefined)
|
|
783
|
+
continue;
|
|
784
|
+
const manifest = facts.manifests.get(bundle) ?? readJsonFile(join(dir, 'package.json'));
|
|
785
|
+
const declared = asRecord(asRecord(manifest?.['dsh'])?.['bundle'])?.['patch'];
|
|
786
|
+
if (typeof declared !== 'string')
|
|
787
|
+
continue;
|
|
788
|
+
files.push(join(dir, declared));
|
|
789
|
+
}
|
|
790
|
+
return files;
|
|
791
|
+
}
|
|
792
|
+
/**
|
|
793
|
+
* 非致命但仍存在的 id 重名:同一个 id 出现在**不同的顶层 group 或不同 patch 文件**里。
|
|
794
|
+
*
|
|
795
|
+
* 实测(本机 0.1.6-alpha.2):两处都启用时 profile 照常启动——官方 loader 的重复检查是
|
|
796
|
+
* per-group 的(Group.update() 只扫自己那一份 insert 列表)。但按 id 定位的东西(官方启停开关、
|
|
797
|
+
* 逐行配置、我们的 fix target)都会指向其中一行,另一行不可达,所以如实报、给建议,不给修复动作。
|
|
798
|
+
*
|
|
799
|
+
* @param env - 被诊断环境。
|
|
800
|
+
* @param rows - 同一个 id 的全部原始行(按文件顺序、按列表分组后仍多于一处)。
|
|
801
|
+
* @param facts - 静态事实(判断 patch 归属走哪条处置通道)。
|
|
802
|
+
* @returns 一条 report-only 发现。
|
|
803
|
+
*/
|
|
804
|
+
function crossGroupDuplicateIssue(env, rows, facts) {
|
|
805
|
+
const id = rows[0]?.id ?? '';
|
|
806
|
+
const places = [...new Set(rows.map(row => relativeTo(env.dir, row.file) + ':' + row.line))];
|
|
807
|
+
const first = rows[0];
|
|
808
|
+
return makeIssue({
|
|
809
|
+
layer: 'composition',
|
|
810
|
+
severity: 'report-only',
|
|
811
|
+
code: 'duplicate-row-id-across-groups',
|
|
812
|
+
title: '同一个行 id 出现在不同的 insert 列表里:' + id,
|
|
813
|
+
detail: 'id ' + id + ' 在 ' + places.join('、') + ' 各出现一次,但它们不在同一个 insert 列表里:'
|
|
814
|
+
+ '实测这种组合 profile 能正常启动。按 id 定位的操作(官方启停开关、逐行配置)'
|
|
815
|
+
+ '只能命中其中一行,另一行不可达,建议改掉。'
|
|
816
|
+
+ (first === undefined ? '' : manualEditSteps(env, first, id, facts.packageDirs, true, 'either')),
|
|
817
|
+
subjects: [id],
|
|
818
|
+
scope: scopeOfPatchFile(env.name, env.dir, rows[0]?.file),
|
|
819
|
+
evidence: rows.map(row => ({
|
|
820
|
+
kind: 'file',
|
|
821
|
+
at: relativeTo(env.dir, row.file) + ':' + row.line,
|
|
822
|
+
note: '第 ' + (rows.indexOf(row) + 1) + ' 处(name=' + (row.name ?? '未命名') + ')',
|
|
823
|
+
})),
|
|
824
|
+
id: 'duplicate-row-id-across-groups:' + id,
|
|
825
|
+
});
|
|
826
|
+
}
|
|
827
|
+
/** L2:行 id 重复、被禁用依赖、孤立行、无显式 id 的行。 */
|
|
828
|
+
function compositionLayer(env, facts, composition, skipped) {
|
|
829
|
+
const issues = [];
|
|
830
|
+
// 输入事实先说清:manifest 读不懂时,"层栈为空"不是结论而是未知。
|
|
831
|
+
const manifestUnknown = manifestUnknownSkip(facts);
|
|
832
|
+
if (manifestUnknown !== undefined)
|
|
833
|
+
skipped.push(manifestUnknown);
|
|
834
|
+
// 1. duplicate-row-id:**同一个 insert 列表**里同一个显式 id 出现多次。
|
|
835
|
+
// 官方 loader 的 Group.update() 只对自己那一份 insert 列表查重,命中即抛
|
|
836
|
+
// TypeError(duplicate loader entry id),整个 profile 起不来(真机实测:exit=1,
|
|
837
|
+
// 服务器从未开始监听)。所以判定的分组口径必须与那条 insert 列表对齐:按
|
|
838
|
+
// **patch 文件 + 顶层 group** 分组;把不同文件、不同 group 的同名行并成一组会误判为致命
|
|
839
|
+
// ——实测那种组合能正常启动,走 crossGroupDuplicateIssue 如实报。
|
|
840
|
+
const byList = new Map();
|
|
841
|
+
for (const row of composition.rawRows) {
|
|
842
|
+
if (row.id === undefined)
|
|
843
|
+
continue;
|
|
844
|
+
const key = row.file + '|' + (row.group ?? '') + '|' + row.id;
|
|
845
|
+
const list = byList.get(key) ?? [];
|
|
846
|
+
list.push(row);
|
|
847
|
+
byList.set(key, list);
|
|
848
|
+
}
|
|
849
|
+
// 同一个 id **跨多个 insert 列表**:单独如实报(非致命,见 crossGroupDuplicateIssue)。
|
|
850
|
+
// 只报跨列表的那种:同一个列表里的重复已经在下面按致命处理,两个都报会把一条问题说成两条。
|
|
851
|
+
const spread = new Map();
|
|
852
|
+
for (const rows of byList.values()) {
|
|
853
|
+
const id = rows[0]?.id ?? '';
|
|
854
|
+
const lists = spread.get(id) ?? [];
|
|
855
|
+
lists.push([...rows]);
|
|
856
|
+
spread.set(id, lists);
|
|
857
|
+
}
|
|
858
|
+
for (const lists of spread.values()) {
|
|
859
|
+
if (lists.length < 2)
|
|
860
|
+
continue;
|
|
861
|
+
issues.push(crossGroupDuplicateIssue(env, lists.flat(), facts));
|
|
862
|
+
}
|
|
863
|
+
for (const rows of byList.values()) {
|
|
864
|
+
if (rows.length < 2)
|
|
865
|
+
continue;
|
|
866
|
+
const id = rows[0]?.id ?? '';
|
|
867
|
+
const group = rows[0]?.group;
|
|
868
|
+
const first = rows[0];
|
|
869
|
+
if (first === undefined)
|
|
870
|
+
continue;
|
|
871
|
+
issues.push(makeIssue({
|
|
872
|
+
layer: 'composition',
|
|
873
|
+
// 不是 safe-fix:这条没有可自动执行的动作(我们不代写 patch),必须用户自己确认后再动手。
|
|
874
|
+
severity: 'confirm-fix',
|
|
875
|
+
code: 'duplicate-row-id',
|
|
876
|
+
title: 'loader 行 id 重复:' + id,
|
|
877
|
+
detail: 'id ' + id + ' 在' + (group === undefined ? '同一个 insert 列表里' : ' group ' + group + ' 下')
|
|
878
|
+
+ '出现了 ' + rows.length + ' 次:挂载时抛 duplicate loader entry id,'
|
|
879
|
+
+ '整个 profile 起不来(真机实测:进程 exit=1,HTTP 服务从未开始监听)。'
|
|
880
|
+
+ manualEditSteps(env, first, id, facts.packageDirs, rowReachedComposition(composition, first)),
|
|
881
|
+
subjects: [id, ...(group === undefined ? [] : [group])],
|
|
882
|
+
extra: {
|
|
883
|
+
operation: '删除 ' + relativeTo(env.dir, first.file) + ' 第 ' + first.line + ' 行起的 id=' + id
|
|
884
|
+
+ ' 的重复 insert 行,只保留第一处,然后重启该环境',
|
|
885
|
+
},
|
|
886
|
+
scope: scopeOfPatchFile(env.name, env.dir, rows[0]?.file),
|
|
887
|
+
evidence: [
|
|
888
|
+
...rows.map((row, index) => ({
|
|
889
|
+
kind: 'file',
|
|
890
|
+
at: relativeTo(env.dir, row.file) + ':' + row.line,
|
|
891
|
+
note: '第 ' + (index + 1) + ' 次出现(name=' + (row.name ?? '未命名') + ')',
|
|
892
|
+
})),
|
|
893
|
+
{
|
|
894
|
+
kind: 'official',
|
|
895
|
+
at: 'loader entry id "' + id + '"',
|
|
896
|
+
note: '@deepseek-ai/cordis-plugin-loader 的 Group.update() 对同组重复 id 抛 TypeError,profile 启动失败',
|
|
897
|
+
},
|
|
898
|
+
],
|
|
899
|
+
fix: {
|
|
900
|
+
action: 'remove-duplicate-row',
|
|
901
|
+
target: id,
|
|
902
|
+
summary: '请手工保留第一处 id=' + id + '(' + first.file + ':' + first.line + '),删除其余 '
|
|
903
|
+
+ (rows.length - 1) + ' 处,然后重启该环境(我们不代写 patch 文件)',
|
|
904
|
+
},
|
|
905
|
+
id: 'duplicate-row-id:' + id,
|
|
906
|
+
}));
|
|
907
|
+
}
|
|
908
|
+
// 2. unaddressable-row:insert 行没写显式 id。
|
|
909
|
+
// loader 的 EntryTree.ensureId() 用 Math.random() 生成 8 位十六进制 id,
|
|
910
|
+
// 每次挂载都不同 —— 任何按 id 定位的补丁或开关都无法稳定指向它。
|
|
911
|
+
for (const row of composition.rawRows) {
|
|
912
|
+
if (row.id !== undefined)
|
|
913
|
+
continue;
|
|
914
|
+
const at = relativeTo(env.dir, row.file) + ':' + row.line;
|
|
915
|
+
issues.push(makeIssue({
|
|
916
|
+
layer: 'composition',
|
|
917
|
+
severity: 'report-only',
|
|
918
|
+
code: 'unaddressable-row',
|
|
919
|
+
title: 'insert 行没有显式 id(' + (row.name ?? '未命名') + ')',
|
|
920
|
+
detail: '这一行只写了 name,loader 挂载时会用随机值补 id。行本身能加载,'
|
|
921
|
+
+ '但任何按 id 定位的操作(官方启停开关、后续 patch、行级配置)都无法稳定指向它,'
|
|
922
|
+
+ '重启一次就换了身份。给这一行补一个显式 id,改动是安全的。',
|
|
923
|
+
subjects: [row.name ?? '(anonymous)', at],
|
|
924
|
+
scope: scopeOfPatchFile(env.name, env.dir, row.file),
|
|
925
|
+
evidence: [{ kind: 'file', at, note: '缺少显式 id 的 insert 行' }],
|
|
926
|
+
id: 'unaddressable-row:' + at,
|
|
927
|
+
}));
|
|
928
|
+
}
|
|
929
|
+
// 3. orphan-row:insert 行的 name 解析不到任何模块。
|
|
930
|
+
// 解析根包含安装锚点(official 同款 createRequire 口径):官方包由安装侧提供,
|
|
931
|
+
// 只看 profile 会把它们的每一行都误报成孤儿,所以结论必须说清**查过哪些根**。
|
|
932
|
+
// 后果的写法有实测依据:一行解析不到不是这一行失败,而是整个 profile 起不来
|
|
933
|
+
// (探针实测 exit=1、停在 plugin tree failed to load、HTTP 服务从未监听;
|
|
934
|
+
// 同一行若 disabled、或它所在的 group 被禁用,则照常启动——所以措辞写成条件式,不夸大)。
|
|
935
|
+
for (const row of composition.rawRows) {
|
|
936
|
+
const name = row.name;
|
|
937
|
+
if (name === undefined || name.length === 0)
|
|
938
|
+
continue;
|
|
939
|
+
if (rowTargetResolves(env.dir, row.file, name, facts.installAnchor))
|
|
940
|
+
continue;
|
|
941
|
+
const rowId = row.id ?? name;
|
|
942
|
+
const at = relativeTo(env.dir, row.file) + ':' + row.line;
|
|
943
|
+
issues.push(makeIssue({
|
|
944
|
+
layer: 'composition',
|
|
945
|
+
severity: 'confirm-fix',
|
|
946
|
+
code: 'orphan-row',
|
|
947
|
+
title: 'insert 行的模块名解析不到:' + name,
|
|
948
|
+
detail: 'patch 插入了 name=' + name + ' 的行,但该说明符在 ' + resolutionRootsNote(env.dir, facts)
|
|
949
|
+
+ ' 下都解析不到:这一行被启用时(它的 group 也启用),整个 profile 起不来'
|
|
950
|
+
+ '(真机实测:进程 exit=1,HTTP 服务从未开始监听、ERR_MODULE_NOT_FOUND;'
|
|
951
|
+
+ '同一行写成 disabled,或它所在的 group 被禁用时 profile 照常启动)。'
|
|
952
|
+
+ manualEditSteps(env, row, rowId, facts.packageDirs, rowReachedComposition(composition, row))
|
|
953
|
+
+ '· 或把 ' + name + ' 装进来。',
|
|
954
|
+
subjects: [name],
|
|
955
|
+
scope: scopeOfPatchFile(env.name, env.dir, row.file),
|
|
956
|
+
extra: {
|
|
957
|
+
operation: '删除 ' + at + ' 的 insert 行(id=' + rowId + ')后重启该环境;或先安装 ' + name,
|
|
958
|
+
},
|
|
959
|
+
evidence: [{ kind: 'file', at, note: '解析不到的 insert 行(启用即致命)' }],
|
|
960
|
+
fix: {
|
|
961
|
+
action: 'remove-row',
|
|
962
|
+
target: rowId,
|
|
963
|
+
summary: '请手工删除 ' + row.file + ':' + row.line + ' 的 id=' + rowId
|
|
964
|
+
+ ' 行后重启该环境(我们不代写 patch 文件)',
|
|
965
|
+
},
|
|
966
|
+
id: 'orphan-row:' + name,
|
|
967
|
+
}));
|
|
968
|
+
}
|
|
969
|
+
// 4. disabled-dependency:被依赖的行被禁用(需要官方合并出的有效状态)。
|
|
970
|
+
if (!composition.effectiveState)
|
|
971
|
+
return issues;
|
|
972
|
+
const rowsByPackage = new Map();
|
|
973
|
+
for (const row of composition.rows) {
|
|
974
|
+
const pkg = packageNameOf(row.name);
|
|
975
|
+
if (pkg === undefined || rowsByPackage.has(pkg))
|
|
976
|
+
continue;
|
|
977
|
+
rowsByPackage.set(pkg, row);
|
|
978
|
+
}
|
|
979
|
+
for (const edge of facts.edges) {
|
|
980
|
+
const target = rowsByPackage.get(edge.to);
|
|
981
|
+
if (target === undefined || target.enabled)
|
|
982
|
+
continue;
|
|
983
|
+
const consumer = rowsByPackage.get(edge.from);
|
|
984
|
+
if (consumer !== undefined && !consumer.enabled)
|
|
985
|
+
continue;
|
|
986
|
+
const reason = target.conditional
|
|
987
|
+
? '它的 disabled 是条件表达式(静态判为"可能禁用")'
|
|
988
|
+
: '';
|
|
989
|
+
issues.push(makeIssue({
|
|
990
|
+
layer: 'composition',
|
|
991
|
+
severity: 'safe-fix',
|
|
992
|
+
code: 'disabled-dependency',
|
|
993
|
+
title: edge.from + ' 依赖的 ' + edge.to + ' 被禁用',
|
|
994
|
+
detail: edge.from + ' 在 ' + relativeTo(env.dir, edge.file) + ':' + edge.line + ' 导入 ' + edge.spec
|
|
995
|
+
+ ',但 ' + edge.to + ' 的 loader 行(id=' + target.id + ')是禁用状态。' + reason
|
|
996
|
+
+ '消费者会卡在 pending(注入的服务永远不就绪),或在首次使用时失败。',
|
|
997
|
+
subjects: [edge.from, edge.to, target.id],
|
|
998
|
+
scope: edge.from,
|
|
999
|
+
evidence: [
|
|
1000
|
+
{ kind: 'file', at: relativeTo(env.dir, edge.file) + ':' + edge.line, note: 'consumer 的 import 位置' },
|
|
1001
|
+
{ kind: 'official', at: 'loader entry "' + target.id + '"', note: '该行在官方组合结果里是禁用的' },
|
|
1002
|
+
],
|
|
1003
|
+
fix: {
|
|
1004
|
+
action: 'enable-row',
|
|
1005
|
+
target: target.id,
|
|
1006
|
+
summary: '重新启用 loader 行 ' + target.id + '(' + edge.to + ')',
|
|
1007
|
+
},
|
|
1008
|
+
id: 'disabled-dependency:' + edge.from + '>' + edge.to,
|
|
1009
|
+
}));
|
|
1010
|
+
}
|
|
1011
|
+
return issues;
|
|
1012
|
+
}
|
|
1013
|
+
/**
|
|
1014
|
+
* 一个 patch 行里的模块名能否解析到。
|
|
1015
|
+
*
|
|
1016
|
+
* @param envDir - 环境目录(解析锚点)。
|
|
1017
|
+
* @param patchFile - 该行所在的 patch 文件(相对名按它的目录解析)。
|
|
1018
|
+
* @param name - 行里的模块名。
|
|
1019
|
+
* @param installAnchor - dsh 应用包的 package.json;官方包由安装侧提供,必须有它才解析得到。
|
|
1020
|
+
* @returns 能否解析到。
|
|
1021
|
+
*/
|
|
1022
|
+
function rowTargetResolves(envDir, patchFile, name, installAnchor) {
|
|
1023
|
+
if (name.startsWith('cordis:'))
|
|
1024
|
+
return true;
|
|
1025
|
+
if (name.startsWith('file:')) {
|
|
1026
|
+
try {
|
|
1027
|
+
return existsSync(fileURLToPath(name));
|
|
1028
|
+
}
|
|
1029
|
+
catch {
|
|
1030
|
+
return false;
|
|
1031
|
+
}
|
|
1032
|
+
}
|
|
1033
|
+
if (name.startsWith('.'))
|
|
1034
|
+
return existsSync(resolve(dirname(patchFile), name));
|
|
1035
|
+
if (isAbsolute(name))
|
|
1036
|
+
return existsSync(name);
|
|
1037
|
+
return specifierResolves(envDir, name, installAnchor);
|
|
1038
|
+
}
|
|
1039
|
+
/**
|
|
1040
|
+
* 原始文本里的 insert 行定位。
|
|
1041
|
+
*
|
|
1042
|
+
* 这不是 YAML 解析器:语义一律取官方返回值(见 readComposition),
|
|
1043
|
+
* 这里只负责"哪一行、有没有显式 id、字面量上写没写 disabled"。
|
|
1044
|
+
* 做法是按 insert 键的列号推出行缩进,再把该缩进上的列表条目当作一行,
|
|
1045
|
+
* 因此不需要理解 YAML 的类型系统,也不会被条件表达式绊倒。
|
|
1046
|
+
*
|
|
1047
|
+
* @param file - patch 文件的绝对路径。
|
|
1048
|
+
* @returns 按文件顺序排列的原始行;文件不可读时为空数组。
|
|
1049
|
+
*/
|
|
1050
|
+
export function locatePatchRows(file) {
|
|
1051
|
+
const text = readTextFile(file);
|
|
1052
|
+
if (text === undefined)
|
|
1053
|
+
return [];
|
|
1054
|
+
const lines = text.split('\n');
|
|
1055
|
+
const rows = [];
|
|
1056
|
+
let rowIndent;
|
|
1057
|
+
let group;
|
|
1058
|
+
let current;
|
|
1059
|
+
const flush = () => {
|
|
1060
|
+
if (current === undefined)
|
|
1061
|
+
return;
|
|
1062
|
+
rows.push({
|
|
1063
|
+
file, line: current.line, id: current.id, name: current.name,
|
|
1064
|
+
disabled: current.disabled, group,
|
|
1065
|
+
});
|
|
1066
|
+
current = undefined;
|
|
1067
|
+
};
|
|
1068
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
1069
|
+
const raw = lines[index] ?? '';
|
|
1070
|
+
const indent = raw.length - raw.trimStart().length;
|
|
1071
|
+
const body = raw.trim();
|
|
1072
|
+
if (body.length === 0 || body.startsWith('#'))
|
|
1073
|
+
continue;
|
|
1074
|
+
// insert 块:由 insert 键的列号推出行缩进("- insert:" 在 0 列时行为 4 列缩进)。
|
|
1075
|
+
if (/^(\s*)(?:-\s+)?insert:\s*$/.test(raw)) {
|
|
1076
|
+
flush();
|
|
1077
|
+
if (indent === 0)
|
|
1078
|
+
group = undefined;
|
|
1079
|
+
rowIndent = raw.indexOf('insert:') + 2;
|
|
1080
|
+
continue;
|
|
1081
|
+
}
|
|
1082
|
+
// 顶层列表项:记录它的 id,作为其后 insert 块的 group 归属。
|
|
1083
|
+
if (indent === 0 && body.startsWith('- ')) {
|
|
1084
|
+
flush();
|
|
1085
|
+
rowIndent = undefined;
|
|
1086
|
+
const idMatch = /^-\s+id:\s*(.+)$/.exec(body);
|
|
1087
|
+
group = idMatch === null ? undefined : unquote(idMatch[1] ?? '');
|
|
1088
|
+
continue;
|
|
1089
|
+
}
|
|
1090
|
+
if (rowIndent === undefined)
|
|
1091
|
+
continue;
|
|
1092
|
+
if (indent < rowIndent) {
|
|
1093
|
+
flush();
|
|
1094
|
+
rowIndent = undefined;
|
|
1095
|
+
if (indent === 0)
|
|
1096
|
+
group = undefined;
|
|
1097
|
+
continue;
|
|
1098
|
+
}
|
|
1099
|
+
if (indent === rowIndent && body.startsWith('- ')) {
|
|
1100
|
+
flush();
|
|
1101
|
+
current = { line: index + 1, disabled: 'false', sealed: false };
|
|
1102
|
+
applyRowField(current, body.slice(2));
|
|
1103
|
+
continue;
|
|
1104
|
+
}
|
|
1105
|
+
if (current !== undefined && indent > rowIndent && !current.sealed) {
|
|
1106
|
+
if (/^config:\s*/.test(body))
|
|
1107
|
+
current.sealed = true;
|
|
1108
|
+
else
|
|
1109
|
+
applyRowField(current, body);
|
|
1110
|
+
}
|
|
1111
|
+
}
|
|
1112
|
+
flush();
|
|
1113
|
+
return rows;
|
|
1114
|
+
}
|
|
1115
|
+
/** 把一行 key: value 文本并入当前 insert 行。 */
|
|
1116
|
+
function applyRowField(row, body) {
|
|
1117
|
+
const match = /^(-?\s*)([A-Za-z_][\w-]*):\s*(.*)$/.exec(body);
|
|
1118
|
+
if (match === null)
|
|
1119
|
+
return;
|
|
1120
|
+
const key = match[2] ?? '';
|
|
1121
|
+
const value = (match[3] ?? '').trim();
|
|
1122
|
+
if (value.length === 0 || value.startsWith('#') || value.startsWith('|') || value.startsWith('>'))
|
|
1123
|
+
return;
|
|
1124
|
+
if (key === 'id' && row.id === undefined)
|
|
1125
|
+
row.id = unquote(value);
|
|
1126
|
+
else if (key === 'name' && row.name === undefined)
|
|
1127
|
+
row.name = unquote(value);
|
|
1128
|
+
else if (key === 'disabled')
|
|
1129
|
+
row.disabled = disabledLiteral(value);
|
|
1130
|
+
}
|
|
1131
|
+
/** YAML 标量里的 disabled 取值。 */
|
|
1132
|
+
function disabledLiteral(value) {
|
|
1133
|
+
if (/^!!js\b/.test(value))
|
|
1134
|
+
return 'conditional';
|
|
1135
|
+
if (/^true$/i.test(value))
|
|
1136
|
+
return 'true';
|
|
1137
|
+
if (/^false$/i.test(value))
|
|
1138
|
+
return 'false';
|
|
1139
|
+
return 'conditional';
|
|
1140
|
+
}
|
|
1141
|
+
/** 去掉包裹的引号与行尾注释。 */
|
|
1142
|
+
function unquote(value) {
|
|
1143
|
+
const trimmed = value.trim().replace(/\s+#.*$/, '');
|
|
1144
|
+
if (trimmed.length >= 2) {
|
|
1145
|
+
const first = trimmed[0];
|
|
1146
|
+
const last = trimmed[trimmed.length - 1];
|
|
1147
|
+
if ((first === '"' && last === '"') || (first === "'" && last === "'"))
|
|
1148
|
+
return trimmed.slice(1, -1);
|
|
1149
|
+
}
|
|
1150
|
+
return trimmed;
|
|
1151
|
+
}
|
|
1152
|
+
// ── L3 运行时层 ─────────────────────────────────────────────────────────
|
|
1153
|
+
/** L3:fiber 相位异常 + 注册名冲突。 */
|
|
1154
|
+
function runtimeLayer(env, facts, composition, runtime) {
|
|
1155
|
+
if (runtime.source === 'unavailable')
|
|
1156
|
+
return [];
|
|
1157
|
+
const issues = [];
|
|
1158
|
+
const live = new Set();
|
|
1159
|
+
for (const entry of runtime.entries) {
|
|
1160
|
+
const moduleName = String(entry.moduleName ?? '');
|
|
1161
|
+
const rowId = String(entry.entryId ?? '');
|
|
1162
|
+
if (entry.enabled === false)
|
|
1163
|
+
continue;
|
|
1164
|
+
if (entry.fiberPhase === 'failed') {
|
|
1165
|
+
issues.push(makeIssue({
|
|
1166
|
+
layer: 'runtime',
|
|
1167
|
+
severity: 'confirm-fix',
|
|
1168
|
+
code: 'failed-fiber',
|
|
1169
|
+
title: '加载失败:' + (moduleName || rowId),
|
|
1170
|
+
detail: moduleName + '(行 id=' + rowId + ')的 fiber 相位是 failed:它的 apply() 抛了错或 import 失败,'
|
|
1171
|
+
+ '依赖它的行也无法完成装配。先看启动日志里的原始错误,判断是配置问题还是包本身的问题;'
|
|
1172
|
+
+ '确认这一行确实不该加载时,禁用它是最快的恢复手段。',
|
|
1173
|
+
subjects: [moduleName, rowId],
|
|
1174
|
+
evidence: [
|
|
1175
|
+
{
|
|
1176
|
+
kind: 'runtime',
|
|
1177
|
+
at: 'loader entry "' + rowId + '"',
|
|
1178
|
+
note: 'fiber 相位 failed(moduleName=' + moduleName + ')',
|
|
1179
|
+
},
|
|
1180
|
+
runtime.source === 'official'
|
|
1181
|
+
? { kind: 'official', at: 'readPluginInventory()', note: '官方运行时投影' }
|
|
1182
|
+
: {
|
|
1183
|
+
kind: 'official',
|
|
1184
|
+
at: 'ctx.loader.entries()',
|
|
1185
|
+
note: '官方投影不可用时的 Loader 直读结果',
|
|
1186
|
+
},
|
|
1187
|
+
],
|
|
1188
|
+
fix: {
|
|
1189
|
+
action: 'disable-row',
|
|
1190
|
+
target: rowId,
|
|
1191
|
+
summary: '禁用加载失败的行 ' + rowId + '(' + moduleName + '),先让 profile 起得来',
|
|
1192
|
+
},
|
|
1193
|
+
id: 'failed-fiber:' + rowId,
|
|
1194
|
+
}));
|
|
1195
|
+
continue;
|
|
1196
|
+
}
|
|
1197
|
+
if (entry.fiberPhase === 'pending') {
|
|
1198
|
+
const pkg = packageNameOf(moduleName);
|
|
1199
|
+
const injects = pkg === undefined ? [] : (facts.scans.get(pkg)?.injects ?? []);
|
|
1200
|
+
const missing = [...new Set(injects
|
|
1201
|
+
.map(hit => hit.name)
|
|
1202
|
+
.filter(service => !hasProvider(facts, composition, service)))];
|
|
1203
|
+
const disabledProvider = composition.rows.find(row => !row.enabled
|
|
1204
|
+
&& missing.some(service => (facts.scans.get(packageNameOf(row.name) ?? '')?.services ?? []).some(hit => hit.name === service)));
|
|
1205
|
+
const details = ['这一行的 fiber 长期停在 pending:它 inject 的服务从未变成可用。'];
|
|
1206
|
+
if (missing.length > 0)
|
|
1207
|
+
details.push('缺的服务:' + missing.join('、') + '。');
|
|
1208
|
+
if (disabledProvider !== undefined) {
|
|
1209
|
+
details.push('其中至少一个服务由 ' + disabledProvider.name + ' 提供,'
|
|
1210
|
+
+ '但它的 loader 行(id=' + disabledProvider.id + ')是禁用状态。');
|
|
1211
|
+
}
|
|
1212
|
+
issues.push(makeIssue({
|
|
1213
|
+
layer: 'runtime',
|
|
1214
|
+
severity: disabledProvider === undefined ? 'report-only' : 'confirm-fix',
|
|
1215
|
+
code: 'pending-fiber',
|
|
1216
|
+
title: '装配未完成(pending):' + (moduleName || rowId),
|
|
1217
|
+
detail: details.join(''),
|
|
1218
|
+
subjects: [moduleName, rowId, ...missing],
|
|
1219
|
+
evidence: [{ kind: 'runtime', at: 'loader entry "' + rowId + '"', note: 'fiber 相位 pending' }],
|
|
1220
|
+
...(disabledProvider === undefined ? {} : {
|
|
1221
|
+
fix: {
|
|
1222
|
+
action: 'enable-row',
|
|
1223
|
+
target: disabledProvider.id,
|
|
1224
|
+
summary: '重新启用提供该服务的行 ' + disabledProvider.id + '(' + disabledProvider.name + ')',
|
|
1225
|
+
},
|
|
1226
|
+
}),
|
|
1227
|
+
id: 'pending-fiber:' + rowId,
|
|
1228
|
+
}));
|
|
1229
|
+
continue;
|
|
1230
|
+
}
|
|
1231
|
+
if (entry.fiberPhase === 'active' && moduleName.length > 0)
|
|
1232
|
+
live.add(moduleName);
|
|
1233
|
+
}
|
|
1234
|
+
// 注册名冲突:只在**存活行**的包之间比对。
|
|
1235
|
+
// 官方依据:cordis 的 ctx.provide() 重名抛 service has been registered;
|
|
1236
|
+
// dsh-tools 的 register 与 systemPrompt.section 对同作用域重名抛错;
|
|
1237
|
+
// webserver.register 对同 kind+path 抛错。静态扫描只能给"疑似",
|
|
1238
|
+
// 因此证据里同时给出源码行,让用户自己核实。
|
|
1239
|
+
const packages = new Set();
|
|
1240
|
+
for (const moduleName of live) {
|
|
1241
|
+
const pkg = packageNameOf(moduleName);
|
|
1242
|
+
if (pkg !== undefined && facts.scans.has(pkg))
|
|
1243
|
+
packages.add(pkg);
|
|
1244
|
+
}
|
|
1245
|
+
const families = [
|
|
1246
|
+
{ code: 'service-name-conflict', label: '服务名', pick: scan => scan.services },
|
|
1247
|
+
{ code: 'tool-name-conflict', label: '工具名', pick: scan => scan.tools },
|
|
1248
|
+
{ code: 'section-name-conflict', label: '提示词段落名', pick: scan => scan.sections },
|
|
1249
|
+
{ code: 'route-conflict', label: 'HTTP 路由', pick: scan => scan.routes },
|
|
1250
|
+
];
|
|
1251
|
+
for (const family of families) {
|
|
1252
|
+
const owners = new Map();
|
|
1253
|
+
for (const pkg of packages) {
|
|
1254
|
+
const scan = facts.scans.get(pkg);
|
|
1255
|
+
if (scan === undefined)
|
|
1256
|
+
continue;
|
|
1257
|
+
for (const hit of family.pick(scan)) {
|
|
1258
|
+
const list = owners.get(hit.name) ?? [];
|
|
1259
|
+
list.push({ name: pkg, hit });
|
|
1260
|
+
owners.set(hit.name, list);
|
|
1261
|
+
}
|
|
1262
|
+
}
|
|
1263
|
+
for (const [registered, list] of owners) {
|
|
1264
|
+
const distinct = [...new Map(list.map(item => [item.name, item])).values()];
|
|
1265
|
+
if (distinct.length < 2)
|
|
1266
|
+
continue;
|
|
1267
|
+
const later = distinct[distinct.length - 1];
|
|
1268
|
+
if (later === undefined)
|
|
1269
|
+
continue;
|
|
1270
|
+
issues.push(makeIssue({
|
|
1271
|
+
layer: 'runtime',
|
|
1272
|
+
severity: 'confirm-fix',
|
|
1273
|
+
code: family.code,
|
|
1274
|
+
title: family.label + '冲突:' + registered,
|
|
1275
|
+
detail: family.label + ' ' + registered + ' 被 ' + distinct.map(item => item.name).join(' 与 ')
|
|
1276
|
+
+ ' 同时注册,且两者的 loader 行都处于存活状态。后注册的一方会失败或覆盖先注册的一方'
|
|
1277
|
+
+ '(官方托管注册表对重名直接抛错)。这是源码静态扫描的结论:请先核对下面几处源码,'
|
|
1278
|
+
+ '确认它们确实在同一作用域注册,再决定禁用哪一个。',
|
|
1279
|
+
subjects: [registered, ...distinct.map(item => item.name)],
|
|
1280
|
+
evidence: distinct.slice(0, 3).map(item => ({
|
|
1281
|
+
kind: 'file',
|
|
1282
|
+
at: relativeTo(env.dir, item.hit.file) + ':' + item.hit.line,
|
|
1283
|
+
note: item.name + ' 注册 ' + registered,
|
|
1284
|
+
})),
|
|
1285
|
+
fix: {
|
|
1286
|
+
action: 'disable-row',
|
|
1287
|
+
target: rowIdOfPackage(composition, later.name),
|
|
1288
|
+
summary: '禁用后注册者 ' + later.name + ' 的行',
|
|
1289
|
+
},
|
|
1290
|
+
id: family.code + ':' + registered,
|
|
1291
|
+
}));
|
|
1292
|
+
}
|
|
1293
|
+
}
|
|
1294
|
+
return issues;
|
|
1295
|
+
}
|
|
1296
|
+
/** 该服务名在当前环境里有没有任何注册点(存活或禁用的包都算"存在")。 */
|
|
1297
|
+
function hasProvider(facts, composition, service) {
|
|
1298
|
+
for (const scan of facts.scans.values()) {
|
|
1299
|
+
if (scan.services.some(hit => hit.name === service))
|
|
1300
|
+
return true;
|
|
1301
|
+
}
|
|
1302
|
+
for (const row of composition.rows) {
|
|
1303
|
+
const scan = facts.scans.get(packageNameOf(row.name) ?? '');
|
|
1304
|
+
if (scan?.services.some(hit => hit.name === service))
|
|
1305
|
+
return true;
|
|
1306
|
+
}
|
|
1307
|
+
return false;
|
|
1308
|
+
}
|
|
1309
|
+
/** 一个包对应的 loader 行 id(找不到时退回包名)。 */
|
|
1310
|
+
function rowIdOfPackage(composition, packageName) {
|
|
1311
|
+
const row = composition.rows.find(candidate => packageNameOf(candidate.name) === packageName);
|
|
1312
|
+
if (row !== undefined)
|
|
1313
|
+
return row.id;
|
|
1314
|
+
const raw = composition.rawRows.find(candidate => candidate.name !== undefined && packageNameOf(candidate.name) === packageName);
|
|
1315
|
+
return raw?.id ?? packageName;
|
|
1316
|
+
}
|
|
1317
|
+
// ── L4 一致性层 ─────────────────────────────────────────────────────────
|
|
1318
|
+
/** L4:官方 inventory × 本地 manifest。 */
|
|
1319
|
+
function consistencyLayer(env, facts, composition, runtime) {
|
|
1320
|
+
if (runtime.source === 'unavailable')
|
|
1321
|
+
return [];
|
|
1322
|
+
const issues = [];
|
|
1323
|
+
const loaded = new Set();
|
|
1324
|
+
for (const entry of runtime.entries) {
|
|
1325
|
+
const pkg = packageNameOf(String(entry.moduleName ?? ''));
|
|
1326
|
+
if (pkg !== undefined)
|
|
1327
|
+
loaded.add(pkg);
|
|
1328
|
+
}
|
|
1329
|
+
const manifestText = readTextFile(join(env.dir, 'package.json'));
|
|
1330
|
+
const closure = dependencyClosure(facts);
|
|
1331
|
+
// 1. declared-not-loaded:bundle 在 bundles 列表里,它的行却一行都不在 loader 树上。
|
|
1332
|
+
for (const bundle of facts.manifest.bundles) {
|
|
1333
|
+
const rows = composition.rows.filter(row => rowBelongsToBundle(facts, row, bundle));
|
|
1334
|
+
if (rows.length === 0)
|
|
1335
|
+
continue;
|
|
1336
|
+
if (rows.some(row => loaded.has(packageNameOf(row.name) ?? '')))
|
|
1337
|
+
continue;
|
|
1338
|
+
const line = manifestText === undefined ? undefined : jsonValueLine(manifestText, bundle);
|
|
1339
|
+
issues.push(makeIssue({
|
|
1340
|
+
layer: 'consistency',
|
|
1341
|
+
severity: 'report-only',
|
|
1342
|
+
code: 'declared-not-loaded',
|
|
1343
|
+
title: 'bundle ' + bundle + ' 声明了,loader 树里却一行都没有',
|
|
1344
|
+
detail: 'dsh.profile.bundles 里的 ' + bundle + ' 会插入 ' + rows.length
|
|
1345
|
+
+ ' 个 loader 行,但当前 loader 树里没有任何一行来自它:要么它这次没参与 boot,'
|
|
1346
|
+
+ '要么它的行全被后续层改写掉了。',
|
|
1347
|
+
subjects: [bundle],
|
|
1348
|
+
evidence: [
|
|
1349
|
+
{ kind: 'file', at: line === undefined ? 'package.json' : 'package.json:' + line, note: 'bundle 声明位置' },
|
|
1350
|
+
{ kind: 'runtime', at: 'loader entries', note: '当前 loader 树里没有该 bundle 的行' },
|
|
1351
|
+
],
|
|
1352
|
+
id: 'declared-not-loaded:' + bundle,
|
|
1353
|
+
}));
|
|
1354
|
+
}
|
|
1355
|
+
// 2. unmounted-dependency 与 3. loaded-not-declared:profile 本地包与 loader 树对照。
|
|
1356
|
+
for (const name of facts.manifest.dependencies) {
|
|
1357
|
+
const dir = facts.packageDirs.get(name);
|
|
1358
|
+
const manifest = facts.manifests.get(name);
|
|
1359
|
+
if (dir === undefined || manifest === undefined)
|
|
1360
|
+
continue;
|
|
1361
|
+
const isBundle = asRecord(asRecord(manifest['dsh'])?.['bundle'])?.['patch'] !== undefined
|
|
1362
|
+
|| facts.manifest.bundles.includes(name);
|
|
1363
|
+
if (isBundle)
|
|
1364
|
+
continue;
|
|
1365
|
+
const line = manifestText === undefined ? undefined : jsonKeyLine(manifestText, 'dependencies', name);
|
|
1366
|
+
const at = line === undefined ? 'package.json' : 'package.json:' + line;
|
|
1367
|
+
if (!loaded.has(name)) {
|
|
1368
|
+
if (!exportsPluginShape(facts.scans.get(name)))
|
|
1369
|
+
continue;
|
|
1370
|
+
issues.push(makeIssue({
|
|
1371
|
+
layer: 'consistency',
|
|
1372
|
+
severity: 'report-only',
|
|
1373
|
+
code: 'unmounted-dependency',
|
|
1374
|
+
title: '装上了但没被挂载:' + name,
|
|
1375
|
+
detail: name + ' 的入口导出了插件形态(apply),但当前 loader 树里没有任何行指向它:'
|
|
1376
|
+
+ '它对运行中的环境完全不起作用。要让它生效,需要为它加一个 loader 行。',
|
|
1377
|
+
subjects: [name],
|
|
1378
|
+
evidence: [
|
|
1379
|
+
{ kind: 'file', at, note: '依赖声明位置' },
|
|
1380
|
+
{ kind: 'runtime', at: 'loader entries', note: '没有指向该包的行' },
|
|
1381
|
+
],
|
|
1382
|
+
id: 'unmounted-dependency:' + name,
|
|
1383
|
+
}));
|
|
1384
|
+
continue;
|
|
1385
|
+
}
|
|
1386
|
+
if (!closure.has(name)) {
|
|
1387
|
+
issues.push(makeIssue({
|
|
1388
|
+
layer: 'consistency',
|
|
1389
|
+
severity: 'report-only',
|
|
1390
|
+
code: 'loaded-not-declared',
|
|
1391
|
+
title: '挂着但不在依赖闭包里:' + name,
|
|
1392
|
+
detail: name + ' 是当前 loader 树里的一行,但它不在 profile 声明依赖的闭包里'
|
|
1393
|
+
+ '(手工放进 node_modules,或依赖已被移除而包还在)。依赖树上任何一次安装操作都可能把它清掉。',
|
|
1394
|
+
subjects: [name],
|
|
1395
|
+
evidence: [
|
|
1396
|
+
{ kind: 'runtime', at: 'loader entry "' + rowIdOfPackage(composition, name) + '"', note: '该行存在' },
|
|
1397
|
+
{ kind: 'file', at, note: '不在该声明与其闭包内' },
|
|
1398
|
+
],
|
|
1399
|
+
id: 'loaded-not-declared:' + name,
|
|
1400
|
+
}));
|
|
1401
|
+
}
|
|
1402
|
+
}
|
|
1403
|
+
return issues;
|
|
1404
|
+
}
|
|
1405
|
+
/**
|
|
1406
|
+
* 这一行有没有真的进到官方组合结果里(决定"照这里删"这句话能不能确定地说)。
|
|
1407
|
+
*
|
|
1408
|
+
* 行定位器是文本扫描(见 locatePatchRows):判"同名同 id 的行确实在最终组合里"是可靠的,
|
|
1409
|
+
* 反过来(没找到)不能证明那一行不存在,只能说明我们无法确认——此时的指示要降一档语气。
|
|
1410
|
+
*
|
|
1411
|
+
* @param composition - 官方组合结果(不可用时它的 rows 为空数组)。
|
|
1412
|
+
* @param row - 文本扫描到的原始行。
|
|
1413
|
+
* @returns 组合结果里有没有匹配的行(无显式 id 时按 name 匹配)。
|
|
1414
|
+
*/
|
|
1415
|
+
function rowReachedComposition(composition, row) {
|
|
1416
|
+
return composition.rows.some(candidate => row.id === undefined
|
|
1417
|
+
? candidate.name === row.name
|
|
1418
|
+
: candidate.id === row.id);
|
|
1419
|
+
}
|
|
1420
|
+
/** loader 行是否属于某个 bundle 提供的包。 */
|
|
1421
|
+
function rowBelongsToBundle(facts, row, bundle) {
|
|
1422
|
+
const pkg = packageNameOf(row.name);
|
|
1423
|
+
if (pkg === undefined)
|
|
1424
|
+
return false;
|
|
1425
|
+
if (pkg === bundle)
|
|
1426
|
+
return true;
|
|
1427
|
+
const declared = facts.manifests.get(bundle);
|
|
1428
|
+
return declared !== undefined && declaresDependency(declared, pkg);
|
|
1429
|
+
}
|
|
1430
|
+
/** 入口源码里是否出现插件形态(导出 apply 或缺省导出)。 */
|
|
1431
|
+
function exportsPluginShape(scan) {
|
|
1432
|
+
if (scan === undefined || scan.reason !== undefined)
|
|
1433
|
+
return false;
|
|
1434
|
+
return scan.entryFiles.some(file => {
|
|
1435
|
+
const text = readTextFile(file);
|
|
1436
|
+
if (text === undefined)
|
|
1437
|
+
return false;
|
|
1438
|
+
return /\bexport\s+(?:default\b|(?:async\s+)?function\s+apply\b|const\s+apply\b|\{[^}]*\bapply\b)/.test(text);
|
|
1439
|
+
});
|
|
1440
|
+
}
|
|
1441
|
+
/** 从 profile 的声明依赖出发做有界闭包(只读 manifest,不扫源码)。 */
|
|
1442
|
+
function dependencyClosure(facts) {
|
|
1443
|
+
const roots = moduleRoots(facts.envDir, facts.installAnchor);
|
|
1444
|
+
const seen = new Set(facts.manifest.bundles);
|
|
1445
|
+
const queue = [...facts.manifest.dependencies];
|
|
1446
|
+
while (queue.length > 0 && seen.size < CLOSURE_MAX_PACKAGES) {
|
|
1447
|
+
const name = queue.shift();
|
|
1448
|
+
if (name === undefined || seen.has(name))
|
|
1449
|
+
continue;
|
|
1450
|
+
seen.add(name);
|
|
1451
|
+
const dir = facts.packageDirs.get(name) ?? findInstalledDir(roots, name);
|
|
1452
|
+
if (dir === undefined)
|
|
1453
|
+
continue;
|
|
1454
|
+
const manifest = facts.manifests.get(name) ?? readJsonFile(join(dir, 'package.json'));
|
|
1455
|
+
if (manifest === undefined)
|
|
1456
|
+
continue;
|
|
1457
|
+
for (const dep of declaredNames(manifest))
|
|
1458
|
+
if (!seen.has(dep))
|
|
1459
|
+
queue.push(dep);
|
|
1460
|
+
}
|
|
1461
|
+
return seen;
|
|
1462
|
+
}
|
|
1463
|
+
// ── L5 生态层(最小骨架)────────────────────────────────────────────────
|
|
1464
|
+
/**
|
|
1465
|
+
* L5:市场索引的更新与风险信息。
|
|
1466
|
+
*
|
|
1467
|
+
* **只留骨架**:索引抓取、合并、缓存归市场模块(src/marketplace.ts 等),
|
|
1468
|
+
* 诊断引擎不重复实现数据源,也不在诊断路径上访问网络,因此这一层暂无可判定的事实,
|
|
1469
|
+
* 由 analyzeEnvironment 记入 report.skipped。将来市场模块把索引递进来时,更新与废弃检查挂在这里。
|
|
1470
|
+
*
|
|
1471
|
+
* @returns 恒为空数组(骨架)。
|
|
1472
|
+
*/
|
|
1473
|
+
function ecosystemLayer() {
|
|
1474
|
+
return [];
|
|
1475
|
+
}
|
|
1476
|
+
const WORD_START = /[A-Za-z_$]/;
|
|
1477
|
+
const WORD_CHAR = /[A-Za-z0-9_$]/;
|
|
1478
|
+
const BACKTICK = '\u0060';
|
|
1479
|
+
/**
|
|
1480
|
+
* 极简词法扫描:切出 word / string / punct 三类记号,跳过注释。
|
|
1481
|
+
*
|
|
1482
|
+
* 与"正则剥注释"相比的两点改进:字符串字面量内部不会被当成代码
|
|
1483
|
+
* ("from 'x'" 不产生 import),注释里的 new Service(ctx, 'x') 不产生注册。
|
|
1484
|
+
* 已知不做的事:正则字面量不做完整识别(含双斜杠的正则可能被当成注释吞掉),
|
|
1485
|
+
* 模板字面量里的插值不求值。
|
|
1486
|
+
*
|
|
1487
|
+
* @param code - 源码文本。
|
|
1488
|
+
* @returns 按出现顺序排列的记号;行号从 1 起。
|
|
1489
|
+
*/
|
|
1490
|
+
export function tokenize(code) {
|
|
1491
|
+
const tokens = [];
|
|
1492
|
+
let index = 0;
|
|
1493
|
+
let line = 1;
|
|
1494
|
+
const length = code.length;
|
|
1495
|
+
while (index < length) {
|
|
1496
|
+
const char = code[index] ?? '';
|
|
1497
|
+
if (char === '\n') {
|
|
1498
|
+
line += 1;
|
|
1499
|
+
index += 1;
|
|
1500
|
+
continue;
|
|
1501
|
+
}
|
|
1502
|
+
if (char === ' ' || char === '\t' || char === '\r') {
|
|
1503
|
+
index += 1;
|
|
1504
|
+
continue;
|
|
1505
|
+
}
|
|
1506
|
+
if (char === '/' && code[index + 1] === '/') {
|
|
1507
|
+
while (index < length && code[index] !== '\n')
|
|
1508
|
+
index += 1;
|
|
1509
|
+
continue;
|
|
1510
|
+
}
|
|
1511
|
+
if (char === '/' && code[index + 1] === '*') {
|
|
1512
|
+
index += 2;
|
|
1513
|
+
while (index < length && !(code[index] === '*' && code[index + 1] === '/')) {
|
|
1514
|
+
if (code[index] === '\n')
|
|
1515
|
+
line += 1;
|
|
1516
|
+
index += 1;
|
|
1517
|
+
}
|
|
1518
|
+
index += 2;
|
|
1519
|
+
continue;
|
|
1520
|
+
}
|
|
1521
|
+
if (char === '"' || char === "'") {
|
|
1522
|
+
const startLine = line;
|
|
1523
|
+
let value = '';
|
|
1524
|
+
index += 1;
|
|
1525
|
+
while (index < length) {
|
|
1526
|
+
const inner = code[index] ?? '';
|
|
1527
|
+
if (inner === '\\') {
|
|
1528
|
+
value += code[index + 1] ?? '';
|
|
1529
|
+
index += 2;
|
|
1530
|
+
continue;
|
|
1531
|
+
}
|
|
1532
|
+
if (inner === char) {
|
|
1533
|
+
index += 1;
|
|
1534
|
+
break;
|
|
1535
|
+
}
|
|
1536
|
+
if (inner === '\n')
|
|
1537
|
+
line += 1;
|
|
1538
|
+
value += inner;
|
|
1539
|
+
index += 1;
|
|
1540
|
+
}
|
|
1541
|
+
tokens.push({ kind: 'string', value, line: startLine });
|
|
1542
|
+
continue;
|
|
1543
|
+
}
|
|
1544
|
+
if (char === BACKTICK) {
|
|
1545
|
+
const startLine = line;
|
|
1546
|
+
let value = '';
|
|
1547
|
+
let interpolated = false;
|
|
1548
|
+
index += 1;
|
|
1549
|
+
while (index < length) {
|
|
1550
|
+
const inner = code[index] ?? '';
|
|
1551
|
+
if (inner === '\\') {
|
|
1552
|
+
value += code[index + 1] ?? '';
|
|
1553
|
+
index += 2;
|
|
1554
|
+
continue;
|
|
1555
|
+
}
|
|
1556
|
+
if (inner === '$' && code[index + 1] === '{')
|
|
1557
|
+
interpolated = true;
|
|
1558
|
+
if (inner === BACKTICK) {
|
|
1559
|
+
index += 1;
|
|
1560
|
+
break;
|
|
1561
|
+
}
|
|
1562
|
+
if (inner === '\n')
|
|
1563
|
+
line += 1;
|
|
1564
|
+
value += inner;
|
|
1565
|
+
index += 1;
|
|
1566
|
+
}
|
|
1567
|
+
if (!interpolated)
|
|
1568
|
+
tokens.push({ kind: 'string', value, line: startLine });
|
|
1569
|
+
continue;
|
|
1570
|
+
}
|
|
1571
|
+
if (WORD_START.test(char)) {
|
|
1572
|
+
let value = char;
|
|
1573
|
+
index += 1;
|
|
1574
|
+
while (index < length && WORD_CHAR.test(code[index] ?? '')) {
|
|
1575
|
+
value += code[index] ?? '';
|
|
1576
|
+
index += 1;
|
|
1577
|
+
}
|
|
1578
|
+
tokens.push({ kind: 'word', value, line });
|
|
1579
|
+
continue;
|
|
1580
|
+
}
|
|
1581
|
+
tokens.push({ kind: 'punct', value: char, line });
|
|
1582
|
+
index += 1;
|
|
1583
|
+
}
|
|
1584
|
+
return tokens;
|
|
1585
|
+
}
|
|
1586
|
+
/**
|
|
1587
|
+
* 扫描一份 JS/TS 源码:静态与动态 import、re-export、require,
|
|
1588
|
+
* 以及服务、工具、提示词段落、HTTP 路由的命名注册点。
|
|
1589
|
+
*
|
|
1590
|
+
* @param code - 源码文本。
|
|
1591
|
+
* @returns 说明符与注册点,均带行号。
|
|
1592
|
+
*/
|
|
1593
|
+
export function scanCode(code) {
|
|
1594
|
+
const tokens = tokenize(code);
|
|
1595
|
+
const imports = [];
|
|
1596
|
+
const services = [];
|
|
1597
|
+
const injects = [];
|
|
1598
|
+
const tools = [];
|
|
1599
|
+
const sections = [];
|
|
1600
|
+
const routes = [];
|
|
1601
|
+
for (let i = 0; i < tokens.length; i += 1) {
|
|
1602
|
+
const token = tokens[i];
|
|
1603
|
+
if (token === undefined || token.kind !== 'word')
|
|
1604
|
+
continue;
|
|
1605
|
+
const next = tokens[i + 1];
|
|
1606
|
+
const prev1 = tokens[i - 1];
|
|
1607
|
+
const prev2 = tokens[i - 2];
|
|
1608
|
+
if (token.value === 'import' || token.value === 'export') {
|
|
1609
|
+
if (next?.kind === 'string') {
|
|
1610
|
+
imports.push({
|
|
1611
|
+
spec: next.value,
|
|
1612
|
+
line: next.line,
|
|
1613
|
+
kind: token.value === 'import' ? 'static' : 're-export',
|
|
1614
|
+
});
|
|
1615
|
+
continue;
|
|
1616
|
+
}
|
|
1617
|
+
if (next?.kind === 'punct' && next.value === '(') {
|
|
1618
|
+
const arg = tokens[i + 2];
|
|
1619
|
+
if (arg?.kind === 'string')
|
|
1620
|
+
imports.push({ spec: arg.value, line: arg.line, kind: 'dynamic' });
|
|
1621
|
+
continue;
|
|
1622
|
+
}
|
|
1623
|
+
if (next?.kind === 'punct' && next.value === '.')
|
|
1624
|
+
continue;
|
|
1625
|
+
if (token.value === 'import' && next?.kind === 'word' && next.value === 'type')
|
|
1626
|
+
continue;
|
|
1627
|
+
for (let k = i + 1; k < Math.min(tokens.length, i + 60); k += 1) {
|
|
1628
|
+
const cursor = tokens[k];
|
|
1629
|
+
if (cursor === undefined)
|
|
1630
|
+
break;
|
|
1631
|
+
if (cursor.kind === 'punct' && cursor.value === ';')
|
|
1632
|
+
break;
|
|
1633
|
+
if (cursor.kind === 'word' && (cursor.value === 'import' || cursor.value === 'export'))
|
|
1634
|
+
break;
|
|
1635
|
+
if (cursor.kind === 'word' && cursor.value === 'from') {
|
|
1636
|
+
const source = tokens[k + 1];
|
|
1637
|
+
if (source?.kind === 'string') {
|
|
1638
|
+
imports.push({
|
|
1639
|
+
spec: source.value,
|
|
1640
|
+
line: source.line,
|
|
1641
|
+
kind: token.value === 'import' ? 'static' : 're-export',
|
|
1642
|
+
});
|
|
1643
|
+
}
|
|
1644
|
+
break;
|
|
1645
|
+
}
|
|
1646
|
+
}
|
|
1647
|
+
continue;
|
|
1648
|
+
}
|
|
1649
|
+
if (token.value === 'require' && next?.kind === 'punct' && next.value === '(') {
|
|
1650
|
+
const arg = tokens[i + 2];
|
|
1651
|
+
if (arg?.kind === 'string')
|
|
1652
|
+
imports.push({ spec: arg.value, line: arg.line, kind: 'require' });
|
|
1653
|
+
continue;
|
|
1654
|
+
}
|
|
1655
|
+
if (token.value === 'Service' && prev1?.kind === 'word' && prev1.value === 'new') {
|
|
1656
|
+
const hit = firstStringArgument(tokens, i + 1);
|
|
1657
|
+
if (hit !== undefined)
|
|
1658
|
+
services.push(hit);
|
|
1659
|
+
continue;
|
|
1660
|
+
}
|
|
1661
|
+
if (token.value === 'super' && next?.kind === 'punct' && next.value === '(') {
|
|
1662
|
+
const hit = firstStringArgument(tokens, i);
|
|
1663
|
+
if (hit !== undefined)
|
|
1664
|
+
services.push(hit);
|
|
1665
|
+
continue;
|
|
1666
|
+
}
|
|
1667
|
+
if (token.value === 'provide' && prev1?.kind === 'punct' && prev1.value === '.'
|
|
1668
|
+
&& next?.kind === 'punct' && next.value === '(') {
|
|
1669
|
+
const hit = firstStringArgument(tokens, i);
|
|
1670
|
+
if (hit !== undefined)
|
|
1671
|
+
services.push(hit);
|
|
1672
|
+
continue;
|
|
1673
|
+
}
|
|
1674
|
+
if (token.value === 'inject' && next?.kind === 'punct' && (next.value === '=' || next.value === ':')) {
|
|
1675
|
+
const open = tokens[i + 2];
|
|
1676
|
+
if (open?.kind === 'punct' && open.value === '[') {
|
|
1677
|
+
for (let k = i + 3; k < Math.min(tokens.length, i + 40); k += 1) {
|
|
1678
|
+
const cursor = tokens[k];
|
|
1679
|
+
if (cursor === undefined)
|
|
1680
|
+
break;
|
|
1681
|
+
if (cursor.kind === 'punct' && cursor.value === ']')
|
|
1682
|
+
break;
|
|
1683
|
+
if (cursor.kind === 'string')
|
|
1684
|
+
injects.push({ name: cursor.value, line: cursor.line });
|
|
1685
|
+
}
|
|
1686
|
+
}
|
|
1687
|
+
continue;
|
|
1688
|
+
}
|
|
1689
|
+
if (token.value === 'defineTool' && next?.kind === 'punct' && next.value === '(') {
|
|
1690
|
+
const hit = objectFieldString(tokens, i + 1, 'name');
|
|
1691
|
+
if (hit !== undefined)
|
|
1692
|
+
tools.push(hit);
|
|
1693
|
+
continue;
|
|
1694
|
+
}
|
|
1695
|
+
if (token.value === 'register' && prev1?.kind === 'punct' && prev1.value === '.'
|
|
1696
|
+
&& prev2 !== undefined && prev2.kind === 'word') {
|
|
1697
|
+
if (prev2.value === 'tools') {
|
|
1698
|
+
const hit = objectFieldString(tokens, i + 1, 'name');
|
|
1699
|
+
if (hit !== undefined)
|
|
1700
|
+
tools.push(hit);
|
|
1701
|
+
continue;
|
|
1702
|
+
}
|
|
1703
|
+
if (prev2.value === 'webServer') {
|
|
1704
|
+
const hit = objectFieldString(tokens, i + 1, 'path');
|
|
1705
|
+
if (hit !== undefined)
|
|
1706
|
+
routes.push(hit);
|
|
1707
|
+
continue;
|
|
1708
|
+
}
|
|
1709
|
+
}
|
|
1710
|
+
if (token.value === 'section' && prev1?.kind === 'punct' && prev1.value === '.'
|
|
1711
|
+
&& prev2 !== undefined && prev2.kind === 'word' && prev2.value === 'systemPrompt') {
|
|
1712
|
+
const hit = objectFieldString(tokens, i + 1, 'name');
|
|
1713
|
+
if (hit !== undefined)
|
|
1714
|
+
sections.push(hit);
|
|
1715
|
+
}
|
|
1716
|
+
}
|
|
1717
|
+
return { imports, services, injects, tools, sections, routes };
|
|
1718
|
+
}
|
|
1719
|
+
/** 括号内的第一个字符串实参(new Service(ctx, 'x') / super(ctx, 'x') / ctx.provide('x'))。 */
|
|
1720
|
+
function firstStringArgument(tokens, keywordIndex) {
|
|
1721
|
+
let depth = 0;
|
|
1722
|
+
for (let k = keywordIndex; k < Math.min(tokens.length, keywordIndex + 40); k += 1) {
|
|
1723
|
+
const token = tokens[k];
|
|
1724
|
+
if (token === undefined)
|
|
1725
|
+
break;
|
|
1726
|
+
if (token.kind === 'punct' && token.value === '(')
|
|
1727
|
+
depth += 1;
|
|
1728
|
+
else if (token.kind === 'punct' && token.value === ')') {
|
|
1729
|
+
depth -= 1;
|
|
1730
|
+
if (depth <= 0)
|
|
1731
|
+
return undefined;
|
|
1732
|
+
}
|
|
1733
|
+
else if (token.kind === 'string' && depth === 1) {
|
|
1734
|
+
return { name: token.value, line: token.line };
|
|
1735
|
+
}
|
|
1736
|
+
}
|
|
1737
|
+
return undefined;
|
|
1738
|
+
}
|
|
1739
|
+
/** 调用实参里的对象字段字符串(register({ name: 'x' }))。 */
|
|
1740
|
+
function objectFieldString(tokens, callIndex, field) {
|
|
1741
|
+
let depth = 0;
|
|
1742
|
+
for (let k = callIndex; k < Math.min(tokens.length, callIndex + 200); k += 1) {
|
|
1743
|
+
const token = tokens[k];
|
|
1744
|
+
if (token === undefined)
|
|
1745
|
+
break;
|
|
1746
|
+
if (token.kind === 'punct' && (token.value === '(' || token.value === '{' || token.value === '['))
|
|
1747
|
+
depth += 1;
|
|
1748
|
+
else if (token.kind === 'punct' && (token.value === ')' || token.value === '}' || token.value === ']')) {
|
|
1749
|
+
depth -= 1;
|
|
1750
|
+
if (depth <= 0)
|
|
1751
|
+
return undefined;
|
|
1752
|
+
}
|
|
1753
|
+
else if (token.kind === 'word' && token.value === field && depth >= 1) {
|
|
1754
|
+
const colon = tokens[k + 1];
|
|
1755
|
+
const value = tokens[k + 2];
|
|
1756
|
+
if (colon?.kind === 'punct' && colon.value === ':' && value?.kind === 'string') {
|
|
1757
|
+
return { name: value.value, line: value.line };
|
|
1758
|
+
}
|
|
1759
|
+
}
|
|
1760
|
+
}
|
|
1761
|
+
return undefined;
|
|
1762
|
+
}
|
|
1763
|
+
/**
|
|
1764
|
+
* 这条裸说明符看起来**可能**是一个真模块名吗(扫描器的输入过滤)。
|
|
1765
|
+
*
|
|
1766
|
+
* 为什么需要它:打包产物会把模板字面量拆成 "字面量 + ${…} 表达式",字面量部分于是成了
|
|
1767
|
+
* **无插值的反引号字符串**,被词法器当成 string 记号收集,形如 " || token.value === "、
|
|
1768
|
+
* ") {\n if (next?.kind === "。这些片段被当成说明符后,会给"这个包导入了不存在的依赖"这类
|
|
1769
|
+
* 结论提供假输入(实测:CLI 逃生口路径上 9 条假 missing-import)。
|
|
1770
|
+
*
|
|
1771
|
+
* 判据只做"合法裸说明符"的形状检查:首字符字母或数字,其余字母/数字/._-~/%@+ 或查询串,
|
|
1772
|
+
* 且不含空白与控制字符。npm 包名本来就不允许空白与这些符号,因此被丢弃的必然是打包碎片;
|
|
1773
|
+
* 相对路径与绝对路径走的是另外的分支(由调用方先判),本函数只作用于裸说明符。
|
|
1774
|
+
*
|
|
1775
|
+
* @param spec - 说明符。
|
|
1776
|
+
* @returns 是否可能是模块名(false 表示应当丢弃)。
|
|
1777
|
+
*/
|
|
1778
|
+
export function isPlausibleSpecifier(spec) {
|
|
1779
|
+
if (spec.length === 0)
|
|
1780
|
+
return false;
|
|
1781
|
+
return /^@?[a-z0-9][a-z0-9._\-~/@%+?=&:]*$/i.test(spec);
|
|
1782
|
+
}
|
|
1783
|
+
/**
|
|
1784
|
+
* 扫描一个已安装包:入口 → 相对 import 可达文件(有界 BFS)。
|
|
1785
|
+
*
|
|
1786
|
+
* @param pkgDir - 包目录绝对路径。
|
|
1787
|
+
* @param manifest - 该包 package.json 的解析结果。
|
|
1788
|
+
* @param maxFiles - 本次可用的文件预算(逐包递减,来自全局预算)。
|
|
1789
|
+
* @returns 扫描结果;没有可解析入口时给出 reason。
|
|
1790
|
+
*/
|
|
1791
|
+
export function scanPackage(pkgDir, manifest, maxFiles = SCAN_MAX_FILES_PER_PACKAGE) {
|
|
1792
|
+
const entryFiles = packageEntryFiles(pkgDir, manifest);
|
|
1793
|
+
const imports = [];
|
|
1794
|
+
const services = [];
|
|
1795
|
+
const injects = [];
|
|
1796
|
+
const tools = [];
|
|
1797
|
+
const sections = [];
|
|
1798
|
+
const routes = [];
|
|
1799
|
+
const base = {
|
|
1800
|
+
imports, services, injects, tools, sections, routes, entryFiles,
|
|
1801
|
+
filesScanned: 0, truncated: false,
|
|
1802
|
+
};
|
|
1803
|
+
if (entryFiles.length === 0) {
|
|
1804
|
+
return { ...base, reason: 'no resolvable entry file (exports/main/index.js)' };
|
|
1805
|
+
}
|
|
1806
|
+
const limit = Math.max(1, Math.min(maxFiles, SCAN_MAX_FILES_PER_PACKAGE));
|
|
1807
|
+
const seen = new Set(entryFiles);
|
|
1808
|
+
const queue = entryFiles.map(file => ({ file, depth: 0 }));
|
|
1809
|
+
let scanned = 0;
|
|
1810
|
+
let truncated = false;
|
|
1811
|
+
while (queue.length > 0) {
|
|
1812
|
+
if (scanned >= limit) {
|
|
1813
|
+
truncated = true;
|
|
1814
|
+
break;
|
|
1815
|
+
}
|
|
1816
|
+
const current = queue.shift();
|
|
1817
|
+
if (current === undefined)
|
|
1818
|
+
break;
|
|
1819
|
+
scanned += 1;
|
|
1820
|
+
const code = readTextFile(current.file);
|
|
1821
|
+
if (code === undefined)
|
|
1822
|
+
continue;
|
|
1823
|
+
const result = scanCode(code);
|
|
1824
|
+
for (const hit of result.imports) {
|
|
1825
|
+
if (hit.spec.startsWith('.')) {
|
|
1826
|
+
if (current.depth >= SCAN_MAX_DEPTH) {
|
|
1827
|
+
truncated = true;
|
|
1828
|
+
continue;
|
|
1829
|
+
}
|
|
1830
|
+
const target = resolveRelativeFile(dirname(current.file), hit.spec);
|
|
1831
|
+
if (target === undefined || seen.has(target) || !isInside(pkgDir, target))
|
|
1832
|
+
continue;
|
|
1833
|
+
seen.add(target);
|
|
1834
|
+
queue.push({ file: target, depth: current.depth + 1 });
|
|
1835
|
+
continue;
|
|
1836
|
+
}
|
|
1837
|
+
if (hit.spec.startsWith('/') || isBuiltin(hit.spec))
|
|
1838
|
+
continue;
|
|
1839
|
+
// 输入过滤(不是判定口径):打包器会把模板字面量拆成 `${…}` 片段,字面量部分会变成
|
|
1840
|
+
// 无插值的反引号字符串被词法器当成 string 记号收集。含空白/控制字符的裸说明符在 Node 里
|
|
1841
|
+
// 本来就不可能解析(ERR_INVALID_MODULE_SPECIFIER),丢弃它们不会掩盖真依赖。
|
|
1842
|
+
if (!isPlausibleSpecifier(hit.spec))
|
|
1843
|
+
continue;
|
|
1844
|
+
imports.push({ spec: hit.spec, file: current.file, line: hit.line, kind: hit.kind });
|
|
1845
|
+
}
|
|
1846
|
+
for (const hit of result.services)
|
|
1847
|
+
services.push({ name: hit.name, file: current.file, line: hit.line });
|
|
1848
|
+
for (const hit of result.injects)
|
|
1849
|
+
injects.push({ name: hit.name, file: current.file, line: hit.line });
|
|
1850
|
+
for (const hit of result.tools)
|
|
1851
|
+
tools.push({ name: hit.name, file: current.file, line: hit.line });
|
|
1852
|
+
for (const hit of result.sections)
|
|
1853
|
+
sections.push({ name: hit.name, file: current.file, line: hit.line });
|
|
1854
|
+
for (const hit of result.routes)
|
|
1855
|
+
routes.push({ name: hit.name, file: current.file, line: hit.line });
|
|
1856
|
+
}
|
|
1857
|
+
return { ...base, filesScanned: scanned, truncated };
|
|
1858
|
+
}
|
|
1859
|
+
/**
|
|
1860
|
+
* 一个包声明的全部入口文件(exports 的每个子路径 + main + module)。
|
|
1861
|
+
*
|
|
1862
|
+
* 只取一个入口是历史缺陷:DSH 插件常同时带 host/client/worker 多个入口,
|
|
1863
|
+
* 未声明依赖若只出现在 exports["./server"] 指向的文件里,单入口检查会整条漏过,
|
|
1864
|
+
* 而挂载该子路径的行会让整个 profile 起不来。
|
|
1865
|
+
*
|
|
1866
|
+
* @param pkgDir - 包目录绝对路径。
|
|
1867
|
+
* @param manifest - 该包 package.json 的解析结果。
|
|
1868
|
+
* @returns 去重后仍存在的入口文件路径。
|
|
1869
|
+
*/
|
|
1870
|
+
export function packageEntryFiles(pkgDir, manifest) {
|
|
1871
|
+
const candidates = [];
|
|
1872
|
+
collectExportTargets(manifest['exports'], candidates);
|
|
1873
|
+
for (const key of ['main', 'module']) {
|
|
1874
|
+
const value = manifest[key];
|
|
1875
|
+
if (typeof value === 'string')
|
|
1876
|
+
candidates.push(value);
|
|
1877
|
+
}
|
|
1878
|
+
const out = [];
|
|
1879
|
+
const seen = new Set();
|
|
1880
|
+
for (const candidate of candidates) {
|
|
1881
|
+
if (candidate.length === 0 || !isScriptTarget(candidate))
|
|
1882
|
+
continue;
|
|
1883
|
+
const file = resolve(pkgDir, candidate);
|
|
1884
|
+
if (seen.has(file) || !isInside(pkgDir, file) || !existsSync(file))
|
|
1885
|
+
continue;
|
|
1886
|
+
try {
|
|
1887
|
+
if (!statSync(file).isFile())
|
|
1888
|
+
continue;
|
|
1889
|
+
}
|
|
1890
|
+
catch {
|
|
1891
|
+
continue;
|
|
1892
|
+
}
|
|
1893
|
+
seen.add(file);
|
|
1894
|
+
out.push(file);
|
|
1895
|
+
}
|
|
1896
|
+
if (out.length === 0) {
|
|
1897
|
+
for (const fallback of ['index.js', 'index.mjs', 'index.cjs']) {
|
|
1898
|
+
const file = join(pkgDir, fallback);
|
|
1899
|
+
if (existsSync(file)) {
|
|
1900
|
+
out.push(file);
|
|
1901
|
+
break;
|
|
1902
|
+
}
|
|
1903
|
+
}
|
|
1904
|
+
}
|
|
1905
|
+
return out;
|
|
1906
|
+
}
|
|
1907
|
+
/** 递归收集 exports 节点里的字符串目标。 */
|
|
1908
|
+
function collectExportTargets(node, out) {
|
|
1909
|
+
if (typeof node === 'string') {
|
|
1910
|
+
out.push(node);
|
|
1911
|
+
return;
|
|
1912
|
+
}
|
|
1913
|
+
if (node === null || typeof node !== 'object')
|
|
1914
|
+
return;
|
|
1915
|
+
for (const value of Object.values(node))
|
|
1916
|
+
collectExportTargets(value, out);
|
|
1917
|
+
}
|
|
1918
|
+
/** 只扫脚本目标:json/css/md/图片等资产里不可能有运行期 import。 */
|
|
1919
|
+
function isScriptTarget(target) {
|
|
1920
|
+
return !/\.(?:json|jsonc|css|md|markdown|txt|wasm|node|map|html?|svg|png|jpe?g|gif|ya?ml|toml|d\.ts)$/i.test(target);
|
|
1921
|
+
}
|
|
1922
|
+
/**
|
|
1923
|
+
* 按 Node 的解析顺序探测相对说明符指向的文件。
|
|
1924
|
+
* @param baseDir - 相对说明符的基准目录。
|
|
1925
|
+
* @param spec - 相对说明符。
|
|
1926
|
+
* @returns 命中文件的绝对路径;都不存在时 undefined。
|
|
1927
|
+
*/
|
|
1928
|
+
export function resolveRelativeFile(baseDir, spec) {
|
|
1929
|
+
const base = resolve(baseDir, spec);
|
|
1930
|
+
for (const suffix of ['', '.js', '.mjs', '.cjs', '.ts', '.tsx', '.mts', '.cts',
|
|
1931
|
+
'/index.js', '/index.mjs', '/index.cjs', '/index.ts']) {
|
|
1932
|
+
const candidate = base + suffix;
|
|
1933
|
+
try {
|
|
1934
|
+
if (existsSync(candidate) && statSync(candidate).isFile())
|
|
1935
|
+
return candidate;
|
|
1936
|
+
}
|
|
1937
|
+
catch {
|
|
1938
|
+
// 探测失败继续下一个后缀
|
|
1939
|
+
}
|
|
1940
|
+
}
|
|
1941
|
+
return undefined;
|
|
1942
|
+
}
|
|
1943
|
+
// ── 解析与判定工具 ──────────────────────────────────────────────────────
|
|
1944
|
+
/**
|
|
1945
|
+
* 安装锚点对应的 Node 解析根:**官方同款口径**。
|
|
1946
|
+
*
|
|
1947
|
+
* 官方 @deepseek-ai/dsh-app-boot/profile.ts 的 packageDirFromAnchor 从锚点走
|
|
1948
|
+
* createRequire(anchor).resolve.paths(packageName),本函数取同一串搜索路径里
|
|
1949
|
+
* 以 node_modules 结尾的那些(Node 会按顺序往上走:包内 → 安装目录 → 祖先目录)。
|
|
1950
|
+
* 官方包里已经存在的路径不再重复放进来。
|
|
1951
|
+
*
|
|
1952
|
+
* 锚点必须先 realpath:pnpm 的 bin shim 指向全局安装目录下的一个符号链接,
|
|
1953
|
+
* 而 Node 用的是**字面路径**,不 realpath 时那条链上一个官方包都解析不到。
|
|
1954
|
+
*
|
|
1955
|
+
* @param installAnchor - dsh 应用包的 package.json 绝对路径(ProfileContext.installAnchor)。
|
|
1956
|
+
* @returns 解析根(按 Node 的查找顺序);锚点缺失、读不到或解析不出节点时为 null。
|
|
1957
|
+
*/
|
|
1958
|
+
export function installAnchorRoots(installAnchor) {
|
|
1959
|
+
if (installAnchor === undefined || installAnchor.length === 0)
|
|
1960
|
+
return null;
|
|
1961
|
+
const cached = installPathCache.get(installAnchor);
|
|
1962
|
+
if (cached !== undefined)
|
|
1963
|
+
return cached.length === 0 ? null : [...cached];
|
|
1964
|
+
const roots = [];
|
|
1965
|
+
try {
|
|
1966
|
+
const anchor = realpathSync(installAnchor);
|
|
1967
|
+
for (const searchPath of createRequire(anchor).resolve.paths('node_modules') ?? []) {
|
|
1968
|
+
if (searchPath.endsWith('node_modules') && !roots.includes(searchPath))
|
|
1969
|
+
roots.push(searchPath);
|
|
1970
|
+
}
|
|
1971
|
+
}
|
|
1972
|
+
catch {
|
|
1973
|
+
// 锚点不可读或不是可解析的文件路径:如实返回 null,由调用方记 skipped。
|
|
1974
|
+
}
|
|
1975
|
+
installPathCache.set(installAnchor, roots);
|
|
1976
|
+
return roots.length === 0 ? null : roots;
|
|
1977
|
+
}
|
|
1978
|
+
/**
|
|
1979
|
+
* 环境的模块解析顺序:profile 层 → 共享兜底层 → Harness home 的 profiles 兜底 → 安装锚点。
|
|
1980
|
+
*
|
|
1981
|
+
* 前三个是 profile 自己的依赖;安装锚点由**安装侧**提供(官方包、bundle 本体与它们携带的
|
|
1982
|
+
* 传递依赖都在这里),缺少它会把官方包的每一行都判成孤儿。
|
|
1983
|
+
*
|
|
1984
|
+
* @param envDir - 环境目录。
|
|
1985
|
+
* @param installAnchor - dsh 应用包的 package.json(可省略)。
|
|
1986
|
+
* @returns 去重后的解析根,按查找优先级排列。
|
|
1987
|
+
*/
|
|
1988
|
+
export function moduleRoots(envDir, installAnchor) {
|
|
1989
|
+
const roots = [join(envDir, 'node_modules')];
|
|
1990
|
+
const sibling = join(dirname(resolve(envDir)), 'node_modules');
|
|
1991
|
+
if (!roots.includes(sibling))
|
|
1992
|
+
roots.push(sibling);
|
|
1993
|
+
const shared = join(profilesRoot(), 'node_modules');
|
|
1994
|
+
if (!roots.includes(shared))
|
|
1995
|
+
roots.push(shared);
|
|
1996
|
+
for (const root of installAnchorRoots(installAnchor) ?? []) {
|
|
1997
|
+
if (!roots.includes(root))
|
|
1998
|
+
roots.push(root);
|
|
1999
|
+
}
|
|
2000
|
+
return roots;
|
|
2001
|
+
}
|
|
2002
|
+
/**
|
|
2003
|
+
* 一个包在某个环境里的安装目录(profile 层优先,其次共享兜底层,最后是安装锚点)。
|
|
2004
|
+
*
|
|
2005
|
+
* 质量门与诊断共用这一套解析顺序,避免两处口径漂移。
|
|
2006
|
+
*
|
|
2007
|
+
* @param envDir - 环境目录。
|
|
2008
|
+
* @param packageName - 包名。
|
|
2009
|
+
* @param installAnchor - dsh 应用包的 package.json;省略时只按 profile 侧解析。
|
|
2010
|
+
* @returns 包目录绝对路径;未安装时 undefined。
|
|
2011
|
+
*/
|
|
2012
|
+
export function installedPackageDir(envDir, packageName, installAnchor) {
|
|
2013
|
+
return findInstalledDir(moduleRoots(envDir, installAnchor), packageName);
|
|
2014
|
+
}
|
|
2015
|
+
/** 在解析根里找已安装的包目录(同时回传命中的解析根:调用方要用它作 Node 解析基准)。 */
|
|
2016
|
+
function findInstalled(roots, name) {
|
|
2017
|
+
for (const root of roots) {
|
|
2018
|
+
const dir = join(root, name);
|
|
2019
|
+
if (existsSync(join(dir, 'package.json')))
|
|
2020
|
+
return { dir, root };
|
|
2021
|
+
}
|
|
2022
|
+
return undefined;
|
|
2023
|
+
}
|
|
2024
|
+
/** 在解析根里找已安装的包目录。 */
|
|
2025
|
+
function findInstalledDir(roots, name) {
|
|
2026
|
+
return findInstalled(roots, name)?.dir;
|
|
2027
|
+
}
|
|
2028
|
+
/** 一个 node_modules 根里直接可见的包名(含 @scope/name)。 */
|
|
2029
|
+
function listInstalledNames(root) {
|
|
2030
|
+
const names = new Set();
|
|
2031
|
+
let entries;
|
|
2032
|
+
try {
|
|
2033
|
+
entries = readdirSync(root, { withFileTypes: true });
|
|
2034
|
+
}
|
|
2035
|
+
catch {
|
|
2036
|
+
return names;
|
|
2037
|
+
}
|
|
2038
|
+
for (const entry of entries) {
|
|
2039
|
+
if (entry.name.startsWith('.'))
|
|
2040
|
+
continue;
|
|
2041
|
+
if (!entry.name.startsWith('@')) {
|
|
2042
|
+
names.add(entry.name);
|
|
2043
|
+
continue;
|
|
2044
|
+
}
|
|
2045
|
+
try {
|
|
2046
|
+
for (const child of readdirSync(join(root, entry.name), { withFileTypes: true })) {
|
|
2047
|
+
if (!child.name.startsWith('.'))
|
|
2048
|
+
names.add(entry.name + '/' + child.name);
|
|
2049
|
+
}
|
|
2050
|
+
}
|
|
2051
|
+
catch {
|
|
2052
|
+
// 读不到 scope 目录就跳过它
|
|
2053
|
+
}
|
|
2054
|
+
}
|
|
2055
|
+
return names;
|
|
2056
|
+
}
|
|
2057
|
+
/** 说明符的包名部分。 */
|
|
2058
|
+
function packageNameOf(spec) {
|
|
2059
|
+
if (spec.length === 0 || spec.startsWith('.') || spec.startsWith('/'))
|
|
2060
|
+
return undefined;
|
|
2061
|
+
const parts = spec.split('/');
|
|
2062
|
+
if (spec.startsWith('@')) {
|
|
2063
|
+
if (parts.length < 2 || (parts[1] ?? '').length === 0)
|
|
2064
|
+
return undefined;
|
|
2065
|
+
return parts[0] + '/' + parts[1];
|
|
2066
|
+
}
|
|
2067
|
+
return parts[0];
|
|
2068
|
+
}
|
|
2069
|
+
/** 在已安装表里按最长前缀找提供者。 */
|
|
2070
|
+
function providerOf(spec, installed) {
|
|
2071
|
+
const parts = spec.split('/');
|
|
2072
|
+
for (let take = parts.length; take >= 1; take -= 1) {
|
|
2073
|
+
const candidate = parts.slice(0, take).join('/');
|
|
2074
|
+
if (candidate.startsWith('@') && take === 1)
|
|
2075
|
+
continue;
|
|
2076
|
+
if (installed.has(candidate))
|
|
2077
|
+
return candidate;
|
|
2078
|
+
}
|
|
2079
|
+
return undefined;
|
|
2080
|
+
}
|
|
2081
|
+
/**
|
|
2082
|
+
* 一个说明符在当前环境里能否解析。
|
|
2083
|
+
*
|
|
2084
|
+
* 判定"能"用两把尺子(任一成立即可):Node 的真实解析(createRequire)与
|
|
2085
|
+
* 文件/exports 探测;判定"不能"必须两者都不成立——这样 scoped 子路径不会被
|
|
2086
|
+
* 误判为可解析(前身仓库在这一点上有过 Critical 缺陷:只要 @scope/pkg 目录存在,
|
|
2087
|
+
* @scope/pkg 下的任意子路径都返回 true,最后一道兜底检查因此完全失效)。
|
|
2088
|
+
*
|
|
2089
|
+
* @param envDir - 环境目录(解析锚点)。
|
|
2090
|
+
* @param spec - 裸说明符或带子路径的说明符。
|
|
2091
|
+
* @param installAnchor - dsh 应用包的 package.json;省略时只按 profile 侧解析。
|
|
2092
|
+
* @returns 能否解析到模块。
|
|
2093
|
+
*/
|
|
2094
|
+
export function specifierResolves(envDir, spec, installAnchor) {
|
|
2095
|
+
if (isBuiltin(spec))
|
|
2096
|
+
return true;
|
|
2097
|
+
const pkgName = packageNameOf(spec);
|
|
2098
|
+
if (pkgName === undefined)
|
|
2099
|
+
return false;
|
|
2100
|
+
const hit = findInstalled(moduleRoots(envDir, installAnchor), pkgName);
|
|
2101
|
+
if (hit === undefined)
|
|
2102
|
+
return false;
|
|
2103
|
+
const dir = hit.dir;
|
|
2104
|
+
const subpath = spec.slice(pkgName.length);
|
|
2105
|
+
if (subpath.length === 0)
|
|
2106
|
+
return true;
|
|
2107
|
+
// 先按真实 Node 解析判真:它认得带 exports 子路径与条件导出的官方包。
|
|
2108
|
+
// 基准点取包自己的 package.json(包内 import 的真实解析起点,link / hoisted 布局都覆盖),
|
|
2109
|
+
// 再退到环境目录(走 profile / 共享兜底 / 安装锚点三层 node_modules)。
|
|
2110
|
+
if (resolvesFrom(join(dir, 'package.json'), spec))
|
|
2111
|
+
return true;
|
|
2112
|
+
if (resolvesFrom(join(envDir, 'package.json'), spec))
|
|
2113
|
+
return true;
|
|
2114
|
+
// 再走文件与 exports 探测(判"不能解析"必须两者都不成立)。
|
|
2115
|
+
const relativePath = subpath.replace(/^\/+/, '');
|
|
2116
|
+
if (resolveRelativeFile(dir, relativePath) !== undefined)
|
|
2117
|
+
return true;
|
|
2118
|
+
const manifest = readJsonFile(join(dir, 'package.json'));
|
|
2119
|
+
const exportsField = manifest?.['exports'];
|
|
2120
|
+
if (exportsField !== undefined)
|
|
2121
|
+
return exportsDeclaresSubpath(exportsField, './' + relativePath);
|
|
2122
|
+
return false;
|
|
2123
|
+
}
|
|
2124
|
+
/** Node 能否从某个基准文件解析出说明符(解析失败只代表这一条路走不通)。 */
|
|
2125
|
+
function resolvesFrom(base, spec) {
|
|
2126
|
+
try {
|
|
2127
|
+
createRequire(base).resolve(spec);
|
|
2128
|
+
return true;
|
|
2129
|
+
}
|
|
2130
|
+
catch {
|
|
2131
|
+
// conditional export 可能只给 import 条件,require 解析失败不代表不可用。
|
|
2132
|
+
return false;
|
|
2133
|
+
}
|
|
2134
|
+
}
|
|
2135
|
+
/**
|
|
2136
|
+
* exports 是否声明了某个子路径(支持星号通配)。
|
|
2137
|
+
* @param exportsField - package.json 的 exports 字段。
|
|
2138
|
+
* @param key - 形如 "./sub" 的子路径键。
|
|
2139
|
+
* @returns 是否声明。
|
|
2140
|
+
*/
|
|
2141
|
+
export function exportsDeclaresSubpath(exportsField, key) {
|
|
2142
|
+
if (typeof exportsField === 'string' || exportsField === null || typeof exportsField !== 'object') {
|
|
2143
|
+
return key === '.';
|
|
2144
|
+
}
|
|
2145
|
+
const record = exportsField;
|
|
2146
|
+
const keys = Object.keys(record);
|
|
2147
|
+
if (keys.length === 0 || !keys.every(candidate => candidate.startsWith('.')))
|
|
2148
|
+
return key === '.';
|
|
2149
|
+
if (key in record)
|
|
2150
|
+
return true;
|
|
2151
|
+
for (const pattern of keys) {
|
|
2152
|
+
const star = pattern.indexOf('*');
|
|
2153
|
+
if (star < 0)
|
|
2154
|
+
continue;
|
|
2155
|
+
const prefix = pattern.slice(0, star);
|
|
2156
|
+
const suffix = pattern.slice(star + 1);
|
|
2157
|
+
if (key.startsWith(prefix) && key.endsWith(suffix) && key.length >= prefix.length + suffix.length) {
|
|
2158
|
+
return true;
|
|
2159
|
+
}
|
|
2160
|
+
}
|
|
2161
|
+
return false;
|
|
2162
|
+
}
|
|
2163
|
+
/** 说明符是否被某个包的声明覆盖(声明了 unpdf 即覆盖 unpdf/pdfjs)。 */
|
|
2164
|
+
function declaresSpecifier(manifest, spec, selfName) {
|
|
2165
|
+
if (spec === selfName || spec.startsWith(selfName + '/'))
|
|
2166
|
+
return true;
|
|
2167
|
+
for (const name of declaredNames(manifest)) {
|
|
2168
|
+
if (spec === name || spec.startsWith(name + '/'))
|
|
2169
|
+
return true;
|
|
2170
|
+
}
|
|
2171
|
+
return false;
|
|
2172
|
+
}
|
|
2173
|
+
/** manifest 是否把这个包声明成**普通**依赖(dependencies / optionalDependencies)。 */
|
|
2174
|
+
function declaresRegularDependency(manifest, name) {
|
|
2175
|
+
for (const section of ['dependencies', 'optionalDependencies']) {
|
|
2176
|
+
const record = asRecord(manifest[section]);
|
|
2177
|
+
if (record !== undefined && name in record)
|
|
2178
|
+
return true;
|
|
2179
|
+
}
|
|
2180
|
+
return false;
|
|
2181
|
+
}
|
|
2182
|
+
/** manifest 的任一依赖段是否声明了这个包。 */
|
|
2183
|
+
function declaresDependency(manifest, name) {
|
|
2184
|
+
for (const section of ['dependencies', 'peerDependencies', 'optionalDependencies']) {
|
|
2185
|
+
const record = asRecord(manifest[section]);
|
|
2186
|
+
if (record !== undefined && name in record)
|
|
2187
|
+
return true;
|
|
2188
|
+
}
|
|
2189
|
+
return false;
|
|
2190
|
+
}
|
|
2191
|
+
/** manifest 声明的全部依赖名。 */
|
|
2192
|
+
function declaredNames(manifest) {
|
|
2193
|
+
const names = new Set();
|
|
2194
|
+
for (const section of ['dependencies', 'peerDependencies', 'optionalDependencies']) {
|
|
2195
|
+
const record = asRecord(manifest[section]);
|
|
2196
|
+
if (record === undefined)
|
|
2197
|
+
continue;
|
|
2198
|
+
for (const name of Object.keys(record))
|
|
2199
|
+
names.add(name);
|
|
2200
|
+
}
|
|
2201
|
+
return [...names];
|
|
2202
|
+
}
|
|
2203
|
+
/**
|
|
2204
|
+
* 简化 semver:判断安装版本是否满足一个范围。
|
|
2205
|
+
*
|
|
2206
|
+
* 支持星号、=、^、~、>、>=、<、<=,空格或逗号连接的合取,以及 || 析取。
|
|
2207
|
+
* prerelease 只参与"基线相同"时的排序(不实现 semver 的 prerelease 全规则),
|
|
2208
|
+
* 因此不会把 0.1.6-alpha.2 这类官方版本误判为不满足。
|
|
2209
|
+
*
|
|
2210
|
+
* @param installed - 实际安装的版本。
|
|
2211
|
+
* @param range - 声明的要求范围。
|
|
2212
|
+
* @returns 是否满足。
|
|
2213
|
+
*/
|
|
2214
|
+
export function satisfiesRange(installed, range) {
|
|
2215
|
+
const wanted = range.trim();
|
|
2216
|
+
if (wanted.length === 0 || wanted === '*' || wanted === 'x' || wanted === 'latest')
|
|
2217
|
+
return true;
|
|
2218
|
+
return wanted.split('||').some(branch => branch.trim().split(/[\s,]+/).filter(part => part.length > 0)
|
|
2219
|
+
.every(token => satisfiesToken(installed, token)));
|
|
2220
|
+
}
|
|
2221
|
+
/** 单个比较算子。 */
|
|
2222
|
+
function satisfiesToken(installed, token) {
|
|
2223
|
+
const match = /^(\^|~|>=|<=|>|<|=)?\s*(.+)$/.exec(token);
|
|
2224
|
+
if (match === null)
|
|
2225
|
+
return true;
|
|
2226
|
+
const operator = match[1] ?? '';
|
|
2227
|
+
const requested = match[2] ?? '';
|
|
2228
|
+
if (/^[*x]$/.test(requested))
|
|
2229
|
+
return true;
|
|
2230
|
+
const target = parseVersion(requested);
|
|
2231
|
+
const actual = parseVersion(installed);
|
|
2232
|
+
if (target === undefined || actual === undefined)
|
|
2233
|
+
return true;
|
|
2234
|
+
const order = compareParsed(actual, target);
|
|
2235
|
+
if (operator === '>=')
|
|
2236
|
+
return order >= 0;
|
|
2237
|
+
if (operator === '<=')
|
|
2238
|
+
return order <= 0;
|
|
2239
|
+
if (operator === '>')
|
|
2240
|
+
return order > 0;
|
|
2241
|
+
if (operator === '<')
|
|
2242
|
+
return order < 0;
|
|
2243
|
+
if (operator === '=')
|
|
2244
|
+
return order === 0;
|
|
2245
|
+
if (operator === '~') {
|
|
2246
|
+
if (order < 0)
|
|
2247
|
+
return false;
|
|
2248
|
+
return actual.major === target.major && actual.minor === target.minor;
|
|
2249
|
+
}
|
|
2250
|
+
if (operator === '^') {
|
|
2251
|
+
if (order < 0)
|
|
2252
|
+
return false;
|
|
2253
|
+
if (target.major > 0)
|
|
2254
|
+
return actual.major === target.major;
|
|
2255
|
+
if (target.minor > 0)
|
|
2256
|
+
return actual.major === 0 && actual.minor === target.minor;
|
|
2257
|
+
return actual.major === 0 && actual.minor === 0 && actual.patch === target.patch;
|
|
2258
|
+
}
|
|
2259
|
+
if (/[*x]/.test(requested))
|
|
2260
|
+
return order >= 0 && actual.major === target.major;
|
|
2261
|
+
return order === 0;
|
|
2262
|
+
}
|
|
2263
|
+
/** 版本号解析(缺省段补 0,prerelease 原样保留)。 */
|
|
2264
|
+
function parseVersion(value) {
|
|
2265
|
+
const match = /^v?(\d+)(?:\.(\d+))?(?:\.(\d+))?(?:[-+](.*))?$/.exec(value.trim());
|
|
2266
|
+
if (match === null)
|
|
2267
|
+
return undefined;
|
|
2268
|
+
return {
|
|
2269
|
+
major: Number(match[1]),
|
|
2270
|
+
minor: Number(match[2] ?? '0'),
|
|
2271
|
+
patch: Number(match[3] ?? '0'),
|
|
2272
|
+
prerelease: match[4] ?? '',
|
|
2273
|
+
};
|
|
2274
|
+
}
|
|
2275
|
+
/** 版本比较:数字段优先,prerelease 只在基线相同时参与(空 prerelease 更大)。 */
|
|
2276
|
+
function compareParsed(left, right) {
|
|
2277
|
+
if (left.major !== right.major)
|
|
2278
|
+
return left.major < right.major ? -1 : 1;
|
|
2279
|
+
if (left.minor !== right.minor)
|
|
2280
|
+
return left.minor < right.minor ? -1 : 1;
|
|
2281
|
+
if (left.patch !== right.patch)
|
|
2282
|
+
return left.patch < right.patch ? -1 : 1;
|
|
2283
|
+
if (left.prerelease === right.prerelease)
|
|
2284
|
+
return 0;
|
|
2285
|
+
if (left.prerelease === '')
|
|
2286
|
+
return 1;
|
|
2287
|
+
if (right.prerelease === '')
|
|
2288
|
+
return -1;
|
|
2289
|
+
return left.prerelease < right.prerelease ? -1 : 1;
|
|
2290
|
+
}
|
|
2291
|
+
/**
|
|
2292
|
+
* JSON 文本里某个键所在的行号(证据要能点回 package.json:12)。
|
|
2293
|
+
*
|
|
2294
|
+
* @param text - package.json 的原始文本。
|
|
2295
|
+
* @param section - 顶层段名(dependencies / peerDependencies);传空串表示全文查找。
|
|
2296
|
+
* @param key - 段内的键。
|
|
2297
|
+
* @returns 1 起的行号;找不到时 undefined。
|
|
2298
|
+
*/
|
|
2299
|
+
export function jsonKeyLine(text, section, key) {
|
|
2300
|
+
const lines = text.split('\n');
|
|
2301
|
+
const escaped = key.replace(/[.*+?^$(){}|[\]\\]/g, '\\$&');
|
|
2302
|
+
const keyPattern = new RegExp('^\\s*"' + escaped + '"\\s*:');
|
|
2303
|
+
if (section !== '') {
|
|
2304
|
+
let inSection = false;
|
|
2305
|
+
let depth = 0;
|
|
2306
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
2307
|
+
const line = lines[index] ?? '';
|
|
2308
|
+
if (!inSection && new RegExp('^\\s*"' + section + '"\\s*:').test(line)) {
|
|
2309
|
+
inSection = true;
|
|
2310
|
+
depth = 0;
|
|
2311
|
+
continue;
|
|
2312
|
+
}
|
|
2313
|
+
if (!inSection)
|
|
2314
|
+
continue;
|
|
2315
|
+
depth += (line.match(/[{[]/g) ?? []).length;
|
|
2316
|
+
depth -= (line.match(/[}\]]/g) ?? []).length;
|
|
2317
|
+
if (keyPattern.test(line))
|
|
2318
|
+
return index + 1;
|
|
2319
|
+
if (depth <= 0 && (line.includes('}') || line.includes(']')))
|
|
2320
|
+
inSection = false;
|
|
2321
|
+
}
|
|
2322
|
+
}
|
|
2323
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
2324
|
+
if (keyPattern.test(lines[index] ?? ''))
|
|
2325
|
+
return index + 1;
|
|
2326
|
+
}
|
|
2327
|
+
return undefined;
|
|
2328
|
+
}
|
|
2329
|
+
/** JSON 文本里某个字符串值所在的行号(数组项,如 dsh.profile.bundles 里的包名)。 */
|
|
2330
|
+
function jsonValueLine(text, value) {
|
|
2331
|
+
const escaped = value.replace(/[.*+?^$(){}|[\]\\]/g, '\\$&');
|
|
2332
|
+
const pattern = new RegExp('"\\s*' + escaped + '\\s*"');
|
|
2333
|
+
const lines = text.split('\n');
|
|
2334
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
2335
|
+
if (pattern.test(lines[index] ?? ''))
|
|
2336
|
+
return index + 1;
|
|
2337
|
+
}
|
|
2338
|
+
return undefined;
|
|
2339
|
+
}
|
|
2340
|
+
// ── 通用小工具 ──────────────────────────────────────────────────────────
|
|
2341
|
+
/** 组装一条发现(分级处置的约束在此集中表达)。 */
|
|
2342
|
+
function makeIssue(input) {
|
|
2343
|
+
return {
|
|
2344
|
+
id: input.id,
|
|
2345
|
+
layer: input.layer,
|
|
2346
|
+
severity: input.severity,
|
|
2347
|
+
code: input.code,
|
|
2348
|
+
title: input.title,
|
|
2349
|
+
detail: input.detail,
|
|
2350
|
+
subjects: input.subjects.filter(subject => subject.length > 0),
|
|
2351
|
+
evidence: input.evidence,
|
|
2352
|
+
...(input.fix === undefined ? {} : { fix: input.fix }),
|
|
2353
|
+
...(input.scope === undefined ? {} : { scope: input.scope }),
|
|
2354
|
+
...(input.extra === undefined ? {} : { extra: input.extra }),
|
|
2355
|
+
};
|
|
2356
|
+
}
|
|
2357
|
+
/**
|
|
2358
|
+
* 这两类问题的处置主通道:改用户自己那份 cordis.patch.yml,还是动 bundle(官方通道)。
|
|
2359
|
+
*
|
|
2360
|
+
* 判据只有一条事实:patch 文件在不在这个 profile 的 dsh.profile.bundles 里——在,说明它随
|
|
2361
|
+
* bundle 升级被覆盖,改它只是临时救急;不在,它就是用户自己的补丁层,改了长期有效。
|
|
2362
|
+
*
|
|
2363
|
+
* @param envDir - 环境目录。
|
|
2364
|
+
* @param patchFile - patch 文件绝对路径。
|
|
2365
|
+
* @param bundleDirs - 各 bundle 的安装目录(来自 StaticFacts 的 packageDirs)。
|
|
2366
|
+
* @returns 主通道标签。
|
|
2367
|
+
*/
|
|
2368
|
+
function mainCaseOf(envDir, patchFile, bundleDirs) {
|
|
2369
|
+
// 环境目录下、但不在 node_modules 里 = 用户自己那份 cordis.patch.yml:改它才算改到这个 profile 的组合。
|
|
2370
|
+
// envDir/node_modules 里的包(profile 本地装的 bundle、link 安装的插件)自带的 patch 一律算包自带,
|
|
2371
|
+
// 随包升级会被覆盖,长期处置要走官方通道。
|
|
2372
|
+
if (isInside(envDir, patchFile) && !isInside(join(envDir, 'node_modules'), patchFile))
|
|
2373
|
+
return 'profile-patch';
|
|
2374
|
+
// 环境外、又归属某个已安装包(bundle 自带的那一层,或其它包里的 patch):随包升级被覆盖,
|
|
2375
|
+
// 只能临时救急;长期处置要走官方通道。
|
|
2376
|
+
for (const dir of new Set(bundleDirs.values())) {
|
|
2377
|
+
if (isInside(dir, patchFile))
|
|
2378
|
+
return 'bundle';
|
|
2379
|
+
}
|
|
2380
|
+
return 'bundle';
|
|
2381
|
+
}
|
|
2382
|
+
/**
|
|
2383
|
+
* 需要人工删行时的可照着做的三要素:文件路径、行 id、删完做什么。
|
|
2384
|
+
*
|
|
2385
|
+
* 为什么不给按钮:删除 patch 行要求改 cordis.patch.yml 的结构,官方只有行级启停、没有删行能力,
|
|
2386
|
+
* 我们自己写这个文件会引入两个写者并发改同一份组合(见 docs/CODE-POLICY 与 DESIGN 的写路径红线),
|
|
2387
|
+
* 所以这里只给位置与内容,由用户自己动手。
|
|
2388
|
+
*
|
|
2389
|
+
* @param env - 被诊断环境。
|
|
2390
|
+
* @param row - 要处理的那一行(文本扫描结果,带文件与行号)。
|
|
2391
|
+
* @param id - 该行的 id(无显式 id 时由调用方回退成 name)。
|
|
2392
|
+
* @param bundleDirs - 各 bundle 的安装目录(判断 patch 归属走哪条处置通道)。
|
|
2393
|
+
* @param applied - 这一行是否已在官方组合结果里确认到(决定指示是否降一档语气)。
|
|
2394
|
+
* @param edit - 怎么改:删除多余的重复行,还是给其中一行换掉 id(跨列表重名的场景)。
|
|
2395
|
+
* @returns 追加到 detail 末尾的处置说明。
|
|
2396
|
+
*/
|
|
2397
|
+
function manualEditSteps(env, row, id, bundleDirs, applied, edit = 'delete') {
|
|
2398
|
+
const at = relativeTo(env.dir, row.file) + ':' + row.line;
|
|
2399
|
+
const main = mainCaseOf(env.dir, row.file, bundleDirs);
|
|
2400
|
+
const steps = '【怎么修】要改的文件:' + row.file + ',问题行在第 '
|
|
2401
|
+
+ row.line + ' 行起(报告里的 ' + at + ' 就是这一处)。要动的就是 id=' + id
|
|
2402
|
+
+ ' 那一行(连同它缩进内的附属键:name、config 等)。' + (edit === 'delete'
|
|
2403
|
+
? '删掉多余的那几行后保存。'
|
|
2404
|
+
: '给其中一行换一个不会撞的 id,或删掉不再需要的那一行,然后保存。')
|
|
2405
|
+
+ '改完重启该环境(patch 只在启动时读,不重启不生效)。';
|
|
2406
|
+
const recovery = '如果这个环境已经起不来:用 dsh --profile ' + (env.name.length > 0 ? env.name : '<环境名>')
|
|
2407
|
+
+ ' --patch <一份空 patch.yml> 先把它拉起来,再按上面的位置改。';
|
|
2408
|
+
const uncertain = applied
|
|
2409
|
+
? ''
|
|
2410
|
+
: '(注意:本次没有在官方组合结果里确认到这个 id,可能这个 patch 层当前没生效——'
|
|
2411
|
+
+ '请按报出的文件与行号自己核对一遍再动手。)';
|
|
2412
|
+
return steps + uncertain + (main === 'profile-patch'
|
|
2413
|
+
? '这份 patch 就是这个环境自己的 cordis.patch.yml。' + recovery
|
|
2414
|
+
: '这个文件来自安装的包(bundle 自带的那一层,随包升级会被覆盖):长期处置走官方通道——'
|
|
2415
|
+
+ '在官方插件页的已安装列表里把对应的组合包取消勾选(或卸载),由官方改组合层栈,我们不代写;'
|
|
2416
|
+
+ '只想先让环境起来,可以按上面的行号临时删掉那一行(重新安装或升级该包后会被放回)。' + recovery);
|
|
2417
|
+
}
|
|
2418
|
+
/**
|
|
2419
|
+
* 一条发现的「作用域」标签:环境级的问题用**环境名**,包级的问题用**包名**。
|
|
2420
|
+
*
|
|
2421
|
+
* 环境名的权威来源是**目录名**(官方 resolveProfileDir(\`dsh --profile <name>\`) 的入参;
|
|
2422
|
+
* loadProfileDirectory 返回的 Profile.name = basename(dir))。profile manifest 里的 name 只是
|
|
2423
|
+
* 初始化时写进去的一个值,之后不同步——用户手工重命名环境目录后它还是旧值,拿它当环境身份
|
|
2424
|
+
* 就会出现「同一页两个名字」(auditor 真机验证)。所以:
|
|
2425
|
+
* · patch 落在环境目录内、且不在它的 node_modules 下 → 用户自己那份 cordis.patch.yml → 环境名;
|
|
2426
|
+
* · 其它情况(包自带的 patch)→ 该包自己的 package.json name。
|
|
2427
|
+
*
|
|
2428
|
+
* @param envName - 环境名(EnvironmentInfo.name,即目录名)。
|
|
2429
|
+
* @param envDir - 环境目录绝对路径。
|
|
2430
|
+
* @param file - patch 文件绝对路径;缺省时 undefined。
|
|
2431
|
+
* @returns 作用域标签;两边都判不出来时 undefined(调用方按环境名兜底)。
|
|
2432
|
+
*/
|
|
2433
|
+
function scopeOfPatchFile(envName, envDir, file) {
|
|
2434
|
+
if (file === undefined)
|
|
2435
|
+
return envName.length > 0 ? envName : undefined;
|
|
2436
|
+
const inProfilePatch = isInside(envDir, file) && !isInside(join(envDir, 'node_modules'), file);
|
|
2437
|
+
if (inProfilePatch)
|
|
2438
|
+
return envName.length > 0 ? envName : undefined;
|
|
2439
|
+
return packageNameOfFile(file) ?? (envName.length > 0 ? envName : undefined);
|
|
2440
|
+
}
|
|
2441
|
+
/**
|
|
2442
|
+
* 把解析出的启动阻断根因变成一条正式发现。
|
|
2443
|
+
*
|
|
2444
|
+
* @param blocker - bootBlockerFromError 的结果。
|
|
2445
|
+
* @returns 一条发现(severity 与 evidence 都来自解析,不在这里改判定)。
|
|
2446
|
+
*/
|
|
2447
|
+
function toBootIssue(blocker) {
|
|
2448
|
+
return makeIssue({
|
|
2449
|
+
layer: 'composition',
|
|
2450
|
+
severity: blocker.severity,
|
|
2451
|
+
code: blocker.code,
|
|
2452
|
+
title: blocker.title,
|
|
2453
|
+
detail: blocker.detail,
|
|
2454
|
+
subjects: blocker.subjects,
|
|
2455
|
+
evidence: blocker.evidence,
|
|
2456
|
+
extra: { operation: blocker.operation },
|
|
2457
|
+
id: blocker.id,
|
|
2458
|
+
});
|
|
2459
|
+
}
|
|
2460
|
+
/**
|
|
2461
|
+
* 合成结果里的启动阻断:**同一个 insert 列表内**重复 id。
|
|
2462
|
+
*
|
|
2463
|
+
* 这条由官方 composeEntries 自己抛(duplicate loader entry id: <id>),所以它一定致命;
|
|
2464
|
+
* duplicate-row-id 只扫原始文本、官方不可用时也在跑,两者互相补位。
|
|
2465
|
+
*
|
|
2466
|
+
* @param rows - 官方合成出的行。
|
|
2467
|
+
* @param env - 被诊断环境。
|
|
2468
|
+
* @returns 重复 id 的发现;没有重复时 undefined。
|
|
2469
|
+
*/
|
|
2470
|
+
function duplicateIdBootIssue(rows, env) {
|
|
2471
|
+
const seen = new Set();
|
|
2472
|
+
for (const row of rows) {
|
|
2473
|
+
if (row.id.length === 0)
|
|
2474
|
+
continue;
|
|
2475
|
+
if (!seen.has(row.id)) {
|
|
2476
|
+
seen.add(row.id);
|
|
2477
|
+
continue;
|
|
2478
|
+
}
|
|
2479
|
+
return makeIssue({
|
|
2480
|
+
layer: 'composition',
|
|
2481
|
+
severity: 'confirm-fix',
|
|
2482
|
+
code: 'boot-blocker-row',
|
|
2483
|
+
title: '组合层启动被阻断:同一个 insert 列表里重复的 id ' + row.id,
|
|
2484
|
+
detail: '官方 composeEntries 在这个组合上直接抛 TypeError(duplicate loader entry id: ' + row.id + '):'
|
|
2485
|
+
+ '整个 profile 起不来,启动阶段就停在 plugin tree failed to load,HTTP 服务从未开始监听。'
|
|
2486
|
+
+ '删掉多余的重复行(只保留一处)后重启该环境才会生效。',
|
|
2487
|
+
subjects: [row.id, env.name],
|
|
2488
|
+
evidence: [{
|
|
2489
|
+
kind: 'official',
|
|
2490
|
+
at: 'composeEntries(profile layers)',
|
|
2491
|
+
note: '官方合成时对同一 insert 列表内的重复 id 抛 duplicate loader entry id: ' + row.id,
|
|
2492
|
+
}],
|
|
2493
|
+
extra: { operation: '删除 ' + row.id + ' 的重复 insert 行(只保留一处),然后重启该环境' },
|
|
2494
|
+
id: 'boot-blocker-row:' + row.id,
|
|
2495
|
+
});
|
|
2496
|
+
}
|
|
2497
|
+
return undefined;
|
|
2498
|
+
}
|
|
2499
|
+
/**
|
|
2500
|
+
* patch 文件归属的包名:从它的目录起往上找最近的 package.json(包根),读那一个包的 name。
|
|
2501
|
+
*
|
|
2502
|
+
* @param file - patch 文件绝对路径。
|
|
2503
|
+
* @returns 包名;找不到时 undefined。
|
|
2504
|
+
*/
|
|
2505
|
+
function packageNameOfFile(file) {
|
|
2506
|
+
let dir = dirname(file);
|
|
2507
|
+
for (let depth = 0; depth < 6; depth += 1) {
|
|
2508
|
+
const manifest = readJsonFile(join(dir, 'package.json'));
|
|
2509
|
+
const name = manifest?.['name'];
|
|
2510
|
+
if (typeof name === 'string' && name.length > 0)
|
|
2511
|
+
return name;
|
|
2512
|
+
const parent = dirname(dir);
|
|
2513
|
+
if (parent === dir)
|
|
2514
|
+
break;
|
|
2515
|
+
dir = parent;
|
|
2516
|
+
}
|
|
2517
|
+
return undefined;
|
|
2518
|
+
}
|
|
2519
|
+
/** 本次解析真正查过的根(写进 detail:结论必须说清查过什么,用户才能判断可信度)。 */
|
|
2520
|
+
function resolutionRootsNote(envDir, facts) {
|
|
2521
|
+
const labels = [
|
|
2522
|
+
relativeTo(envDir, join(facts.envDir, 'node_modules')),
|
|
2523
|
+
relativeTo(envDir, join(dirname(resolve(envDir)), 'node_modules')),
|
|
2524
|
+
'Harness home 的 profiles/node_modules',
|
|
2525
|
+
];
|
|
2526
|
+
if (facts.installRoots !== null)
|
|
2527
|
+
labels.push('安装锚点提供的模块根(' + facts.installAnchor + ')');
|
|
2528
|
+
else
|
|
2529
|
+
labels.push('安装锚点(不可用,见 skipped 的 install-anchor)');
|
|
2530
|
+
return labels.join('、') + '、以及 patch 文件自身的相对路径';
|
|
2531
|
+
}
|
|
2532
|
+
/** 读文本文件;读不到时 undefined。 */
|
|
2533
|
+
function readTextFile(path) {
|
|
2534
|
+
try {
|
|
2535
|
+
return readFileSync(path, 'utf8');
|
|
2536
|
+
}
|
|
2537
|
+
catch {
|
|
2538
|
+
return undefined;
|
|
2539
|
+
}
|
|
2540
|
+
}
|
|
2541
|
+
/** 读 JSON 文件;不是对象时 undefined。 */
|
|
2542
|
+
function readJsonFile(path) {
|
|
2543
|
+
const text = readTextFile(path);
|
|
2544
|
+
if (text === undefined)
|
|
2545
|
+
return undefined;
|
|
2546
|
+
try {
|
|
2547
|
+
const parsed = JSON.parse(text);
|
|
2548
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed))
|
|
2549
|
+
return undefined;
|
|
2550
|
+
return parsed;
|
|
2551
|
+
}
|
|
2552
|
+
catch {
|
|
2553
|
+
return undefined;
|
|
2554
|
+
}
|
|
2555
|
+
}
|
|
2556
|
+
/** 取对象字段(非对象返回 undefined)。 */
|
|
2557
|
+
function asRecord(value) {
|
|
2558
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value))
|
|
2559
|
+
return undefined;
|
|
2560
|
+
return value;
|
|
2561
|
+
}
|
|
2562
|
+
/**
|
|
2563
|
+
* 路径归属判定用的规范形:解析为绝对路径,能解析到实体就取真实路径,大小写不敏感的平台上一并折叠。
|
|
2564
|
+
*
|
|
2565
|
+
* 为什么要有它:判断"这个 patch 文件属于环境自己还是属于某个包"原来靠
|
|
2566
|
+
* `target.startsWith(base + '/')` 这种字符串前缀比较。三个平台差异会让它判错:
|
|
2567
|
+
* · Windows 的路径分隔符是反斜杠(resolve 会统一,但拼接的分隔符不该由我们猜);
|
|
2568
|
+
* · Windows / macOS 默认文件系统**大小写不敏感**:`C:\\DSH\\profiles\\demo` 与
|
|
2569
|
+
* `C:\\dsh\\profiles\\demo` 是同一个目录,前缀比较会把用户自己的 patch 判成"环境外",
|
|
2570
|
+
* 于是处置建议说反(本该改环境自己的文件,却让人去动包)。
|
|
2571
|
+
*
|
|
2572
|
+
* realpath 顺带把符号链接折叠掉:link 安装(profile 里指向源码目录的软链)也归到真实位置。
|
|
2573
|
+
* 解析不到实体(文件还不存在)时退回 resolve 的结果,判定依然成立。
|
|
2574
|
+
*
|
|
2575
|
+
* @param target - 待规范的路径。
|
|
2576
|
+
* @returns 规范形(分隔符统一、大小写按平台折叠)。
|
|
2577
|
+
*/
|
|
2578
|
+
function canonicalPath(target) {
|
|
2579
|
+
const absolute = resolve(target);
|
|
2580
|
+
let real = absolute;
|
|
2581
|
+
try {
|
|
2582
|
+
real = realpathSync(absolute);
|
|
2583
|
+
}
|
|
2584
|
+
catch {
|
|
2585
|
+
// 路径还不存在(或中间段不可读):按解析出的绝对路径判定。
|
|
2586
|
+
}
|
|
2587
|
+
return caseInsensitiveFs() ? real.toLowerCase() : real;
|
|
2588
|
+
}
|
|
2589
|
+
/** 平台默认文件系统是否大小写不敏感(口径与 paths.ts 的 caseInsensitiveFs() 一致)。 */
|
|
2590
|
+
function caseInsensitiveFs() {
|
|
2591
|
+
return process.platform === 'win32' || process.platform === 'darwin';
|
|
2592
|
+
}
|
|
2593
|
+
/**
|
|
2594
|
+
* 路径是否在目录内(含目录自身)——按**相对路径**判定,不拼分隔符。
|
|
2595
|
+
*
|
|
2596
|
+
* @param dir - 目录。
|
|
2597
|
+
* @param candidate - 待判定的路径。
|
|
2598
|
+
* @returns 是否在目录内(或就是它本身)。
|
|
2599
|
+
*/
|
|
2600
|
+
function isInside(dir, candidate) {
|
|
2601
|
+
const base = canonicalPath(dir);
|
|
2602
|
+
const target = canonicalPath(candidate);
|
|
2603
|
+
if (target === base)
|
|
2604
|
+
return true;
|
|
2605
|
+
const rel = relative(base, target);
|
|
2606
|
+
// 空串在上面已判过;`..` / `../x` 是越界;isAbsolute 覆盖 Windows 的不同盘符
|
|
2607
|
+
// (跨卷时 path.relative 返回目标绝对路径,而不是相对路径)。
|
|
2608
|
+
return rel.length > 0 && !rel.startsWith('..') && !isAbsolute(rel);
|
|
2609
|
+
}
|
|
2610
|
+
/**
|
|
2611
|
+
* 相对路径(不在目录内时返回绝对路径)。
|
|
2612
|
+
*
|
|
2613
|
+
* `..foo` 这种以两点开头的**普通名字**不算越界——原来用 `startsWith('..')` 会把
|
|
2614
|
+
* `<env>/..hidden.yml` 判成环境外,于是证据里给出一条绝对路径、处置通道也说反。
|
|
2615
|
+
*
|
|
2616
|
+
* @param base - 基准目录。
|
|
2617
|
+
* @param file - 目标文件。
|
|
2618
|
+
* @returns 目录内的相对路径;不在目录内时原样返回。
|
|
2619
|
+
*/
|
|
2620
|
+
function relativeTo(base, file) {
|
|
2621
|
+
const rel = relative(base, file);
|
|
2622
|
+
if (rel.length === 0)
|
|
2623
|
+
return file;
|
|
2624
|
+
if (rel === '..' || rel.startsWith('..' + sep) || isAbsolute(rel))
|
|
2625
|
+
return file;
|
|
2626
|
+
return rel;
|
|
2627
|
+
}
|
|
2628
|
+
/** 错误消息。 */
|
|
2629
|
+
function messageOf(error) {
|
|
2630
|
+
return error instanceof Error ? error.message : String(error);
|
|
2631
|
+
}
|