dsh-plugin-compat-check 0.1.4 → 0.1.6

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.
Files changed (43) hide show
  1. package/README.md +39 -17
  2. package/THIRD_PARTY_NOTICES.md +139 -0
  3. package/index.js +282 -53
  4. package/kernel/lib/author-ci-output.mjs +188 -0
  5. package/kernel/lib/author-ci-policy.mjs +285 -0
  6. package/kernel/lib/catalog.mjs +38 -0
  7. package/kernel/lib/contracts.mjs +88 -0
  8. package/kernel/lib/dependency-graph.mjs +650 -0
  9. package/kernel/lib/extract.mjs +14 -0
  10. package/kernel/lib/finding-guidance.mjs +72 -0
  11. package/kernel/lib/finding-review-store.mjs +245 -0
  12. package/kernel/lib/finding-review.mjs +338 -0
  13. package/kernel/lib/functional-health-contract.mjs +337 -0
  14. package/kernel/lib/issue-report.mjs +217 -0
  15. package/kernel/lib/observation.mjs +19 -9
  16. package/kernel/lib/report-identity-worker.mjs +153 -0
  17. package/kernel/lib/report-identity.mjs +15 -0
  18. package/kernel/lib/report-service.mjs +196 -11
  19. package/kernel/lib/report.mjs +76 -11
  20. package/kernel/lib/source-analysis.mjs +24 -6
  21. package/kernel/lib/upgrade-capture-worker.mjs +20 -0
  22. package/kernel/lib/upgrade-cleanup-worker.mjs +28 -0
  23. package/kernel/lib/upgrade-contract.mjs +471 -0
  24. package/kernel/lib/upgrade-input.mjs +309 -0
  25. package/kernel/lib/upgrade-preview.mjs +256 -0
  26. package/kernel/lib/upgrade-worker.mjs +50 -0
  27. package/kernel/lib/version.mjs +1 -1
  28. package/kernel/lib/worker-analysis.mjs +16 -1
  29. package/kernel/lib/workflow-panels.mjs +706 -0
  30. package/kernel/rules/README.md +5 -5
  31. package/lib/client.js +2974 -69
  32. package/lib/dependency-graph.js +651 -0
  33. package/lib/finding-guidance.js +73 -0
  34. package/lib/finding-review.js +339 -0
  35. package/lib/functional-health-contract.js +338 -0
  36. package/lib/report-contract.js +88 -0
  37. package/lib/upgrade-contract.js +472 -0
  38. package/lib/workflow-panels.js +707 -0
  39. package/package.json +10 -5
  40. package/scripts/prepublish-check.mjs +28 -13
  41. package/scripts/stage-contract.mjs +91 -29
  42. package/scripts/stage-kernel.mjs +3 -2
  43. package/skill/SKILL.md +90 -39
