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,309 @@
|
|
|
1
|
+
/** Private bounded static snapshots. Target files are data, never modules. */
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import { runWorkerTask } from "./worker-analysis.mjs";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { createHash } from "node:crypto";
|
|
7
|
+
|
|
8
|
+
export const sha256 = (bytes) =>
|
|
9
|
+
createHash("sha256").update(bytes).digest("hex");
|
|
10
|
+
export const UPGRADE_CAPTURE_LIMITS = Object.freeze({
|
|
11
|
+
bytes: 256 * 1024 * 1024,
|
|
12
|
+
fileBytes: 8 * 1024 * 1024,
|
|
13
|
+
entries: 50000,
|
|
14
|
+
depth: 32,
|
|
15
|
+
timeMs: 10000,
|
|
16
|
+
});
|
|
17
|
+
const stamp = (s) => [s.dev, s.ino, s.size, s.mtimeMs, s.ctimeMs];
|
|
18
|
+
const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
|
|
19
|
+
const inside = (root, file) =>
|
|
20
|
+
file === root || file.startsWith(root + path.sep);
|
|
21
|
+
const fail = (message) => new Error(`升级预检输入无法稳定捕获:${message}`);
|
|
22
|
+
|
|
23
|
+
/** Worker-only capture into the root already owned by its supervisor. */
|
|
24
|
+
export function captureUpgradeInputOwned(nm, overrides = {}, root) {
|
|
25
|
+
const limits = { ...UPGRADE_CAPTURE_LIMITS, ...overrides };
|
|
26
|
+
for (const [key, value] of Object.entries(limits))
|
|
27
|
+
if (
|
|
28
|
+
!Object.hasOwn(UPGRADE_CAPTURE_LIMITS, key) ||
|
|
29
|
+
!Number.isSafeInteger(value) ||
|
|
30
|
+
value <= 0 ||
|
|
31
|
+
value > UPGRADE_CAPTURE_LIMITS[key]
|
|
32
|
+
)
|
|
33
|
+
throw fail("无效捕获预算 " + key);
|
|
34
|
+
if (typeof nm !== "string" || !nm) throw fail("node_modules 路径缺失");
|
|
35
|
+
const snapshotNm = path.join(root, "node_modules"),
|
|
36
|
+
started = Date.now();
|
|
37
|
+
const observe = (copy) => {
|
|
38
|
+
let bytes = 0,
|
|
39
|
+
entries = 0;
|
|
40
|
+
const records = [],
|
|
41
|
+
packages = [],
|
|
42
|
+
inventory = [];
|
|
43
|
+
const check = () => {
|
|
44
|
+
if (
|
|
45
|
+
bytes > limits.bytes ||
|
|
46
|
+
entries > limits.entries ||
|
|
47
|
+
Date.now() - started > limits.timeMs
|
|
48
|
+
)
|
|
49
|
+
throw fail("捕获预算超限,不发布部分输入");
|
|
50
|
+
};
|
|
51
|
+
const list = (dir) => {
|
|
52
|
+
const before = fs.lstatSync(dir);
|
|
53
|
+
if (!before.isDirectory() || fs.realpathSync(dir) !== dir)
|
|
54
|
+
throw fail("目录身份变化");
|
|
55
|
+
const names = fs.readdirSync(dir).sort();
|
|
56
|
+
entries += names.length;
|
|
57
|
+
check();
|
|
58
|
+
records.push(["directory", dir, stamp(before), names]);
|
|
59
|
+
return { names, before };
|
|
60
|
+
};
|
|
61
|
+
const verifyDir = (dir, { names, before }) => {
|
|
62
|
+
if (
|
|
63
|
+
!same(stamp(before), stamp(fs.lstatSync(dir))) ||
|
|
64
|
+
!same(names, fs.readdirSync(dir).sort()) ||
|
|
65
|
+
fs.realpathSync(dir) !== dir
|
|
66
|
+
)
|
|
67
|
+
throw fail("目录在读取时变化");
|
|
68
|
+
};
|
|
69
|
+
const read = (file) => {
|
|
70
|
+
let fd;
|
|
71
|
+
try {
|
|
72
|
+
const before = fs.lstatSync(file);
|
|
73
|
+
if (!before.isFile() || fs.realpathSync(file) !== file)
|
|
74
|
+
throw fail("文件包含链接或不是普通文件");
|
|
75
|
+
bytes += before.size;
|
|
76
|
+
check();
|
|
77
|
+
if (before.size > limits.fileBytes) throw fail("单文件预算超限");
|
|
78
|
+
fd = fs.openSync(
|
|
79
|
+
file,
|
|
80
|
+
fs.constants.O_RDONLY |
|
|
81
|
+
fs.constants.O_NOFOLLOW |
|
|
82
|
+
fs.constants.O_NONBLOCK,
|
|
83
|
+
);
|
|
84
|
+
const opened = fs.fstatSync(fd);
|
|
85
|
+
if (!opened.isFile() || !same(stamp(before), stamp(opened)))
|
|
86
|
+
throw fail("文件身份变化");
|
|
87
|
+
const buffer = Buffer.alloc(before.size + 1);
|
|
88
|
+
let length = 0,
|
|
89
|
+
n;
|
|
90
|
+
while (
|
|
91
|
+
length < buffer.length &&
|
|
92
|
+
(n = fs.readSync(fd, buffer, length, buffer.length - length, null))
|
|
93
|
+
)
|
|
94
|
+
length += n;
|
|
95
|
+
if (
|
|
96
|
+
length !== before.size ||
|
|
97
|
+
!same(stamp(before), stamp(fs.fstatSync(fd))) ||
|
|
98
|
+
!same(stamp(before), stamp(fs.lstatSync(file))) ||
|
|
99
|
+
fs.realpathSync(file) !== file
|
|
100
|
+
)
|
|
101
|
+
throw fail("文件在读取时变化");
|
|
102
|
+
const data = buffer.subarray(0, length);
|
|
103
|
+
records.push(["file", file, stamp(before), sha256(data)]);
|
|
104
|
+
return data;
|
|
105
|
+
} finally {
|
|
106
|
+
if (fd !== undefined) fs.closeSync(fd);
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
const write = (dest, data) => {
|
|
110
|
+
if (!copy) return;
|
|
111
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true, mode: 0o700 });
|
|
112
|
+
fs.writeFileSync(dest, data, { flag: "wx", mode: 0o400 });
|
|
113
|
+
};
|
|
114
|
+
const walk = (
|
|
115
|
+
source,
|
|
116
|
+
dest,
|
|
117
|
+
packageRoot,
|
|
118
|
+
packageDest,
|
|
119
|
+
rows,
|
|
120
|
+
relative = "",
|
|
121
|
+
depth = 0,
|
|
122
|
+
) => {
|
|
123
|
+
if (depth > limits.depth) throw fail("目录深度预算超限");
|
|
124
|
+
check();
|
|
125
|
+
const stat = fs.lstatSync(source);
|
|
126
|
+
if (stat.isSymbolicLink()) {
|
|
127
|
+
const target = fs.readlinkSync(source);
|
|
128
|
+
let real = null;
|
|
129
|
+
try {
|
|
130
|
+
real = fs.realpathSync(source);
|
|
131
|
+
} catch (e) {
|
|
132
|
+
if (!["ENOENT", "ELOOP"].includes(e.code)) throw e;
|
|
133
|
+
}
|
|
134
|
+
records.push(["link", source, stamp(stat), target, real]);
|
|
135
|
+
rows.push([relative, "symlink", target]);
|
|
136
|
+
if (copy) {
|
|
137
|
+
// Escaped/broken links stay visibly unresolved inside the owned tree.
|
|
138
|
+
const newTarget =
|
|
139
|
+
real && inside(packageRoot, real)
|
|
140
|
+
? path.join(packageDest, path.relative(packageRoot, real))
|
|
141
|
+
: path.join(root, "unresolved-links", sha256(relative));
|
|
142
|
+
fs.symlinkSync(path.relative(path.dirname(dest), newTarget), dest);
|
|
143
|
+
}
|
|
144
|
+
} else if (stat.isDirectory()) {
|
|
145
|
+
const observed = list(source);
|
|
146
|
+
rows.push([relative, "directory"]);
|
|
147
|
+
if (copy) fs.mkdirSync(dest, { recursive: true, mode: 0o700 });
|
|
148
|
+
for (const name of observed.names)
|
|
149
|
+
walk(
|
|
150
|
+
path.join(source, name),
|
|
151
|
+
path.join(dest, name),
|
|
152
|
+
packageRoot,
|
|
153
|
+
packageDest,
|
|
154
|
+
rows,
|
|
155
|
+
relative ? relative + "/" + name : name,
|
|
156
|
+
depth + 1,
|
|
157
|
+
);
|
|
158
|
+
verifyDir(source, observed);
|
|
159
|
+
} else if (stat.isFile()) {
|
|
160
|
+
const data = read(source);
|
|
161
|
+
rows.push([relative, data.length, sha256(data)]);
|
|
162
|
+
write(dest, data);
|
|
163
|
+
} else throw fail("不支持设备、管道等输入类型");
|
|
164
|
+
};
|
|
165
|
+
const canonical = fs.realpathSync(nm),
|
|
166
|
+
nmLink = fs.lstatSync(nm);
|
|
167
|
+
records.push(["node_modules", nm, stamp(nmLink), canonical]);
|
|
168
|
+
const observed = list(canonical);
|
|
169
|
+
if (copy) fs.mkdirSync(snapshotNm, { mode: 0o700 });
|
|
170
|
+
for (const entry of observed.names) {
|
|
171
|
+
let bases = [entry],
|
|
172
|
+
scopeObservation;
|
|
173
|
+
if (entry.startsWith("@")) {
|
|
174
|
+
scopeObservation = list(path.join(canonical, entry));
|
|
175
|
+
bases = scopeObservation.names.map((name) => path.join(entry, name));
|
|
176
|
+
}
|
|
177
|
+
for (const base of bases) {
|
|
178
|
+
const source = path.join(canonical, base);
|
|
179
|
+
let packageRoot;
|
|
180
|
+
try {
|
|
181
|
+
packageRoot = fs.realpathSync(source);
|
|
182
|
+
} catch (e) {
|
|
183
|
+
if (e.code === "ENOENT") {
|
|
184
|
+
records.push(["missing", source]);
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
throw e;
|
|
188
|
+
}
|
|
189
|
+
const packageStat = fs.lstatSync(source);
|
|
190
|
+
records.push(["package-root", source, stamp(packageStat), packageRoot]);
|
|
191
|
+
if (!fs.lstatSync(packageRoot).isDirectory()) continue;
|
|
192
|
+
const manifest = path.join(packageRoot, "package.json");
|
|
193
|
+
let data;
|
|
194
|
+
try {
|
|
195
|
+
data = read(manifest);
|
|
196
|
+
} catch (e) {
|
|
197
|
+
if (["ENOENT", "ENOTDIR"].includes(e.code)) {
|
|
198
|
+
records.push(["no-manifest", source]);
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
throw e;
|
|
202
|
+
}
|
|
203
|
+
let meta;
|
|
204
|
+
try {
|
|
205
|
+
meta = JSON.parse(data);
|
|
206
|
+
} catch {
|
|
207
|
+
/* Keep invalid metadata for inventory diagnostics. */
|
|
208
|
+
}
|
|
209
|
+
const plugin =
|
|
210
|
+
meta &&
|
|
211
|
+
typeof meta === "object" &&
|
|
212
|
+
!Array.isArray(meta) &&
|
|
213
|
+
"dsh" in meta &&
|
|
214
|
+
!String(meta.name ?? "").startsWith("@deepseek-ai/");
|
|
215
|
+
const dest = path.join(snapshotNm, base),
|
|
216
|
+
rows = [];
|
|
217
|
+
if (plugin) {
|
|
218
|
+
const name = typeof meta.name === "string" ? meta.name : base;
|
|
219
|
+
if (packages.some((item) => item.name === name))
|
|
220
|
+
throw fail("同名插件重复安装,无法按插件身份可靠比较:" + name);
|
|
221
|
+
walk(packageRoot, dest, packageRoot, dest, rows);
|
|
222
|
+
packages.push({
|
|
223
|
+
name: typeof meta.name === "string" ? meta.name : base,
|
|
224
|
+
version: typeof meta.version === "string" ? meta.version : null,
|
|
225
|
+
sha256: sha256(JSON.stringify(rows)),
|
|
226
|
+
});
|
|
227
|
+
} else write(path.join(dest, "package.json"), data);
|
|
228
|
+
inventory.push([
|
|
229
|
+
base,
|
|
230
|
+
sha256(data),
|
|
231
|
+
plugin ? sha256(JSON.stringify(rows)) : null,
|
|
232
|
+
]);
|
|
233
|
+
}
|
|
234
|
+
if (scopeObservation)
|
|
235
|
+
verifyDir(path.join(canonical, entry), scopeObservation);
|
|
236
|
+
}
|
|
237
|
+
verifyDir(canonical, observed);
|
|
238
|
+
if (
|
|
239
|
+
fs.realpathSync(nm) !== canonical ||
|
|
240
|
+
!same(stamp(nmLink), stamp(fs.lstatSync(nm)))
|
|
241
|
+
)
|
|
242
|
+
throw fail("node_modules 身份变化");
|
|
243
|
+
return {
|
|
244
|
+
signature: sha256(JSON.stringify(records)),
|
|
245
|
+
artifact: { sha256: sha256(JSON.stringify(inventory)), packages },
|
|
246
|
+
};
|
|
247
|
+
};
|
|
248
|
+
const first = observe(true),
|
|
249
|
+
second = observe(false);
|
|
250
|
+
if (first.signature !== second.signature)
|
|
251
|
+
throw fail("两次观察的目录、文件字节或链接身份不一致");
|
|
252
|
+
const freeze = (dir) => {
|
|
253
|
+
for (const e of fs.readdirSync(dir, { withFileTypes: true }))
|
|
254
|
+
if (e.isDirectory()) freeze(path.join(dir, e.name));
|
|
255
|
+
fs.chmodSync(dir, 0o500);
|
|
256
|
+
};
|
|
257
|
+
freeze(root);
|
|
258
|
+
return { nm: snapshotNm, artifact: first.artifact };
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** Only this closure can request deletion of its freshly allocated private root. */
|
|
262
|
+
export async function captureUpgradeInput(nm, overrides = {}, { signal } = {}) {
|
|
263
|
+
if (signal?.aborted) throw abortCapture();
|
|
264
|
+
const root = await fs.promises.mkdtemp(
|
|
265
|
+
path.join(os.tmpdir(), "dsc-upgrade-input-"),
|
|
266
|
+
);
|
|
267
|
+
const owned = await fs.promises.lstat(root);
|
|
268
|
+
let disposal;
|
|
269
|
+
const dispose = () =>
|
|
270
|
+
(disposal ||= runWorkerTask(
|
|
271
|
+
new URL("./upgrade-cleanup-worker.mjs", import.meta.url),
|
|
272
|
+
{ root, identity: { dev: owned.dev, ino: owned.ino } },
|
|
273
|
+
{ timeoutMs: UPGRADE_CAPTURE_LIMITS.timeMs },
|
|
274
|
+
));
|
|
275
|
+
try {
|
|
276
|
+
const snapshot = await runWorkerTask(
|
|
277
|
+
new URL("./upgrade-capture-worker.mjs", import.meta.url),
|
|
278
|
+
{ nm, limits: overrides, root },
|
|
279
|
+
{
|
|
280
|
+
timeoutMs: Math.min(
|
|
281
|
+
overrides.timeMs ?? UPGRADE_CAPTURE_LIMITS.timeMs,
|
|
282
|
+
UPGRADE_CAPTURE_LIMITS.timeMs,
|
|
283
|
+
),
|
|
284
|
+
signal,
|
|
285
|
+
},
|
|
286
|
+
);
|
|
287
|
+
return { ...snapshot, dispose };
|
|
288
|
+
} catch (error) {
|
|
289
|
+
try {
|
|
290
|
+
await dispose();
|
|
291
|
+
} catch (cleanupError) {
|
|
292
|
+
throw upgradeCleanupError(error, cleanupError);
|
|
293
|
+
}
|
|
294
|
+
throw error;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
export function upgradeCleanupError(primary, cleanup) {
|
|
298
|
+
const error = new Error(
|
|
299
|
+
`${primary ? String(primary.message || primary) + ";" : ""}私有输入清理未完成:${cleanup.message || cleanup}`,
|
|
300
|
+
primary ? { cause: primary } : undefined,
|
|
301
|
+
);
|
|
302
|
+
error.code = "DSC_UPGRADE_CLEANUP_FAILED";
|
|
303
|
+
return error;
|
|
304
|
+
}
|
|
305
|
+
function abortCapture() {
|
|
306
|
+
const error = new Error("升级预检已取消");
|
|
307
|
+
error.code = "DSC_WORKER_ABORTED";
|
|
308
|
+
return error;
|
|
309
|
+
}
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
/** Offline version prediction: no installation, runtime probe, cache or baseline. */
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { loadCatalog } from "./catalog.mjs";
|
|
6
|
+
import { TOOL_VERSION } from "./version.mjs";
|
|
7
|
+
import {
|
|
8
|
+
captureUpgradeInput,
|
|
9
|
+
sha256,
|
|
10
|
+
upgradeCleanupError,
|
|
11
|
+
} from "./upgrade-input.mjs";
|
|
12
|
+
import { runWorkerTask } from "./worker-analysis.mjs";
|
|
13
|
+
import { assertReportBody } from "./contracts.mjs";
|
|
14
|
+
import {
|
|
15
|
+
SUPPORTED_PREVIEW_VERSIONS,
|
|
16
|
+
assertUpgradePreview,
|
|
17
|
+
compareUpgradeFindings,
|
|
18
|
+
upgradeExitCode,
|
|
19
|
+
createUpgradeFailure as createContractUpgradeFailure,
|
|
20
|
+
UPGRADE_PREVIEW_LIMITATIONS,
|
|
21
|
+
} from "./upgrade-contract.mjs";
|
|
22
|
+
export { SUPPORTED_PREVIEW_VERSIONS } from "./upgrade-contract.mjs";
|
|
23
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
24
|
+
const limitations = UPGRADE_PREVIEW_LIMITATIONS;
|
|
25
|
+
/** Preserve the public API while the pure factory also works without a kernel. */
|
|
26
|
+
export function createUpgradeFailure(error, options = {}) {
|
|
27
|
+
if (error && typeof error === "object" && !(error instanceof Error))
|
|
28
|
+
return createContractUpgradeFailure({
|
|
29
|
+
...error,
|
|
30
|
+
toolVersion: TOOL_VERSION,
|
|
31
|
+
});
|
|
32
|
+
return createContractUpgradeFailure(error, {
|
|
33
|
+
...options,
|
|
34
|
+
toolVersion: TOOL_VERSION,
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Capture a caller-selected installed node_modules once and predict only exact
|
|
40
|
+
* catalogue versions. Budgets may only lower the production capture/worker caps.
|
|
41
|
+
*/
|
|
42
|
+
export async function runUpgradePreview(input = {}, options = {}) {
|
|
43
|
+
let snapshot, primaryError;
|
|
44
|
+
const { currentVersion, targetVersion } = input || {};
|
|
45
|
+
const failure = (error) =>
|
|
46
|
+
createUpgradeFailure(error, { currentVersion, targetVersion });
|
|
47
|
+
const privateMessage = (error) =>
|
|
48
|
+
String(error?.message || error)
|
|
49
|
+
.replaceAll(snapshot?.nm || "\u0000", "<private-input>")
|
|
50
|
+
.replace(/\/[^\s:'"]*dsc-upgrade-input-[^\s:'"]*/g, "<private-input>");
|
|
51
|
+
try {
|
|
52
|
+
if (
|
|
53
|
+
!input ||
|
|
54
|
+
typeof input !== "object" ||
|
|
55
|
+
Array.isArray(input) ||
|
|
56
|
+
Object.keys(input).some(
|
|
57
|
+
(k) => !["nm", "currentVersion", "targetVersion"].includes(k),
|
|
58
|
+
)
|
|
59
|
+
)
|
|
60
|
+
throw new Error(
|
|
61
|
+
"升级预检仅接受 nm、currentVersion、targetVersion;运行时、探针及覆盖参数不可混用",
|
|
62
|
+
);
|
|
63
|
+
if (
|
|
64
|
+
!SUPPORTED_PREVIEW_VERSIONS.includes(currentVersion) ||
|
|
65
|
+
!SUPPORTED_PREVIEW_VERSIONS.includes(targetVersion)
|
|
66
|
+
)
|
|
67
|
+
return createUpgradeFailure({
|
|
68
|
+
error:
|
|
69
|
+
"版本未认证:仅支持有明确API目录的精确版本 " +
|
|
70
|
+
SUPPORTED_PREVIEW_VERSIONS.join("、"),
|
|
71
|
+
currentVersion,
|
|
72
|
+
targetVersion,
|
|
73
|
+
status: "unsupported-version",
|
|
74
|
+
});
|
|
75
|
+
if (
|
|
76
|
+
!options ||
|
|
77
|
+
typeof options !== "object" ||
|
|
78
|
+
Array.isArray(options) ||
|
|
79
|
+
Object.keys(options).some(
|
|
80
|
+
(k) => !["timeoutMs", "captureLimits", "signal"].includes(k),
|
|
81
|
+
)
|
|
82
|
+
)
|
|
83
|
+
throw new Error("升级预检预算参数无效");
|
|
84
|
+
const { timeoutMs = 10000, captureLimits = {}, signal } = options;
|
|
85
|
+
if (
|
|
86
|
+
!Number.isSafeInteger(timeoutMs) ||
|
|
87
|
+
timeoutMs <= 0 ||
|
|
88
|
+
timeoutMs > 10000 ||
|
|
89
|
+
!captureLimits ||
|
|
90
|
+
typeof captureLimits !== "object" ||
|
|
91
|
+
Array.isArray(captureLimits)
|
|
92
|
+
)
|
|
93
|
+
throw new Error("升级预检 worker / 捕获预算无效");
|
|
94
|
+
const reviewedCatalogFor = (version) => {
|
|
95
|
+
const { file, catalog, evidence } = loadCatalog(ROOT, version);
|
|
96
|
+
if (
|
|
97
|
+
!catalog.host ||
|
|
98
|
+
!catalog.client ||
|
|
99
|
+
!catalog._source?.host ||
|
|
100
|
+
!catalog._source?.client
|
|
101
|
+
)
|
|
102
|
+
throw new Error("API目录来源不完整");
|
|
103
|
+
return {
|
|
104
|
+
version,
|
|
105
|
+
catalog,
|
|
106
|
+
evidence,
|
|
107
|
+
identity: {
|
|
108
|
+
version,
|
|
109
|
+
file: path.relative(ROOT, file).split(path.sep).join("/"),
|
|
110
|
+
sha256: evidence.sha256,
|
|
111
|
+
source: { ...catalog._source },
|
|
112
|
+
},
|
|
113
|
+
};
|
|
114
|
+
};
|
|
115
|
+
const current = reviewedCatalogFor(currentVersion),
|
|
116
|
+
target = reviewedCatalogFor(targetVersion);
|
|
117
|
+
const rulesFile = path.join(ROOT, "rules", "severity.json"),
|
|
118
|
+
rulesBytes = fs.readFileSync(rulesFile);
|
|
119
|
+
const rules = JSON.parse(rulesBytes);
|
|
120
|
+
if (typeof input.nm !== "string" || !input.nm)
|
|
121
|
+
throw new Error("升级预检需要 node_modules 或 profile 路径");
|
|
122
|
+
snapshot = await captureUpgradeInput(input.nm, captureLimits, { signal });
|
|
123
|
+
const reports = await runWorkerTask(
|
|
124
|
+
new URL("./upgrade-worker.mjs", import.meta.url),
|
|
125
|
+
{
|
|
126
|
+
nm: snapshot.nm,
|
|
127
|
+
artifact: snapshot.artifact,
|
|
128
|
+
current: {
|
|
129
|
+
version: currentVersion,
|
|
130
|
+
catalog: current.catalog,
|
|
131
|
+
evidence: current.evidence,
|
|
132
|
+
},
|
|
133
|
+
target: {
|
|
134
|
+
version: targetVersion,
|
|
135
|
+
catalog: target.catalog,
|
|
136
|
+
evidence: target.evidence,
|
|
137
|
+
},
|
|
138
|
+
rules,
|
|
139
|
+
},
|
|
140
|
+
{ timeoutMs, signal },
|
|
141
|
+
);
|
|
142
|
+
// Supervision resolves only after worker termination; cleanup occurs in finally.
|
|
143
|
+
assertReportBody(reports.currentReport);
|
|
144
|
+
assertReportBody(reports.targetReport);
|
|
145
|
+
const changes = compareUpgradeFindings(
|
|
146
|
+
reports.currentReport,
|
|
147
|
+
reports.targetReport,
|
|
148
|
+
);
|
|
149
|
+
const limited = new Set(
|
|
150
|
+
[
|
|
151
|
+
...Object.values(reports.currentReport.plugins),
|
|
152
|
+
...Object.values(reports.targetReport.plugins),
|
|
153
|
+
]
|
|
154
|
+
.filter((p) => p.coverage.status === "limited")
|
|
155
|
+
.map((p) => p.name),
|
|
156
|
+
);
|
|
157
|
+
const notes = [...limitations];
|
|
158
|
+
if (
|
|
159
|
+
!reports.currentReport.extraction.scanComplete ||
|
|
160
|
+
!reports.targetReport.extraction.scanComplete
|
|
161
|
+
)
|
|
162
|
+
notes.push(
|
|
163
|
+
"源码或插件清单覆盖不完整:保留已发现风险和诊断,未发现或已消失的发现不能证明修复或完整业务通过。",
|
|
164
|
+
);
|
|
165
|
+
return assertUpgradePreview({
|
|
166
|
+
schemaVersion: 1,
|
|
167
|
+
kind: "plugin-compat-check/upgrade-preview",
|
|
168
|
+
toolVersion: TOOL_VERSION,
|
|
169
|
+
mode: "static-version-comparison",
|
|
170
|
+
status: "compared",
|
|
171
|
+
ok: true,
|
|
172
|
+
error: null,
|
|
173
|
+
currentVersion,
|
|
174
|
+
targetVersion,
|
|
175
|
+
supportedVersions: [...SUPPORTED_PREVIEW_VERSIONS],
|
|
176
|
+
catalogs: { current: current.identity, target: target.identity },
|
|
177
|
+
artifact: snapshot.artifact,
|
|
178
|
+
rules: { file: "rules/severity.json", sha256: sha256(rulesBytes) },
|
|
179
|
+
...reports,
|
|
180
|
+
changes,
|
|
181
|
+
summary: {
|
|
182
|
+
...Object.fromEntries(
|
|
183
|
+
Object.entries(changes).map(([k, v]) => [k, v.length]),
|
|
184
|
+
),
|
|
185
|
+
limitedPlugins: limited.size,
|
|
186
|
+
},
|
|
187
|
+
exitCode: upgradeExitCode(changes),
|
|
188
|
+
limitations: notes,
|
|
189
|
+
baseline: null,
|
|
190
|
+
cache: null,
|
|
191
|
+
});
|
|
192
|
+
} catch (error) {
|
|
193
|
+
primaryError = error;
|
|
194
|
+
return failure(privateMessage(error));
|
|
195
|
+
} finally {
|
|
196
|
+
try {
|
|
197
|
+
await snapshot?.dispose();
|
|
198
|
+
} catch (cleanupError) {
|
|
199
|
+
return failure(
|
|
200
|
+
privateMessage(upgradeCleanupError(primaryError, cleanupError)),
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export function renderUpgradePreview(result) {
|
|
207
|
+
assertUpgradePreview(result);
|
|
208
|
+
const lines = [
|
|
209
|
+
`升级前兼容性预检(静态预测):${result.currentVersion ?? "未知"} → ${result.targetVersion ?? "未知"}`,
|
|
210
|
+
];
|
|
211
|
+
if (!result.ok)
|
|
212
|
+
return [
|
|
213
|
+
...lines,
|
|
214
|
+
`无法判定:${result.error}`,
|
|
215
|
+
...result.limitations.map((x) => `限制:${x}`),
|
|
216
|
+
].join("\n");
|
|
217
|
+
lines.push(
|
|
218
|
+
`新增 ${result.summary.added};变重 ${result.summary.worsened};消失 ${result.summary.resolved};减轻 ${result.summary.improved};不变 ${result.summary.unchanged}`,
|
|
219
|
+
);
|
|
220
|
+
if (!result.summary.added && !result.summary.worsened)
|
|
221
|
+
lines.push("无新增或变重的静态风险;不代表未来业务通过。");
|
|
222
|
+
for (const [group, label] of [
|
|
223
|
+
["added", "新增"],
|
|
224
|
+
["worsened", "变重"],
|
|
225
|
+
["resolved", "消失"],
|
|
226
|
+
["improved", "减轻"],
|
|
227
|
+
["unchanged", "不变"],
|
|
228
|
+
])
|
|
229
|
+
for (const item of result.changes[group]) {
|
|
230
|
+
const f = item.finding || item.after;
|
|
231
|
+
lines.push(
|
|
232
|
+
`${label}:${item.plugin} ${f.category} ${[f.service, f.method].filter(Boolean).join(".")} ${f.severity}/${f.certainty}/${f.impactBasis}${f.location ? ` @${f.location.file}:${f.location.line}` : ""}`,
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
lines.push(`输入SHA-256:${result.artifact.sha256}`);
|
|
236
|
+
for (const item of result.artifact.packages)
|
|
237
|
+
lines.push(
|
|
238
|
+
`插件制品:${item.name}@${item.version ?? "未知"} ${item.sha256}`,
|
|
239
|
+
);
|
|
240
|
+
for (const side of ["current", "target"]) {
|
|
241
|
+
const item = result.catalogs[side];
|
|
242
|
+
lines.push(
|
|
243
|
+
`API目录:${item.version} ${item.file} SHA-256 ${item.sha256};来源 ${item.source.host} / ${item.source.client}`,
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
lines.push(`规则:${result.rules.file} SHA-256 ${result.rules.sha256}`);
|
|
247
|
+
for (const side of ["currentReport", "targetReport"])
|
|
248
|
+
for (const p of Object.values(result[side].plugins)) {
|
|
249
|
+
lines.push(
|
|
250
|
+
`${result[side].dshVersion} ${p.name}:${p.verdict};覆盖 ${p.coverage.status};已验证调用 ${p.coverage.verifiedCalls}${p.vacuous ? ";无已验证调用" : ""}`,
|
|
251
|
+
);
|
|
252
|
+
for (const note of p.coverage.limitations)
|
|
253
|
+
lines.push(` 覆盖限制:${note}`);
|
|
254
|
+
}
|
|
255
|
+
return [...lines, ...result.limitations.map((x) => `限制:${x}`)].join("\n");
|
|
256
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { parentPort, workerData } from "node:worker_threads";
|
|
2
|
+
import { extractWithProvenance } from "./extract.mjs";
|
|
3
|
+
import { buildVerdicts } from "./verdict.mjs";
|
|
4
|
+
import { sealReportBody } from "./contracts.mjs";
|
|
5
|
+
import { TOOL_VERSION } from "./version.mjs";
|
|
6
|
+
|
|
7
|
+
try {
|
|
8
|
+
const { nm, current, target, rules, artifact } = workerData;
|
|
9
|
+
const catalog = {
|
|
10
|
+
host: { ...current.catalog.host, ...target.catalog.host },
|
|
11
|
+
client: { ...current.catalog.client, ...target.catalog.client },
|
|
12
|
+
};
|
|
13
|
+
// One extraction of the same copied bytes; catalogue union affects only
|
|
14
|
+
// recognition of weak bare/alias names. Each verdict uses its own catalogue.
|
|
15
|
+
const extracted = extractWithProvenance(nm, { catalog });
|
|
16
|
+
const reportFor = (item) =>
|
|
17
|
+
sealReportBody({
|
|
18
|
+
...buildVerdicts({
|
|
19
|
+
report: extracted.report,
|
|
20
|
+
hostSurface: null,
|
|
21
|
+
entryStates: [],
|
|
22
|
+
rules,
|
|
23
|
+
catalog: item.catalog,
|
|
24
|
+
dshVersion: item.version,
|
|
25
|
+
}),
|
|
26
|
+
toolVersion: TOOL_VERSION,
|
|
27
|
+
catalog: item.evidence,
|
|
28
|
+
inputs: {
|
|
29
|
+
mode: "static-version-comparison",
|
|
30
|
+
artifactSha256: artifact.sha256,
|
|
31
|
+
hasRuntimeSurface: false,
|
|
32
|
+
},
|
|
33
|
+
extraction: {
|
|
34
|
+
scanComplete: extracted.report.scanComplete,
|
|
35
|
+
diagnostics: extracted.report.diagnostics,
|
|
36
|
+
manifests: extracted.report.manifests,
|
|
37
|
+
},
|
|
38
|
+
baseline: null,
|
|
39
|
+
cache: null,
|
|
40
|
+
});
|
|
41
|
+
parentPort.postMessage({
|
|
42
|
+
ok: true,
|
|
43
|
+
value: {
|
|
44
|
+
currentReport: reportFor(current),
|
|
45
|
+
targetReport: reportFor(target),
|
|
46
|
+
},
|
|
47
|
+
});
|
|
48
|
+
} catch (error) {
|
|
49
|
+
parentPort.postMessage({ ok: false, error: error.message });
|
|
50
|
+
}
|
package/kernel/lib/version.mjs
CHANGED
|
@@ -4,7 +4,7 @@ import { Worker } from "node:worker_threads";
|
|
|
4
4
|
export function runWorkerTask(
|
|
5
5
|
url,
|
|
6
6
|
data,
|
|
7
|
-
{ timeoutMs = 10000, resourceLimits = {} } = {},
|
|
7
|
+
{ timeoutMs = 10000, resourceLimits = {}, signal } = {},
|
|
8
8
|
) {
|
|
9
9
|
return new Promise((resolve, reject) => {
|
|
10
10
|
const limits = {
|
|
@@ -13,6 +13,11 @@ export function runWorkerTask(
|
|
|
13
13
|
...resourceLimits,
|
|
14
14
|
};
|
|
15
15
|
if (
|
|
16
|
+
(signal !== undefined &&
|
|
17
|
+
(!signal ||
|
|
18
|
+
typeof signal.aborted !== "boolean" ||
|
|
19
|
+
typeof signal.addEventListener !== "function" ||
|
|
20
|
+
typeof signal.removeEventListener !== "function")) ||
|
|
16
21
|
!Number.isInteger(timeoutMs) ||
|
|
17
22
|
timeoutMs <= 0 ||
|
|
18
23
|
timeoutMs > 2147483647 ||
|
|
@@ -27,6 +32,12 @@ export function runWorkerTask(
|
|
|
27
32
|
)
|
|
28
33
|
)
|
|
29
34
|
return reject(new Error("Invalid worker resource budget / 预算"));
|
|
35
|
+
const aborted = () => {
|
|
36
|
+
const error = new Error("静态分析已取消,已终止工作线程");
|
|
37
|
+
error.code = "DSC_WORKER_ABORTED";
|
|
38
|
+
return error;
|
|
39
|
+
};
|
|
40
|
+
if (signal?.aborted) return reject(aborted());
|
|
30
41
|
let worker;
|
|
31
42
|
try {
|
|
32
43
|
worker = new Worker(url, {
|
|
@@ -43,6 +54,7 @@ export function runWorkerTask(
|
|
|
43
54
|
if (finishing) return;
|
|
44
55
|
finishing = true;
|
|
45
56
|
clearTimeout(timer);
|
|
57
|
+
signal?.removeEventListener("abort", onAbort);
|
|
46
58
|
try {
|
|
47
59
|
await worker.terminate();
|
|
48
60
|
} catch (cleanupError) {
|
|
@@ -55,6 +67,9 @@ export function runWorkerTask(
|
|
|
55
67
|
error.code = "DSC_WORKER_TIMEOUT";
|
|
56
68
|
void finish(error);
|
|
57
69
|
}, timeoutMs);
|
|
70
|
+
const onAbort = () => void finish(aborted());
|
|
71
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
72
|
+
if (signal?.aborted) onAbort();
|
|
58
73
|
worker.once("message", (message) => {
|
|
59
74
|
if (
|
|
60
75
|
!message ||
|