dsh-plugin-compat-check 0.1.0 → 0.1.2

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.
@@ -1,1419 +1,90 @@
1
- /**
2
- * 静态提取:从插件源码里找出它「实际调用」的服务与方法。
3
- *
4
- * 这是 dsh-plugin-compat-check.sh 里那段 python(③ 静态提取)的 Node 移植版。
5
- * 移植的验收标准是 **与 python 版输出逐字段一致** —— 对拍脚本见 tools/parity/parity.sh,
6
- * 它直接从 .sh 里抽出 python 源码来跑,避免比到一个已经漂移的副本。
7
- *
8
- * 为什么必须移植:Windows 上既没有 bash 也没有 python3,而插件产物要跨平台。
9
- *
10
- * 这里刻意保留 python 版的全部行为特征,**包括它的粗糙**:
11
- * - 服务集合与调用集合是**跨插件全局累积**的(python 版就是这样,逐插件归属是后续要改的);
12
- * - `svcFiles` 的填充用的是全局 services 集合,所以文件归属会跨插件混在一起;
13
- * - 方法级判定是正则启发式,含已知误报(例如 `locale.startsWith` 这种字符串方法)。
14
- * 行为对齐优先于顺手修正:先拿到「两边一致」,再谈改。
15
- */
16
-
17
- import fs from 'node:fs'
18
- import path from 'node:path'
19
-
20
- const { join, dirname, resolve } = path.posix
21
-
22
- /* ── 路径助手:对齐 python 的 os.path 语义 ─────────────────────────── */
23
-
24
- function normpath(p) {
25
- if (p === '') return '.'
26
- const abs = p.startsWith('/')
27
- const out = []
28
- for (const part of p.split('/')) {
29
- if (part === '' || part === '.') continue
30
- if (part === '..') {
31
- if (out.length && out[out.length - 1] !== '..') out.pop()
32
- else if (!abs) out.push('..')
33
- continue
34
- }
35
- out.push(part)
36
- }
37
- let r = out.join('/')
38
- if (abs) r = '/' + r
39
- return r === '' ? (abs ? '/' : '.') : r
40
- }
41
-
42
- /** python 的 str.lstrip('./') 是**按字符集**剥离,不是剥离前缀字符串 */
43
- function lstripChars(s, chars) {
44
- let i = 0
45
- while (i < s.length && chars.includes(s[i])) i++
46
- return s.slice(i)
47
- }
48
-
49
- function relpath(p, start) {
50
- const pp = resolve(p)
51
- const sp = resolve(start)
52
- if (pp === sp) return '.'
53
- const pa = pp.split('/').filter(Boolean)
54
- const sa = sp.split('/').filter(Boolean)
55
- let i = 0
56
- while (i < pa.length && i < sa.length && pa[i] === sa[i]) i++
57
- const parts = []
58
- for (let j = i; j < sa.length; j++) parts.push('..')
59
- return parts.concat(pa.slice(i)).join('/')
60
- }
61
-
62
- function isFile(p) {
63
- try { return fs.statSync(p).isFile() } catch { return false }
64
- }
65
-
66
- function readText(p) {
67
- // python 版用 encoding='utf-8', errors='ignore';Node 会把非法字节换成 U+FFFD
68
- // 而不是丢弃。对「提取服务/方法名」这个用途没有实际影响,记录在此备查。
69
- try { return fs.readFileSync(p, 'utf8') } catch { return null }
70
- }
71
-
72
- /* ── 插件入口与本地依赖图 ──────────────────────────────────────── */
73
-
74
- function pkgJson(dir) {
75
- try { return JSON.parse(fs.readFileSync(join(dir, 'package.json'), 'utf8')) } catch { return null }
76
- }
77
-
78
- /** package.json 声明的入口:exports['.'] 优先,退回 main */
79
- function mainEntry(j) {
80
- const exp = j.exports
81
- if (exp && typeof exp === 'object' && !Array.isArray(exp)) {
82
- const dot = exp['.']
83
- if (typeof dot === 'string') return dot
84
- if (dot && typeof dot === 'object') {
85
- for (const k of ['default', 'import', 'require']) {
86
- if (typeof dot[k] === 'string') return dot[k]
87
- }
88
- }
89
- }
90
- return j.main || 'index.js'
91
- }
92
-
93
- /**
94
- * 从 dsh.bundle.patch → cordis.patch.yml 读出**实际被挂载**的入口。
95
- *
96
- * 这是精度关键:包里常有不会被加载的伴随模块(实测有插件带一个 lib/invariant.js,
97
- * 主入口并不 import 它、patch 也没挂载它)。整包扫描会把这些孤儿模块需要的服务
98
- * 当成真实缺口,把判定压得过低。
99
- */
100
- function mountedEntries(full, j) {
101
- const patch = ((j.dsh || {}).bundle || {}).patch
102
- const seeds = []
103
- if (patch) {
104
- const txt = readText(join(full, patch))
105
- if (txt !== null) {
106
- for (const m of txt.matchAll(/^\s*-\s*name:\s*['"]?([^\s'"]+)/gm)) {
107
- seeds.push(m[1].startsWith('.') ? normpath(m[1]) : mainEntry(j))
108
- }
109
- }
110
- }
111
- if (!seeds.length) seeds.push(mainEntry(j))
112
- return seeds.map((s) => lstripChars(s, './'))
113
- }
114
-
115
- /** 从入口出发收集本地相对依赖图 */
116
- function localGraph(full, seeds) {
117
- // seen = 已成功解析出的文件(返回值与原版严格一致)
118
- // visited = 已出队检查过的**路径形态**,用来堵住原版的无界循环:
119
- //
120
- // 原版只用 seen 做去重,而「靠补扩展名才解析成功」时它把**解析后**的路径
121
- // (b.js)放进 seen,队列里传的却是**未解析**形态(b)。于是 rel in seen
122
- // 永远不成立 → 每次访问都重新读文件、重新展开它的导入 → 只要有环就无界膨胀。
123
- // 实测:3 个 JS 文件(无扩展名 ESM 导入 + 成环)即可让它 98% CPU 空转不停,
124
- // 真实插件里 @liustack/modlens 打包的 undici 就是这个写法。
125
- //
126
- // 修法是把「检查过」与「解析成功」分开,不改变返回的图,只去掉重复展开。
127
- const seen = new Set()
128
- const visited = new Set()
129
- const queue = seeds.slice()
130
- while (queue.length) {
131
- let rel = queue.pop()
132
- if (!rel || visited.has(rel)) continue
133
- visited.add(rel)
134
- let p = join(full, rel)
135
- if (!isFile(p)) {
136
- let hit = null
137
- for (const e of ['.js', '.mjs', '.cjs', '.ts', '/index.js', '/index.mjs', '/index.ts']) {
138
- if (isFile(p + e)) { hit = p + e; break }
139
- }
140
- if (!hit) continue
141
- p = hit
142
- rel = relpath(p, full)
143
- }
144
- if (seen.has(rel)) continue
145
- seen.add(rel)
146
- const txt = readText(p)
147
- if (txt === null) continue
148
- for (const m of txt.matchAll(
149
- /from\s*['"]([^'"]+)['"]|require\(\s*['"]([^'"]+)['"]\s*\)|import\(\s*['"]([^'"]+)['"]\s*\)/g)) {
150
- const spec = m[1] || m[2] || m[3] || ''
151
- if (spec.startsWith('.')) queue.push(normpath(join(dirname(rel), spec)))
152
- }
153
- }
154
- return seen
155
- }
156
-
157
- function walkFiles(root) {
158
- const out = []
159
- const stack = ['']
160
- while (stack.length) {
161
- const relDir = stack.pop()
162
- let entries
163
- try { entries = fs.readdirSync(join(root, relDir), { withFileTypes: true }) } catch { continue }
164
- for (const e of entries) {
165
- const rel = relDir ? join(relDir, e.name) : e.name
166
- if (e.isDirectory()) { stack.push(rel); continue }
167
- if (e.isFile()) { out.push(rel); continue }
168
- // ⚠️ readdirSync 的 Dirent 对符号链接**两个判题都是 false**(探针实测:
169
- // isFile=false / isDirectory=false / isSymbolicLink=true)。而 pnpm 的价值
170
- // 恰恰在于把包目录做成符号链接。只认 isFile/isDirectory 会让「连到别处的包」
171
- // 与「包内指向别处的文件」整片消失 —— 而且是**静默**消失。
172
- // 用 statSync(跟随链接)补一次判定;断链就跳过,不抛。
173
- if (e.isSymbolicLink()) {
174
- try {
175
- const st = fs.statSync(join(root, rel))
176
- if (st.isDirectory()) stack.push(rel)
177
- else if (st.isFile()) out.push(rel)
178
- } catch { /* 断链:跳过,不抛 */ }
179
- }
180
- }
181
- }
182
- return out
183
- }
184
-
185
- /** package.json 声明的客户端入口(`exports["./client"]`),没有则 null */
186
- function declaredClientEntry(j) {
187
- const exp = j.exports
188
- if (exp && typeof exp === 'object' && !Array.isArray(exp)) {
189
- const c = exp['./client']
190
- if (typeof c === 'string') return c
191
- if (c && typeof c === 'object' && typeof c.default === 'string') return c.default
192
- }
193
- return null
194
- }
195
-
196
- /**
197
- * 客户端半边**能不能真的端出来** —— 这是 H1'(客户端对账)在**宿主侧**能落地的那一半。
198
- *
199
- * ## 为什么这个检查值得单独做
200
- *
201
- * 实测过一次分界(见 Plan.md §6「宿主只警告,客户端才致命」):
202
- * 客户端条目加载失败时,**整个界面会被一张错误屏替代**(`#root` 从 35397 字符剩 450)。
203
- * 也就是说:插件声明了 `dsh.client` 却拿不出 `exports["./client"]`,
204
- * 后果**不是某个功能消失,而是用户什么都用不了**。
205
- *
206
- * ## 它**不是**完整的 H1'
207
- *
208
- * H1' 的原定义是「**计划加载**的 `__DSH_BOOT__.entries` vs **实际到货**」。
209
- * 下面这个检查回答的是**前半个问题的静态版本**:
210
- * "声明了客户端半边,但那半边真的存在、真的能被解析到吗?"
211
- *
212
- * - ✅ 它能抓到:声明了却缺文件、`exports["./client"]` 写错、文件被 `.npmignore` 之类排掉
213
- * - ❌ 它**抓不到**:文件在、但浏览器里 import 失败(语法错、依赖缺失、运行时报错)
214
- *
215
- * 后者只有浏览器能回答(`window.__DSH_BOOT__.entries` 是浏览器全局)。
216
- * **不要把这条检查说成"客户端对账已完成"** —— 它是必要条件,不是充分条件。
217
- */
1
+ /** Static package inventory and public extraction/report adapter. */
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { analyzeSources } from "./source-analysis.mjs";
6
+ import {
7
+ collectPackageSources,
8
+ clientEntry as declaredClientEntry,
9
+ } from "./source-graph.mjs";
10
+ const { join, dirname, resolve } = path;
11
+ const cmp = (a, b) =>
12
+ String(a ?? "") < String(b ?? "")
13
+ ? -1
14
+ : String(a ?? "") > String(b ?? "")
15
+ ? 1
16
+ : 0;
17
+ const pkgJson = (dir) => {
18
+ try {
19
+ const p = JSON.parse(fs.readFileSync(join(dir, "package.json"), "utf8"));
20
+ return p && typeof p === "object" && !Array.isArray(p) ? p : null;
21
+ } catch {
22
+ return null;
23
+ }
24
+ };
25
+ const sortCalls = (calls) =>
26
+ Object.fromEntries(
27
+ [...calls]
28
+ .sort(([a], [b]) => cmp(a, b))
29
+ .map(([s, ms]) => [s, [...ms].sort()]),
30
+ );
218
31
  function clientHalfDeliverable(pkgDir, j) {
219
- const client = (j.dsh && j.dsh.client) || null;
32
+ const client = j.dsh?.client;
220
33
  if (!client) return { declared: false, ok: null, why: null };
221
- const platform = client.platform ?? null;
222
- if (platform !== 'web') {
223
- // 解析器会跳过非 web 的 platform —— 那不是缺陷,是它不被加载
224
- return { declared: true, platform, ok: null, why: `platform=${platform}(只加载 web)` };
225
- }
226
- const declaredPath = declaredClientEntry(j);
227
- if (declaredPath === null) {
34
+ if (client.platform !== "web")
228
35
  return {
229
- declared: true, platform, ok: false, declaredEntry: null,
230
- why: '声明了 dsh.client(platform=web)却没有 exports["./client"] —— 客户端半边无处可取',
36
+ declared: true,
37
+ platform: client.platform ?? null,
38
+ ok: null,
39
+ why: "Only web platform is loaded",
40
+ };
41
+ const entry = declaredClientEntry(j);
42
+ if (!entry)
43
+ return {
44
+ declared: true,
45
+ platform: "web",
46
+ ok: false,
47
+ declaredEntry: null,
48
+ why: '声明了 dsh.client 却没有 exports["./client"]',
231
49
  };
232
- }
233
50
  try {
234
- const abs = path.join(pkgDir, declaredPath);
235
- if (fs.existsSync(abs)) return { declared: true, platform, ok: true, declaredEntry: declaredPath, why: null };
51
+ const root = fs.realpathSync(pkgDir),
52
+ file = fs.realpathSync(join(root, entry));
53
+ const inside =
54
+ file.startsWith(root + path.sep) && fs.statSync(file).isFile();
236
55
  return {
237
- declared: true, platform, ok: false, declaredEntry: declaredPath,
238
- why: `exports["./client"] 指向 ${declaredPath},而那个文件不存在`,
56
+ declared: true,
57
+ platform: "web",
58
+ ok: inside,
59
+ declaredEntry: entry,
60
+ why: inside ? null : "客户端入口不在包根内或不是文件",
61
+ };
62
+ } catch {
63
+ return {
64
+ declared: true,
65
+ platform: "web",
66
+ ok: false,
67
+ declaredEntry: entry,
68
+ why: `客户端入口 ${entry} 不存在或不可读`,
239
69
  };
240
- } catch (error) {
241
- return { declared: true, platform, ok: null, declaredEntry: declaredPath, why: `检查失败: ${error.message}` };
242
- }
243
- }
244
-
245
- /**
246
- * 包里所有「长得像客户端代码」的文件。
247
- *
248
- * ⚠️ 这里有个反直觉的事实,是实测换来的,**不要按"只扫声明入口"去收窄**:
249
- * 插件可以把客户端代码切成懒加载 chunk,用自己宿主半边的 HTTP 路由按 URL 取回来。
250
- * 实例 `dsh-better-sidebar`:`exports["./client"]` 只是 `lib/client.js`,而
251
- * `lib/client-editor.js` / `client-locale.js` / `client-mermaid.js` 是通过
252
- * `CHUNK_URL = (name) => `/sidebar/bundle/${name}.js`` 懒加载的真实代码 ——
253
- * 从声明入口做静态可达性分析**永远够不到它们**。
254
- *
255
- * 所以:宁可多扫(假阳性,可用确定性标注降级),也不能少扫(假阴性,会让结论撒谎)。
256
- * 来源由 collectFiles 分桶标出,供判定层区分确定性。
257
- */
258
- function walkClientSeeds(full) {
259
- const seeds = []
260
- for (const rel of walkFiles(full)) {
261
- if (/\.(js|mjs|cjs|ts)$/.test(rel) && rel.toLowerCase().includes('client')) seeds.push(rel)
262
- }
263
- return seeds
264
- }
265
-
266
- /**
267
- * `client-extra` 桶的**默认**排除规则:跳过包内的 TS 源码树。
268
- *
269
- * 依据(实测,见 Plan.md §5 A4b):真正运行的是构建后的 `lib/` / `dist/` 产物;
270
- * `src/` 是它的源,扫它等于把同一份逻辑扫两遍。在 desktop profile 上排除它有
271
- * **零信息损失**:487 → 167 个文件,而服务 −0、调用点 −0、clientHints 零变化。
272
- *
273
- * 边界(重要,别记错):
274
- * - 只作用于 `client-extra` 这个「广度桶」。`host-entry` / `client-entry` 是从
275
- * 真实入口可达的权威代码,永不受影响 —— 所以「源码直发、入口就指向 src/」
276
- * 的插件完全不受本规则影响。
277
- * - 只阻止该文件**被扫描**,不改变依赖图遍历。源码文件仍会被读来解析 import,
278
- * 所以它省的是扫描与上报面,不是全部 IO。
279
- */
280
- const DEFAULT_SKIP_CLIENT_EXTRA = (rel) => rel.startsWith('src/') || rel.includes('/src/')
281
-
282
- /**
283
- * 返回 [需要扫描的文件 [[相对路径, 文本]], 是否退回整包扫描, 来源分桶 Map<rel, bucket>, 被跳过的文件]。
284
- *
285
- * 来源分桶**不改变被扫描的文件集合**(默认排除规则除外),只记录每个文件是从哪条路进来的:
286
- * - `host-entry` :从 dsh.bundle.patch / main 声明的宿主入口可达 → 确定
287
- * - `client-entry` :从 exports["./client"] 声明的客户端入口可达 → 确定
288
- * - `client-extra` :同包内其它「像客户端」的文件,可能是懒加载 chunk → 疑似
289
- * - `fallback` :连入口都解析不出来,退回整包扫描 → 整体存疑
290
- */
291
- function collectFiles(full, j, options = {}) {
292
- const graph = localGraph(full, mountedEntries(full, j))
293
- // 退回整包扫描的条件:**入口图是空的**(连入口都解析不出来)。
294
- //
295
- // ⚠️ 这里曾经被误改成「patch 文件读不到挂载点也退回整包」,那是个错误的修法:
296
- // 实测 desktop profile 上 18/21 个插件的 patch 都写成 `- insert: [- id: ...]`
297
- // 而不含 `- name:`,于是**全 profile 都退回整包扫描**,host-entry 从 118 掉到 10、
298
- // fallback 涨到 1359 个文件。那不是修 bug,那是把污染面放大一个数量级。
299
- // 真正的原因是另一处的假阴性(`const inject = [...]` 不被识别),已在该处修掉。
300
- const fallback = graph.size === 0
301
- const files = []
302
- const rels = new Set()
303
- const buckets = new Map()
304
- const skipped = []
305
-
306
- const add = (rel, bucket) => {
307
- rel = normpath(rel)
308
- if (rels.has(rel)) return
309
- // `.d.ts` 是纯类型声明,不含任何可运行代码 —— 但它的文件名以 `.ts` 结尾,
310
- // 会被「像客户端」的路径遍历成片捞进来。实测 desktop profile 的客户端桶里
311
- // 846 个文件有 342 个是 .d.ts,全是噪音。排除它零风险。
312
- if (rel.endsWith('.d.ts')) return
313
- const txt = readText(join(full, rel))
314
- if (txt === null) return
315
- files.push([rel, txt])
316
- rels.add(rel)
317
- buckets.set(rel, bucket)
318
- }
319
-
320
- if (fallback) {
321
- // 必须排序。walkFiles 是栈式遍历,顺序取决于 readdir 的返回顺序 ——
322
- // 探针实测:把 readdirSync 反转后,fallback 包的 perPlugin[*].callSites
323
- // **顺序会变**(结论相同但字节不同)。快照测试最不能容忍的就是这个。
324
- for (const rel of walkFiles(full).sort()) {
325
- if (/\.(js|mjs|cjs|ts)$/.test(rel)) add(rel, 'fallback')
326
- }
327
- } else {
328
- for (const rel of [...graph].sort()) add(rel, 'host-entry')
329
-
330
- const declared = declaredClientEntry(j)
331
- if (declared) {
332
- for (const rel of [...localGraph(full, [lstripChars(declared, './')])].sort()) {
333
- add(rel, 'client-entry')
334
- }
335
- }
336
-
337
- // 其余「像客户端」的文件单独成图。可达性的并集与原实现完全一致
338
- // (从种子并集出发的可达闭包 = 各自可达闭包的并集)。
339
- const walked = walkClientSeeds(full).map((s) => lstripChars(s, './')).filter(Boolean)
340
- // 只作用于 client-extra 这一桶:host-entry / client-entry 是从真实入口可达的
341
- // 权威代码,任何情况下都不该被排除。传 () => false 可关掉默认排除(对拍/分析用)。
342
- const skip = options.skipClientExtra === undefined ? DEFAULT_SKIP_CLIENT_EXTRA : options.skipClientExtra
343
- for (const rel of [...localGraph(full, walked)].sort()) {
344
- if (skip && skip(normpath(rel))) { skipped.push(normpath(rel)); continue }
345
- add(rel, 'client-extra')
346
- }
347
- }
348
- return [files, fallback, buckets, skipped]
349
- }
350
-
351
- /* ── 逐文件扫描 + 行号 + 逐插件归属 ────────────────────────────── */
352
-
353
- function lineStarts(text) {
354
- const starts = [0]
355
- for (let i = 0; i < text.length; i++) if (text.charCodeAt(i) === 10) starts.push(i + 1)
356
- return starts
357
- }
358
-
359
- /** 由字符下标反查 1-based 行号(二分) */
360
- function lineOf(starts, idx) {
361
- if (!Number.isFinite(idx)) return null
362
- let lo = 0
363
- let hi = starts.length - 1
364
- while (lo < hi) {
365
- const mid = (lo + hi + 1) >> 1
366
- if (starts[mid] <= idx) lo = mid
367
- else hi = mid - 1
368
- }
369
- return lo + 1
370
- }
371
-
372
- /**
373
- * 局部绑定:把源码里的**变量名**映射到它承载的服务名。
374
- *
375
- * ## 为什么需要它(这一节的每一句都有实测依据)
376
- *
377
- * 「服务被取出来赋给变量、再用变量调用方法」是这个生态里最常见的写法,而原实现
378
- * 只用一条正则去抓它,同时踩了三个坑。三个坑都在 `dsh-better-sidebar` 上实测到:
379
- *
380
- * 1. **正则太紧**:原式是 `ctx\.get\(`,而真实代码写的是 `ctx.get ("x")` 或
381
- * (压缩后)`typeof ctx.get === "function" ? ctx.get("x") : void 0`。
382
- * 一条都匹配不到 → 该服务的绑定变量成了「无法解释的变量」,
383
- * 而下面那条「裸名直呼」规则又会用它去匹配一切,凭空造出方法。
384
- *
385
- * 2. **只认一层**:真实代码会转手:
386
- * `const store = ctx.get("betterLocale")` → `const store2 = store`。
387
- * 原实现只认第一跳,于是 `store2.foo()` 找不到归属,`store.foo()` 却因为
388
- * 「变量名恰好是 store」而被记到**另一个**服务头上。
389
- *
390
- * 3. **作用域注入的参数名**:`ctx.inject(["jobs"], (jobs) => { jobs.list() })` ——
391
- * 这里 `jobs` 是**参数**,不是 `ctx.get` 的产物,原实现完全看不见。
392
- *
393
- * 关键洞察:**方法调用的匹配式里必须用 `\b`。** 否则源码里的
394
- * `subagents.listDescendants(` 会因为子串 `agents.` 被别名 `agents` 命中
395
- * (实测 dsh-better-sidebar 里正好 2 处)。
396
- */
397
-
398
- /** ① `const v = ctx.get ("x")` —— `ctx` 与 `get` 之间允许空白;`get` 允许带第二参数 */
399
- const BIND_CTX_GET = /(?:const|let|var)\s+(\w+)\s*=\s*([^;=]{0,40}?)ctx\s*\.\s*get\s*\(\s*['"]([A-Za-z][\w.]*)['"]\s*(,\s*(?:false|0))?\s*\)/g
400
-
401
- /** 同上,但专门找**带第二参数**的软取:`ctx.get("x", false)`(false = 缺失不报错) */
402
- const CTX_GET_ANY = /ctx\s*\.\s*get\s*\(\s*['"]([A-Za-z][\w.]*)['"]\s*(?:,\s*(false|0|true|1))?\s*\)/g
403
-
404
- /** ② 解构:`const { register, bind } = ctx.get("locale")`;成员是服务名的**同名**方法 */
405
- const BIND_DESTRUCT_GET = /(?:const|let|var)\s*\{([^{}]*)\}\s*=\s*[^;=]{0,40}?ctx\s*\.\s*get\s*\(\s*['"]([A-Za-z][\w.]*)['"]/g
406
-
407
- /** ③ 转手:`const store2 = store` / `const store2 = other.store` / `store2 = store` */
408
- const BIND_ALIAS = /(?:const|let|var\s+)?(\w+)\s*=\s*(?:[\w$]+\.)?(\w+)\s*(?=[;,)\n}])/g
409
-
410
- /** ⑥ `const svc = ctx.pet` 这类成员取用(成员名本身就是服务名) */
411
- const BIND_CTX_MEMBER = /(?:const|let|var)\s+(\w+)\s*=\s*ctx\s*\.\s*([a-zA-Z]\w*)\b(?!\s*\()/g
412
-
413
- /**
414
- * 赋值右边是不是「**服务 → 一次性取出的东西**」——
415
- * 也就是变量拿到的是**方法返回的值**,而不是服务本身。
416
- *
417
- * ## 为什么必须有这一条(两个真实假阳性换来的)
418
- *
419
- * 上面两条绑定规则的本意是「`const X = ctx.<svc>` → X 就是那个服务」。
420
- * 但**它们没看右边的尾巴**,于是这两种写法被一并收了:
421
- *
422
- * ```js
423
- * const captain = ctx.agents.get(sessionId) // captain 是**一个 agent 对象**
424
- * const live = this.ctx.get("sessions")?.get(sessionId) // live 是**一个会话对象**
425
- * ```
426
- *
427
- * 后果是 `captain.steer()` 被归到 `agents` 头上、`live.snapshotEvents()` 被归到
428
- * `sessions` 头上 —— 而这两个方法都**不属于那个服务**,于是报出
429
- * 「宿主方法缺失」的**假阳性**。实测这两个在真实 desktop profile 上各自
430
- * 把一个插件误判成了「部分可用」(`@nanmicoder/dsh-agent-teams`、
431
- * `@michengai/dsh-archive-manager`)。
432
- *
433
- * ## 判据只看**本条语句的剩余部分**
434
- *
435
- * 从赋值表达式结束处往后扫到语句末尾(`;` / 换行 / 长度上限),
436
- * 只要中间还有一次成员取用 + 调用(`.foo(` / `?.foo(` / `.foo.bar`),
437
- * 就说明变量是**派生物**,不是服务。
438
- *
439
- * 反过来 `const slots = ctx.slots;`、`const agent = ctx.agents` 的剩余部分是空的,
440
- * 照旧绑定 —— 那些是真的把服务取到变量里。
441
- *
442
- * ⚠️ **这不影响「服务自己被调用」的采集**:`ctx.agents.get(...)` 本身仍由
443
- * ctx-member 调用点规则收下(那是一次真实的服务调用)。这里只是**不再把它的返回值
444
- * 也当成服务**。也就是说这个修复只会**去掉错误的归属**,不会造成新的漏报。
445
- */
446
- function holdsDerivedValue(text, from) {
447
- // 扫到语句末尾为止;`;` 与换行都算结束(一行一条赋值是绝大多数写法)
448
- let end = from
449
- const limit = Math.min(text.length, from + 200)
450
- while (end < limit) {
451
- const ch = text[end]
452
- if (ch === ';' || ch === '\n' || ch === '\r') break
453
- end++
454
- }
455
- const rest = text.slice(from, end)
456
- // `.foo(` / `?.foo(` / `.foo.bar(` —— 只要还有一次成员取用,就不是服务本身
457
- return /(?:\?\.|\.)\s*[A-Za-z_$][\w$]*/.test(rest)
458
- }
459
-
460
-
461
- /**
462
- * 别名/裸名规则**只对它成立**的名字集合。
463
- *
464
- * 这是本轮修掉的核心缺陷(数据:desktop profile 上 163/942 个调用点无法在源码行上验证)。
465
- * 原因不是「归属算错了」,而是**匹配本身没有边界**:
466
- * 一个名字只要在某个文件的字符串里出现过(文档、包名、配置键),
467
- * 它就会去匹配该文件里**任何** `名字.方法()` —— 于是 `store`、`scale`、`index`、`root`
468
- * 这些压缩代码里的局部变量全被卷进来,凭空造出几千个调用点。
469
- *
470
- * 边界怎么定:一个服务名值得用「裸名/别名」去匹配,**必须有一个可信声明**:
471
- * - 出现在 `inject: [...]` / `ctx.inject([...])` / `ctx.get("x")` 里(插件自己说的),或
472
- * - 出现在官方双端 API 目录里(DSH 自己说的)。
473
- * 两个都没有的名字,只有「字符串里出现过」这一条证据 —— 那不够。
474
- */
475
- function makeKnownService(catalogNames) {
476
- return (name) => catalogNames.has(name)
477
- }
478
-
479
- /** 该变量名是否像压缩产物的临时名(单/双字母、`$x`、`_$x`) */
480
- function looksTemporary(name) {
481
- return /^[A-Za-z$_][A-Za-z$_0-9]?$/.test(name)
482
- }
483
-
484
- /**
485
- * 这个标识符在某段源码里是否**被当成局部变量用过**(即被遮蔽)。
486
- *
487
- * 为什么这是必需的:`slots` / `tools` / `sessions` 这些词在压缩后的前端代码里
488
- * 大量作为局部变量出现(`const slots = []`、`function f(slots, i)`)。
489
- * 一旦一个服务名被遮蔽,同一个文件里所有 `slots.xxx()` 都成了**别的对象**的方法 ——
490
- * 实测 `dsh-mnemon` 的 `lib/client.js` 因此把
491
- * `addEventListener` / `querySelector` / `getBoundingClientRect` / `animate` / `focus`
492
- * 这一整套 **DOM 元素方法**记到了 `slots` 服务头上(DOM 元素变量恰好也叫 slots)。
493
- *
494
- * 这是**保守**检查,只覆盖显式声明的绑定形态。漏掉一条影子绑定只会多几个假阳性,
495
- * 不会少报真实调用 —— 方向是安全的。
496
- */
497
- function isShadowedIdent(text, name) {
498
- const n = escapeRe(name)
499
- return new RegExp(
500
- `\\b(?:const|let|var)\\s+${n}\\b` // const slots = ...
501
- + `|\\bfunction\\s+${n}\\s*\\(` // function slots(...)
502
- + `|\\bclass\\s+${n}\\b` // class slots ...
503
- + `|\\(\\s*[^()]{0,120}?\\b${n}\\b[^()]{0,120}?\\)\\s*=>`, // (slots, i) =>
504
- ).test(text)
505
- }
506
-
507
- /**
508
- * 把**注释**与**字符串字面量的内容**抹成空格,逐字节等长,换行一个不动。
509
- *
510
- * ## 为什么需要它(一次真实缺陷)
511
- *
512
- * 这个提取器的所有规则都是**基于文本**的正则,而正则看不见注释 —— 于是
513
- * **写在注释里的示例代码会被当成真的调用**。
514
- *
515
- * 实测怎么发现的:我给夹具写负向对照时,注释里为了说明"我故意**不**调用
516
- * `ctx.provide('internalOnlyService', ...)`",把那个调用原样写了出来。
517
- * 结果提取器把那个服务当真了 —— **负向对照当场失效**(它本该是 broken,
518
- * 却因为"有人提供"变成了 ok)。也就是说:我用来挡假阴性的钉子,
519
- * 是被我自己的一行注释拔掉的。
520
- *
521
- * 真实插件里这个形态是存在的(`dsh-better-sidebar` 有两个文件在注释里写了
522
- * `ctx.provide(...)`)。后果分两个方向:
523
- * - 假阴性:注释里的 `ctx.get("x")` 会让一个**并不存在的依赖**被当成声明过;
524
- * - 假阳性:注释里的 `ctx.provide("x")` 会让一个**没人提供的服务**看起来有人提供。
525
- *
526
- * ## 为什么要等长、不删行
527
- *
528
- * 调用点要报 `file:line`,而 line 是靠**字符偏移**换算出来的(`lineStarts`)。
529
- * 删掉注释会让后面所有行号整体前移 —— 那就是拿"注释里写不写示例"去改变
530
- * 真实调用的行号。抹成空格、保留换行,偏移与行号就都原样不动。
531
- *
532
- * ## 为什么保留引号本身
533
- *
534
- * 规则要匹配 `ctx.get('svc')` 这种**带引号**的形状。抹掉引号会让所有
535
- * 字符串参数规则一起失效;只抹内容、留引号,形状就还在,而内容不再能被
536
- * 当成"标识符"误读。
537
- */
538
- function maskCommentsAndStrings(text, { maskStrings = true } = {}) {
539
- const out = []
540
- const n = text.length
541
- let i = 0
542
- while (i < n) {
543
- const c = text[i]
544
- const d = text[i + 1]
545
- // 行注释
546
- if (c === '/' && d === '/') {
547
- while (i < n && text[i] !== '\n') { out.push(' '); i++ }
548
- continue
549
- }
550
- // 块注释
551
- if (c === '/' && d === '*') {
552
- out.push(' ', ' ')
553
- i += 2
554
- while (i < n && !(text[i] === '*' && text[i + 1] === '/')) {
555
- out.push(text[i] === '\n' ? '\n' : ' ')
556
- i++
557
- }
558
- if (i < n) { out.push(' ', ' '); i += 2 }
559
- continue
560
- }
561
- // 字符串 / 模板串
562
- //
563
- // `maskStrings` 的分工很重要,写错就会一边修一边坏:
564
- // - **要抹**(调用点扫描):注释里的 `ctx.get("x")` 会让不存在的依赖被当成
565
- // 声明过(假阴性);注释里的 `ctx.provide("x")` 会让没人提供的服务看起来
566
- // 有人提供(假阳性)。
567
- // - **不要抹**(声明解析):`inject = ["vaultService"]` 这类规则**必须**读到
568
- // 字符串内容 —— 抹掉就等于把这些声明全删了。实测:抹掉之后两个服务都被报成
569
- // `host-service-missing`,一次修出两个新 bug。
570
- if (c === '"' || c === "'" || c === '`') {
571
- if (!maskStrings) { out.push(c); i++; continue } // 只抹注释,字符串原样留
572
- out.push(c)
573
- i++
574
- while (i < n) {
575
- const ch = text[i]
576
- if (ch === '\\') { // 转义:连同下一个字符一起抹
577
- const nx = text[i + 1]
578
- out.push(' ', nx === '\n' ? '\n' : ' ')
579
- i += 2
580
- continue
581
- }
582
- if (ch === c) { out.push(ch); i++; break }
583
- out.push(ch === '\n' ? '\n' : ' ')
584
- i++
585
- }
586
- continue
587
- }
588
- out.push(c)
589
- i++
590
- }
591
- return out.join('')
592
- }
593
-
594
- /**
595
- * 解析出 `localBindings[variable] = {service, form, from?}`。
596
- *
597
- * `form` 是**可审计的证据形态**,它直接决定调用点的可信度:
598
- * - `ctx-get` :`ctx.get("svc")`,软取,缺失可降级
599
- * - `ctx-get-soft` :`ctx.get("svc", false)`,显式声明「缺失也不报错」
600
- * - `ctx-get-destructure`:解构出来的是服务的同名方法
601
- * - `inject-param` :作用域注入的参数,硬依赖
602
- * - `inject-destructure`:注入参数解构出来的同名方法
603
- * - `ctx-member` :`ctx.svc` 成员取用
604
- * - `alias` :转手绑定,`from` 是上一跳的变量名
605
- *
606
- * ⚠️ **只接受「服务名在可信来源里成立」的绑定**。原实现只认 `ctx.get` 的**直接**
607
- * 产物,导致 `const store = ctx.get("betterLocale")` 之后 `const store2 = store`
608
- * 就断了链,而压缩产物里这种转手极多 —— 断链后 `store2.foo()` 找不到归属。
609
- * 但反过来,如果**任何** `x = y` 都允许作为转手,压缩代码会把几千个临时变量
610
- * 串成链(实测调用点从 942 暴涨到 7477)。所以两个闸门:目标变量名必须不像
611
- * 临时名,且源变量的服务名必须有可信来源。
612
- */
613
- function resolveLocalBindings(text) {
614
- const byVar = new Map() // var -> {service, form, from?}
615
- const put = (v, rec) => {
616
- if (!v || byVar.has(v)) return
617
- byVar.set(v, rec)
618
- }
619
-
620
- // ⚠️ 逐条判「变量拿到的是**服务本身**,还是它某个方法的**返回值**」。
621
- // 漏了这一步就是两个真实假阳性 —— 见 holdsDerivedValue() 的注释。
622
- for (const m of text.matchAll(BIND_CTX_GET)) {
623
- if (holdsDerivedValue(text, m.index + m[0].length)) continue
624
- put(m[1], { service: m[3], form: m[4] ? 'ctx-get-soft' : 'ctx-get' })
625
- }
626
- for (const m of text.matchAll(BIND_CTX_MEMBER)) {
627
- if (holdsDerivedValue(text, m.index + m[0].length)) continue
628
- put(m[1], { service: m[2], form: 'ctx-member' })
629
- }
630
- for (const m of text.matchAll(BIND_DESTRUCT_GET)) {
631
- if (holdsDerivedValue(text, m.index + m[0].length)) continue
632
- for (const part of m[1].split(',')) {
633
- const name = part.split(':').pop().trim().split(/\s|=/)[0].trim()
634
- if (/^\w+$/.test(name)) put(name, { service: m[2], form: 'ctx-get-destructure' })
635
- }
636
- }
637
-
638
- // 转手链收敛:`a = ctx.get("x")` → `b = a` → `c = b`。
639
- // 上限 4 跳:真实代码里超过 4 跳的转手几乎没有,而放开上限的代价是指数级的匹配量。
640
- for (let pass = 0; pass < 4; pass++) {
641
- let changed = false
642
- for (const m of text.matchAll(BIND_ALIAS)) {
643
- const [, target, source] = m
644
- if (target === source || byVar.has(target) || looksTemporary(target)) continue
645
- const src = byVar.get(source)
646
- if (!src) continue
647
- byVar.set(target, { service: src.service, form: 'alias', from: source })
648
- changed = true
649
- }
650
- if (!changed) break
651
- }
652
- return byVar
653
- }
654
-
655
- /**
656
- * 匹配点前面是不是**别人的成员访问**(`ws.sessions.has(` 里的 `ws.`)。
657
- *
658
- * ## 为什么需要它(实测出来的假阳性)
659
- *
660
- * 别名/裸名规则用的词边界是 `\b`,而 `\b` **允许前面是 `.`**:
661
- * 于是 `ws.sessions.has(sidKey)` 里的 `sessions` 被当成那个变量,
662
- * `has` 被归到 DSH 的 `sessions` 服务头上。
663
- *
664
- * 实测 `dsh-recall-plugin/lib/client.js`:文件里确实有 `const sessions = ctx.sessions`
665
- * (第 1311 行),但假站点在第 **800** 行 —— 那里是
666
- * `ws = { …, sessions: new Map() }`,`ws.sessions` 是**它自己的 Map 字段**。
667
- * 一次归属错误就造出 5 个假方法(`has`/`set`/`values`/`sort`/`reduce`),
668
- * 而它们看起来像"DSH 少了一堆会话 API"。
669
- *
670
- * ## 判据:前面是 `.` 时,只认 `this`
671
- *
672
- * `this.<var>.<method>(` 是合法的(类式插件把服务存在字段上,实测 6000+ 处这种写法);
673
- * 而 `<别的对象>.<var>.<method>(` 说明那个 var 是**那个对象的字段**,与这里的绑定无关。
674
- *
675
- * 注意这与 `EXTRA_CONTEXT_HEADS` 是**同一个取舍**:能实测的窄规则,胜过放开的宽规则。
676
- */
677
- function isForeignMemberAccess(code, index) {
678
- if (index === 0) return false
679
- if (code[index - 1] !== '.') return false
680
- // 前面是 `.` —— 看接收者是不是 `this`
681
- const before = code.slice(Math.max(0, index - 12), index - 1)
682
- return !/\bthis$/.test(before)
683
- }
684
-
685
- /**
686
- * 作用域注入:`ctx.inject(["a","b"], cb)` 里,回调的形参**就是**被注入的服务。
687
- *
688
- * `ctx.inject(["jobs"], (jobs) => jobs.list())` 的 `jobs` 既没有 `ctx.get`,
689
- * 也没有 `inject:` 字段 —— 原实现完全看不见它,于是这类真实调用点会整体消失。
690
- *
691
- * ⚠️ **形参是按位置绑定的,不是按名字。** 实测 `@linxin666/dsh-client-ui-graph`
692
- * 写的是 `inject(["subprocess", "jobs"], (subprocess, jobs) => ...)` —— 名字恰好
693
- * 相等,所以「按名字匹配」也能中。但那只在作者愿意同名时成立;一旦写成
694
- * `(sp, j) => sp.spawn(...)`,按名字匹配就全丢。所以这里按**位置**绑定。
695
- *
696
- * 解构形态 `({ list }) => ...` 只在注入列表有**恰好一个**服务时才敢断言它属于谁:
697
- * 多个服务时谁的方法叫什么名字无从判断,猜就是在造假结论。
698
- */
699
- function injectParamBindings(text) {
700
- const out = []
701
- const re = /ctx\s*\.\s*inject\s*\(\s*\[([^\]]*)\]([\s\S]{0,200})/g
702
- for (const m of text.matchAll(re)) {
703
- const names = [...m[1].matchAll(/['"]([A-Za-z][\w.]*)['"]/g)].map((x) => x[1])
704
- if (!names.length) continue
705
- const rest = m[2]
706
-
707
- const record = (params) => {
708
- params.forEach((part, i) => {
709
- const raw = part.trim()
710
- if (!raw) return
711
- if (/^\{[\s\S]*\}$/.test(raw)) {
712
- if (names.length !== 1) return // 归不到具体服务就不猜
713
- for (const p of raw.slice(1, -1).split(',')) {
714
- const n = p.split(':')[0].trim().split(/\s|=/)[0].trim()
715
- if (/^\w+$/.test(n)) out.push({ name: n, form: 'inject-destructure', svc: names[0], names })
716
- }
717
- return
718
- }
719
- const n = raw.split(':')[0].trim().split(/\s|=/)[0].trim()
720
- if (/^\w+$/.test(n) && names[i]) out.push({ name: n, form: 'inject-param', svc: names[i], names })
721
- })
722
- }
723
-
724
- const paren = rest.match(/^\s*\(\s*([^)]*)\)\s*=>/)
725
- if (paren) { record(splitTopLevel(paren[1])); continue }
726
- const single = rest.match(/^\s*(\w+)\s*=>/)
727
- if (single && names[0]) out.push({ name: single[1], form: 'inject-param', svc: names[0], names })
728
- }
729
- return out
730
- }
731
-
732
- /** 按顶层逗号切分参数表(`({ a, b }, c)` → `['({ a, b }', 'c']`) */
733
- function splitTopLevel(s) {
734
- const out = []
735
- let depth = 0
736
- let cur = ''
737
- for (const ch of s) {
738
- if (ch === '{' || ch === '(' || ch === '[') depth++
739
- else if (ch === '}' || ch === ')' || ch === ']') depth--
740
- if (ch === ',' && depth === 0) { out.push(cur); cur = ''; continue }
741
- cur += ch
742
- }
743
- if (cur.trim()) out.push(cur)
744
- return out
745
- }
746
-
747
- /**
748
- * 认出**上下文对象**的名字。
749
- *
750
- * 判据两条,都很窄:
751
- * 1. 字面量 `ctx`;
752
- * 2. `ctx.inject([...], cb)` 回调的形参。
753
- *
754
- * **不靠「参数名恰好是服务名」来认** —— 位置是契约,名字不是。
755
- * 实测 `@michengai/dsh-archive-manager` 压缩后回调参数叫 `N`,而它确实是子上下文。
756
- *
757
- * ## ⚠️ 单参数形态曾经完全认不出来(一次实测出来的假阴性)
758
- *
759
- * 早先这里要求 `parts.length >= 2`,把**第二个**形参当上下文 ——
760
- * 理由是"第一个是被注入的服务"。**这个前提在真实插件上几乎不成立**:
761
- *
762
- * | 回调形参个数 | 实测次数(desktop profile) |
763
- * |---|---|
764
- * | 0 个 | 1 |
765
- * | **1 个** | **27** |
766
- * | 2 个 | **0** |
767
- *
768
- * 也就是说**27 处全被漏掉**,而它们都是 `<head>.<svc>.<method>()` 这种最常见的写法。
769
- * 后果是**假阴性**(最贵的那类):实测 desktop 上 38 处 `no-calls` 里有 **21 处**
770
- * 其实是真实存在的调用。例:
771
- *
772
- * ```js
773
- * ctx.inject(["tools"], (toolsCtx) => {
774
- * toolsCtx.effect(function* () {
775
- * yield toolsCtx.tools.register(createRenderUiTool()); // ← 原来是看不见的
776
- * });
777
- * });
778
- * ```
779
- *
780
- * 修法:单参数时那个参数**就是**上下文对象;多参数时保留原来的"取第二个"约定。
781
- *
782
- * 为什么加这一条是**安全**的:它只让 `<head>.<svc>.<method>()`(**两个点**)变得可判,
783
- * 而两个点要求作者确实写了"上下文对象的成员再取服务再调方法"。
784
- * 若某插件把参数直接当服务用(`(tools) => tools.register(...)`,一个点),
785
- * 这个改动不会多认 —— 那条通路归 `injectParamBindings` 按位置管。
786
- */
787
- function contextObjectNames(text) {
788
- // ⚠️ `this.ctx` 只在**文件里真的出现**时才加进名单。
789
- // 无条件加会让每一份 `subContexts` 都多出一条 `this.ctx` —— 而 `subContexts` 是
790
- // **审计记录**("这个文件里认出了哪些上下文对象"),往里塞一个文件里根本没有的名字
791
- // 就等于让它撒谎。实测:无条件加会让两个快照的**每一个插件**都发生变化。
792
- const names = new Set(['ctx'])
793
- for (const head of EXTRA_CONTEXT_HEADS) {
794
- if (text.includes(head)) names.add(head)
795
- }
796
- // ── `<某对象>.ctx` ──
797
- //
798
- // 插件常把 DSH 的 ctx 挂在自己的对象上:`input.ctx` / `target.ctx` / `agent.ctx`。
799
- // 实测这种形态有 **19 处**(`this.ctx` 14 处已单独处理,剩下 5 处是 `X.ctx`),
800
- // 而它们此前**一条都看不见** —— `tools/extractor-recall.mjs` 的独立枚举把它们
801
- // 报成了"对不上账",逐条看源码确认**都是真漏报**(`target.ctx.storageDomain.open(…)`
802
- // 这种)。
803
- //
804
- // ⚠️ 这与缺陷 26(`this.ctx`)的取舍**在这里变了方向**,理由是量出来的:
805
- // 那次担心"`foo.ctx` 可能是别人的上下文(React context、请求上下文)",
806
- // 而实际数据里 19 处**全是 DSH 的**。而且这条规则后面还卡着两道强过滤 ——
807
- // 第三段必须是**已知服务名**、第四段必须是调用。放开的代价比想象的窄。
808
- for (const m of text.matchAll(/\b([A-Za-z_$][\w$]*)\.ctx\b/g)) {
809
- names.add(`${m[1]}.ctx`)
810
- }
811
- const re = /ctx\s*\.\s*inject\s*\(\s*\[[^\]]*\]\s*,\s*\(\s*([^()]*)\)\s*=>/g
812
- for (const m of text.matchAll(re)) {
813
- const parts = splitTopLevel(m[1])
814
- // 单参数 → 它自己就是上下文;多参数 → 取第二个(第一个通常是被注入的服务或事件参数)
815
- const pick = parts.length === 1 ? parts[0] : (parts.length >= 2 ? parts[1] : null)
816
- if (pick === null) continue
817
- const n = pick.trim().split(':')[0].trim().split(/\s|=/)[0].trim()
818
- if (/^\w+$/.test(n)) names.add(n)
819
- }
820
-
821
- // ── 上下文对象的**别名**(`const scoped = scopedCtx`)──
822
- //
823
- // 实测规模很小但是真的:desktop profile 上 27 个上下文对象里有 **3 个**被这样转了手
824
- // (`dshmarket` 的 `const scoped = scopedCtx` / `const host = hostCtx`)。
825
- // 不收的话它们名下所有调用点**一条都看不见** —— 又是假阴性那一类。
826
- //
827
- // 迭代到不动点(上限 4 轮,与别处的绑定追踪同一个上限),这样声明顺序无关。
828
- // **上限是防"别名成环"**(`a = b; b = a`),不是怕慢。
829
- //
830
- // 只认**整句就是赋值**的形式(`const X = <已认出的上下文>`),不认赋值表达式里的其它写法 ——
831
- // 越窄越好:多认一个假名字,会让该名字下所有 `<name>.<svc>.<m>()` 都变成"真调用"。
832
- for (let round = 0; round < 4; round++) {
833
- const before = names.size
834
- for (const n of [...names]) {
835
- const aliasRe = new RegExp(
836
- String.raw`(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*` + escapeRe(n) + String.raw`\s*(?:[;\n]|$)`, 'g')
837
- for (const m of text.matchAll(aliasRe)) {
838
- if (/^\w+$/.test(m[1])) names.add(m[1])
839
- }
840
- }
841
- if (names.size === before) break
842
- }
843
- return names
844
- }
845
-
846
- /**
847
- * 类式插件的惯用上下文取值:`this.ctx.<svc>.<method>()`。
848
- *
849
- * ## 为什么单独列出来(实测,不是设想)
850
- *
851
- * 主规则 `ctx.<svc>.<method>()` 有一条 `(?<![\w$.])` 的 lookbehind ——
852
- * 挡住 `myctx.` 这类前缀是对的,但它**顺带也挡住了 `this.ctx.`**(`ctx` 前面是 `.`)。
853
- * 而 class 式插件(这个生态里很常见)到处写 `this.ctx.agents.get(...)`。
854
- *
855
- * 实测 desktop profile:`this.ctx.<svc>.<method>(` 出现 **32 次**,涉及
856
- * `agents` / `logger` / `sessionPersistence` / `storageDomain` 四个服务 ——
857
- * 全部不可见。其中 `dsh-mnemon` 的 `this.ctx.agents.get(...)` 甚至让它被判成
858
- * 「`agents` 没有任何调用点」,也就是把一个**真调用**报成了"没用到"。
859
- *
860
- * ## 为什么用「加一个名字」而不是「放宽 lookbehind」
861
- *
862
- * 放宽成 `(?<![\w$])` 会让 `foo.ctx.<svc>.<m>()` 也命中 —— 而那个 `ctx`
863
- * 可能是别人的上下文(React context、请求上下文……)。加名字是**精确**的:
864
- * 只认字面量 `this.ctx`,其余前缀一概不认。要支持别的写法时,
865
- * 同样按"先去真实插件里量出现次数"再来加。
866
- */
867
- const EXTRA_CONTEXT_HEADS = ['this.ctx']
868
-
869
- /**
870
- * 调用点附近是否有能力检测。只看匹配起点**往前 400 字符**:再远就说不清是不是在判
871
- * 这个方法本身,而把远处的检测算成这里的安全网会掩盖真实失效。
872
- */
873
- /**
874
- * 找出**辅助函数里的能力检测** —— 这一类正则看不见,而它造成过一条 `certain` 的假阳性。
875
- *
876
- * ## 实测的病例(`dshmarket`,`certainty: certain`,`severity: feature-dead`)
877
- *
878
- * ```js
879
- * function canRegister(service, ctx) {
880
- * if (typeof service.register === 'function') return true; // ← 能力检测在这里
881
- * namespaceState = 'unsupported-by-host';
882
- * logger?.warn('…the market keeps its composed configuration and shows no allowRestart switch… (#677)');
883
- * return false;
884
- * }
885
- * // …
886
- * if (!canRegister(scoped.settings, scoped)) return; // ← 调用点守住了
887
- * scoped.settings.register(MARKET_SETTINGS_NS, z.object({}), { base: {} });
888
- * ```
889
- *
890
- * 工具报的是「功能失效 / 设置页会消失」,而作者**早就兜住了**,还在日志里写清了降级行为、
891
- * 留了 issue 编号。**事实对了(方法确实不在运行时里),推断错了。**
892
- *
893
- * ## 判据(很窄,故意的)
894
- *
895
- * 只认这一种形状:同文件里有个函数,它的形参被 `typeof <参数>.<方法> === "function"`
896
- * 检查过;而调用点前面**紧挨着**一句 `if (!<那个函数>(…))` 或 `if (<那个函数>(…) === false)`。
897
- *
898
- * ## 为什么只认这么窄
899
- *
900
- * 每放宽一点,就更像在写一个解析器 —— 而这个项目的取舍一直是
901
- * **「能实测的窄规则,胜过放开的宽规则」**(见 `EXTRA_CONTEXT_HEADS`、`isForeignMemberAccess`)。
902
- * 这条规则只解决**已实测到的那一个形状**;下一个形状出现时,先量它有多普遍再来加。
903
- */
904
- function helperGuards(code) {
905
- const out = new Map(); // 函数名 → 它检查过的方法名集合
906
- // 函数体最多看 600 字符:真实的守卫函数都短,而放宽只会误伤
907
- const fnRe = /function\s+([A-Za-z_$][\w$]*)\s*\(([^)]*)\)\s*\{([\s\S]{0,600}?)\n\}/g
908
- for (const m of code.matchAll(fnRe)) {
909
- const [, name, params, body] = m;
910
- const paramNames = params.split(',').map((p) => p.trim().split(/[\s=:]/)[0]).filter(Boolean);
911
- const methods = new Set();
912
- for (const p of paramNames) {
913
- if (!p) continue;
914
- const re = new RegExp(String.raw`typeof\s+` + escapeRe(p) + String.raw`\s*\.\s*([A-Za-z_]\w*)\s*(?:===|!==|==|!=)\s*["']function["']`, 'g');
915
- for (const mm of body.matchAll(re)) methods.add(mm[1]);
916
- }
917
- if (methods.size) out.set(name, methods);
918
- }
919
- return out;
920
- }
921
-
922
- /**
923
- * 调用点前面**紧挨着**的那个守卫调用,检查的是不是这个方法。
924
- * 见 `helperGuards` 的注释 —— 判据故意很窄。
925
- */
926
- function isGuardedByHelper(code, index, method, helpers) {
927
- if (helpers.size === 0) return false;
928
- // 窗口与 `isGuardedNear` 的前向窗口取同一个值(400)——
929
- // 实测这个病例里 `if (!canRegister(…)` 与调用点隔了 **309 字符**(中间还有三行注释),
930
- // 第一版写 200 时它**静默不生效**:代码看起来"加了守卫识别",实际一条都没认出来。
931
- const from = Math.max(0, index - 400);
932
- const window_ = code.slice(from, index);
933
- for (const [name, methods] of helpers) {
934
- if (!methods.has(method)) continue;
935
- // `if (!canRegister(` / `if (canRegister(` / `if (canRegister(...) === false)`
936
- const re = new RegExp(String.raw`if\s*\(\s*!?\s*` + escapeRe(name) + String.raw`\s*\(`);
937
- if (re.test(window_)) return true;
938
- }
939
- return false;
940
- }
941
-
942
- function isGuardedNear(text, index, service, method, altHead = null, helpers = null) {
943
- const from = Math.max(0, index - 400)
944
- // ⚠️ `?.` 在方法名**之后**:`ctx.llm.listProviders?.()`
945
- // ^^^^^ 兜底标记在匹配点右边
946
- // 只看前面的话这个标记永远在窗口外 —— 结果就是"收下了调用点却认不出兜底",
947
- // 报出一条假阳性。所以窗口改成"前 400 / 后 80"。
948
- const to = Math.min(text.length, index + 80)
949
- const window_ = text.slice(from, to)
950
- if (guardRegex(service, method).test(window_)) return true
951
- // ⚠️ **作者常常是对着「变量」做能力检测,而不是对着服务名。**
952
- //
953
- // ```js
954
- // const store = ctx.get("storage") // 合法:store 就是那个服务
955
- // if (typeof store.read === "function") store.read(...)
956
- // ```
957
- //
958
- // 这里调用点的归属是 `storage`,但源码里根本没有 `storage.read` 这几个字 ——
959
- // 只有 `store.read`。只看服务名就认不出兜底,于是报一条**假阳性**,
960
- // 而被冤枉的作者明明写得比大多数人都仔细。
961
- //
962
- // 只在别名档需要(`altHead` = 变量名):其余两档的归属头本身就是源码里的写法。
963
- if (altHead !== null && altHead !== service && guardRegex(altHead, method).test(window_)) return true
964
- // ④ 能力检测藏在**同文件的辅助函数**里(`if (!canRegister(svc)) return;`)——
965
- // 正则看不见它,而它造成过一条 certain 的假阳性。见 helperGuards。
966
- if (helpers !== null && isGuardedByHelper(text, index, method, helpers)) return true
967
- return false
968
- }
969
-
970
- /**
971
- * 这个调用点是否**被作者自己做了能力检测**。
972
- *
973
- * 为什么必须认这件事:`dsh-pet` 写的是
974
- * typeof llm?.listProviders === "function" ? llm.listProviders() : []
975
- * —— 它**明知**这个方法可能不在,并自己处理了。把它报成「功能失效」是**假阳性**:
976
- * 用户实际没有失去任何东西。而假阳性正是这个工具唯一卖点的反面。
977
- *
978
- * 认两种形态,都是「不会因为方法不存在而抛错」的写法:
979
- * 1. `x?.method(` —— 可选链
980
- * 2. `typeof x.method === "function"` 附近的同类判定
981
- *
982
- * 判定范围刻意只看调用点**前面两行**:再远就说不清是不是在判这个方法。
983
- * 代价:守在很远处的检测认不出来(仍会报),这是**偏保守**的方向 ——
984
- * 宁可多报一条,也不要因为误认成「有防护」而漏掉真实的失效。
985
- */
986
- function guardRegex(service, method) {
987
- const s = escapeRe(service)
988
- const m = escapeRe(method)
989
- // 形态清单**是从真实插件里 grep 出来的**,不是凭印象写的 —— 上一版就是凭印象,
990
- // 漏掉了最常见的 ②(`?.()`),于是 `dsh-better-sidebar` 的
991
- // `ctx.sessions.binding?.(newId)` 被判成"宿主方法缺失",而作者明明写了兜底。
992
- return new RegExp(
993
- // ① 可选服务:`svc?.method`(前缀允许 `ctx.` / `this.ctx.` 这类链)
994
- `[\\w$]*\\b${s}\\s*\\?\\s*\\.\\s*${m}`
995
- // ② 可选方法:`svc.method?.(` —— 实测最常见,也最容易被漏
996
- + `|[\\w$]*\\b${s}\\s*\\.\\s*${m}\\s*\\?\\s*\\.\\s*\\(`
997
- // ③ 显式能力检测:`typeof svc.method === "function"`(`?.` 可有可无)
998
- + `|typeof\\s+(?:[\\w$]*\\s*\\.\\s*)*${s}\\s*\\.\\s*${m}\\s*(?:===|!==|==|!=)\\s*["\']function["\']`,
999
- )
1000
- }
1001
-
1002
- /**
1003
- * 码元序比较 —— **不要用 `localeCompare`**。
1004
- *
1005
- * `localeCompare` 的结果取决于 ICU 数据与进程语言环境(`LANG`),也就是说
1006
- * 同一份输入在两台机器上可能排出不同顺序。对一个要拿输出算快照哈希的工具有两重危害:
1007
- * 1. 快照会在换机器/换语言环境时无故变红;
1008
- * 2. 更糟的是,它可能**掩盖**真实差异 —— 顺序变了但哈希恰好没变是运气,反之亦然。
1009
- * 实测在当前名字集合上两种比较结果恰好一致,所以这是一个**潜伏**问题而不是现行故障;
1010
- * 这类问题必须在它变成故障之前修掉。
1011
- */
1012
- function cmp(a, b) {
1013
- const x = String(a)
1014
- const y = String(b)
1015
- return x < y ? -1 : (x > y ? 1 : 0)
1016
- }
1017
-
1018
- function sortCalls(calls) {
1019
- const out = {}
1020
- for (const k of [...calls.keys()].sort()) out[k] = [...calls.get(k)].sort()
1021
- return out
1022
- }
1023
-
1024
- /**
1025
- * 扫描**一个插件**的全部已选文件,产出归属该插件的结果。
1026
- *
1027
- * 与原实现的关键区别:原实现把「服务集合」做成了跨插件累积的全局量,并用它去扫
1028
- * 当前插件的源码。后果是别的插件发现的服务名,会被套到本插件的**同名局部变量**上。
1029
- * 实测 desktop profile 的 280 个调用点里有 **51 个**是这种污染,例如
1030
- * `dshmarket` 里的 `const commands = { probePnpm, provisionPnpm, ... }` 被当成
1031
- * DSH 的 `commands` 服务。逐插件归属后是**严格子集**(只删不增),所以这是纯收益。
1032
- */
1033
- /**
1034
- * 模块是否把这个绑定**导出**了(含 `export { a, b }` 与 `export { x as y }` 两种形态)。
1035
- *
1036
- * 为什么需要它:`const provide = [...]` 只有**导出**时才是插件契约。
1037
- * 包内 vendored 代码、内部辅助模块里也可能出现同名字面量,把那些也算进来
1038
- * 会让"这个服务有人提供"变成假象 —— 那是**假阴性**方向,比假阳性更难发现。
1039
- */
1040
- function exportsBinding(text, name) {
1041
- for (const m of text.matchAll(/export\s*\{([^}]*)\}/g)) {
1042
- for (const part of m[1].split(',')) {
1043
- const bits = part.trim().split(/\s+as\s+/).map((x) => x.trim())
1044
- // `as` 左边的才是模块内的绑定名
1045
- if (bits[0] === name) return true
1046
- }
1047
- }
1048
- return false
1049
- }
1050
-
1051
- function scanPlugin(blob, catalogNames, pkgName) {
1052
- const isKnownService = makeKnownService(catalogNames)
1053
- const services = new Set()
1054
- // 「可信声明」的服务名:出现在 inject: [...] / ctx.inject([...]) / ctx.get("x") 里。
1055
- // 这是三档确定性判据的第一档来源(见 Plan.md §2.2)—— 有可信声明的算「确定」,
1056
- // 只靠松散形式 ctx.<svc>.<method>() 命中的算「疑似」。
1057
- const declared = new Set()
1058
- // 其中「软取」的那部分:ctx.get("x") 在写法上允许缺失,与 inject 的硬依赖不同。
1059
- // 判定引擎要分开对待(硬依赖缺失 = 条目停在 pending = 致命;软取缺失 = 降级)。
1060
- const softDeclared = new Set()
1061
- // 「可以被点号调用的服务名」。**比 `services` 窄**:`services` 还包含
1062
- // 「只在某个文件的字符串里出现过」的名字(那可能是文档、是别的包名、是配置键),
1063
- // 由这个名单去驱动匹配,会让那些名字开始匹配同文件里的**任何** `名字.方法()`。
1064
- // 实测这是 163/942 个调用点无法在源码行上验证的原因(见 docs/extraction-findings.md)。
1065
- const addressable = new Set()
1066
- const calls = new Map()
1067
- const sites = []
1068
- const provided = new Set()
1069
- const serviceFiles = new Map()
1070
- // 变量名 → 它承载的服务。既用于归属,也用于**验证归属**(调用点的 via/matched 必须能被它解释)
1071
- const localBindings = new Map()
1072
- // `(变量名|文件) → 服务集合` —— 与上面那张表**并存的、文件维度的**视图。
1073
- //
1074
- // 为什么需要它:`localBindings` 只按变量名聚,于是**同名变量在不同文件里绑到不同服务**
1075
- // 时只能整体记成「歧义」,并把 valid 的那条覆盖掉。实测后果:
1076
- // `dsh-better-sidebar` 的 `session` 在 `lib/client-registry.js` 里同时承载
1077
- // `remote` 与 `sessions`(歧义),在 `lib/index.js` 里却是干净的一个 ——
1078
- // 合并之后 `localBindings` 只剩歧义那条,`lib/index.js` 的别名调用点就
1079
- // "在绑定表里查不到自己",不变式 ⑤ 因此报红。
1080
- //
1081
- // 消歧策略**不变**(仍然是"同名变量绑到多个服务就整体弃权"),
1082
- // 这张表只是把**每个文件各自的绑定**如实留下来,供对账与排查用。
1083
- const bindingsByFile = new Map()
1084
-
1085
- const addCall = (svc, method, rel, line, via, matched, guarded = false) => {
1086
- if (!calls.has(svc)) calls.set(svc, new Set())
1087
- calls.get(svc).add(method)
1088
- sites.push({ service: svc, method, plugin: pkgName, file: rel, line, via, matched, guarded })
1089
- // ⚠️ 这里**不要**直接累积 `unguardedPairs`(下面按去重后的站点统一算)。
1090
- // 踩过的坑:同一个 `svc.method` 常常被**多条规则**在同一行各收一次
1091
- // (`ctx.settings.register('x')` 会同时被 ctx-member 档和裸名档收下),
1092
- // 而两档的 `guarded` 判定不一定一致。在 `addCall` 里累积 = **没去重就下结论**,
1093
- // 于是"输掉去重的那一条"会把结果污染:明明每个站点都有兜底,`unguardedPairs`
1094
- // 里却多出一条,判定层照报 —— **兜底永远不生效**。
1095
- }
1096
-
1097
- const ambiguousAliases = []
1098
- // 因「服务名被同名局部变量遮蔽」而放弃的裸名匹配(可审计,不是静默丢)
1099
- const shadowedBare = []
1100
- // 因「绑定来源不可信」而放弃的别名归属(同上,弃权也要有据可查)
1101
- const unresolvedBindings = []
1102
- // 每个文件里被认出来的子上下文变量名(可审计:它决定了哪些 `<x>.<svc>.<m>()` 被收下)
1103
- const subContexts = []
1104
- // 注:`guardedPairs` / `unguardedPairs` 都在**去重之后**统一派生(见文件末尾),
1105
- // 不在这里提前声明 —— 提前声明会让 `addCall` 顺手往里塞,而那正是上面那个 bug。
1106
- const perFile = []
1107
- for (const [rel, text] of blob) {
1108
- const starts = lineStarts(text)
1109
- // ⚠️ 所有**声明/调用**规则都跑在这个 `code` 上,而不是 `text` 上。
1110
- // 它是 `text` 把注释与字符串内容抹成空格后的版本(等长、行号不动)。
1111
- // 理由见 `maskCommentsAndStrings` 的注释:注释里的示例代码会被正则当真,
1112
- // 实测已经发生过一次(我的夹具负向对照被自己的一行注释拔掉)。
1113
- //
1114
- // 注意:`text` 仍然用于「导入/文件类型」这类与注释无关的判断,
1115
- // 以及所有需要**真实字符**的场景(例如报 `file:line` 时的行内容)。
1116
- // 声明解析:**只抹注释、保留字符串** —— `inject = ["svc"]` 这类规则要靠字符串内容
1117
- const decl = maskCommentsAndStrings(text, { maskStrings: false })
1118
- // 调用点扫描:注释和字符串都抹 —— 这里的误读会直接造出假阳性/假阴性
1119
- const code = maskCommentsAndStrings(text)
1120
- for (const m of decl.matchAll(/inject\s*:\s*\[([^\]]*)\]/g)) {
1121
- for (const s of m[1].matchAll(/["']([A-Za-z][\w.]*)["']/g)) {
1122
- services.add(s[1]); declared.add(s[1])
1123
- }
1124
- }
1125
- // ⚠️ `inject` 也可能是**先声明后引用**,而不是字面量字段:
1126
- // const inject = ["mnemonMemory"]
1127
- // return { name, inject, ... }
1128
- // 实测 `dsh-mnemon-strategy-*` 一族就是这个写法,而且那个服务
1129
- // (mnemonMemory)**当前 profile 里无人提供**。只认 `inject:` 字面量会让这整类
1130
- // 依赖不可见 —— 插件被判成「0 个服务」进而得到「可用」,那是**假阴性**:
1131
- // 工具对真实缺口只字不提,而用户以为自己拿到了结论。
1132
- // 这里顺带覆盖 `injectDeps` / `scopeInject` 这类同类命名。
1133
- for (const m of decl.matchAll(/\binject(?:Deps|Scope|s)?\s*[:=]\s*\[([^\]]*)\]/g)) {
1134
- for (const s of m[1].matchAll(/["']([A-Za-z][\w.]*)["']/g)) {
1135
- services.add(s[1]); declared.add(s[1])
1136
- }
1137
- }
1138
- // `ctx.get("x")` 与 `ctx.get("x", false)` 都算可信声明。第二参数为假值表示
1139
- // 「缺失也不抛」的显式软取 —— 它改变的是**严重度**(降级 vs 致命),不改变
1140
- // 「作者声明了这个依赖」这个事实。早先只认单参数形态,于是 dsh-pet 的
1141
- // deepseekAccount 整条依赖被判成不可信,反而丢掉了真实结论。
1142
- for (const m of decl.matchAll(/ctx\.get\(\s*['"]([A-Za-z][\w.]*)['"]\s*(?:,\s*(?:false|0))?\s*\)/g)) {
1143
- services.add(m[1]); declared.add(m[1]); softDeclared.add(m[1])
1144
- }
1145
- // 作用域注入:`ctx.inject(["a","b"], cb)` —— 同样是可信声明。
1146
- // ⚠️ 注意 `ctx.inject` 这个写法本身就意味着 `ctx` 是**服务容器**,
1147
- // 所以下面还要把函数形参上的 `ctx` 绑定补上(`(ctx) => ctx.jobs.list()`)。
1148
- for (const m of decl.matchAll(/ctx\.inject\(\s*\[([^\]]*)\]/g)) {
1149
- for (const s of m[1].matchAll(/["']([A-Za-z][\w.]*)["']/g)) {
1150
- services.add(s[1]); declared.add(s[1])
1151
- }
1152
- }
1153
- // 插件自己提供的服务 —— 它们**不是**宿主服务,不该被判成"无人提供"。
1154
- //
1155
- // ⚠️ `provide` 也有**数组声明再导出**的写法,而且它与 `inject` 是**对偶**的:
1156
- // const provide = ["mnemonMemory"]
1157
- // export { apply, inject, name, provide }
1158
- // 实测 `dsh-mnemon` 正是这个形态(`lib/core.js` / `lib/index.js` / `lib/starter.js`),
1159
- // 而它提供的 `mnemonMemory` 被**一整个插件族**声明依赖。只认 `ctx.provide(...)`
1160
- // 的后果不是漏一条,而是**反过来**:7 个 `dsh-mnemon-*` 被判成「部分可用 ——
1161
- // 依赖的服务由其它插件提供,当前 profile 里没有提供方」,而提供方**就在同一目录里**。
1162
- // 也就是说:工具把"装好了"报成了"缺依赖"。这是假阳性里最刺眼的一种。
1163
- //
1164
- // 只认 `ctx.provide("x")` 是不够的,还要认**导出的**数组声明。为什么必须要求
1165
- // "导出":包内 vendored 代码里也可能出现同名字面量,而只有导出的才是插件契约
1166
- // (与 `export { inject }` 对称)。这一条把 `dsh-mnemon` 收进来、把噪音挡在外面。
1167
- for (const m of decl.matchAll(/ctx\.provide\(\s*['"]([A-Za-z][\w.]*)['"]/g)) provided.add(m[1])
1168
- // ⚠️ 这条用 `decl`(保留字符串)——数组里装的就是**服务名字符串**,
1169
- // 用 `code` 会把它们抹成空格,于是 `provide` 一个都认不出来。
1170
- // 实测踩过:改成 `code` 之后 `dsh-mnemon` 提供的 `mnemonMemory` 消失,
1171
- // 7 个消费者的假阳性**全部复活**。变量的**名字**在两者里都还在,
1172
- // 所以只抹字符串这条规则会"看起来匹配到了"(varName=provide),却一个服务都收不到。
1173
- for (const m of decl.matchAll(/(?:^|\n)\s*(export\s+)?const\s+([A-Za-z_$][\w$]*)\s*=\s*\[([^\]]*)\]/g)) {
1174
- const [, inlineExport, varName, body] = m
1175
- if (!/^provide/.test(varName)) continue
1176
- const exported = Boolean(inlineExport) || exportsBinding(decl, varName)
1177
- if (!exported) continue
1178
- for (const q of body.matchAll(/["']([A-Za-z][\w.]*)["']/g)) provided.add(q[1])
1179
- }
1180
-
1181
- // ── 局部绑定:把变量名映射到服务名 ──
1182
- // 同文件里"藏在辅助函数中的能力检测"(`if (!canRegister(svc)) return;`)
1183
- // 只算一次,三个调用点共用 —— 它读的是整个文件,不是窗口。
1184
- //
1185
- // ⚠️ **必须用 `decl`(抹注释、保留字符串),不能用 `code`。**
1186
- // 判据本身长这样:`typeof service.register === 'function'` —— 那个 `'function'`
1187
- // 是**字符串字面量**,被 `code` 抹成空格之后正则永远匹配不上,
1188
- // 于是整条识别**静默不生效**(代码看起来加了,实际一条都没认出来)。
1189
- // 这与缺陷 19 那条「两种掩码不能混用」是同一个坑,只是这次换了个方向。
1190
- const helpers = helperGuards(decl)
1191
- const byVar = resolveLocalBindings(text)
1192
- for (const params of injectParamBindings(text)) {
1193
- services.add(params.svc); declared.add(params.svc)
1194
- if (!byVar.has(params.name)) byVar.set(params.name, { service: params.svc, form: params.form })
1195
- }
1196
- for (const [v, rec] of byVar) {
1197
- services.add(rec.service)
1198
- // 文件维度的视图:**每次出现都记**,不覆盖。
1199
- const fk = `${v}|${rel}`
1200
- if (!bindingsByFile.has(fk)) bindingsByFile.set(fk, new Set())
1201
- if (rec.service) bindingsByFile.get(fk).add(rec.service)
1202
- const prev = localBindings.get(v)
1203
- if (!prev) localBindings.set(v, { ...rec, file: rel })
1204
- else if (prev.service !== rec.service) {
1205
- // 同名变量在不同文件/位置绑到了不同服务 —— 是歧义,不能猜。
1206
- // 用一个哨兵表示,下面的别名规则遇到它就弃权并记账。
1207
- localBindings.set(v, {
1208
- service: null, form: 'ambiguous',
1209
- targets: [...new Set([...(prev.targets || [prev.service]), rec.service])].filter(Boolean).sort(),
1210
- file: rel,
1211
- })
1212
- }
1213
- }
1214
-
1215
- // 用绑定变量调用方法:`store.getPrefs()` 归给 `store` 所承载的服务
1216
- const aliasTargets = new Map()
1217
- for (const [v, rec] of byVar) {
1218
- if (!aliasTargets.has(v)) aliasTargets.set(v, new Set())
1219
- aliasTargets.get(v).add(rec.service)
1220
- }
1221
- for (const [variable, targets] of aliasTargets) {
1222
- if (targets.size > 1) {
1223
- ambiguousAliases.push({ file: rel, variable, services: [...targets].sort() })
1224
- continue
1225
- }
1226
- const svc = [...targets][0]
1227
- // 转手链的源头如果是「未知服务」,说明它一路来自某个 `ctx.get` 但名字
1228
- // 不在任何可信来源里 —— 这种归属不够格,弃权并记账。
1229
- if (!declared.has(svc) && !isKnownService(svc)) {
1230
- unresolvedBindings.push({ file: rel, variable, service: svc, reason: '绑定来源的服务名既不在插件声明里,也不在官方目录里' })
1231
- continue
1232
- }
1233
- // `(?:\?\.)?`:**可选调用也要收**。`x.method?.(...)` 是"没有就跳过"的标准写法,
1234
- // 不认它这条调用点就**整条不可见**(假阴性),而不是"保守"。
1235
- const re = new RegExp(String.raw`\b` + escapeRe(variable) + String.raw`\.([A-Za-z_]\w*)(?:\?\.)?\s*\(`, 'g')
1236
- // ⚠️ 扫 `code`(抹过注释与字符串)而不是 `text` —— 注释里写的示例代码
1237
- // 会被正则当成真调用(缺陷 19)。掩码等长,所以 `c.index` 换算行号仍然正确。
1238
- for (const c of code.matchAll(re)) {
1239
- // `ws.sessions.has(` —— 那个 `sessions` 是别人的字段,不是这个绑定
1240
- if (isForeignMemberAccess(code, c.index)) continue
1241
- addCall(svc, c[1], rel, lineOf(starts, c.index), 'alias', variable,
1242
- isGuardedNear(text, c.index, svc, c[1], variable, helpers))
1243
- }
1244
- }
1245
-
1246
- // `<ctx对象>.<svc>.<method>()` —— **最强证据**(服务名与调用点同处一个表达式)。
1247
- // 上下文对象有多个:字面量 `ctx`,以及注入回调的第二个形参(子上下文)。
1248
- // 从「第二个形参」这个位置认上下文对象,而不是从名字猜 —— 见 contextObjectNames。
1249
- // ⚠️ 传 `decl`(抹注释、**保留字符串**),不是 `text`。
1250
- //
1251
- // 第一版传的是原文,于是**注释里的示例文字会被当成真的上下文对象**:
1252
- // 我在夹具注释里写了一句「任何 `X.ctx`」,结果 `X.ctx` 进了 `subContexts`。
1253
- // `subContexts` 是**审计记录**("这个文件里认出了哪些上下文对象"),
1254
- // 往里塞一个注释里的名字就是让它撒谎 —— 与缺陷 19 是同一个坑。
1255
- //
1256
- // 为什么是 `decl` 而不是 `code`:这条规则要读 `ctx.inject([...], cb)`,
1257
- // 而数组里装的是**服务名字符串**;`code` 会把它们抹掉。名字本身用不上,
1258
- // 但保持与声明解析同一口径最省心(见缺陷 19 那张两列对照表)。
1259
- const ctxObjs = contextObjectNames(decl)
1260
- subContexts.push({ file: rel, names: [...ctxObjs].sort() })
1261
- for (const head of ctxObjs) {
1262
- const re = new RegExp(
1263
- String.raw`(?<![\w$.])` + escapeRe(head) + String.raw`\.([A-Za-z_]\w*)\.([A-Za-z_]\w*)(?:\?\.)?\s*\(`, 'g')
1264
- for (const m of code.matchAll(re)) {
1265
- services.add(m[1])
1266
- addressable.add(m[1])
1267
- // `ctx.<svc>.<method>()` 里服务名与调用点**同处一个表达式** —— 这本身就是
1268
- // 插件对「我依赖这个服务」的声明,强度不低于 `ctx.get("svc")`。
1269
- // 不认它会出事:官方目录不是运行时面(实测它不收录 `logger`),
1270
- // 于是 `ctx.logger.info()` 会被判成「无可信声明」而**静默丢掉** ——
1271
- // 那不是保守,那是漏报。探针 fixture `@fixture/clean` 就是这个形状。
1272
- declared.add(m[1])
1273
- addCall(m[1], m[2], rel, lineOf(starts, m.index), 'ctx-member', `${head}.${m[1]}`,
1274
- isGuardedNear(text, m.index, m[1], m[2], null, helpers))
1275
- }
1276
- }
1277
- // `perFile` 里要**同时**留两份文本,用途不同(混用会出错,见下):
1278
- // - `text: code` —— 抹掉注释**与字符串**。给"裸名调用扫描"用:
1279
- // 这里匹配的是 `svc.method(` 这种**代码**形状,字符串里的同名文字不算调用。
1280
- // - `mention: decl` —— 只抹注释、**保留字符串**。给"服务名出现在哪些文件"用:
1281
- // `ctx.get("remote.commands")` 里的名字就在**字符串**里,
1282
- // 抹掉它会让这个服务"哪儿都没出现过",于是从「客户端半边」掉进「宿主服务缺失」——
1283
- // 实测就是这么把 `dsh-pet` 从「可用」打成「不兼容」的。
1284
- // 两份都是等长的,行号与索引不受影响。
1285
- perFile.push({ rel, text: code, mention: decl, starts, helpers })
1286
- }
1287
-
1288
- // 服务名出现在哪些文件 —— 客户端服务在宿主探针里必然缺席,必须分开判定
1289
- for (const s of services) {
1290
- const re = new RegExp(String.raw`\b` + escapeRe(s) + String.raw`\b`)
1291
- // ⚠️ 用 `mention`(保留字符串)—— 判据是"这个服务名在这个文件里**被提到过**",
1292
- // 而 `ctx.get("svc")` 这种提到方式恰恰在字符串里。用抹掉字符串的 `code` 会让
1293
- // 一批服务"神秘消失",进而被判成"宿主服务缺失"(实测把 dsh-pet 打成不兼容)。
1294
- for (const { rel, mention: text } of perFile) {
1295
- if (re.test(text)) {
1296
- if (!serviceFiles.has(s)) serviceFiles.set(s, new Set())
1297
- serviceFiles.get(s).add(rel)
1298
- }
1299
- }
1300
- }
1301
-
1302
- // 裸名直呼:`svc.<method>()`。
1303
- //
1304
- // **这不再是主要证据来源**,而是 ②③④⑤ 都覆盖不到的兜底。顺序很重要:
1305
- // 它跑在绑定规则之后,因为绑定规则知道「这个变量到底是什么」,
1306
- // 而这里只能靠「名字恰好等于服务名」—— 这是**最弱的一档证据**,
1307
- // 所以它的调用点标记为 via='bare'(判定层据此可以降级标注)。
1308
- //
1309
- // 只对 `addressable` 匹配,不对 `services` 匹配 —— 这是本轮修掉的关键缺陷:
1310
- // `services` 里有「只在字符串/注释里出现过的名字」,用它们匹配会命中
1311
- // 同文件里毫不相干的局部变量(实测 163/942 个调用点在源码行上验证不出来)。
1312
- for (const { rel, text, starts, helpers } of perFile) {
1313
- for (const svc of addressable) {
1314
- if (localBindings.has(svc)) continue // 该名字被本地绑定占了,裸名含义不明
1315
- // 可信来源有两个:插件自己声明过(inject / ctx.inject / ctx.get),或官方目录里有。
1316
- // 两个都没有的名字只有「字符串里出现过」这一条证据 —— 不够。
1317
- if (!declared.has(svc) && !isKnownService(svc)) continue
1318
- // 该标识符在这个文件里被当作局部变量用过 → 裸名含义不明,弃权。
1319
- // 这是本轮的第二个闸门,实测把 DOM 元素方法(addEventListener/querySelector/…)
1320
- // 从 `slots` 服务名下清掉。弃权记录进 shadowedBare,可审计。
1321
- if (isShadowedIdent(text, svc)) { shadowedBare.push({ file: rel, service: svc }); continue }
1322
- const re = new RegExp(String.raw`\b` + escapeRe(svc) + String.raw`\.([A-Za-z_]\w*)(?:\?\.)?\s*\(`, 'g')
1323
- for (const m of text.matchAll(re)) {
1324
- // 同上的洞:`\b` 允许前面是 `.`。裸名档的 text 已是掩码后的 code,语义一致。
1325
- if (isForeignMemberAccess(text, m.index)) continue
1326
- addCall(svc, m[1], rel, lineOf(starts, m.index), 'bare', svc,
1327
- isGuardedNear(text, m.index, svc, m[1], null, helpers))
1328
- }
1329
- }
1330
- }
1331
-
1332
- // 同一 service.method 在同一行可能被多条规则同时命中,去重。
1333
- // 保留**证据最强**的那条:ctx-member > alias > bare。
1334
- const RANK = { 'ctx-member': 0, alias: 1, bare: 2 }
1335
- // 只有「每一处都做过能力检测」的 service.method 对才进这个名单。
1336
- // 去重之后才算,否则被去重掉的那一处会影响结论。
1337
- const guardedPairs = new Set()
1338
- const seen = new Map()
1339
- for (const s of sites) {
1340
- const k = `${s.service}|${s.method}|${s.file}|${s.line}`
1341
- const prev = seen.get(k)
1342
- if (!prev || RANK[s.via] < RANK[prev.via]) seen.set(k, s)
1343
- }
1344
- const uniqueSites = [...seen.values()]
1345
- // 两个集合都**从去重后的站点派生** —— 同一条规则的两面,必须用同一个口径。
1346
- // 只要**有一处**没做能力检测,这个对就必须照实报告(偏保守)。
1347
- const unguardedPairs = new Set(
1348
- uniqueSites.filter((x) => !x.guarded).map((x) => x.service + '.' + x.method),
1349
- )
1350
- for (const x of uniqueSites) {
1351
- const k = x.service + '.' + x.method
1352
- if (x.guarded && !unguardedPairs.has(k)) guardedPairs.add(k)
1353
- }
1354
-
1355
- // 现场绑定 → 可审计的形态:只留**服务名**是字符串字面量的那部分键,
1356
- // 否则 `byVar` 会把源码里所有 `x = y` 的转手都记下来(噪音)。
1357
- // 每个绑定一条记录,**带 `files[]`**:一个变量可能在多个文件里各自干净地绑定。
1358
- // 不变式 ⑤ 用 `(变量名, 文件)` 对账;`bindingsByFile` 就是那份账。
1359
- const bindingRecords = []
1360
- for (const [k, svcs] of [...bindingsByFile.entries()].sort()) {
1361
- const idx = k.lastIndexOf('|')
1362
- const variable = k.slice(0, idx)
1363
- const file = k.slice(idx + 1)
1364
- const entry = localBindings.get(variable) || {}
1365
- bindingRecords.push({
1366
- variable,
1367
- file,
1368
- service: svcs.size === 1 ? [...svcs][0] : null,
1369
- ...(svcs.size > 1 ? { form: 'ambiguous', targets: [...svcs].sort() } : { form: entry.form ?? 'unknown' }),
1370
- ...(entry.from ? { from: entry.from } : {}),
1371
- })
1372
- }
1373
- const bindings = bindingRecords
1374
- .filter((rec) => rec.service || rec.form === 'ambiguous')
1375
- .map((rec) => ({
1376
- variable: rec.variable,
1377
- service: rec.service,
1378
- form: rec.form,
1379
- // 转手链的上一跳:`const b = a` 里的 `a`。有它才说得清"这个归属是怎么来的"。
1380
- ...(rec.from ? { from: rec.from } : {}),
1381
- ...(rec.targets ? { targets: rec.targets } : {}),
1382
- file: rec.file,
1383
- }))
1384
- .sort((a, b) => cmp(a.variable, b.variable) || cmp(a.service, b.service))
1385
-
1386
- return {
1387
- services, declared, softDeclared, addressable, calls, serviceFiles,
1388
- sites: uniqueSites, provided, ambiguousAliases, bindings, shadowedBare, unresolvedBindings, subContexts,
1389
- guardedPairs: [...guardedPairs].sort(), unguardedPairs: [...unguardedPairs].sort(),
1390
70
  }
1391
71
  }
1392
-
1393
- /* ── 客户端归属启发式 ──────────────────────────────────────────── */
1394
-
1395
- /**
1396
- * 客户端提示:服务名属于已知客户端服务,或只在疑似客户端的路径里出现。
1397
- *
1398
- * 重要:这**只是提示,不是判据**。实测两个插件都存在同一服务被宿主与客户端
1399
- * 同时引用的情况,所以文件路径无法可靠区分宿端/客户端。真正的判据是运行时探针
1400
- * (插件形态下则直接查 ctx.loader 的真实 API 面)。
1401
- */
1402
- const KNOWN_CLIENT = new Set(['slots', 'locale', 'uiRenderer', 'uiSlots', 'themeStore',
1403
- 'modules', 'clientModules', 'connection'])
1404
-
1405
- function isClientFile(p) {
1406
- return p.replace(/\\/g, '/').split('/').some((seg) => seg.toLowerCase().includes('client'))
1407
- }
1408
-
72
+ const KNOWN_CLIENT = new Set([
73
+ "slots",
74
+ "locale",
75
+ "uiRenderer",
76
+ "uiSlots",
77
+ "themeStore",
78
+ "modules",
79
+ "clientModules",
80
+ "connection",
81
+ ]);
1409
82
  function clientHint(svc, files) {
1410
- if (KNOWN_CLIENT.has(svc)) return true
1411
- for (const f of files) if (isClientFile(f)) return true
1412
- return false
83
+ return (
84
+ KNOWN_CLIENT.has(svc) ||
85
+ [...files].some((f) => f.toLowerCase().includes("client"))
86
+ );
1413
87
  }
1414
-
1415
- /* ── 主流程 ───────────────────────────────────────────────────── */
1416
-
1417
88
  export function extractWithProvenance(nm, options = {}) {
1418
89
  // 官方双端 API 目录里出现过的服务名。用途**只有一个**:给「裸名/别名」这两条
1419
90
  // 证据较弱的匹配规则划定边界(见 makeKnownService)。它**不参与**「服务存不存在」
@@ -1421,46 +92,69 @@ export function extractWithProvenance(nm, options = {}) {
1421
92
  const catalogNames = new Set([
1422
93
  ...Object.keys(options.catalog?.host || {}),
1423
94
  ...Object.keys(options.catalog?.client || {}),
1424
- ])
95
+ ]);
1425
96
  // 找出「社区插件」:package.json 里有 dsh 清单、且非 @deepseek-ai 官方
1426
- const targets = []
97
+ const inventoryDiagnostics = [];
98
+ const targets = [];
1427
99
  for (const entry of fs.readdirSync(nm).sort()) {
1428
- const names = entry.startsWith('@')
1429
- ? (() => { try { return fs.readdirSync(join(nm, entry)).sort().map((s) => join(entry, s)) } catch { return [] } })()
1430
- : [entry]
100
+ const names = entry.startsWith("@")
101
+ ? (() => {
102
+ try {
103
+ return fs
104
+ .readdirSync(join(nm, entry))
105
+ .sort()
106
+ .map((s) => join(entry, s));
107
+ } catch (error) {
108
+ throw new Error(
109
+ `无法读取插件 scope ${entry}:${error.code || error.message}`,
110
+ );
111
+ }
112
+ })()
113
+ : [entry];
1431
114
  for (const base of names) {
1432
- const full = join(nm, base)
1433
- const j = pkgJson(full)
1434
- if (!j || !('dsh' in j) || String(j.name ?? '').startsWith('@deepseek-ai/')) continue
1435
- targets.push([base, full, j])
1436
- }
1437
- }
1438
-
1439
- const services = new Set()
1440
- const calls = new Map()
1441
- const manifests = []
1442
- const svcFiles = new Map()
1443
- const fallbackPkgs = []
115
+ const full = join(nm, base);
116
+ const j = pkgJson(full);
117
+ if (!j && fs.existsSync(join(full, "package.json")))
118
+ inventoryDiagnostics.push({
119
+ kind: "invalid-package-metadata",
120
+ file: base,
121
+ });
122
+ if (
123
+ !j ||
124
+ !("dsh" in j) ||
125
+ String(j.name ?? "").startsWith("@deepseek-ai/")
126
+ )
127
+ continue;
128
+ targets.push([base, full, j]);
129
+ }
130
+ }
131
+
132
+ const services = new Set();
133
+ const calls = new Map();
134
+ const manifests = [];
135
+ const svcFiles = new Map();
136
+ const fallbackPkgs = [];
1444
137
  // ── v2 新增的累积量 ──
1445
- const perPlugin = {} // 权威视图:逐插件归属
1446
- const providedAll = new Set() // 所有插件自己 provide 出来的服务名
1447
- const allSites = [] // 全局调用点(带 file:line)
138
+ const perPlugin = Object.create(null); // 权威视图:逐插件归属
139
+ const providedAll = new Set(); // 所有插件自己 provide 出来的服务名
140
+ const allSites = []; // 全局调用点(带 file:line)
1448
141
  const provenance = {
1449
142
  schemaVersion: 1,
1450
- note: '每个被扫描文件的来源分桶。client-extra = 同包内其它「像客户端」的文件(可能是懒加载 chunk),'
1451
- + '它们的调用点应当标为「疑似」而不是「确定」。skippedClientExtra = 被默认规则排除的文件(包内 src/ 源码树),'
1452
- + '记下来是为了可审计:空谈"排了什么"没有意义,要能逐条查。',
1453
- packages: {},
1454
- }
143
+ note:
144
+ "每个被扫描文件的来源分桶。client-extra = 同包内其它「像客户端」的文件(可能是懒加载 chunk)," +
145
+ "它们的调用点应当标为「疑似」而不是「确定」。skippedClientExtra = 被默认规则排除的文件(包内 src/ 源码树)," +
146
+ '记下来是为了可审计:空谈"排了什么"没有意义,要能逐条查。',
147
+ packages: Object.create(null),
148
+ };
1455
149
 
1456
150
  const addCall = (svc, method) => {
1457
- if (!calls.has(svc)) calls.set(svc, new Set())
1458
- calls.get(svc).add(method)
1459
- }
151
+ if (!calls.has(svc)) calls.set(svc, new Set());
152
+ calls.get(svc).add(method);
153
+ };
1460
154
 
1461
155
  for (const [, full, j] of targets) {
1462
- const dsh = j.dsh || {}
1463
- const client = dsh.client || {}
156
+ const dsh = j.dsh || {};
157
+ const client = dsh.client || {};
1464
158
  // 注意:这里必须把 undefined 归一成 null。python 的 json.dump 会把缺失值写成
1465
159
  // null,而 JSON.stringify **直接省略** undefined 的键 —— 对拍一开始就是被这个
1466
160
  // 抓到的(三个字段只在 python 版出现)。
@@ -1474,35 +168,58 @@ export function extractWithProvenance(nm, options = {}) {
1474
168
  // ⚠️ `manifests` 是对拍严格比对的**旧兼容视图**,不要往里加新字段 ——
1475
169
  // engines / compatibility 放在 perPlugin 里(对拍只严格比 manifests/services/fallbackPackages,
1476
170
  // 加了新键会让对拍报"只在 node 版出现")。这条是踩过的:加完当场被对拍抓住。
1477
- })
1478
-
1479
- const [blob, fellBack, buckets, skipped] = collectFiles(full, j, options)
1480
- if (fellBack) fallbackPkgs.push(j.name || full)
1481
-
1482
- const byBucket = { 'host-entry': [], 'client-entry': [], 'client-extra': [], fallback: [] }
1483
- for (const [rel, bucket] of buckets) byBucket[bucket].push(rel)
1484
- for (const k of Object.keys(byBucket)) byBucket[k].sort()
171
+ });
172
+
173
+ const [
174
+ blob,
175
+ fellBack,
176
+ buckets,
177
+ skipped,
178
+ graphDiagnostics,
179
+ fileSides,
180
+ entryFiles,
181
+ moduleLinks,
182
+ ] = collectPackageSources(full, j, options);
183
+ if (fellBack) fallbackPkgs.push(j.name || full);
184
+
185
+ const byBucket = {
186
+ "host-entry": [],
187
+ "client-entry": [],
188
+ "client-extra": [],
189
+ fallback: [],
190
+ };
191
+ for (const [rel, bucket] of buckets) byBucket[bucket].push(rel);
192
+ for (const k of Object.keys(byBucket)) byBucket[k].sort();
1485
193
  provenance.packages[j.name || full] = {
1486
194
  declaredClientEntry: declaredClientEntry(j),
1487
195
  fallback: fellBack,
1488
- counts: Object.fromEntries(Object.entries(byBucket).map(([k, v]) => [k, v.length])),
196
+ counts: Object.fromEntries(
197
+ Object.entries(byBucket).map(([k, v]) => [k, v.length]),
198
+ ),
1489
199
  files: byBucket,
1490
200
  // 跳过的文件必须记下来:否则以后没人说得清"到底排了什么"
1491
201
  skippedClientExtra: skipped.sort(),
1492
- }
202
+ };
1493
203
 
1494
204
  // ── 逐插件归属(A6)+ 行号(A7)+ 插件自有服务(A5 的数据基础)──
1495
- const scanned = scanPlugin(blob, catalogNames, j.name || full)
1496
- for (const s of scanned.services) services.add(s)
205
+ const scanned = analyzeSources(
206
+ blob,
207
+ catalogNames,
208
+ j.name || full,
209
+ fileSides,
210
+ entryFiles,
211
+ moduleLinks,
212
+ );
213
+ for (const s of scanned.services) services.add(s);
1497
214
  for (const [s, files] of scanned.serviceFiles) {
1498
- if (!svcFiles.has(s)) svcFiles.set(s, new Set())
1499
- for (const f of files) svcFiles.get(s).add(f)
215
+ if (!svcFiles.has(s)) svcFiles.set(s, new Set());
216
+ for (const f of files) svcFiles.get(s).add(f);
1500
217
  }
1501
- for (const [s, ms] of scanned.calls) for (const m of ms) addCall(s, m)
1502
- for (const s of scanned.provided) providedAll.add(s)
1503
- for (const s of scanned.sites) allSites.push(s)
218
+ for (const [s, ms] of scanned.calls) for (const m of ms) addCall(s, m);
219
+ for (const s of scanned.provided) providedAll.add(s);
220
+ for (const s of scanned.sites) allSites.push(s);
1504
221
 
1505
- const pkgName = j.name || full
222
+ const pkgName = j.name || full;
1506
223
  perPlugin[pkgName] = {
1507
224
  name: j.name ?? null,
1508
225
  version: j.version ?? null,
@@ -1516,10 +233,14 @@ export function extractWithProvenance(nm, options = {}) {
1516
233
  peerDependencies: j.peerDependencies ?? null,
1517
234
  // 声明的兼容性口径(第 8 题:「声明与实测不符」这一类结论的输入)
1518
235
  engines: j.engines ?? null,
1519
- compatibility: (dsh.compatibility ?? null),
236
+ compatibility: dsh.compatibility ?? null,
1520
237
  declaredClientEntry: declaredClientEntry(j),
1521
238
  fallback: fellBack,
1522
239
  files: byBucket,
240
+ fileSides,
241
+ entryFiles,
242
+ dependencies: scanned.dependencies,
243
+ diagnostics: [...graphDiagnostics, ...scanned.diagnostics],
1523
244
  services: [...scanned.services].sort(),
1524
245
  // 哪些服务来自可信声明(inject / ctx.inject / ctx.get)—— 确定性分档用
1525
246
  declaredServices: [...scanned.declared].sort(),
@@ -1548,20 +269,34 @@ export function extractWithProvenance(nm, options = {}) {
1548
269
  // 服务名 → 出现在哪些文件。判定层据此判断某个服务是宿主侧还是客户端侧
1549
270
  // (只看客户端桶的文件 = 客户端服务),比按名字猜可靠得多。
1550
271
  serviceFiles: Object.fromEntries(
1551
- [...scanned.serviceFiles.entries()].sort().map(([k, v]) => [k, [...v].sort()]),
272
+ [...scanned.serviceFiles.entries()]
273
+ .sort()
274
+ .map(([k, v]) => [k, [...v].sort()]),
1552
275
  ),
1553
276
  clientHints: Object.fromEntries(
1554
- [...scanned.services].sort().map((s) => [s, clientHint(s, scanned.serviceFiles.get(s) || new Set())]),
277
+ [...scanned.services]
278
+ .sort()
279
+ .map((s) => [
280
+ s,
281
+ clientHint(s, scanned.serviceFiles.get(s) || new Set()),
282
+ ]),
1555
283
  ),
1556
- }
284
+ };
1557
285
  }
1558
286
 
1559
- const clientHints = {}
1560
- for (const s of [...services].sort()) clientHints[s] = clientHint(s, svcFiles.get(s) || new Set())
287
+ const clientHints = {};
288
+ for (const s of [...services].sort())
289
+ clientHints[s] = clientHint(s, svcFiles.get(s) || new Set());
1561
290
 
1562
291
  return {
1563
292
  report: {
1564
293
  schemaVersion: 2,
294
+ diagnostics: inventoryDiagnostics,
295
+ scanComplete:
296
+ inventoryDiagnostics.length === 0 &&
297
+ Object.values(perPlugin).every(
298
+ (p) => !p.diagnostics.some((d) => d.kind !== "entry-fallback"),
299
+ ),
1565
300
  manifests,
1566
301
  services: [...services].sort(),
1567
302
  clientHints,
@@ -1574,70 +309,55 @@ export function extractWithProvenance(nm, options = {}) {
1574
309
  // 排序必须带 plugin:探针实测两个包都有 lib/index.js 时,两条调用点都是
1575
310
  // {file:"lib/index.js",line:1} —— **按 file+line 定位会取到错插件的源码**。
1576
311
  // 「位置」正是本工具的核心产物之一,所以 plugin 必须进排序键。
1577
- callSites: allSites.sort((a, b) =>
1578
- cmp(a.plugin, b.plugin) || cmp(a.file, b.file)
1579
- || (a.line ?? 0) - (b.line ?? 0)
1580
- || cmp(a.service, b.service) || cmp(a.method, b.method)),
312
+ callSites: allSites.sort(
313
+ (a, b) =>
314
+ cmp(a.plugin, b.plugin) ||
315
+ cmp(a.file, b.file) ||
316
+ (a.line ?? 0) - (b.line ?? 0) ||
317
+ cmp(a.service, b.service) ||
318
+ cmp(a.method, b.method),
319
+ ),
1581
320
  // 全部插件自己 provide 出来的服务名 —— 它们不该被当成宿主服务来判缺失
1582
321
  providedServices: [...providedAll].sort(),
1583
322
  },
1584
323
  provenance,
1585
- }
324
+ };
1586
325
  }
1587
326
 
1588
- /** 兼容入口:只拿 static.json 那份报告(与 python 版契约一致) */
1589
327
  export function extract(nm) {
1590
- return extractWithProvenance(nm).report
1591
- }
1592
-
1593
- function escapeRe(s) {
1594
- return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
1595
- }
1596
-
1597
- /* ── CLI:与 python 版一样写 static.json / services.json ─────────── */
1598
-
1599
- const REPO_ROOT = resolve(dirname(new URL(import.meta.url).pathname), '..')
1600
-
1601
- /**
1602
- * CLI 用的目录加载:**读不到就不给边界**。
1603
- *
1604
- * 目录文件是 `rules/dsh-api-catalog.json`(由 tools/catalog 从 asar 现抽生成),
1605
- * 相对本文件定位。读不到时返回 null,裸名/别名规则的边界退化为「插件自己声明过的
1606
- * 名字」—— 行为仍然正确,只是更保守,而不是崩掉或放宽。
1607
- */
1608
- function loadCatalog() {
1609
- try {
1610
- return JSON.parse(fs.readFileSync(resolve(join(REPO_ROOT, 'rules/dsh-api-catalog.json')), 'utf8'))
1611
- } catch { return null }
1612
- }
1613
-
1614
- if (process.argv[1] && resolve(process.argv[1]) === resolve(new URL(import.meta.url).pathname)) {
1615
- const [nm, workdir] = process.argv.slice(2)
328
+ return extractWithProvenance(nm).report;
329
+ }
330
+ const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
331
+ if (
332
+ process.argv[1] &&
333
+ resolve(process.argv[1]) === fileURLToPath(import.meta.url)
334
+ ) {
335
+ const [nm, workdir] = process.argv.slice(2);
1616
336
  if (!nm || !workdir) {
1617
- console.error('用法: node lib/extract.mjs <node_modules 目录> <输出目录>')
1618
- process.exit(2)
337
+ console.error("用法: node lib/extract.mjs <node_modules 目录> <输出目录>");
338
+ process.exit(2);
1619
339
  }
1620
- const { report, provenance } = extractWithProvenance(nm, { catalog: loadCatalog() })
1621
- // ⚠️ **输出目录必须自己建。** 不建的话第一次跑就是 `ENOENT: no such file or directory,
1622
- // open '.../out/static.json'` —— 而文档里把这条命令当成推荐用法写着。
1623
- //
1624
- // 这是**同一个失败模式第二次出现**:探针的 `w()` 曾经也这样(`try/catch` 把 ENOENT
1625
- // 咽掉,于是"目录不存在"表现为"探针什么都不产出、也不报错")。
1626
- // 这次的形状不同(这里直接抛,至少看得见),但根因一样:**假定目录已经在了**。
1627
- fs.mkdirSync(workdir, { recursive: true })
1628
- fs.writeFileSync(join(workdir, 'static.json'), JSON.stringify(report, null, 2))
1629
- fs.writeFileSync(join(workdir, 'services.json'), JSON.stringify({ services: report.services }, null, 2))
1630
- // 来源分桶写到**单独文件**:static.json 的契约保持与 python 版逐字一致,
1631
- // 对拍才有意义。分桶是给判定层用的附加信息,不是契约的一部分。
1632
- fs.writeFileSync(join(workdir, 'provenance.json'), JSON.stringify(provenance, null, 2))
1633
- for (const m of report.manifests) {
1634
- console.log(` 插件: ${m.name}@${m.version} bundle=${m.dshBundle} client=${m.clientPlatform}`)
340
+ try {
341
+ const catalog = JSON.parse(
342
+ fs.readFileSync(join(REPO_ROOT, "rules/dsh-api-catalog.json"), "utf8"),
343
+ );
344
+ const { report, provenance } = extractWithProvenance(nm, { catalog });
345
+ fs.mkdirSync(workdir, { recursive: true });
346
+ for (const [file, data] of [
347
+ ["static.json", report],
348
+ ["services.json", { services: report.services }],
349
+ ["provenance.json", provenance],
350
+ ])
351
+ fs.writeFileSync(join(workdir, file), JSON.stringify(data, null, 2));
352
+ for (const m of report.manifests)
353
+ console.log(
354
+ `插件: ${m.name}@${m.version} bundle=${m.dshBundle} client=${m.clientPlatform}`,
355
+ );
356
+ console.log(
357
+ `提取到 ${report.services.length} 个服务引用;${report.callSites.length} 个调用点`,
358
+ );
359
+ } catch (error) {
360
+ console.error(`静态提取失败: ${error.message}`);
361
+ process.exitCode = 3;
1635
362
  }
1636
- console.log(` 提取到 ${report.services.length} 个服务引用: ${report.services.join(', ') || '(未识别)'}`)
1637
- const tot = Object.values(provenance.packages).reduce((a, p) => {
1638
- for (const [k, v] of Object.entries(p.counts)) a[k] = (a[k] || 0) + v
1639
- return a
1640
- }, {})
1641
- const nSkipped = Object.values(provenance.packages).reduce((a, p) => a + p.skippedClientExtra.length, 0)
1642
- console.log(` 文件来源: ${Object.entries(tot).map(([k, v]) => `${k}=${v}`).join(' ')} 跳过(默认规则)=${nSkipped}`)
1643
363
  }