@@ -0,0 +1,650 @@
1
+ /** Browser-safe explanatory graph. Declarations never establish activation or failure. */
2
+ const GRAPH_KIND = "plugin-compat-check/dependency-graph";
3
+ const GRAPH_LIMITS = Object.freeze({
4
+ nodes: 1000,
5
+ edges: 4000,
6
+ traversal: 20000,
7
+ items: 20000,
8
+ });
9
+ const graphRecord = (value) =>
10
+ value !== null && typeof value === "object" && !Array.isArray(value);
11
+ const graphText = (value) => typeof value === "string" && value.length > 0;
12
+ const graphArray = (value) => (Array.isArray(value) ? value : []);
13
+ const graphSides = (value) => {
14
+ const sides = graphArray(value).filter(
15
+ (side) => side === "host" || side === "client",
16
+ );
17
+ return sides.length ? [...new Set(sides)] : ["unknown"];
18
+ };
19
+ const graphId = (...parts) => JSON.stringify(parts);
20
+ const graphSource = (value) => ({
21
+ file: typeof value?.file === "string" ? value.file : null,
22
+ line: Number.isSafeInteger(value?.line) ? value.line : null,
23
+ start: Number.isSafeInteger(value?.start) ? value.start : null,
24
+ end: Number.isSafeInteger(value?.end) ? value.end : null,
25
+ });
26
+ const graphRequirement = (kind) =>
27
+ kind === "required-inject"
28
+ ? "required"
29
+ : kind === "scoped-inject"
30
+ ? "subcontext"
31
+ : ["optional-read", "read"].includes(kind)
32
+ ? "optional"
33
+ : "unknown";
34
+
35
+ export function buildDependencyGraph({
36
+ extraction,
37
+ verdict,
38
+ entries,
39
+ limits = {},
40
+ } = {}) {
41
+ const report = extraction?.report || extraction || {};
42
+ const budget = Object.fromEntries(
43
+ Object.entries(GRAPH_LIMITS).map(([key, max]) => [
44
+ key,
45
+ Number.isSafeInteger(limits[key]) && limits[key] >= 0
46
+ ? Math.min(limits[key], max)
47
+ : max,
48
+ ]),
49
+ );
50
+ const nodes = [],
51
+ edges = [],
52
+ impacts = [],
53
+ cycles = [],
54
+ reasons = new Set();
55
+ const nodeMap = new Map(),
56
+ edgeKeys = new Set(),
57
+ plugins = new Map();
58
+ let truncated = false,
59
+ items = 0,
60
+ steps = 0,
61
+ pathItems = 0;
62
+ const limit = (reason) => {
63
+ truncated = true;
64
+ reasons.add(reason);
65
+ };
66
+ const consume = () => {
67
+ if (items >= budget.items) {
68
+ limit("输入条目达到预算,关系及来源未完整读取");
69
+ return false;
70
+ }
71
+ items++;
72
+ return true;
73
+ };
74
+ const addNode = (node) => {
75
+ if (nodeMap.has(node.id)) return nodeMap.get(node.id);
76
+ if (nodes.length >= budget.nodes) {
77
+ limit("节点达到预算,关系图已截断");
78
+ return null;
79
+ }
80
+ nodes.push(node);
81
+ nodeMap.set(node.id, node);
82
+ return node;
83
+ };
84
+ const addEdge = (edge) => {
85
+ if (!nodeMap.has(edge.from) || !nodeMap.has(edge.to)) return;
86
+ const key = JSON.stringify(edge);
87
+ if (edgeKeys.has(key)) return;
88
+ if (edges.length >= budget.edges) {
89
+ limit("关系达到预算,关系图已截断");
90
+ return;
91
+ }
92
+ edgeKeys.add(key);
93
+ edges.push({ id: `edge:${edges.length}`, ...edge });
94
+ };
95
+ const service = (name, side) =>
96
+ addNode({
97
+ id: graphId("service", side, name),
98
+ kind: "service",
99
+ name,
100
+ side,
101
+ state: "unknown",
102
+ });
103
+ const entryMap = new Map();
104
+ const rows = Array.isArray(entries)
105
+ ? entries
106
+ : graphArray(verdict?.observation?.entries?.plugins);
107
+ for (const row of rows) {
108
+ if (!consume()) break;
109
+ if (!graphRecord(row) || !graphText(row.packageName) || row.group) continue;
110
+ if (!entryMap.has(row.packageName)) entryMap.set(row.packageName, []);
111
+ entryMap.get(row.packageName).push({
112
+ id: typeof row.id === "string" ? row.id : null,
113
+ state: typeof row.state === "string" ? row.state : "unknown",
114
+ disabled: row.disabled === true,
115
+ });
116
+ }
117
+ if (report.scanComplete !== true)
118
+ reasons.add("静态提取覆盖不完整或未声明,关系可能缺失");
119
+ for (const [name, p] of Object.entries(report.perPlugin || {})) {
120
+ if (!consume()) break;
121
+ if (!graphRecord(p)) continue;
122
+ const entryRows = entryMap.get(name) || [];
123
+ const enabled = entryRows.filter((row) => !row.disabled);
124
+ const states = [...new Set(enabled.map((row) => row.state))];
125
+ const loaderState =
126
+ entryRows.length && !enabled.length
127
+ ? "disabled"
128
+ : states.length === 1
129
+ ? states[0]
130
+ : states.length > 1
131
+ ? "mixed"
132
+ : "unknown";
133
+ if (loaderState === "unknown")
134
+ reasons.add("部分插件未提供可映射 loader 条目,状态为未知");
135
+ const node = addNode({
136
+ id: graphId("plugin", name),
137
+ kind: "plugin",
138
+ name,
139
+ entries: entryRows,
140
+ loaderState,
141
+ subContexts: [],
142
+ });
143
+ if (!node) break;
144
+ plugins.set(name, { node, plugin: p });
145
+ if (graphArray(p.diagnostics).length)
146
+ reasons.add("部分源码存在提取诊断,关系可能缺失");
147
+ for (const context of graphArray(p.subContexts)) {
148
+ if (!consume()) break;
149
+ if (!graphRecord(context)) continue;
150
+ const names = [];
151
+ for (const name of graphArray(context.names)) {
152
+ if (!consume()) break;
153
+ if (graphText(name)) names.push(name);
154
+ }
155
+ node.subContexts.push({
156
+ file: typeof context.file === "string" ? context.file : null,
157
+ names,
158
+ });
159
+ }
160
+ }
161
+ for (const { node, plugin: p } of plugins.values()) {
162
+ const declaredRelations = new Set();
163
+ for (const dependency of graphArray(p.dependencies)) {
164
+ if (!consume()) break;
165
+ if (!graphText(dependency?.service)) continue;
166
+ const sides = graphSides(dependency.sides);
167
+ if (sides.includes("unknown"))
168
+ reasons.add("部分依赖端归属未知,不用于传播推导");
169
+ if (graphRequirement(dependency.kind) === "unknown")
170
+ reasons.add("部分依赖必需性未证实,保留未知标记");
171
+ for (const side of sides) {
172
+ declaredRelations.add(graphId(dependency.service, side));
173
+ const target = service(dependency.service, side);
174
+ if (target)
175
+ addEdge({
176
+ kind: "dependency",
177
+ from: node.id,
178
+ to: target.id,
179
+ service: dependency.service,
180
+ side,
181
+ requirement: graphRequirement(dependency.kind),
182
+ declarationKind:
183
+ typeof dependency.kind === "string" ? dependency.kind : "unknown",
184
+ source: graphSource(dependency),
185
+ potential: true,
186
+ });
187
+ }
188
+ }
189
+ for (const site of graphArray(p.callSites)) {
190
+ if (!consume()) break;
191
+ if (!graphText(site?.service)) continue;
192
+ for (const side of graphSides(site.sides)) {
193
+ const key = graphId(site.service, side);
194
+ if (declaredRelations.has(key)) continue;
195
+ declaredRelations.add(key);
196
+ reasons.add("部分服务只见调用点,依赖必需性未知");
197
+ const target = service(site.service, side);
198
+ if (target)
199
+ addEdge({
200
+ kind: "dependency",
201
+ from: node.id,
202
+ to: target.id,
203
+ service: site.service,
204
+ side,
205
+ requirement: "unknown",
206
+ declarationKind: "call",
207
+ source: graphSource(site),
208
+ potential: true,
209
+ });
210
+ }
211
+ }
212
+ const provided = new Set();
213
+ for (const site of graphArray(p.provideSites)) {
214
+ if (!consume()) break;
215
+ if (!graphText(site?.service)) continue;
216
+ provided.add(site.service);
217
+ for (const side of graphSides(site.sides)) {
218
+ if (side === "unknown")
219
+ reasons.add("部分提供声明缺少端归属,只列候选,不用于传播推导");
220
+ const target = service(site.service, side);
221
+ if (target)
222
+ addEdge({
223
+ kind: "candidate-provider",
224
+ from: node.id,
225
+ to: target.id,
226
+ service: site.service,
227
+ side,
228
+ source: graphSource(site),
229
+ sideMatch: side === "unknown" ? "unverified" : "source",
230
+ potential: true,
231
+ });
232
+ }
233
+ }
234
+ for (const name of graphArray(p.providedServices)) {
235
+ if (!consume()) break;
236
+ if (!graphText(name) || provided.has(name)) continue;
237
+ reasons.add("部分提供声明缺少端归属,只列候选,不用于传播推导");
238
+ const target = service(name, "unknown");
239
+ if (target)
240
+ addEdge({
241
+ kind: "candidate-provider",
242
+ from: node.id,
243
+ to: target.id,
244
+ service: name,
245
+ side: "unknown",
246
+ source: graphSource(null),
247
+ sideMatch: "unverified",
248
+ potential: true,
249
+ });
250
+ }
251
+ if (
252
+ p.clientInject !== undefined &&
253
+ p.clientInject !== null &&
254
+ !Array.isArray(p.clientInject)
255
+ )
256
+ reasons.add("客户端包 inject 声明格式未识别,未猜测服务关系");
257
+ for (const name of graphArray(p.clientInject)) {
258
+ if (!consume()) break;
259
+ if (!graphText(name)) {
260
+ reasons.add("客户端包 inject 含未识别条目");
261
+ continue;
262
+ }
263
+ const target = addNode({
264
+ id: graphId("client-package", name),
265
+ kind: "client-package",
266
+ name,
267
+ side: "client",
268
+ });
269
+ if (target)
270
+ addEdge({
271
+ kind: "client-package-inject",
272
+ from: node.id,
273
+ to: target.id,
274
+ side: "client",
275
+ potential: true,
276
+ });
277
+ }
278
+ }
279
+ const provisions = new Map(),
280
+ consumers = new Map(),
281
+ cycleKeys = new Set();
282
+ for (const edge of edges) {
283
+ if (edge.side === "unknown") continue;
284
+ const map =
285
+ edge.kind === "candidate-provider"
286
+ ? provisions
287
+ : edge.kind === "dependency"
288
+ ? consumers
289
+ : null;
290
+ const key = edge.kind === "candidate-provider" ? edge.from : edge.to;
291
+ if (map) {
292
+ if (!map.has(key)) map.set(key, []);
293
+ map.get(key).push(edge);
294
+ }
295
+ }
296
+ const step = () => {
297
+ if (steps >= budget.traversal) {
298
+ limit("传播遍历达到预算,潜在影响及环未完整枚举");
299
+ return false;
300
+ }
301
+ steps++;
302
+ return true;
303
+ };
304
+ for (const { node } of plugins.values()) {
305
+ const affected = [],
306
+ queue = [
307
+ {
308
+ plugin: node.id,
309
+ side: null,
310
+ path: [],
311
+ requirements: [],
312
+ plugins: [node.id],
313
+ },
314
+ ],
315
+ seen = new Set();
316
+ for (let head = 0; head < queue.length; head++) {
317
+ if (!step()) break;
318
+ const current = queue[head];
319
+ for (const provision of provisions.get(current.plugin) || []) {
320
+ if (!step()) break;
321
+ if (current.side && provision.side !== current.side) continue;
322
+ for (const dependency of consumers.get(provision.to) || []) {
323
+ if (!step()) break;
324
+ const path = [...current.path, provision.id, dependency.id];
325
+ const requirement = [...current.requirements, dependency.requirement];
326
+ const pluginPath = [...current.plugins, dependency.from];
327
+ if (pathItems + path.length > budget.traversal) {
328
+ limit("关系路径达到预算,潜在影响及环未完整列出");
329
+ continue;
330
+ }
331
+ if (dependency.from === node.id) {
332
+ const ring = pluginPath.slice(0, -1);
333
+ const key = JSON.stringify([
334
+ provision.side,
335
+ [
336
+ ...ring.map((_, i) =>
337
+ JSON.stringify([...ring.slice(i), ...ring.slice(0, i)]),
338
+ ),
339
+ ].sort()[0],
340
+ ]);
341
+ if (!cycleKeys.has(key)) {
342
+ pathItems += path.length;
343
+ cycleKeys.add(key);
344
+ cycles.push({
345
+ plugins: pluginPath,
346
+ side: provision.side,
347
+ path,
348
+ potential: true,
349
+ });
350
+ }
351
+ continue;
352
+ }
353
+ const key = graphId(dependency.from, provision.side);
354
+ if (seen.has(key)) continue;
355
+ seen.add(key);
356
+ pathItems += path.length;
357
+ affected.push({
358
+ plugin: dependency.from,
359
+ side: provision.side,
360
+ path,
361
+ requirements: requirement,
362
+ potential: true,
363
+ });
364
+ queue.push({
365
+ plugin: dependency.from,
366
+ side: provision.side,
367
+ path,
368
+ requirements: requirement,
369
+ plugins: pluginPath,
370
+ });
371
+ }
372
+ }
373
+ }
374
+ impacts.push({ plugin: node.id, potential: true, affected });
375
+ }
376
+ return {
377
+ schemaVersion: 1,
378
+ kind: GRAPH_KIND,
379
+ nodes,
380
+ edges,
381
+ impacts,
382
+ cycles,
383
+ coverage: {
384
+ status: reasons.size ? "limited" : "complete",
385
+ truncated,
386
+ reasons: [...reasons],
387
+ limits: budget,
388
+ },
389
+ notes: [
390
+ "提供声明仅为候选线索;loader 条目状态不证明服务已注册。",
391
+ "多个候选不表示实际选择了其中之一;替代提供者、禁用条目及可选依赖均需在真实宿主复核。",
392
+ "受影响者及环仅表示同端声明关系的潜在影响,不表示实际挂起或失败;可选及子作用域依赖不等于根插件阻断。",
393
+ "host/client 独立;client 包 inject 不当作 host 服务依赖;端未知的声明不用于传播推导。",
394
+ ],
395
+ };
396
+ }
397
+
398
+ /** Reject malformed optional views before rendering without changing the core report. */
399
+ export function validateDependencyGraph(graph) {
400
+ if (
401
+ !graphRecord(graph) ||
402
+ graph.kind !== GRAPH_KIND ||
403
+ graph.schemaVersion !== 1
404
+ )
405
+ return false;
406
+ const coverage = graph.coverage;
407
+ if (
408
+ !graphRecord(coverage) ||
409
+ !["limited", "complete"].includes(coverage.status) ||
410
+ typeof coverage.truncated !== "boolean" ||
411
+ !Array.isArray(coverage.reasons) ||
412
+ coverage.reasons.length > 100 ||
413
+ !coverage.reasons.every(graphText) ||
414
+ !graphRecord(coverage.limits)
415
+ )
416
+ return false;
417
+ if (
418
+ (coverage.truncated && coverage.status !== "limited") ||
419
+ (coverage.status === "complete" && coverage.reasons.length) ||
420
+ (coverage.status === "limited" && !coverage.reasons.length)
421
+ )
422
+ return false;
423
+ for (const [key, max] of Object.entries(GRAPH_LIMITS))
424
+ if (
425
+ !Number.isSafeInteger(coverage.limits[key]) ||
426
+ coverage.limits[key] < 0 ||
427
+ coverage.limits[key] > max
428
+ )
429
+ return false;
430
+ if (
431
+ !Array.isArray(graph.nodes) ||
432
+ graph.nodes.length > coverage.limits.nodes ||
433
+ !Array.isArray(graph.edges) ||
434
+ graph.edges.length > coverage.limits.edges ||
435
+ !Array.isArray(graph.impacts) ||
436
+ !Array.isArray(graph.cycles) ||
437
+ !Array.isArray(graph.notes) ||
438
+ graph.notes.length > 100 ||
439
+ !graph.notes.every(graphText)
440
+ )
441
+ return false;
442
+ const nodes = new Map(),
443
+ edges = new Map();
444
+ const validSide = (side) => ["host", "client", "unknown"].includes(side);
445
+ const nonnegative = (value) =>
446
+ value === null || (Number.isSafeInteger(value) && value >= 0);
447
+ const source = (value) =>
448
+ graphRecord(value) &&
449
+ (value.file === null || typeof value.file === "string") &&
450
+ nonnegative(value.line) &&
451
+ nonnegative(value.start) &&
452
+ nonnegative(value.end) &&
453
+ (value.start === null || value.end === null || value.end >= value.start);
454
+ let nestedItems = 0,
455
+ pathItems = 0;
456
+ for (const node of graph.nodes) {
457
+ if (
458
+ !graphRecord(node) ||
459
+ !graphText(node.id) ||
460
+ nodes.has(node.id) ||
461
+ !graphText(node.name) ||
462
+ !["plugin", "service", "client-package"].includes(node.kind)
463
+ )
464
+ return false;
465
+ if (node.kind === "plugin") {
466
+ if (
467
+ !graphText(node.loaderState) ||
468
+ !Array.isArray(node.entries) ||
469
+ !Array.isArray(node.subContexts)
470
+ )
471
+ return false;
472
+ nestedItems += node.entries.length + node.subContexts.length;
473
+ if (nestedItems > coverage.limits.items) return false;
474
+ if (
475
+ !node.entries.every(
476
+ (row) =>
477
+ graphRecord(row) &&
478
+ (row.id === null || typeof row.id === "string") &&
479
+ graphText(row.state) &&
480
+ typeof row.disabled === "boolean",
481
+ )
482
+ )
483
+ return false;
484
+ for (const context of node.subContexts) {
485
+ if (
486
+ !graphRecord(context) ||
487
+ (context.file !== null && typeof context.file !== "string") ||
488
+ !Array.isArray(context.names)
489
+ )
490
+ return false;
491
+ nestedItems += context.names.length;
492
+ if (
493
+ nestedItems > coverage.limits.items ||
494
+ !context.names.every(graphText)
495
+ )
496
+ return false;
497
+ }
498
+ } else if (
499
+ !validSide(node.side) ||
500
+ (node.kind === "service" && node.state !== "unknown") ||
501
+ (node.kind === "client-package" && node.side !== "client")
502
+ )
503
+ return false;
504
+ nodes.set(node.id, node);
505
+ }
506
+ for (const edge of graph.edges) {
507
+ if (
508
+ !graphRecord(edge) ||
509
+ !graphText(edge.id) ||
510
+ edges.has(edge.id) ||
511
+ !nodes.has(edge.from) ||
512
+ !nodes.has(edge.to) ||
513
+ nodes.get(edge.from).kind !== "plugin" ||
514
+ !validSide(edge.side) ||
515
+ edge.potential !== true
516
+ )
517
+ return false;
518
+ if (edge.kind === "client-package-inject") {
519
+ if (
520
+ nodes.get(edge.to).kind !== "client-package" ||
521
+ edge.side !== "client"
522
+ )
523
+ return false;
524
+ } else if (["dependency", "candidate-provider"].includes(edge.kind)) {
525
+ if (
526
+ nodes.get(edge.to).kind !== "service" ||
527
+ !graphText(edge.service) ||
528
+ nodes.get(edge.to).name !== edge.service ||
529
+ nodes.get(edge.to).side !== edge.side ||
530
+ !source(edge.source)
531
+ )
532
+ return false;
533
+ if (
534
+ edge.kind === "dependency" &&
535
+ (!["required", "optional", "subcontext", "unknown"].includes(
536
+ edge.requirement,
537
+ ) ||
538
+ !graphText(edge.declarationKind) ||
539
+ edge.requirement !== graphRequirement(edge.declarationKind))
540
+ )
541
+ return false;
542
+ if (
543
+ edge.kind === "candidate-provider" &&
544
+ edge.sideMatch !== (edge.side === "unknown" ? "unverified" : "source")
545
+ )
546
+ return false;
547
+ } else return false;
548
+ edges.set(edge.id, edge);
549
+ }
550
+ const validPath = (path, from, to, side, requirements, pluginPath) => {
551
+ if (!Array.isArray(path) || path.length < 2 || path.length % 2)
552
+ return false;
553
+ pathItems += path.length;
554
+ if (pathItems > coverage.limits.traversal) return false;
555
+ if (
556
+ requirements &&
557
+ (!Array.isArray(requirements) || requirements.length * 2 !== path.length)
558
+ )
559
+ return false;
560
+ if (
561
+ pluginPath &&
562
+ (!Array.isArray(pluginPath) ||
563
+ pluginPath.length !== path.length / 2 + 1 ||
564
+ pluginPath[0] !== from)
565
+ )
566
+ return false;
567
+ let current = from;
568
+ for (let i = 0; i < path.length; i += 2) {
569
+ const provider = edges.get(path[i]),
570
+ consumer = edges.get(path[i + 1]);
571
+ if (
572
+ provider?.kind !== "candidate-provider" ||
573
+ consumer?.kind !== "dependency" ||
574
+ provider.side !== side ||
575
+ consumer.side !== side ||
576
+ provider.from !== current ||
577
+ provider.to !== consumer.to
578
+ )
579
+ return false;
580
+ if (requirements && requirements[i / 2] !== consumer.requirement)
581
+ return false;
582
+ current = consumer.from;
583
+ if (pluginPath && pluginPath[i / 2 + 1] !== current) return false;
584
+ }
585
+ return current === to;
586
+ };
587
+ if (
588
+ graph.impacts.length > graph.nodes.length ||
589
+ graph.cycles.length > coverage.limits.traversal
590
+ )
591
+ return false;
592
+ const roots = new Set();
593
+ let relationships = 0;
594
+ for (const impact of graph.impacts) {
595
+ if (
596
+ !graphRecord(impact) ||
597
+ nodes.get(impact.plugin)?.kind !== "plugin" ||
598
+ roots.has(impact.plugin) ||
599
+ impact.potential !== true ||
600
+ !Array.isArray(impact.affected) ||
601
+ impact.affected.length > coverage.limits.traversal
602
+ )
603
+ return false;
604
+ roots.add(impact.plugin);
605
+ const affected = new Set();
606
+ for (const row of impact.affected) {
607
+ if (
608
+ ++relationships > coverage.limits.traversal ||
609
+ !graphRecord(row) ||
610
+ nodes.get(row.plugin)?.kind !== "plugin" ||
611
+ row.plugin === impact.plugin ||
612
+ row.potential !== true ||
613
+ !["host", "client"].includes(row.side) ||
614
+ !Array.isArray(row.requirements) ||
615
+ !validPath(
616
+ row.path,
617
+ impact.plugin,
618
+ row.plugin,
619
+ row.side,
620
+ row.requirements,
621
+ )
622
+ )
623
+ return false;
624
+ const key = graphId(row.plugin, row.side);
625
+ if (affected.has(key)) return false;
626
+ affected.add(key);
627
+ }
628
+ }
629
+ for (const cycle of graph.cycles) {
630
+ if (
631
+ !graphRecord(cycle) ||
632
+ cycle.potential !== true ||
633
+ !["host", "client"].includes(cycle.side) ||
634
+ !Array.isArray(cycle.plugins) ||
635
+ cycle.plugins.length < 2 ||
636
+ cycle.plugins[0] !== cycle.plugins.at(-1) ||
637
+ !cycle.plugins.every((id) => nodes.get(id)?.kind === "plugin") ||
638
+ !validPath(
639
+ cycle.path,
640
+ cycle.plugins[0],
641
+ cycle.plugins[0],
642
+ cycle.side,
643
+ null,
644
+ cycle.plugins,
645
+ )
646
+ )
647
+ return false;
648
+ }
649
+ return true;
650
+ }
@@ -129,6 +129,19 @@ export function extractWithProvenance(nm, options = {}) {
129
129
  }
