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,153 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { createHash } from "node:crypto";
4
+ import { parentPort, workerData } from "node:worker_threads";
5
+ const hash = createHash("sha256");
6
+ const add = (value) => hash.update(JSON.stringify(value) + "\n");
7
+ let remaining = 50000,
8
+ directories = 10000;
9
+ const services = new Set(),
10
+ manifests = [];
11
+ function metadata(file) {
12
+ if (--remaining < 0) throw Error("缓存身份文件预算超限,等待后续刷新");
13
+ const stat = fs.statSync(file);
14
+ add([
15
+ file,
16
+ fs.realpathSync(file),
17
+ stat.size,
18
+ stat.mtimeMs,
19
+ stat.ctimeMs,
20
+ stat.ino,
21
+ stat.mode,
22
+ ]);
23
+ return stat;
24
+ }
25
+ function smallFile(file, limit = 1024 * 1024) {
26
+ let fd;
27
+ try {
28
+ metadata(file);
29
+ // Open nonblocking before inspecting the same descriptor. A swapped
30
+ // symlink or FIFO cannot turn the following bounded read into a host hang.
31
+ fd = fs.openSync(
32
+ file,
33
+ fs.constants.O_RDONLY | (fs.constants.O_NONBLOCK || 0),
34
+ );
35
+ const stat = fs.fstatSync(fd);
36
+ if (!stat.isFile() || stat.size > limit)
37
+ throw Error("缓存身份输入不是预算内普通文件");
38
+ const buffer = Buffer.alloc(limit + 1),
39
+ length = fs.readSync(fd, buffer, 0, buffer.length, 0);
40
+ if (length > limit) throw Error("缓存身份输入超过读取预算");
41
+ const bytes = buffer.subarray(0, length);
42
+ add(bytes.toString("base64"));
43
+ return bytes;
44
+ } catch (error) {
45
+ if (error.code === "ENOENT" || error.code === "ENOTDIR") {
46
+ add([file, "missing"]);
47
+ return null;
48
+ }
49
+ throw error;
50
+ } finally {
51
+ if (fd !== undefined) fs.closeSync(fd);
52
+ }
53
+ }
54
+
55
+ function walk(root) {
56
+ const seen = new Set(),
57
+ queue = [root],
58
+ canonical = fs.realpathSync(root);
59
+ while (queue.length) {
60
+ if (--directories < 0) throw Error("缓存身份目录预算超限,等待后续刷新");
61
+ const dir = queue.pop(),
62
+ real = fs.realpathSync(dir);
63
+ if (
64
+ seen.has(real) ||
65
+ (real !== canonical && !real.startsWith(canonical + path.sep))
66
+ )
67
+ continue;
68
+ seen.add(real);
69
+ for (const entry of fs.readdirSync(dir).sort()) {
70
+ const file = path.join(dir, entry);
71
+ let stat;
72
+ try {
73
+ stat = metadata(file);
74
+ } catch (error) {
75
+ if (error.code === "ENOENT") {
76
+ add([file, "missing"]);
77
+ continue;
78
+ }
79
+ throw error;
80
+ }
81
+ if (stat.isDirectory()) queue.push(file);
82
+ }
83
+ }
84
+ }
85
+ try {
86
+ const { nodeModules, files = [], context = {} } = workerData;
87
+ add(context);
88
+ metadata(nodeModules);
89
+ for (const name of fs.readdirSync(nodeModules).sort()) {
90
+ const bases = name.startsWith("@")
91
+ ? fs
92
+ .readdirSync(path.join(nodeModules, name))
93
+ .sort()
94
+ .map((n) => path.join(name, n))
95
+ : [name];
96
+ for (const base of bases) {
97
+ const root = path.join(nodeModules, base);
98
+ let bytes;
99
+ try {
100
+ bytes = smallFile(path.join(root, "package.json"), 65536);
101
+ } catch (error) {
102
+ if (remaining < 0) throw error;
103
+ add([root, "unreadable-manifest", error.code || error.message]);
104
+ continue;
105
+ }
106
+ if (!bytes) continue;
107
+ let manifest;
108
+ try {
109
+ manifest = JSON.parse(bytes);
110
+ } catch {
111
+ continue;
112
+ }
113
+ if (
114
+ !manifest ||
115
+ typeof manifest !== "object" ||
116
+ Array.isArray(manifest) ||
117
+ !("dsh" in manifest) ||
118
+ String(manifest.name || "").startsWith("@deepseek-ai/")
119
+ )
120
+ continue;
121
+ manifests.push({ name: manifest.name });
122
+ const inject = manifest.inject;
123
+ for (const name of Array.isArray(inject)
124
+ ? inject
125
+ : inject && typeof inject === "object"
126
+ ? [
127
+ ...(Array.isArray(inject.required) ? inject.required : []),
128
+ ...(Array.isArray(inject.optional) ? inject.optional : []),
129
+ ]
130
+ : [])
131
+ if (typeof name === "string") services.add(name);
132
+ walk(root);
133
+ }
134
+ }
135
+ for (const file of files.filter(Boolean)) {
136
+ const bytes = smallFile(file, 8 * 1024 * 1024);
137
+ if (bytes && path.basename(file).startsWith("dsh-api-catalog"))
138
+ try {
139
+ for (const name of Object.keys(JSON.parse(bytes).host || {}))
140
+ services.add(name);
141
+ } catch {}
142
+ }
143
+ parentPort.postMessage({
144
+ ok: true,
145
+ value: {
146
+ digest: hash.digest("hex"),
147
+ services: [...services].sort(),
148
+ manifests,
149
+ },
150
+ });
151
+ } catch (error) {
152
+ parentPort.postMessage({ ok: false, error: error.message });
153
+ }
@@ -0,0 +1,15 @@
1
+ import { runWorkerTask } from "./worker-analysis.mjs";
2
+ /** Bounded metadata walk runs off the host thread and never imports plugin code. */
3
+ export function reportIdentity(inputs) {
4
+ return runWorkerTask(
5
+ new URL("./report-identity-worker.mjs", import.meta.url),
6
+ inputs,
7
+ {
8
+ timeoutMs: 5000,
9
+ resourceLimits: {
10
+ maxOldGenerationSizeMb: 128,
11
+ maxYoungGenerationSizeMb: 16,
12
+ },
13
+ },
14
+ );
15
+ }
@@ -1,10 +1,16 @@
1
- /** Single flight per target; retain the last valid report on failed refresh. */
1
+ /** Single flight per canonical target; keep valid reports while inputs refresh. */
2
2
  export class ReportService {
3
- constructor(compute, { ttlMs = 300000, clock = Date.now } = {}) {
3
+ constructor(
4
+ compute,
5
+ { ttlMs = 300000, clock = Date.now, identity = null, watchMs = 5000 } = {},
6
+ ) {
4
7
  this.compute = compute;
5
8
  this.ttlMs = ttlMs;
6
9
  this.clock = clock;
10
+ this.identity = identity;
11
+ this.watchMs = watchMs;
7
12
  this.states = new Map();
13
+ this.disposed = false;
8
14
  }
9
15
  state(key) {
10
16
  if (!this.states.has(key))
@@ -13,43 +19,222 @@ export class ReportService {
13
19
  at: 0,
14
20
  inflight: null,
15
21
  error: null,
22
+ generation: 0,
23
+ stale: false,
24
+ identity: undefined,
25
+ checking: null,
26
+ options: {},
27
+ timer: null,
16
28
  });
17
29
  return this.states.get(key);
18
30
  }
31
+ invalidate(key, reason = "报告输入已变化,等待刷新") {
32
+ if (this.disposed) return;
33
+ const state = this.state(key);
34
+ state.generation++;
35
+ state.stale = true;
36
+ state.reason = reason;
37
+ state.error = null;
38
+ if (!state.inflight) void this.start(state, state.options);
39
+ }
40
+ notify() {
41
+ if (this.disposed || this.notifying) return;
42
+ this.notifying = Promise.resolve()
43
+ .then(async () => {
44
+ for (const state of this.states.values()) {
45
+ try {
46
+ await this.observe(state);
47
+ if (!this.disposed && state.stale)
48
+ void this.start(state, state.options);
49
+ } catch {
50
+ /* Keep the valid report and let the watchdog retry. */
51
+ }
52
+ }
53
+ })
54
+ .finally(() => {
55
+ this.notifying = null;
56
+ });
57
+ }
58
+ async observe(state) {
59
+ if (!this.identity || this.disposed) return;
60
+ if (state.checking) return state.checking;
61
+ state.checking = Promise.resolve()
62
+ .then(() => this.identity(state.options))
63
+ .then((identity) => {
64
+ if (this.disposed) return;
65
+ if (state.identity !== undefined && state.identity !== identity) {
66
+ state.generation++;
67
+ state.stale = true;
68
+ state.reason = "报告输入已变化,等待刷新";
69
+ state.error = null;
70
+ }
71
+ state.identity = identity;
72
+ })
73
+ .catch((error) => {
74
+ if (!this.disposed) {
75
+ state.generation++;
76
+ state.stale = true;
77
+ state.error = error.message;
78
+ }
79
+ throw error;
80
+ })
81
+ .finally(() => {
82
+ state.checking = null;
83
+ });
84
+ return state.checking;
85
+ }
86
+ watch(state) {
87
+ if (!this.identity || state.watching || this.disposed) return;
88
+ state.watching = true;
89
+ const schedule = () => {
90
+ if (this.disposed) return;
91
+ state.timer = setTimeout(async () => {
92
+ state.timer = null;
93
+ try {
94
+ await this.observe(state);
95
+ if (
96
+ !this.disposed &&
97
+ (state.stale || this.clock() - state.at > this.ttlMs)
98
+ )
99
+ void this.start(state, state.options);
100
+ } catch {
101
+ /* Expose observation errors and retry at the next bounded tick. */
102
+ }
103
+ schedule();
104
+ }, this.watchMs);
105
+ state.timer.unref?.();
106
+ };
107
+ schedule();
108
+ }
19
109
  start(state, options) {
110
+ if (this.disposed)
111
+ return Promise.resolve({
112
+ ok: false,
113
+ error: "Plugin generation disposed / 已卸载",
114
+ });
20
115
  if (state.inflight) return state.inflight;
116
+ state.options = options;
117
+ let generation;
21
118
  state.inflight = Promise.resolve()
22
- .then(() => this.compute(options))
23
- .then((out) => {
119
+ .then(async () => {
120
+ if (this.identity) await this.observe(state);
121
+ generation = state.generation;
122
+ let commit;
123
+ const guard = {
124
+ commit: (fn) => {
125
+ commit = fn;
126
+ },
127
+ isCurrent: async () => {
128
+ if (this.disposed || generation !== state.generation) return false;
129
+ // A watchdog observation may have sampled before computation
130
+ // finished. Drain it, then start a fresh check for this commit.
131
+ if (state.checking) await state.checking;
132
+ if (this.disposed || generation !== state.generation) return false;
133
+ await this.observe(state);
134
+ return !this.disposed && generation === state.generation;
135
+ },
136
+ };
137
+ const out = await this.compute(options, guard);
138
+ if (!(await guard.isCurrent()))
139
+ return {
140
+ ok: false,
141
+ invalidated: true,
142
+ error: "报告输入已变化,正在重新检查",
143
+ };
24
144
  if (out?.ok === true) {
145
+ commit?.();
25
146
  state.report = out;
147
+ state.publishedIdentity = state.identity;
26
148
  state.at = this.clock();
27
149
  state.error = null;
28
- } else state.error = out?.error || "体检未产生有效报告";
150
+ state.stale = false;
151
+ state.reason = null;
152
+ } else {
153
+ state.error = out?.error || "体检未产生有效报告";
154
+ state.stale = true;
155
+ }
29
156
  return out;
30
157
  })
31
158
  .catch((error) => {
32
- state.error = error.message;
33
- return { ok: false, error: state.error };
159
+ if (!this.disposed) {
160
+ state.error = error.message;
161
+ state.stale = true;
162
+ }
163
+ return { ok: false, error: error.message };
34
164
  })
35
165
  .finally(() => {
36
166
  state.inflight = null;
167
+ if (
168
+ !this.disposed &&
169
+ generation !== undefined &&
170
+ generation !== state.generation &&
171
+ !state.error
172
+ )
173
+ void this.start(state, state.options);
37
174
  });
38
175
  return state.inflight;
39
176
  }
40
177
  async get(key, options = {}, force = false) {
41
- const state = this.state(key),
42
- age = this.clock() - state.at;
43
- if (force || !state.report) return this.start(state, options);
44
- if (age > this.ttlMs) this.start(state, options);
178
+ if (this.disposed)
179
+ return { ok: false, error: "Plugin generation disposed / 已卸载" };
180
+ const state = this.state(key);
181
+ state.options = options;
182
+ this.watch(state);
183
+ const age = this.clock() - state.at;
184
+ if (force || !state.report) {
185
+ // A force request can join a background flight which was superseded
186
+ // while extracting. Wait for the replacement generation, with a bound
187
+ // for continuously changing inputs rather than publishing old success.
188
+ let out;
189
+ for (let attempt = 0; attempt < 3; attempt++) {
190
+ out = await this.start(state, options);
191
+ if (!out?.invalidated || this.disposed) return out;
192
+ }
193
+ return { ok: false, error: "报告输入持续变化,请稍后重试" };
194
+ }
195
+ if ((state.stale && !state.error) || age > this.ttlMs)
196
+ void this.start(state, options);
45
197
  return {
46
198
  ...state.report,
47
199
  cache: {
48
200
  hit: true,
49
201
  ageMs: age,
50
202
  refreshing: !!state.inflight,
203
+ stale: state.stale,
51
204
  refreshError: state.error,
205
+ reason: state.reason || null,
52
206
  },
53
207
  };
54
208
  }
209
+ /** The immutable identity observed for this published report, including retained stale reports. */
210
+ identityForReport(key, report) {
211
+ const state = this.states.get(key);
212
+ return state?.report?.report === report
213
+ ? state.publishedIdentity
214
+ : undefined;
215
+ }
216
+ /** Validate a sidecar edit against the published report without recomputing or advancing its baseline. */
217
+ async isCurrent(key, report) {
218
+ const state = this.states.get(key);
219
+ const matches = () =>
220
+ !this.disposed &&
221
+ state &&
222
+ !state.stale &&
223
+ !state.error &&
224
+ state.report?.report === report;
225
+ if (!matches()) return false;
226
+ const generation = state.generation;
227
+ if (state.checking) await state.checking;
228
+ if (!matches() || generation !== state.generation) return false;
229
+ await this.observe(state);
230
+ return matches() && generation === state.generation;
231
+ }
232
+ dispose() {
233
+ this.disposed = true;
234
+ for (const state of this.states.values()) {
235
+ clearTimeout(state.timer);
236
+ state.timer = null;
237
+ state.generation++;
238
+ }
239
+ }
55
240
  }
@@ -13,9 +13,11 @@
13
13
 
14
14
  import { TOOL_VERSION } from "./version.mjs";
15
15
  import path from "node:path";
16
- import { sealReportBody, createFailure } from "./contracts.mjs";
16
+ import { sealReportBody, createFailure, catalogLines } from "./contracts.mjs";
17
17
  import { DISPOSITION } from "./verdict.mjs";
18
18
  import { describeDiff } from "./baseline.mjs";
19
+ import { nextSteps } from "./finding-guidance.mjs";
20
+ import { buildDependencyGraph } from "./dependency-graph.mjs";
19
21
 
20
22
  const VERDICT_LABEL = {
21
23
  ok: "可用",
@@ -31,7 +33,7 @@ const SEVERITY_LABEL = {
31
33
  const CATEGORY_LABEL = {
32
34
  "host-service-missing": "宿主服务缺失",
33
35
  "host-method-missing": "宿主方法缺失",
34
- "unprovided-dependency": "无人提供的依赖",
36
+ "unprovided-dependency": "依赖服务待确认",
35
37
  "declaration-mismatch": "声明与实测不符",
36
38
  "declared-incompatible": "版本声明不支持",
37
39
  "host-entry-inactive": "宿主条目未激活",
@@ -63,7 +65,15 @@ export function render(result, opts) {
63
65
  if (opts.quiet) {
64
66
  const q = verdict.summary;
65
67
  L.push(
66
- `结论: ${VERDICT_LABEL.ok} ${q.ok} ${VERDICT_LABEL.partial} ${q.partial} ${VERDICT_LABEL.broken} ${q.broken}`,
68
+ `结论: ${VERDICT_LABEL.ok} ${q.ok} ${VERDICT_LABEL.partial} ${q.partial} ${VERDICT_LABEL.broken} ${q.broken}` +
69
+ (q.limited ? ` 覆盖有限 ${q.limited}` : "") +
70
+ (q.vacuousOk ? ` 其中 ${q.vacuousOk} 个无已验证调用` : "") +
71
+ ";" +
72
+ catalogLines(result.catalog).join(";") +
73
+ (verdict.observation?.notes?.length
74
+ ? ";采样说明:" +
75
+ verdict.observation.notes.join(";").replace(/\s+/g, " ")
76
+ : ""),
67
77
  );
68
78
  return L.join("\n");
69
79
  }
@@ -71,6 +81,7 @@ export function render(result, opts) {
71
81
  L.push(
72
82
  `插件数: ${meta.length} 有运行时面: ${result.inputs.hasRuntimeSurface ? "是" : "否(方法级结论降为「疑似」)"}`,
73
83
  );
84
+ L.push(...catalogLines(result.catalog));
74
85
  L.push("");
75
86
  for (const note of verdict.observation?.notes || [])
76
87
  L.push(`采样说明: ${note}`);
@@ -82,7 +93,7 @@ export function render(result, opts) {
82
93
  L.push(
83
94
  `结论: ${VERDICT_LABEL.ok} ${s.ok} ${VERDICT_LABEL.partial} ${s.partial} ${VERDICT_LABEL.broken} ${s.broken}` +
84
95
  (s.vacuousOk
85
- ? ` (其中 ${s.vacuousOk} 个「可用」是空洞的:它没有任何可检查的依赖)`
96
+ ? ` (其中 ${s.vacuousOk} 个「可用」没有已验证调用,不等于业务通过)`
86
97
  : ""),
87
98
  );
88
99
  L.push("");
@@ -122,9 +133,16 @@ export function render(result, opts) {
122
133
  for (const name of names) {
123
134
  const p = verdict.plugins[name];
124
135
  L.push(
125
- `${VERDICT_LABEL[p.verdict]} ${name}@${p.version}${p.vacuous ? " [空洞的结论:无可检查对象]" : ""}`,
136
+ `${VERDICT_LABEL[p.verdict]} ${name}@${p.version}${p.vacuous ? " [无已验证调用]" : ""}`,
126
137
  );
127
138
  if (opts.quiet) continue;
139
+ if (p.coverage) {
140
+ L.push(
141
+ ` 证据覆盖: ${p.coverage.status === "complete" ? "完整" : "有限"};已验证调用 ${p.coverage.verifiedCalls}`,
142
+ );
143
+ for (const note of p.coverage.limitations)
144
+ L.push(` 覆盖限制: ${note}`);
145
+ }
128
146
  for (const f of p.findings) {
129
147
  const api = f.service
130
148
  ? f.method
@@ -132,7 +150,7 @@ export function render(result, opts) {
132
150
  : f.service
133
151
  : "(声明)";
134
152
  const loc = f.location ? `${f.location.file}:${f.location.line}` : "-";
135
- const cert = f.certainty === "certain" ? "" : " [疑似]";
153
+ const cert = f.certainty === "certain" ? " [确定]" : " [疑似]";
136
154
  L.push(
137
155
  ` ${SEVERITY_LABEL[f.severity] ?? f.severity} / ${CATEGORY_LABEL[f.category] ?? f.category}${cert} ${api} @ ${loc}`,
138
156
  );
@@ -142,12 +160,11 @@ export function render(result, opts) {
142
160
  // 标出来,用户才知道哪一句可以去核实。
143
161
  if (f.impact) {
144
162
  L.push(
145
- f.impactBasis === "inferred"
146
- ? ` 影响(推断,非实测): ${f.impact}`
147
- : ` 影响: ${f.impact}`,
163
+ ` 影响${f.impactBasis === "measured" ? "(实测)" : f.impactBasis === "inferred" ? "(推断,非实测)" : "(依据未提供)"}: ${f.impact}`,
148
164
  );
149
165
  }
150
166
  if (f.basis) L.push(` 依据: ${f.basis}`);
167
+ for (const step of nextSteps(f)) L.push(` 下一步: ${step}`);
151
168
  }
152
169
  // 「没报」和「报了」一样要能审:过滤与弃权是结论的一部分。
153
170
  const cs = p.clientSide || [];
@@ -169,13 +186,55 @@ export function render(result, opts) {
169
186
  );
170
187
  }
171
188
 
189
+ const graph = buildDependencyGraph({
190
+ extraction: result.extract,
191
+ verdict,
192
+ entries: result.entryStates,
193
+ });
194
+ const relations = graph.edges.filter((edge) => edge.kind === "dependency");
195
+ const nodeNames = new Map(graph.nodes.map((node) => [node.id, node.name]));
196
+ const requirement = {
197
+ required: "必需",
198
+ optional: "可选",
199
+ subcontext: "子作用域",
200
+ unknown: "必需性未知",
201
+ };
202
+ L.push("");
203
+ L.push(
204
+ `依赖关系: 服务依赖 ${relations.length};潜在声明环 ${graph.cycles.length}${graph.coverage.truncated ? ";关系/传播已截断" : ""}`,
205
+ );
206
+ for (const edge of relations.slice(0, 20)) {
207
+ const candidates = graph.edges.filter(
208
+ (candidate) =>
209
+ candidate.kind === "candidate-provider" &&
210
+ candidate.service === edge.service &&
211
+ (candidate.side === edge.side || candidate.side === "unknown"),
212
+ );
213
+ L.push(
214
+ ` ${nodeNames.get(edge.from)} → ${edge.service} [${edge.side}/${requirement[edge.requirement]}];候选提供者:${candidates.map((candidate) => `${nodeNames.get(candidate.from)}(${candidate.side})`).join("、") || "未定位(不等于宿主缺失)"}`,
215
+ );
216
+ }
217
+ if (relations.length > 20)
218
+ L.push(
219
+ ` 文本仅列出前 20 条依赖;完整的本次关系见 JSON/面板(仍受图预算限制)。`,
220
+ );
221
+ L.push(
222
+ " 提供声明仅为候选;同端路径及环只表示潜在影响,不证明实际挂起或失败;client 包 inject 不当作 host 服务。",
223
+ );
224
+ for (const reason of graph.coverage.reasons)
225
+ L.push(` 关系覆盖限制: ${reason}`);
226
+
172
227
  L.push("");
173
228
  L.push(
174
- "说明: 「疑似」= 没有运行时面、只能靠官方目录推断(那份目录是契约目录,不是运行时面)。",
229
+ "说明: 确定(certain)表示当前证据足以支持该条发现,可来自明确静态事实或合格运行时观测;疑似(suspected)表示当前证据不足以确定该条发现。",
230
+ );
231
+ L.push(
232
+ " impactBasis=measured/inferred 分别表示影响有对应实测依据/影响由证据推断;确定发现不等于影响已实测。官方目录不是运行时面,未观测不等于缺失。",
175
233
  );
176
234
  L.push(
177
- " 假阳性会让用户去做不必要的处置,所以判不准时一律降级,而不是升格成确定。",
235
+ " 没有已验证调用仍可能存在服务依赖或客户端功能;调用核验、无发现和覆盖完整均不等于业务验收通过。",
178
236
  );
237
+ L.push(" 下一步是核实建议;本工具不会自动安装、升级或修改配置与源码。");
179
238
  return L.join("\n");
180
239
  }
181
240
 
@@ -211,6 +270,7 @@ export function toJSON(result) {
211
270
  }
212
271
  : null,
213
272
  dshVersion: result.verdict.dshVersion,
273
+ ...(result.catalog ? { catalog: result.catalog } : {}),
214
274
  summary: result.verdict.summary,
215
275
  observation: {
216
276
  ...result.verdict.observation,
@@ -219,6 +279,11 @@ export function toJSON(result) {
219
279
  : null,
220
280
  },
221
281
  plugins: result.verdict.plugins,
282
+ dependencyGraph: buildDependencyGraph({
283
+ extraction: result.extract,
284
+ verdict: result.verdict,
285
+ entries: result.entryStates,
286
+ }),
222
287
  extraction: {
223
288
  scanComplete: report.scanComplete,
224
289
  diagnostics: report.diagnostics,
@@ -79,8 +79,10 @@ export function analyzeSources(
79
79
  const sites = [],
80
80
  bindings = [],
81
81
  dependencies = [],
82
+ provideSites = [],
82
83
  diagnostics = [],
83
84
  subContexts = [];
85
+ const provideSiteKeys = new Set();
84
86
  let scopeId = 0,
85
87
  invocationCount = 0,
86
88
  constantCalls = 0;
@@ -586,6 +588,24 @@ export function analyzeSources(
586
588
  sides: [...(activeSides || sides)],
587
589
  });
588
590
  };
591
+ const provide = (service, node) => {
592
+ provided.add(service);
593
+ mention(service);
594
+ const sides = [...(activeSides || fileSides[file] || ["unknown"])];
595
+ const site = {
596
+ service,
597
+ file,
598
+ line: node.loc?.start.line ?? 1,
599
+ start: node.start,
600
+ end: node.end,
601
+ sides,
602
+ };
603
+ const key = JSON.stringify([service, file, site.start, sides]);
604
+ if (!provideSiteKeys.has(key)) {
605
+ provideSiteKeys.add(key);
606
+ provideSites.push(site);
607
+ }
608
+ };
589
609
  const literalNames = (node, scope) => {
590
610
  node = unwrap(node);
591
611
  if (node?.type === "Identifier")
@@ -1383,8 +1403,7 @@ export function analyzeSources(
1383
1403
  );
1384
1404
  if (provideNames.has(field))
1385
1405
  for (const s of literalNames(node.init, scope)) {
1386
- provided.add(s);
1387
- mention(s);
1406
+ provide(s, node);
1388
1407
  }
1389
1408
  }
1390
1409
  return;
@@ -1529,8 +1548,7 @@ export function analyzeSources(
1529
1548
  dependency(name, "descriptor-inject", node);
1530
1549
  if (node.type === "AssignmentExpression" && slot.key === "provide")
1531
1550
  for (const name of literalNames(node.right, scope)) {
1532
- provided.add(name);
1533
- mention(name);
1551
+ provide(name, node);
1534
1552
  }
1535
1553
  return;
1536
1554
  }
@@ -1729,8 +1747,7 @@ export function analyzeSources(
1729
1747
  operation === "provide" &&
1730
1748
  constant(node.arguments[0], scope, guards) !== null
1731
1749
  ) {
1732
- provided.add(constant(node.arguments[0], scope, guards));
1733
- mention(constant(node.arguments[0], scope, guards));
1750
+ provide(constant(node.arguments[0], scope, guards), node);
1734
1751
  }
1735
1752
  if (operation === "inject") {
1736
1753
  const injected = literalNames(node.arguments[0], scope);
@@ -1900,6 +1917,7 @@ export function analyzeSources(
1900
1917
  serviceFiles,
1901
1918
  sites,
1902
1919
  provided,
1920
+ provideSites,
1903
1921
  bindings,
1904
1922
  dependencies,
1905
1923
  diagnostics,
@@ -0,0 +1,20 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { parentPort, workerData } from "node:worker_threads";
4
+ import { captureUpgradeInputOwned } from "./upgrade-input.mjs";
5
+
6
+ try {
7
+ const { nm, limits, root } = workerData;
8
+ const selected = path.resolve(nm);
9
+ const installed =
10
+ path.basename(selected) !== "node_modules" &&
11
+ fs.existsSync(path.join(selected, "node_modules"))
12
+ ? path.join(selected, "node_modules")
13
+ : selected;
14
+ parentPort.postMessage({
15
+ ok: true,
16
+ value: captureUpgradeInputOwned(installed, limits, root),
17
+ });
18
+ } catch (error) {
19
+ parentPort.postMessage({ ok: false, error: error.message });
20
+ }