dsh-plugin-compat-check 0.1.4 → 0.1.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +37 -17
- package/THIRD_PARTY_NOTICES.md +139 -0
- package/index.js +282 -53
- package/kernel/lib/author-ci-output.mjs +188 -0
- package/kernel/lib/author-ci-policy.mjs +285 -0
- package/kernel/lib/catalog.mjs +38 -0
- package/kernel/lib/contracts.mjs +88 -0
- package/kernel/lib/dependency-graph.mjs +650 -0
- package/kernel/lib/extract.mjs +14 -0
- package/kernel/lib/finding-guidance.mjs +72 -0
- package/kernel/lib/finding-review-store.mjs +245 -0
- package/kernel/lib/finding-review.mjs +338 -0
- package/kernel/lib/functional-health-contract.mjs +337 -0
- package/kernel/lib/issue-report.mjs +217 -0
- package/kernel/lib/observation.mjs +19 -9
- package/kernel/lib/report-identity-worker.mjs +153 -0
- package/kernel/lib/report-identity.mjs +15 -0
- package/kernel/lib/report-service.mjs +196 -11
- package/kernel/lib/report.mjs +76 -11
- package/kernel/lib/source-analysis.mjs +24 -6
- package/kernel/lib/upgrade-capture-worker.mjs +20 -0
- package/kernel/lib/upgrade-cleanup-worker.mjs +28 -0
- package/kernel/lib/upgrade-contract.mjs +471 -0
- package/kernel/lib/upgrade-input.mjs +309 -0
- package/kernel/lib/upgrade-preview.mjs +256 -0
- package/kernel/lib/upgrade-worker.mjs +50 -0
- package/kernel/lib/version.mjs +1 -1
- package/kernel/lib/worker-analysis.mjs +16 -1
- package/kernel/lib/workflow-panels.mjs +706 -0
- package/kernel/rules/README.md +5 -5
- package/lib/client.js +2974 -69
- package/lib/dependency-graph.js +651 -0
- package/lib/finding-guidance.js +73 -0
- package/lib/finding-review.js +339 -0
- package/lib/functional-health-contract.js +338 -0
- package/lib/report-contract.js +88 -0
- package/lib/upgrade-contract.js +472 -0
- package/lib/workflow-panels.js +707 -0
- package/package.json +3 -2
- package/scripts/prepublish-check.mjs +28 -13
- package/scripts/stage-contract.mjs +91 -29
- package/scripts/stage-kernel.mjs +3 -2
- 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;
|
|
1
|
+
/** Single flight per canonical target; keep valid reports while inputs refresh. */
|
|
2
2
|
export class ReportService {
|
|
3
|
-
constructor(
|
|
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(() =>
|
|
23
|
-
|
|
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
|
-
|
|
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
|
-
|
|
33
|
-
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
}
|
package/kernel/lib/report.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|