130
130
  }
131
131
 
132
+ // The public per-plugin and provenance views are keyed by manifest name.
133
+ // Refuse ambiguous installed aliases before scanning either instance, even
134
+ // when their versions differ, rather than silently replacing earlier data.
135
+ const installedNames = new Map();
136
+ for (const [base, full, j] of targets) {
137
+ const name = String(j.name || full);
138
+ if (installedNames.has(name))
139
+ throw new Error(
140
+ `同名插件重复安装,无法可靠体检:${name}(安装别名 ${installedNames.get(name)}、${base})`,
141
+ );
142
+ installedNames.set(name, base);
143
+ }
144
+
132
145
  const services = new Set();
133
146
  const calls = new Map();
134
147
  const manifests = [];
@@ -257,6 +270,7 @@ export function extractWithProvenance(nm, options = {}) {
257
270
  calls: sortCalls(scanned.calls),
258
271
  callSites: scanned.sites,
259
272
  providedServices: [...scanned.provided].sort(),
273
+ provideSites: scanned.provideSites,
260
274
  // 变量名 → 服务名 的绑定表。调用点上的 via='alias' 必须能在这里找到解释;
261
275
  // 这也是「为什么这个调用点算在它头上」的可审计证据。
262
276
  localBindings: scanned.bindings,