dsh-plugin-compat-check 0.1.6 → 0.1.7
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 +17 -30
- package/THIRD_PARTY_NOTICES.md +1 -1
- package/index.js +1295 -550
- package/kernel/lib/api-semantics.mjs +223 -0
- package/kernel/lib/author-ci-budget.mjs +25 -0
- package/kernel/lib/author-ci-output.mjs +22 -0
- package/kernel/lib/author-ci-policy.mjs +6 -1
- package/kernel/lib/baseline.mjs +109 -38
- package/kernel/lib/catalog.mjs +11 -3
- package/kernel/lib/contracts.mjs +16 -1
- package/kernel/lib/execution-environment.mjs +52 -0
- package/kernel/lib/extract.mjs +57 -13
- package/kernel/lib/finding-guidance.mjs +6 -0
- package/kernel/lib/finding-review-store.mjs +11 -23
- package/kernel/lib/issue-report.mjs +2 -1
- package/kernel/lib/patch-mounts.mjs +232 -0
- package/kernel/lib/report-identity-worker.mjs +47 -26
- package/kernel/lib/report-identity.mjs +4 -3
- package/kernel/lib/report-service.mjs +118 -38
- package/kernel/lib/report.mjs +13 -17
- package/kernel/lib/source-analysis.mjs +258 -42
- package/kernel/lib/source-graph.mjs +353 -75
- package/kernel/lib/state-lock.mjs +206 -0
- package/kernel/lib/state-store.mjs +41 -30
- package/kernel/lib/static-input.mjs +111 -0
- package/kernel/lib/syntax.mjs +242 -20
- package/kernel/lib/upgrade-contract.mjs +1 -0
- package/kernel/lib/upgrade-input.mjs +9 -4
- package/kernel/lib/upgrade-preview.mjs +11 -15
- package/kernel/lib/upgrade-worker.mjs +10 -1
- package/kernel/lib/verdict.mjs +28 -5
- package/kernel/lib/version.mjs +2 -34
- package/kernel/lib/worker-analysis.mjs +74 -3
- package/kernel/rules/README.md +30 -208
- package/kernel/rules/dsh-0.2.1-rc.3-review.json +63 -0
- package/kernel/rules/dsh-api-catalog-0.2.1-rc.3.json +743 -0
- package/kernel/rules/severity.json +77 -0
- package/lib/client.js +4668 -3423
- package/lib/finding-guidance.js +6 -0
- package/lib/local-request.js +44 -0
- package/lib/report-contract.js +16 -1
- package/lib/upgrade-contract.js +1 -0
- package/package.json +3 -2
- package/scripts/prepublish-check.mjs +1 -22
- package/scripts/stage-contract.mjs +7 -0
- package/skill/SKILL.md +13 -12
package/index.js
CHANGED
|
@@ -21,19 +21,33 @@
|
|
|
21
21
|
// ${DSC_OUT_DIR}/loader-introspect.json (explicit owned private directory only)
|
|
22
22
|
// plus structured console lines prefixed with [dsh-plugin-compat-check].
|
|
23
23
|
|
|
24
|
-
import {
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
import {
|
|
32
|
-
import {
|
|
33
|
-
import {
|
|
34
|
-
import {
|
|
35
|
-
|
|
36
|
-
|
|
24
|
+
import {
|
|
25
|
+
existsSync,
|
|
26
|
+
readFileSync,
|
|
27
|
+
realpathSync,
|
|
28
|
+
promises as fsPromises,
|
|
29
|
+
} from "node:fs";
|
|
30
|
+
import { homedir } from "node:os";
|
|
31
|
+
import path, { join } from "node:path";
|
|
32
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
33
|
+
import { createRequire as __createRequire } from "node:module";
|
|
34
|
+
import { createHash } from "node:crypto";
|
|
35
|
+
|
|
36
|
+
import { isTrustedRequest } from "./lib/local-request.js";
|
|
37
|
+
import { loaderObservations, privateDiagnostic } from "./lib/host-resources.js";
|
|
38
|
+
import {
|
|
39
|
+
assertEnvelope,
|
|
40
|
+
failureEnvelope,
|
|
41
|
+
catalogLines,
|
|
42
|
+
terminalText,
|
|
43
|
+
} from "./lib/report-contract.js";
|
|
44
|
+
import { nextSteps } from "./lib/finding-guidance.js";
|
|
45
|
+
import {
|
|
46
|
+
createUpgradeFailure as upgradeFailure,
|
|
47
|
+
SUPPORTED_PREVIEW_VERSIONS,
|
|
48
|
+
} from "./lib/upgrade-contract.js";
|
|
49
|
+
|
|
50
|
+
export const name = "dsh-plugin-compat-check";
|
|
37
51
|
|
|
38
52
|
/**
|
|
39
53
|
* Deliberately empty: every service this plugin reads is read through
|
|
@@ -51,16 +65,16 @@ const SCHEMA_VERSION = 1;
|
|
|
51
65
|
|
|
52
66
|
/** Cordis FiberState (const enum in the host, so mirrored here as data). */
|
|
53
67
|
const FIBER_STATES = {
|
|
54
|
-
0:
|
|
55
|
-
1:
|
|
56
|
-
2:
|
|
57
|
-
3:
|
|
58
|
-
4:
|
|
59
|
-
5:
|
|
68
|
+
0: "PENDING",
|
|
69
|
+
1: "LOADING",
|
|
70
|
+
2: "ACTIVE",
|
|
71
|
+
3: "FAILED",
|
|
72
|
+
4: "DISPOSED",
|
|
73
|
+
5: "UNLOADING",
|
|
60
74
|
};
|
|
61
75
|
|
|
62
76
|
const OUT_DIR = process.env.DSC_OUT_DIR || null;
|
|
63
|
-
const OUT_JSON = OUT_DIR ? join(OUT_DIR,
|
|
77
|
+
const OUT_JSON = OUT_DIR ? join(OUT_DIR, "loader-introspect.json") : null;
|
|
64
78
|
|
|
65
79
|
/** Defensive cap: a pathological iterable must not exhaust the host's heap. */
|
|
66
80
|
const MAX_ENTRIES = 20000;
|
|
@@ -76,19 +90,14 @@ function toPositiveInt(value, fallback) {
|
|
|
76
90
|
}
|
|
77
91
|
|
|
78
92
|
function parseDelays(value, fallback) {
|
|
79
|
-
if (typeof value !==
|
|
93
|
+
if (typeof value !== "string" || value.trim() === "") return fallback;
|
|
80
94
|
const parsed = value
|
|
81
|
-
.split(
|
|
95
|
+
.split(",")
|
|
82
96
|
.map((part) => toPositiveInt(part.trim(), 0))
|
|
83
97
|
.filter((n) => n > 0);
|
|
84
98
|
return parsed.length > 0 ? parsed : fallback;
|
|
85
99
|
}
|
|
86
100
|
|
|
87
|
-
/** `createRequire` 的小包装:同步可用,不要用 await import(apply 不是 async)。 */
|
|
88
|
-
function reqNode() {
|
|
89
|
-
return { createRequire: __createRequire };
|
|
90
|
-
}
|
|
91
|
-
|
|
92
101
|
function log(message) {
|
|
93
102
|
try {
|
|
94
103
|
console.log(`[dsh-plugin-compat-check] ${message}`);
|
|
@@ -101,15 +110,15 @@ function log(message) {
|
|
|
101
110
|
function errText(error) {
|
|
102
111
|
try {
|
|
103
112
|
if (error === null || error === undefined) return String(error);
|
|
104
|
-
if (typeof error ===
|
|
113
|
+
if (typeof error === "string") return error;
|
|
105
114
|
if (error instanceof Error) {
|
|
106
|
-
const stack = typeof error.stack ===
|
|
115
|
+
const stack = typeof error.stack === "string" ? error.stack : "";
|
|
107
116
|
return stack || `${error.name}: ${error.message}`;
|
|
108
117
|
}
|
|
109
118
|
const text = String(error);
|
|
110
|
-
return text ===
|
|
119
|
+
return text === "[object Object]" ? safeJson(error) : text;
|
|
111
120
|
} catch {
|
|
112
|
-
return
|
|
121
|
+
return "<unprintable error>";
|
|
113
122
|
}
|
|
114
123
|
}
|
|
115
124
|
|
|
@@ -117,21 +126,23 @@ function safeJson(value) {
|
|
|
117
126
|
try {
|
|
118
127
|
return JSON.stringify(value);
|
|
119
128
|
} catch {
|
|
120
|
-
return
|
|
129
|
+
return "<unserializable>";
|
|
121
130
|
}
|
|
122
131
|
}
|
|
123
132
|
|
|
124
133
|
function describe(value) {
|
|
125
134
|
try {
|
|
126
|
-
if (value === undefined) return
|
|
127
|
-
if (value === null) return
|
|
135
|
+
if (value === undefined) return "undefined";
|
|
136
|
+
if (value === null) return "null";
|
|
128
137
|
return typeof value;
|
|
129
138
|
} catch {
|
|
130
|
-
return
|
|
139
|
+
return "<unreadable>";
|
|
131
140
|
}
|
|
132
141
|
}
|
|
133
142
|
|
|
134
|
-
function writeReport(report) {
|
|
143
|
+
function writeReport(report) {
|
|
144
|
+
return privateDiagnostic(OUT_JSON, report);
|
|
145
|
+
}
|
|
135
146
|
|
|
136
147
|
// ---------------------------------------------------------------------------
|
|
137
148
|
// B1: loader access
|
|
@@ -152,10 +163,13 @@ function resolveLoader(ctx) {
|
|
|
152
163
|
let loader;
|
|
153
164
|
let via;
|
|
154
165
|
|
|
155
|
-
const looksLikeLoader = (value) =>
|
|
166
|
+
const looksLikeLoader = (value) =>
|
|
167
|
+
value !== undefined &&
|
|
168
|
+
value !== null &&
|
|
169
|
+
typeof value.entries === "function";
|
|
156
170
|
|
|
157
171
|
try {
|
|
158
|
-
const value = ctx.get(
|
|
172
|
+
const value = ctx.get("loader");
|
|
159
173
|
attempts.push(`ctx.get('loader') -> ${describe(value)}`);
|
|
160
174
|
if (looksLikeLoader(value)) {
|
|
161
175
|
loader = value;
|
|
@@ -167,7 +181,7 @@ function resolveLoader(ctx) {
|
|
|
167
181
|
|
|
168
182
|
if (loader === undefined) {
|
|
169
183
|
try {
|
|
170
|
-
const value = ctx.get(
|
|
184
|
+
const value = ctx.get("loader", false);
|
|
171
185
|
attempts.push(`ctx.get('loader', false) -> ${describe(value)}`);
|
|
172
186
|
if (looksLikeLoader(value)) {
|
|
173
187
|
loader = value;
|
|
@@ -185,7 +199,7 @@ function resolveLoader(ctx) {
|
|
|
185
199
|
attempts.push(`ctx.loader -> ${describe(direct)}`);
|
|
186
200
|
if (loader === undefined && looksLikeLoader(direct)) {
|
|
187
201
|
loader = direct;
|
|
188
|
-
via =
|
|
202
|
+
via = "ctx.loader";
|
|
189
203
|
}
|
|
190
204
|
} catch (error) {
|
|
191
205
|
directError = errText(error);
|
|
@@ -223,23 +237,23 @@ function startInjectProbe(ctx, record) {
|
|
|
223
237
|
const startedAt = Date.now();
|
|
224
238
|
record.startedAt = new Date(startedAt).toISOString();
|
|
225
239
|
try {
|
|
226
|
-
if (typeof ctx.inject !==
|
|
227
|
-
record.outcome =
|
|
240
|
+
if (typeof ctx.inject !== "function") {
|
|
241
|
+
record.outcome = "ctx.inject is not a function";
|
|
228
242
|
return null;
|
|
229
243
|
}
|
|
230
244
|
record.registered = true;
|
|
231
|
-
const dispose = ctx.inject([
|
|
245
|
+
const dispose = ctx.inject(["loader"], (scoped) => {
|
|
232
246
|
try {
|
|
233
|
-
record.outcome =
|
|
247
|
+
record.outcome = "fired (child fiber activated)";
|
|
234
248
|
record.firedAfterMs = Date.now() - startedAt;
|
|
235
|
-
const value = scoped?.loader ?? scoped?.get?.(
|
|
249
|
+
const value = scoped?.loader ?? scoped?.get?.("loader");
|
|
236
250
|
record.scopedLoader = describe(value);
|
|
237
|
-
record.scopedLoaderHasEntries = typeof value?.entries ===
|
|
251
|
+
record.scopedLoaderHasEntries = typeof value?.entries === "function";
|
|
238
252
|
} catch (error) {
|
|
239
253
|
record.outcome = `callback threw: ${errText(error)}`;
|
|
240
254
|
}
|
|
241
255
|
});
|
|
242
|
-
return typeof dispose ===
|
|
256
|
+
return typeof dispose === "function" ? dispose : null;
|
|
243
257
|
} catch (error) {
|
|
244
258
|
record.outcome = `ctx.inject threw: ${errText(error)}`;
|
|
245
259
|
return null;
|
|
@@ -265,7 +279,11 @@ async function awaitFiber(fiber, timeoutMs) {
|
|
|
265
279
|
} catch (error) {
|
|
266
280
|
return { ok: false, reason: errText(error), timedOut: false };
|
|
267
281
|
}
|
|
268
|
-
if (
|
|
282
|
+
if (
|
|
283
|
+
promise === undefined ||
|
|
284
|
+
promise === null ||
|
|
285
|
+
typeof promise.then !== "function"
|
|
286
|
+
) {
|
|
269
287
|
return { ok: true, reason: null, timedOut: false };
|
|
270
288
|
}
|
|
271
289
|
|
|
@@ -277,8 +295,11 @@ async function awaitFiber(fiber, timeoutMs) {
|
|
|
277
295
|
let timer;
|
|
278
296
|
try {
|
|
279
297
|
const timeout = new Promise((resolve) => {
|
|
280
|
-
timer = setTimeout(
|
|
281
|
-
|
|
298
|
+
timer = setTimeout(
|
|
299
|
+
() => resolve({ ok: false, reason: null, timedOut: true }),
|
|
300
|
+
timeoutMs,
|
|
301
|
+
);
|
|
302
|
+
if (timer && typeof timer.unref === "function") timer.unref();
|
|
282
303
|
});
|
|
283
304
|
return await Promise.race([settled, timeout]);
|
|
284
305
|
} catch (error) {
|
|
@@ -301,7 +322,7 @@ function declaredInjectKeys(fiber) {
|
|
|
301
322
|
const inject = fiber?.inject;
|
|
302
323
|
if (inject === undefined || inject === null) return [];
|
|
303
324
|
if (Array.isArray(inject)) return inject.map(String);
|
|
304
|
-
if (typeof inject ===
|
|
325
|
+
if (typeof inject === "object") return Object.keys(inject);
|
|
305
326
|
if (inject instanceof Set) return [...inject].map(String);
|
|
306
327
|
return [];
|
|
307
328
|
} catch (error) {
|
|
@@ -329,7 +350,11 @@ function missingServices(fiber, keys) {
|
|
|
329
350
|
function optionsOf(entry) {
|
|
330
351
|
try {
|
|
331
352
|
const options = entry?.options;
|
|
332
|
-
return options !== undefined &&
|
|
353
|
+
return options !== undefined &&
|
|
354
|
+
options !== null &&
|
|
355
|
+
typeof options === "object"
|
|
356
|
+
? options
|
|
357
|
+
: {};
|
|
333
358
|
} catch (error) {
|
|
334
359
|
return { __unreadable: errText(error) };
|
|
335
360
|
}
|
|
@@ -343,7 +368,7 @@ async function introspectEntry(entry) {
|
|
|
343
368
|
group: options.group === true,
|
|
344
369
|
disabled: null,
|
|
345
370
|
disabledError: null,
|
|
346
|
-
fiberState:
|
|
371
|
+
fiberState: "NO_FIBER",
|
|
347
372
|
fiberStateCode: null,
|
|
348
373
|
declaredInject: [],
|
|
349
374
|
declaredInjectCount: 0,
|
|
@@ -367,7 +392,7 @@ async function introspectEntry(entry) {
|
|
|
367
392
|
row.disabled = Boolean(entry.disabled);
|
|
368
393
|
} catch (error) {
|
|
369
394
|
row.disabledError = errText(error);
|
|
370
|
-
row.notes.push(
|
|
395
|
+
row.notes.push("disabled expression threw");
|
|
371
396
|
}
|
|
372
397
|
|
|
373
398
|
let fiber;
|
|
@@ -378,25 +403,28 @@ async function introspectEntry(entry) {
|
|
|
378
403
|
}
|
|
379
404
|
|
|
380
405
|
if (fiber === undefined || fiber === null) {
|
|
381
|
-
row.fiberState =
|
|
382
|
-
row.notes.push(
|
|
383
|
-
if (row.group) row.notes.push(
|
|
406
|
+
row.fiberState = "NO_FIBER";
|
|
407
|
+
row.notes.push("no fiber (loader did not import this entry)");
|
|
408
|
+
if (row.group) row.notes.push("options.group is true");
|
|
384
409
|
return row;
|
|
385
410
|
}
|
|
386
411
|
|
|
387
412
|
try {
|
|
388
413
|
const code = fiber.state;
|
|
389
|
-
row.fiberStateCode = typeof code ===
|
|
414
|
+
row.fiberStateCode = typeof code === "number" ? code : null;
|
|
390
415
|
row.fiberState = FIBER_STATES[code] ?? `UNKNOWN(${String(code)})`;
|
|
391
416
|
} catch (error) {
|
|
392
|
-
row.fiberState =
|
|
417
|
+
row.fiberState = "UNREADABLE";
|
|
393
418
|
row.notes.push(`fiber.state threw: ${errText(error)}`);
|
|
394
419
|
}
|
|
395
420
|
|
|
396
421
|
try {
|
|
397
422
|
row.declaredInject = declaredInjectKeys(fiber);
|
|
398
423
|
row.declaredInjectCount = row.declaredInject.length;
|
|
399
|
-
const { missing, present, unreadable } = missingServices(
|
|
424
|
+
const { missing, present, unreadable } = missingServices(
|
|
425
|
+
fiber,
|
|
426
|
+
row.declaredInject,
|
|
427
|
+
);
|
|
400
428
|
row.missingServices = missing;
|
|
401
429
|
row.presentServices = present;
|
|
402
430
|
row.unreadableServices = unreadable;
|
|
@@ -405,19 +433,21 @@ async function introspectEntry(entry) {
|
|
|
405
433
|
}
|
|
406
434
|
|
|
407
435
|
// Only a fiber that is not ACTIVE can carry an unobserved rejection.
|
|
408
|
-
if (row.fiberState !==
|
|
436
|
+
if (row.fiberState !== "ACTIVE" && row.fiberState !== "DISPOSED") {
|
|
409
437
|
const awaited = await awaitFiber(fiber, AWAIT_TIMEOUT_MS);
|
|
410
438
|
if (awaited.timedOut) {
|
|
411
439
|
row.awaitTimedOut = true;
|
|
412
440
|
row.notes.push(`fiber.await() exceeded ${AWAIT_TIMEOUT_MS}ms`);
|
|
413
441
|
} else if (!awaited.ok) {
|
|
414
442
|
row.failureReason = awaited.reason;
|
|
415
|
-
} else if (row.fiberState ===
|
|
416
|
-
row.notes.push(
|
|
443
|
+
} else if (row.fiberState === "PENDING") {
|
|
444
|
+
row.notes.push(
|
|
445
|
+
"fiber.await() resolved without error (PENDING: waiting for services)",
|
|
446
|
+
);
|
|
417
447
|
}
|
|
418
448
|
}
|
|
419
449
|
|
|
420
|
-
if (row.disabled === true) row.notes.push(
|
|
450
|
+
if (row.disabled === true) row.notes.push("entry is disabled");
|
|
421
451
|
return row;
|
|
422
452
|
}
|
|
423
453
|
|
|
@@ -431,12 +461,14 @@ async function introspectEntry(entry) {
|
|
|
431
461
|
*/
|
|
432
462
|
function toEntryArray(value) {
|
|
433
463
|
const fail = (error) => ({ entries: null, truncated: false, error });
|
|
434
|
-
if (value === null || value === undefined)
|
|
435
|
-
|
|
464
|
+
if (value === null || value === undefined)
|
|
465
|
+
return fail(`entries() returned ${describe(value)}`);
|
|
466
|
+
if (Array.isArray(value))
|
|
467
|
+
return { entries: value, truncated: false, error: null };
|
|
436
468
|
|
|
437
469
|
let iterable = false;
|
|
438
470
|
try {
|
|
439
|
-
iterable = typeof value[Symbol.iterator] ===
|
|
471
|
+
iterable = typeof value[Symbol.iterator] === "function";
|
|
440
472
|
} catch (error) {
|
|
441
473
|
return fail(`probing Symbol.iterator threw: ${errText(error)}`);
|
|
442
474
|
}
|
|
@@ -445,18 +477,27 @@ function toEntryArray(value) {
|
|
|
445
477
|
try {
|
|
446
478
|
for (const item of value) {
|
|
447
479
|
out.push(item);
|
|
448
|
-
if (out.length >= MAX_ENTRIES)
|
|
480
|
+
if (out.length >= MAX_ENTRIES)
|
|
481
|
+
return { entries: out, truncated: true, error: null };
|
|
449
482
|
}
|
|
450
483
|
return { entries: out, truncated: false, error: null };
|
|
451
484
|
} catch (error) {
|
|
452
|
-
return {
|
|
485
|
+
return {
|
|
486
|
+
entries: out,
|
|
487
|
+
truncated: false,
|
|
488
|
+
error: `iterating entries threw: ${errText(error)}`,
|
|
489
|
+
};
|
|
453
490
|
}
|
|
454
491
|
}
|
|
455
492
|
|
|
456
493
|
try {
|
|
457
|
-
if (typeof value.length ===
|
|
494
|
+
if (typeof value.length === "number") {
|
|
458
495
|
const length = Math.min(value.length, MAX_ENTRIES);
|
|
459
|
-
return {
|
|
496
|
+
return {
|
|
497
|
+
entries: Array.prototype.slice.call(value, 0, length),
|
|
498
|
+
truncated: value.length > length,
|
|
499
|
+
error: null,
|
|
500
|
+
};
|
|
460
501
|
}
|
|
461
502
|
} catch (error) {
|
|
462
503
|
return fail(`reading array-like entries threw: ${errText(error)}`);
|
|
@@ -465,7 +506,7 @@ function toEntryArray(value) {
|
|
|
465
506
|
}
|
|
466
507
|
|
|
467
508
|
async function scan(ctx, state, pass) {
|
|
468
|
-
if(state.disposed)return;
|
|
509
|
+
if (state.disposed) return;
|
|
469
510
|
const report = {
|
|
470
511
|
schemaVersion: SCHEMA_VERSION,
|
|
471
512
|
plugin: name,
|
|
@@ -494,12 +535,17 @@ async function scan(ctx, state, pass) {
|
|
|
494
535
|
via = resolved.via;
|
|
495
536
|
state.loader = loader;
|
|
496
537
|
state.loaderVia = via;
|
|
497
|
-
report.loaderAccess = {
|
|
538
|
+
report.loaderAccess = {
|
|
539
|
+
...state.loaderAccess,
|
|
540
|
+
...publicLoaderAccess(resolved),
|
|
541
|
+
};
|
|
498
542
|
}
|
|
499
543
|
|
|
500
544
|
if (loader === undefined) {
|
|
501
|
-
report.errors.push(
|
|
502
|
-
log(
|
|
545
|
+
report.errors.push("loader service not reachable from this plugin context");
|
|
546
|
+
log(
|
|
547
|
+
`ERROR loader not reachable (${JSON.stringify(state.loaderAccess.attempts ?? [])})`,
|
|
548
|
+
);
|
|
503
549
|
writeReport(report);
|
|
504
550
|
return report;
|
|
505
551
|
}
|
|
@@ -513,11 +559,12 @@ async function scan(ctx, state, pass) {
|
|
|
513
559
|
typeof: describe(rawEntries),
|
|
514
560
|
isArray: Array.isArray(rawEntries),
|
|
515
561
|
constructorName: safeJson(rawEntries?.constructor?.name ?? null),
|
|
516
|
-
iterable: typeof rawEntries?.[Symbol.iterator] ===
|
|
562
|
+
iterable: typeof rawEntries?.[Symbol.iterator] === "function",
|
|
517
563
|
truncated: walked.truncated,
|
|
518
564
|
};
|
|
519
565
|
entries = walked.entries;
|
|
520
|
-
if (walked.error !== null)
|
|
566
|
+
if (walked.error !== null)
|
|
567
|
+
report.errors.push(`loader.entries(): ${walked.error}`);
|
|
521
568
|
} catch (error) {
|
|
522
569
|
report.errors.push(`loader.entries() threw: ${errText(error)}`);
|
|
523
570
|
log(`ERROR scan#${pass}: loader.entries() threw: ${errText(error)}`);
|
|
@@ -525,7 +572,8 @@ async function scan(ctx, state, pass) {
|
|
|
525
572
|
return report;
|
|
526
573
|
}
|
|
527
574
|
if (entries === null) {
|
|
528
|
-
const detail =
|
|
575
|
+
const detail =
|
|
576
|
+
report.errors.length > 0 ? report.errors.join("; ") : "unknown reason";
|
|
529
577
|
log(`ERROR scan#${pass}: could not read entries (${detail})`);
|
|
530
578
|
writeReport(report);
|
|
531
579
|
return report;
|
|
@@ -538,33 +586,34 @@ async function scan(ctx, state, pass) {
|
|
|
538
586
|
row = await introspectEntry(entry);
|
|
539
587
|
} catch (error) {
|
|
540
588
|
row = {
|
|
541
|
-
id:
|
|
542
|
-
fiberState:
|
|
589
|
+
id: "<unreadable entry>",
|
|
590
|
+
fiberState: "UNREADABLE",
|
|
543
591
|
notes: [`entry introspection threw: ${errText(error)}`],
|
|
544
592
|
};
|
|
545
593
|
report.errors.push(`entry introspection threw: ${errText(error)}`);
|
|
546
594
|
}
|
|
547
595
|
report.entries.push(row);
|
|
548
|
-
report.stateHistogram[row.fiberState] =
|
|
596
|
+
report.stateHistogram[row.fiberState] =
|
|
597
|
+
(report.stateHistogram[row.fiberState] ?? 0) + 1;
|
|
549
598
|
}
|
|
550
599
|
|
|
551
600
|
report.problems = report.entries.filter((row) => {
|
|
552
601
|
if (row.disabled === true) return false;
|
|
553
|
-
if (row.fiberState ===
|
|
602
|
+
if (row.fiberState === "ACTIVE") return false;
|
|
554
603
|
return true;
|
|
555
604
|
});
|
|
556
605
|
|
|
557
|
-
if(state.disposed)return report;
|
|
606
|
+
if (state.disposed) return report;
|
|
558
607
|
const written = writeReport(report);
|
|
559
608
|
log(
|
|
560
|
-
`scan#${pass}/${SCAN_DELAYS.length} loader-via=${via} entries=${report.entryCount} states=${safeJson(report.stateHistogram)} problems=${report.problems.length} report=${OUT_JSON}${written ?
|
|
609
|
+
`scan#${pass}/${SCAN_DELAYS.length} loader-via=${via} entries=${report.entryCount} states=${safeJson(report.stateHistogram)} problems=${report.problems.length} report=${OUT_JSON}${written ? "" : " (WRITE FAILED)"}`,
|
|
561
610
|
);
|
|
562
611
|
for (const row of report.problems) {
|
|
563
612
|
log(
|
|
564
613
|
` PROBLEM id=${safeJson(row.id)} name=${safeJson(row.name)} state=${row.fiberState}` +
|
|
565
|
-
` missing=[${row.missingServices.join(
|
|
566
|
-
(row.failureReason ? ` reason=${firstLine(row.failureReason)}` :
|
|
567
|
-
(row.awaitTimedOut ?
|
|
614
|
+
` missing=[${row.missingServices.join(", ")}]` +
|
|
615
|
+
(row.failureReason ? ` reason=${firstLine(row.failureReason)}` : "") +
|
|
616
|
+
(row.awaitTimedOut ? " (await timed out)" : ""),
|
|
568
617
|
);
|
|
569
618
|
}
|
|
570
619
|
return report;
|
|
@@ -572,10 +621,10 @@ async function scan(ctx, state, pass) {
|
|
|
572
621
|
|
|
573
622
|
function firstLine(text) {
|
|
574
623
|
try {
|
|
575
|
-
const line = String(text).split(
|
|
624
|
+
const line = String(text).split("\n")[0];
|
|
576
625
|
return line.length > 300 ? `${line.slice(0, 300)}…` : line;
|
|
577
626
|
} catch {
|
|
578
|
-
return
|
|
627
|
+
return "<unprintable>";
|
|
579
628
|
}
|
|
580
629
|
}
|
|
581
630
|
|
|
@@ -615,16 +664,21 @@ function resolveKernelDir() {
|
|
|
615
664
|
const here = dir(fileURLToPath(import.meta.url));
|
|
616
665
|
const candidates = [
|
|
617
666
|
process.env.DSC_KERNEL,
|
|
618
|
-
path.join(here,
|
|
667
|
+
path.join(here, "kernel"), // npm 自足包形态(`npm pack` 时由 prepack 暂存到此)
|
|
619
668
|
here, // 另一种自足布局:内核与 index.js 同级
|
|
620
|
-
path.resolve(here,
|
|
669
|
+
path.resolve(here, ".."), // 仓库形态:plugin/ → 仓库根
|
|
621
670
|
].filter(Boolean);
|
|
622
671
|
for (const c of candidates) {
|
|
623
672
|
try {
|
|
624
|
-
if (
|
|
673
|
+
if (
|
|
674
|
+
existsSync(path.join(c, "lib", "extract.mjs")) &&
|
|
675
|
+
existsSync(path.join(c, "lib", "verdict.mjs"))
|
|
676
|
+
) {
|
|
625
677
|
return c;
|
|
626
678
|
}
|
|
627
|
-
} catch {
|
|
679
|
+
} catch {
|
|
680
|
+
/* 下一个候选 */
|
|
681
|
+
}
|
|
628
682
|
}
|
|
629
683
|
return null;
|
|
630
684
|
}
|
|
@@ -691,32 +745,52 @@ function resolveDshVersion() {
|
|
|
691
745
|
const m = /^(.*[\\/]dsh)[\\/]node_modules[\\/]/.exec(sep);
|
|
692
746
|
if (m && m[1]) {
|
|
693
747
|
const dshRoot = m[1];
|
|
694
|
-
const req = __createRequire(join(dshRoot,
|
|
748
|
+
const req = __createRequire(join(dshRoot, "probe.js"));
|
|
695
749
|
// 两个包实测都能解析;都试一遍是为了不依赖某一个包的存在
|
|
696
|
-
for (const spec of [
|
|
750
|
+
for (const spec of [
|
|
751
|
+
"@deepseek-ai/dsh-desktop-host/package.json",
|
|
752
|
+
"@deepseek-ai/dsh/package.json",
|
|
753
|
+
]) {
|
|
697
754
|
try {
|
|
698
755
|
const meta = req(req.resolve(spec));
|
|
699
|
-
if (meta && typeof meta.version ===
|
|
756
|
+
if (meta && typeof meta.version === "string" && meta.version) {
|
|
700
757
|
return meta.version;
|
|
701
758
|
}
|
|
702
|
-
} catch {
|
|
759
|
+
} catch {
|
|
760
|
+
/* 试下一个 */
|
|
761
|
+
}
|
|
703
762
|
}
|
|
704
763
|
}
|
|
705
764
|
}
|
|
706
|
-
} catch {
|
|
765
|
+
} catch {
|
|
766
|
+
/* 继续 */
|
|
767
|
+
}
|
|
707
768
|
|
|
708
769
|
// npm prefixes need not end in /dsh/. Read only an official runtime's own
|
|
709
770
|
// manifest above the real entry; package.json need not be an exported subpath.
|
|
710
771
|
try {
|
|
711
|
-
let dir=path.dirname(path.resolve(realpathSync(process.argv[1])));
|
|
712
|
-
for(let depth=0;depth<32;depth++){
|
|
713
|
-
try{
|
|
714
|
-
const meta=JSON.parse(
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
772
|
+
let dir = path.dirname(path.resolve(realpathSync(process.argv[1])));
|
|
773
|
+
for (let depth = 0; depth < 32; depth++) {
|
|
774
|
+
try {
|
|
775
|
+
const meta = JSON.parse(
|
|
776
|
+
readFileSync(join(dir, "package.json"), "utf8"),
|
|
777
|
+
);
|
|
778
|
+
if (
|
|
779
|
+
[
|
|
780
|
+
"@deepseek-ai/dsh",
|
|
781
|
+
"@deepseek-ai/dsh-desktop-host",
|
|
782
|
+
"@deepseek-ai/dsh-app-boot",
|
|
783
|
+
].includes(meta.name) &&
|
|
784
|
+
typeof meta.version === "string" &&
|
|
785
|
+
meta.version
|
|
786
|
+
)
|
|
787
|
+
return meta.version;
|
|
788
|
+
} catch {}
|
|
789
|
+
const parent = path.dirname(dir);
|
|
790
|
+
if (parent === dir) break;
|
|
791
|
+
dir = parent;
|
|
718
792
|
}
|
|
719
|
-
}catch{}
|
|
793
|
+
} catch {}
|
|
720
794
|
// ③ 都没有 → **null,不猜**(判定层会据此不产出声明不符的结论)
|
|
721
795
|
return null;
|
|
722
796
|
}
|
|
@@ -738,7 +812,8 @@ function resolveDshVersion() {
|
|
|
738
812
|
* profile 无关。信它们会把 gverify 的结论写进 `baseline.desktop.json`,
|
|
739
813
|
* 于是差分会报出"移除了 17 个插件"这种**完全是编出来的变化**。
|
|
740
814
|
*
|
|
741
|
-
* 所以顺序是:argv(宿主自己声明的)> 显式覆盖 >
|
|
815
|
+
* 所以顺序是:argv(宿主自己声明的)> 显式覆盖 > 环境变量(可能是陈的)。
|
|
816
|
+
* 无法确认时要求显式指定,不猜测 profile。
|
|
742
817
|
*/
|
|
743
818
|
function resolveActiveProfile() {
|
|
744
819
|
// ① 宿主自己的启动参数 —— 最权威。**两种形态都要认**:
|
|
@@ -753,39 +828,74 @@ function resolveActiveProfile() {
|
|
|
753
828
|
const argv = (process.argv || []).map(String);
|
|
754
829
|
for (let i = argv.length - 1; i >= 0; i--) {
|
|
755
830
|
const inline = /^--profile=(.+)$/.exec(argv[i]);
|
|
756
|
-
if (inline)
|
|
757
|
-
|
|
758
|
-
|
|
831
|
+
if (inline)
|
|
832
|
+
return {
|
|
833
|
+
name: inline[1],
|
|
834
|
+
why: `process.argv 的 --profile=${inline[1]}`,
|
|
835
|
+
};
|
|
836
|
+
if (
|
|
837
|
+
argv[i] === "--profile" &&
|
|
838
|
+
argv[i + 1] &&
|
|
839
|
+
!argv[i + 1].startsWith("-")
|
|
840
|
+
) {
|
|
841
|
+
return {
|
|
842
|
+
name: argv[i + 1],
|
|
843
|
+
why: `process.argv 的 --profile ${argv[i + 1]}`,
|
|
844
|
+
};
|
|
759
845
|
}
|
|
760
846
|
}
|
|
761
847
|
// 位置形态:任何指向 `<…>/profiles/<名字>`(或它下面的 node_modules)的绝对路径
|
|
762
848
|
for (const a of argv) {
|
|
763
|
-
if (!a.startsWith(
|
|
849
|
+
if (!a.startsWith("/")) continue;
|
|
764
850
|
const m = /\/profiles\/([^/]+)(?:\/|$)/.exec(a);
|
|
765
|
-
if (m && m[1])
|
|
851
|
+
if (m && m[1])
|
|
852
|
+
return { name: m[1], why: `process.argv 里的 profile 路径 ${a}` };
|
|
766
853
|
}
|
|
767
|
-
} catch {
|
|
854
|
+
} catch {
|
|
855
|
+
/* 继续 */
|
|
856
|
+
}
|
|
768
857
|
// ② 显式覆盖(工具/测试用)
|
|
769
|
-
if (process.env.DSC_PROFILE)
|
|
858
|
+
if (process.env.DSC_PROFILE)
|
|
859
|
+
return {
|
|
860
|
+
name: process.env.DSC_PROFILE,
|
|
861
|
+
why: `DSC_PROFILE=${process.env.DSC_PROFILE}`,
|
|
862
|
+
};
|
|
770
863
|
// ③ 环境变量 —— **可能是陈的**,所以带上警示写进 why
|
|
771
|
-
for (const key of [
|
|
864
|
+
for (const key of ["DSH_PROFILE", "DSH_ACTIVE_PROFILE", "DSH_PROFILE_NAME"]) {
|
|
772
865
|
const v = process.env[key];
|
|
773
|
-
if (v)
|
|
866
|
+
if (v)
|
|
867
|
+
return {
|
|
868
|
+
name: v,
|
|
869
|
+
why: `${key}=${v}(环境变量可能来自启动 app 的那个 shell,未必是当前加载的 profile)`,
|
|
870
|
+
};
|
|
774
871
|
}
|
|
775
|
-
return { name: null, why:
|
|
872
|
+
return { name: null, why: "无法判断" };
|
|
776
873
|
}
|
|
777
874
|
|
|
778
875
|
function resolveNodeModules(requestedProfile = null) {
|
|
779
|
-
const dshHome=process.env.DSH_HOME || join(homedir(),
|
|
780
|
-
const inferred=resolveActiveProfile();
|
|
781
|
-
const selected=requestedProfile || inferred.name;
|
|
782
|
-
if(selected){
|
|
783
|
-
if(!/^[\w.-]+$/.test(selected) || selected===
|
|
784
|
-
|
|
785
|
-
|
|
876
|
+
const dshHome = process.env.DSH_HOME || join(homedir(), ".dsh");
|
|
877
|
+
const inferred = resolveActiveProfile();
|
|
878
|
+
const selected = requestedProfile || inferred.name;
|
|
879
|
+
if (selected) {
|
|
880
|
+
if (!/^[\w.-]+$/.test(selected) || selected === "." || selected === "..")
|
|
881
|
+
return { dir: null, why: "profile 名称非法" };
|
|
882
|
+
const dir = join(dshHome, "profiles", selected, "node_modules");
|
|
883
|
+
return existsSync(dir)
|
|
884
|
+
? {
|
|
885
|
+
dir,
|
|
886
|
+
why: requestedProfile
|
|
887
|
+
? "请求明确指定的 profile " + selected
|
|
888
|
+
: inferred.why,
|
|
889
|
+
}
|
|
890
|
+
: { dir: null, why: "指定 profile 不存在: " + selected };
|
|
891
|
+
}
|
|
892
|
+
if (process.env.DSH_PROFILE_DIR) {
|
|
893
|
+
const dir = join(process.env.DSH_PROFILE_DIR, "node_modules");
|
|
894
|
+
return existsSync(dir)
|
|
895
|
+
? { dir, why: "显式 DSH_PROFILE_DIR" }
|
|
896
|
+
: { dir: null, why: "DSH_PROFILE_DIR 不存在" };
|
|
786
897
|
}
|
|
787
|
-
|
|
788
|
-
return {dir:null,why:'无法确认当前 profile;请显式指定'};
|
|
898
|
+
return { dir: null, why: "无法确认当前 profile;请显式指定" };
|
|
789
899
|
}
|
|
790
900
|
|
|
791
901
|
/**
|
|
@@ -798,74 +908,169 @@ function resolveNodeModules(requestedProfile = null) {
|
|
|
798
908
|
* time-dependent observations even when the sampled identity is unchanged.
|
|
799
909
|
*/
|
|
800
910
|
async function serveReport(state, ctx, force, options = {}) {
|
|
801
|
-
if(state.disposed)
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
911
|
+
if (state.disposed)
|
|
912
|
+
return failureEnvelope("Plugin generation disposed / 已卸载");
|
|
913
|
+
const kernel = resolveKernelDir(),
|
|
914
|
+
nm = resolveNodeModules(options.profile || null);
|
|
915
|
+
if (!kernel) return failureEnvelope("找不到判定内核");
|
|
916
|
+
if (!nm.dir) {
|
|
805
917
|
// Keep computeReport's version identity on target-resolution failures.
|
|
806
|
-
const out=await computeReport(state,ctx,options);
|
|
807
|
-
return failureEnvelope(out.error,out);
|
|
918
|
+
const out = await computeReport(state, ctx, options);
|
|
919
|
+
return failureEnvelope(out.error, out);
|
|
808
920
|
}
|
|
809
|
-
const canonical=await fsPromises.realpath(nm.dir),
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
921
|
+
const canonical = await fsPromises.realpath(nm.dir),
|
|
922
|
+
version = resolveDshVersion();
|
|
923
|
+
const context = {
|
|
924
|
+
nodeModules: canonical,
|
|
925
|
+
observationProfile: path.resolve(nm.dir),
|
|
926
|
+
kernel: await fsPromises.realpath(kernel),
|
|
927
|
+
dshVersion: version,
|
|
928
|
+
probe: process.env.DSC_PROBE_REPORT || null,
|
|
929
|
+
};
|
|
930
|
+
if (!state.reportService) {
|
|
931
|
+
const [
|
|
932
|
+
{ ReportService },
|
|
933
|
+
{ reportIdentity },
|
|
934
|
+
{ catalogPathFor },
|
|
935
|
+
{ sampleServices },
|
|
936
|
+
] = await Promise.all([
|
|
937
|
+
import(pathToFileURL(join(kernel, "lib", "report-service.mjs")).href),
|
|
938
|
+
import(pathToFileURL(join(kernel, "lib", "report-identity.mjs")).href),
|
|
939
|
+
import(pathToFileURL(join(kernel, "lib", "catalog.mjs")).href),
|
|
940
|
+
import(pathToFileURL(join(kernel, "lib", "observation.mjs")).href),
|
|
817
941
|
]);
|
|
818
|
-
if(state.disposed)
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
942
|
+
if (state.disposed)
|
|
943
|
+
return failureEnvelope("Plugin generation disposed / 已卸载");
|
|
944
|
+
if (!state.reportService)
|
|
945
|
+
state.reportService = new ReportService(
|
|
946
|
+
async (options, guard) => {
|
|
947
|
+
const out = await computeReport(state, ctx, {
|
|
948
|
+
...options,
|
|
949
|
+
_cacheGuard: guard,
|
|
950
|
+
});
|
|
951
|
+
return out.ok ? assertEnvelope(out) : failureEnvelope(out.error, out);
|
|
952
|
+
},
|
|
953
|
+
{
|
|
954
|
+
ttlMs: state.cacheTtlMs,
|
|
955
|
+
watchMs: state.cacheWatchMs,
|
|
956
|
+
onEvict: (key) => {
|
|
957
|
+
for (const [token, target] of state.reviewTargets || [])
|
|
958
|
+
if (target.key === key) state.reviewTargets.delete(token);
|
|
959
|
+
state.lastReport = null;
|
|
960
|
+
state.lastReportAt = 0;
|
|
961
|
+
},
|
|
962
|
+
identity: async (options, { signal }) => {
|
|
963
|
+
const scope = options._cacheContext,
|
|
964
|
+
current = resolveNodeModules(),
|
|
965
|
+
currentVersion = resolveDshVersion();
|
|
966
|
+
const active = current.dir
|
|
967
|
+
? await fsPromises.realpath(current.dir)
|
|
968
|
+
: null;
|
|
969
|
+
const requestedTarget = await fsPromises.realpath(
|
|
970
|
+
options._nodeModules.dir,
|
|
971
|
+
);
|
|
972
|
+
const identity = await reportIdentity(
|
|
973
|
+
{
|
|
974
|
+
nodeModules: scope.nodeModules,
|
|
975
|
+
context: {
|
|
976
|
+
...scope,
|
|
977
|
+
currentVersion,
|
|
978
|
+
active,
|
|
979
|
+
requestedTarget,
|
|
980
|
+
probe: process.env.DSC_PROBE_REPORT || null,
|
|
981
|
+
},
|
|
982
|
+
files: [
|
|
983
|
+
join(scope.kernel, "rules", "severity.json"),
|
|
984
|
+
catalogPathFor(scope.kernel, currentVersion),
|
|
985
|
+
process.env.DSC_PROBE_REPORT,
|
|
986
|
+
join(
|
|
987
|
+
path.dirname(options._nodeModules.dir),
|
|
988
|
+
"compatibility.json",
|
|
989
|
+
),
|
|
990
|
+
],
|
|
991
|
+
},
|
|
992
|
+
{ signal },
|
|
993
|
+
);
|
|
994
|
+
const sameHost = active === scope.nodeModules;
|
|
995
|
+
const itemListing = sameHost
|
|
996
|
+
? await (async () => {
|
|
997
|
+
try {
|
|
998
|
+
const listing = await ctx
|
|
999
|
+
.get?.("pluginManager", false)
|
|
1000
|
+
?.list?.();
|
|
1001
|
+
return (
|
|
1002
|
+
Array.isArray(listing)
|
|
1003
|
+
? listing
|
|
1004
|
+
: listing?.items || listing?.plugins || []
|
|
1005
|
+
).map((i) => ({
|
|
1006
|
+
id: i.id ?? i.entryId ?? i.slotId,
|
|
1007
|
+
packageName: i.packageName ?? i.package ?? i.name,
|
|
1008
|
+
}));
|
|
1009
|
+
} catch {
|
|
1010
|
+
return [];
|
|
1011
|
+
}
|
|
1012
|
+
})()
|
|
1013
|
+
: [];
|
|
1014
|
+
const loader = sameHost
|
|
1015
|
+
? loaderObservations(ctx, identity.manifests, itemListing)
|
|
1016
|
+
: null;
|
|
1017
|
+
const observation = sameHost
|
|
1018
|
+
? sampleServices(
|
|
1019
|
+
ctx,
|
|
1020
|
+
[
|
|
1021
|
+
...identity.services,
|
|
1022
|
+
...(options._cacheInputs.services || []),
|
|
1023
|
+
],
|
|
1024
|
+
{ profileId: scope.nodeModules, dshVersion: currentVersion },
|
|
1025
|
+
)
|
|
1026
|
+
: null;
|
|
1027
|
+
// Sampling timestamps are evidence, not a changing service identity.
|
|
1028
|
+
return JSON.stringify([
|
|
1029
|
+
identity.digest,
|
|
1030
|
+
loader,
|
|
1031
|
+
observation?.root || null,
|
|
1032
|
+
]);
|
|
1033
|
+
},
|
|
1034
|
+
},
|
|
1035
|
+
);
|
|
834
1036
|
}
|
|
835
|
-
if(!state.cacheEventDisposers && typeof ctx.on===
|
|
836
|
-
state.cacheEventDisposers=[];
|
|
1037
|
+
if (!state.cacheEventDisposers && typeof ctx.on === "function") {
|
|
1038
|
+
state.cacheEventDisposers = [];
|
|
837
1039
|
// Verified in the bundled loader and plugin-manager emit sites; the
|
|
838
1040
|
// bounded watchdog also catches source edits and hosts without ctx.on.
|
|
839
|
-
for(const event of [
|
|
840
|
-
|
|
1041
|
+
for (const event of [
|
|
1042
|
+
"plugin-manager/changed",
|
|
1043
|
+
"plugin-manager/install-state",
|
|
1044
|
+
"loader/entry-init",
|
|
1045
|
+
"loader/partial-dispose",
|
|
1046
|
+
"loader/volatile-update",
|
|
1047
|
+
]) {
|
|
1048
|
+
try {
|
|
1049
|
+
const off = ctx.on(event, () => state.reportService?.notify());
|
|
1050
|
+
if (typeof off === "function") state.cacheEventDisposers.push(off);
|
|
1051
|
+
} catch {}
|
|
841
1052
|
}
|
|
842
1053
|
}
|
|
843
|
-
const key=JSON.stringify(context);
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
1054
|
+
const key = JSON.stringify(context);
|
|
1055
|
+
const inputs = state.reportService.states.get(key)?.options?._cacheInputs || {
|
|
1056
|
+
services: [],
|
|
1057
|
+
};
|
|
847
1058
|
// Canonicalize cache identity only: preserve the requested path for the
|
|
848
1059
|
// existing exact probe/profile binding and the report's business target.
|
|
849
|
-
const out=await state.reportService.get(
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
try {
|
|
864
|
-
const parts = String(nmDir).split(path.sep);
|
|
865
|
-
const i = parts.lastIndexOf('profiles');
|
|
866
|
-
if (i >= 0 && parts[i + 1]) key = parts[i + 1];
|
|
867
|
-
} catch { /* 保持 unknown */ }
|
|
868
|
-
return join(dshHome, 'dsh-plugin-compat-check', `baseline.${key.replace(/[^\w.-]/g, '_')}.json`);
|
|
1060
|
+
const out = await state.reportService.get(
|
|
1061
|
+
key,
|
|
1062
|
+
{
|
|
1063
|
+
...options,
|
|
1064
|
+
_cacheContext: context,
|
|
1065
|
+
_cacheInputs: inputs,
|
|
1066
|
+
_nodeModules: { dir: nm.dir, why: nm.why },
|
|
1067
|
+
},
|
|
1068
|
+
force,
|
|
1069
|
+
);
|
|
1070
|
+
if (state.disposed)
|
|
1071
|
+
return failureEnvelope("Plugin generation disposed / 已卸载");
|
|
1072
|
+
if (!out?.ok) return failureEnvelope(out?.error, out || {});
|
|
1073
|
+
return attachFindingReviews(state, assertEnvelope(out), key, kernel);
|
|
869
1074
|
}
|
|
870
1075
|
|
|
871
1076
|
/**
|
|
@@ -876,11 +1081,12 @@ function baselineFileFor(nmDir) {
|
|
|
876
1081
|
* - 结论(五元组 + 确定性)是内核的产物,算出唯一一份才不会两边漂移。
|
|
877
1082
|
*/
|
|
878
1083
|
async function computeReport(state, ctx, options = {}) {
|
|
879
|
-
if(state.disposed)
|
|
1084
|
+
if (state.disposed)
|
|
1085
|
+
return failureEnvelope("Plugin generation disposed / 已卸载");
|
|
880
1086
|
const started = Date.now();
|
|
881
1087
|
const out = {
|
|
882
1088
|
schemaVersion: 1,
|
|
883
|
-
kind:
|
|
1089
|
+
kind: "plugin-compat-check/report",
|
|
884
1090
|
computedAt: new Date().toISOString(),
|
|
885
1091
|
ok: false,
|
|
886
1092
|
error: null,
|
|
@@ -897,25 +1103,39 @@ async function computeReport(state, ctx, options = {}) {
|
|
|
897
1103
|
};
|
|
898
1104
|
try {
|
|
899
1105
|
const kernel = resolveKernelDir();
|
|
900
|
-
if
|
|
1106
|
+
if (
|
|
1107
|
+
options._cacheContext &&
|
|
1108
|
+
(options._cacheContext.dshVersion !== resolveDshVersion() ||
|
|
1109
|
+
options._cacheContext.probe !==
|
|
1110
|
+
(process.env.DSC_PROBE_REPORT || null) ||
|
|
1111
|
+
options._cacheContext.nodeModules !==
|
|
1112
|
+
(await fsPromises.realpath(options._nodeModules.dir)) ||
|
|
1113
|
+
options._cacheContext.kernel !==
|
|
1114
|
+
(kernel && (await fsPromises.realpath(kernel))))
|
|
1115
|
+
)
|
|
1116
|
+
return failureEnvelope("报告上下文已变化,等待目标报告刷新");
|
|
901
1117
|
out.kernelDir = kernel;
|
|
902
1118
|
// 版本要**尽早**拿到:连"找不到内核"这条错误路径上,用户也需要知道
|
|
903
1119
|
// 是哪一版工具报的这个错。`version.mjs` 只在有内核时才读得到 ——
|
|
904
1120
|
// 读不到就保持 null,**不猜**。
|
|
905
1121
|
if (kernel) {
|
|
906
1122
|
try {
|
|
907
|
-
const vmod = await import(
|
|
1123
|
+
const vmod = await import(
|
|
1124
|
+
pathToFileURL(join(kernel, "lib", "version.mjs")).href
|
|
1125
|
+
);
|
|
908
1126
|
out.toolVersion = vmod.TOOL_VERSION ?? null;
|
|
909
1127
|
} catch (error) {
|
|
910
1128
|
log(`版本读不到(${errText(error)})—— 报告里的 toolVersion 将是 null`);
|
|
911
1129
|
}
|
|
912
1130
|
}
|
|
913
1131
|
if (!kernel) {
|
|
914
|
-
out.error =
|
|
915
|
-
+
|
|
1132
|
+
out.error =
|
|
1133
|
+
"找不到判定内核(lib/extract.mjs + lib/verdict.mjs)。" +
|
|
1134
|
+
"插件形态下它们是同仓库的相邻目录;发布形态请设 DSC_KERNEL。";
|
|
916
1135
|
return out;
|
|
917
1136
|
}
|
|
918
|
-
const nm =
|
|
1137
|
+
const nm =
|
|
1138
|
+
options._nodeModules || resolveNodeModules(options.profile || null);
|
|
919
1139
|
out.nodeModules = nm.dir;
|
|
920
1140
|
out.nodeModulesWhy = nm.why;
|
|
921
1141
|
if (!nm.dir) {
|
|
@@ -923,48 +1143,100 @@ async function computeReport(state, ctx, options = {}) {
|
|
|
923
1143
|
return out;
|
|
924
1144
|
}
|
|
925
1145
|
|
|
926
|
-
const { extractInWorker } = await import(
|
|
927
|
-
|
|
1146
|
+
const { extractInWorker } = await import(
|
|
1147
|
+
pathToFileURL(join(kernel, "lib", "worker-analysis.mjs")).href
|
|
1148
|
+
);
|
|
1149
|
+
const { buildVerdicts } = await import(
|
|
1150
|
+
pathToFileURL(join(kernel, "lib", "verdict.mjs")).href
|
|
1151
|
+
);
|
|
928
1152
|
|
|
929
|
-
const {loadCatalog}=await import(
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
const
|
|
1153
|
+
const { loadCatalog } = await import(
|
|
1154
|
+
pathToFileURL(join(kernel, "lib", "catalog.mjs")).href
|
|
1155
|
+
);
|
|
1156
|
+
const {
|
|
1157
|
+
file: catalogPath,
|
|
1158
|
+
catalog,
|
|
1159
|
+
evidence: catalogEvidence,
|
|
1160
|
+
} = loadCatalog(kernel, resolveDshVersion());
|
|
1161
|
+
const rulesPath = join(kernel, "rules", "severity.json");
|
|
1162
|
+
const { readStaticFile } = await import(
|
|
1163
|
+
pathToFileURL(join(kernel, "lib", "static-input.mjs")).href
|
|
1164
|
+
);
|
|
1165
|
+
const rulesBytes = readStaticFile(rulesPath, 8 * 1024 * 1024);
|
|
933
1166
|
const rules = JSON.parse(rulesBytes);
|
|
934
|
-
out.analysisIdentity={
|
|
1167
|
+
out.analysisIdentity = {
|
|
1168
|
+
rulesSha256: createHash("sha256").update(rulesBytes).digest("hex"),
|
|
1169
|
+
catalogSha256: catalogEvidence.sha256,
|
|
1170
|
+
};
|
|
935
1171
|
|
|
936
1172
|
// 运行时面:宿主半边**自己就在进程里**,所以它能拿到比探针更准的东西。
|
|
937
1173
|
// 第一版先读探针报告(如果外层给了路径),没有就退回目录 —— 并如实标注。
|
|
938
|
-
let hostSurface = null
|
|
939
|
-
|
|
1174
|
+
let hostSurface = null,
|
|
1175
|
+
probeReadFailure = null;
|
|
1176
|
+
let surfaceWhy = "没有运行时面:结论中的方法级判定降为「疑似」";
|
|
940
1177
|
const probePath = process.env.DSC_PROBE_REPORT;
|
|
941
1178
|
if (probePath && existsSync(probePath)) {
|
|
942
1179
|
try {
|
|
943
|
-
hostSurface = JSON.parse(
|
|
1180
|
+
hostSurface = JSON.parse(
|
|
1181
|
+
readStaticFile(probePath, 8 * 1024 * 1024).toString("utf8"),
|
|
1182
|
+
);
|
|
944
1183
|
surfaceWhy = `运行时面来自探针报告 ${probePath}`;
|
|
945
1184
|
} catch (error) {
|
|
946
|
-
|
|
1185
|
+
probeReadFailure = `探针报告读不动(${errText(error)})`;
|
|
1186
|
+
surfaceWhy = probeReadFailure + ",退回目录";
|
|
947
1187
|
}
|
|
948
1188
|
}
|
|
949
1189
|
out.surfaceWhy = surfaceWhy;
|
|
950
1190
|
|
|
951
|
-
const extracted = await extractInWorker(nm.dir, { catalog }
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
1191
|
+
const extracted = await extractInWorker(nm.dir, { catalog }, 10000, {
|
|
1192
|
+
signal: options._cacheGuard?.signal,
|
|
1193
|
+
});
|
|
1194
|
+
if (state.disposed)
|
|
1195
|
+
return failureEnvelope("Plugin generation disposed / 已卸载");
|
|
1196
|
+
if (options._cacheInputs)
|
|
1197
|
+
options._cacheInputs.services = extracted.report.services;
|
|
1198
|
+
const { sampleServices } = await import(
|
|
1199
|
+
pathToFileURL(join(kernel, "lib", "observation.mjs")).href
|
|
1200
|
+
);
|
|
1201
|
+
const currentTarget = resolveNodeModules();
|
|
1202
|
+
const sameHost =
|
|
1203
|
+
currentTarget.dir &&
|
|
1204
|
+
(await fsPromises.realpath(currentTarget.dir)) ===
|
|
1205
|
+
(await fsPromises.realpath(nm.dir));
|
|
1206
|
+
if (!hostSurface && sameHost) {
|
|
1207
|
+
hostSurface = sampleServices(ctx, extracted.report.services, {
|
|
1208
|
+
profileId: nm.dir,
|
|
1209
|
+
dshVersion: resolveDshVersion(),
|
|
1210
|
+
});
|
|
1211
|
+
surfaceWhy =
|
|
1212
|
+
(probeReadFailure ? probeReadFailure + ";" : "") +
|
|
1213
|
+
"当前宿主进程内采集的服务结构;未采集或读取失败不等于缺失";
|
|
960
1214
|
}
|
|
961
|
-
const itemListing=await (async
|
|
962
|
-
|
|
963
|
-
|
|
1215
|
+
const itemListing = await (async () => {
|
|
1216
|
+
try {
|
|
1217
|
+
const listing = await ctx.get?.("pluginManager", false)?.list?.();
|
|
1218
|
+
return (
|
|
1219
|
+
Array.isArray(listing)
|
|
1220
|
+
? listing
|
|
1221
|
+
: listing?.items || listing?.plugins || []
|
|
1222
|
+
).map((i) => ({
|
|
1223
|
+
id: i.id ?? i.entryId ?? i.slotId,
|
|
1224
|
+
packageName: i.packageName ?? i.package ?? i.name,
|
|
1225
|
+
}));
|
|
1226
|
+
} catch {
|
|
1227
|
+
return [];
|
|
1228
|
+
}
|
|
1229
|
+
})();
|
|
1230
|
+
const loaderState = sameHost
|
|
1231
|
+
? loaderObservations(ctx, extracted.report.manifests, itemListing)
|
|
1232
|
+
: { rows: [], unmapped: [], available: false };
|
|
1233
|
+
out.loaderCoverage = loaderState;
|
|
964
1234
|
|
|
965
|
-
out.surfaceWhy=surfaceWhy;
|
|
966
|
-
const {readProfileCompatibility}=await import(
|
|
967
|
-
|
|
1235
|
+
out.surfaceWhy = surfaceWhy;
|
|
1236
|
+
const { readProfileCompatibility } = await import(
|
|
1237
|
+
pathToFileURL(join(kernel, "lib", "profile-compatibility.mjs")).href
|
|
1238
|
+
);
|
|
1239
|
+
const compatibility = readProfileCompatibility(nm.dir);
|
|
968
1240
|
const verdict = buildVerdicts({
|
|
969
1241
|
report: extracted.report,
|
|
970
1242
|
hostSurface,
|
|
@@ -972,7 +1244,7 @@ async function computeReport(state, ctx, options = {}) {
|
|
|
972
1244
|
catalog,
|
|
973
1245
|
dshVersion: resolveDshVersion(),
|
|
974
1246
|
entryStates: loaderState.rows,
|
|
975
|
-
observationTarget: {profileId:nm.dir,maxAgeMs:24*60*60*1000},
|
|
1247
|
+
observationTarget: { profileId: nm.dir, maxAgeMs: 24 * 60 * 60 * 1000 },
|
|
976
1248
|
compatibilityExemptions: compatibility.exemptions,
|
|
977
1249
|
compatibilityNotes: compatibility.notes,
|
|
978
1250
|
});
|
|
@@ -980,19 +1252,34 @@ async function computeReport(state, ctx, options = {}) {
|
|
|
980
1252
|
// ── C4:基线差分 ──
|
|
981
1253
|
// 用**与 CLI 完全相同**的 lib/baseline.mjs。面板说"变差了"而命令行说"没变"
|
|
982
1254
|
// 这种分叉在结构上就不可能发生。
|
|
983
|
-
let baseline = null,
|
|
1255
|
+
let baseline = null,
|
|
1256
|
+
finalizeBaseline = null;
|
|
984
1257
|
try {
|
|
985
1258
|
const { snapshotForBaseline, diffAgainstBaseline, badgeFor } =
|
|
986
|
-
await import(pathToFileURL(join(kernel,
|
|
987
|
-
const store=await import(
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
const
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
1259
|
+
await import(pathToFileURL(join(kernel, "lib", "baseline.mjs")).href);
|
|
1260
|
+
const store = await import(
|
|
1261
|
+
pathToFileURL(join(kernel, "lib", "state-store.mjs")).href
|
|
1262
|
+
);
|
|
1263
|
+
const identity = store.baselineIdentity(
|
|
1264
|
+
nm.dir,
|
|
1265
|
+
verdict.observation.mode,
|
|
1266
|
+
rules,
|
|
1267
|
+
catalog,
|
|
1268
|
+
);
|
|
1269
|
+
const snap = snapshotForBaseline(verdict, resolveDshVersion(), identity);
|
|
1270
|
+
const bfile = store.baselinePath(nm.dir, verdict.observation.mode);
|
|
1271
|
+
if (state.disposed)
|
|
1272
|
+
return failureEnvelope("Plugin generation disposed / 已卸载");
|
|
1273
|
+
finalizeBaseline = () =>
|
|
1274
|
+
store.baselineTransaction(bfile, snap, {
|
|
1275
|
+
advance: extracted.report.scanComplete,
|
|
1276
|
+
});
|
|
1277
|
+
if (!options._cacheGuard) baseline = finalizeBaseline();
|
|
994
1278
|
} catch (error) {
|
|
995
|
-
baseline = {
|
|
1279
|
+
baseline = {
|
|
1280
|
+
error: errText(error),
|
|
1281
|
+
note: "基线差分没算成(不影响本次结论)",
|
|
1282
|
+
};
|
|
996
1283
|
}
|
|
997
1284
|
|
|
998
1285
|
// 只把面板要用的部分送过去:完整 report 在 desktop profile 上是 ~1 MB,
|
|
@@ -1003,16 +1290,24 @@ async function computeReport(state, ctx, options = {}) {
|
|
|
1003
1290
|
// 自己的只读服务里取这张表,让客户端能解析出"这是哪个插件"。
|
|
1004
1291
|
// 取不到不算失败:客户端会退回到"id 就是包名"的判断,并如实不渲染角标。
|
|
1005
1292
|
try {
|
|
1006
|
-
const pm =
|
|
1007
|
-
|
|
1293
|
+
const pm =
|
|
1294
|
+
typeof ctx.get === "function"
|
|
1295
|
+
? ctx.get("pluginManager", false)
|
|
1296
|
+
: undefined;
|
|
1297
|
+
const listing =
|
|
1298
|
+
pm && typeof pm.list === "function" ? await pm.list() : null;
|
|
1008
1299
|
if (listing) {
|
|
1009
|
-
const arr = Array.isArray(listing)
|
|
1300
|
+
const arr = Array.isArray(listing)
|
|
1301
|
+
? listing
|
|
1302
|
+
: listing.items || listing.plugins || [];
|
|
1010
1303
|
out.items = arr
|
|
1011
1304
|
.map((it) => {
|
|
1012
|
-
if (!it || typeof it !==
|
|
1305
|
+
if (!it || typeof it !== "object") return null;
|
|
1013
1306
|
const id = it.id ?? it.entryId ?? it.slotId ?? null;
|
|
1014
1307
|
const pkg = it.packageName ?? it.package ?? it.name ?? null;
|
|
1015
|
-
return
|
|
1308
|
+
return id && pkg
|
|
1309
|
+
? { id, packageName: String(pkg), label: it.label ?? null }
|
|
1310
|
+
: null;
|
|
1016
1311
|
})
|
|
1017
1312
|
.filter(Boolean);
|
|
1018
1313
|
}
|
|
@@ -1020,22 +1315,51 @@ async function computeReport(state, ctx, options = {}) {
|
|
|
1020
1315
|
out.itemsError = errText(error);
|
|
1021
1316
|
}
|
|
1022
1317
|
|
|
1023
|
-
const {toJSON}=await import(
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
const
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1318
|
+
const { toJSON } = await import(
|
|
1319
|
+
pathToFileURL(join(kernel, "lib", "report.mjs")).href
|
|
1320
|
+
);
|
|
1321
|
+
const { renderIssueReport } = await import(
|
|
1322
|
+
pathToFileURL(join(kernel, "lib", "issue-report.mjs")).href
|
|
1323
|
+
);
|
|
1324
|
+
if (state.disposed)
|
|
1325
|
+
return failureEnvelope("Plugin generation disposed / 已卸载");
|
|
1326
|
+
const canonical = toJSON({
|
|
1327
|
+
error: null,
|
|
1328
|
+
extract: extracted,
|
|
1329
|
+
verdict,
|
|
1330
|
+
entryStates: loaderState.rows,
|
|
1331
|
+
baseline,
|
|
1332
|
+
catalog: catalogEvidence,
|
|
1333
|
+
inputs: {
|
|
1334
|
+
nodeModules: nm.dir,
|
|
1335
|
+
hasRuntimeSurface: verdict.observation.usable,
|
|
1336
|
+
catalog: catalogPath,
|
|
1337
|
+
rules: rulesPath,
|
|
1338
|
+
},
|
|
1033
1339
|
});
|
|
1034
|
-
|
|
1340
|
+
if (options._cacheGuard && finalizeBaseline)
|
|
1341
|
+
options._cacheGuard.commit(() => {
|
|
1342
|
+
baseline = finalizeBaseline();
|
|
1343
|
+
canonical.baseline = baseline;
|
|
1344
|
+
out.baseline = baseline;
|
|
1345
|
+
out.report.baseline = baseline;
|
|
1346
|
+
out.issueReport = renderIssueReport(canonical);
|
|
1347
|
+
});
|
|
1348
|
+
out.issueReport = renderIssueReport(canonical);
|
|
1035
1349
|
out.baseline = canonical.baseline;
|
|
1036
1350
|
out.dshVersion = canonical.dshVersion;
|
|
1037
1351
|
out.exitCode = canonical.exitCode;
|
|
1038
|
-
out.report = {
|
|
1352
|
+
out.report = {
|
|
1353
|
+
...canonical,
|
|
1354
|
+
hasRuntimeSurface: verdict.observation.usable,
|
|
1355
|
+
loaderCoverage: out.loaderCoverage,
|
|
1356
|
+
surfaceWhy,
|
|
1357
|
+
items: out.items || [],
|
|
1358
|
+
itemsError: out.itemsError || null,
|
|
1359
|
+
manifests: extracted.report.manifests,
|
|
1360
|
+
serviceCount: extracted.report.services.length,
|
|
1361
|
+
siteTotal: (extracted.report.callSites || []).length,
|
|
1362
|
+
};
|
|
1039
1363
|
out.ok = true;
|
|
1040
1364
|
out.elapsedMs = Date.now() - started;
|
|
1041
1365
|
} catch (error) {
|
|
@@ -1044,24 +1368,6 @@ async function computeReport(state, ctx, options = {}) {
|
|
|
1044
1368
|
return out;
|
|
1045
1369
|
}
|
|
1046
1370
|
|
|
1047
|
-
/** 本地信任栅栏:只有本机的 DSH 界面该读到这份数据。 */
|
|
1048
|
-
function isTrustedRequest(req) {
|
|
1049
|
-
try {
|
|
1050
|
-
const host = String((req.headers && (req.headers.host || req.headers.Host)) || '');
|
|
1051
|
-
const origin = String((req.headers && (req.headers.origin || req.headers.Origin)) || '');
|
|
1052
|
-
const hostOk = /^(127\.0\.0\.1|localhost|\[::1\])(:\d+)?$/i.test(host);
|
|
1053
|
-
if (!hostOk) return false;
|
|
1054
|
-
const protocol = req.socket?.encrypted === true ? 'https:' : 'http:';
|
|
1055
|
-
const expectedOrigin = new URL(`${protocol}//${host}`).origin;
|
|
1056
|
-
if (!origin) return true;
|
|
1057
|
-
if ( !/^https?:\/\/(127\.0\.0\.1|localhost|\[::1\])(:\d+)?$/i.test(origin)) return false;
|
|
1058
|
-
return new URL(origin).origin === expectedOrigin;
|
|
1059
|
-
} catch {
|
|
1060
|
-
return false;
|
|
1061
|
-
}
|
|
1062
|
-
}
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
1371
|
// ---------------------------------------------------------------------------
|
|
1066
1372
|
// D1: 对话工具 —— 让 agent 能直接答「我这插件兼容吗」
|
|
1067
1373
|
// ---------------------------------------------------------------------------
|
|
@@ -1078,265 +1384,556 @@ function isTrustedRequest(req) {
|
|
|
1078
1384
|
function renderForModel(compat) {
|
|
1079
1385
|
try {
|
|
1080
1386
|
if (!compat || compat.ok !== true) {
|
|
1081
|
-
return `体检没跑成(这是工具的问题,不是插件的结论):${(compat && compat.error) ||
|
|
1387
|
+
return `体检没跑成(这是工具的问题,不是插件的结论):${terminalText((compat && compat.error) || "未知原因")}`;
|
|
1082
1388
|
}
|
|
1083
1389
|
const r = compat.report;
|
|
1084
1390
|
const L = [];
|
|
1085
1391
|
const s = r.summary || {};
|
|
1086
|
-
L.push(
|
|
1087
|
-
|
|
1088
|
-
|
|
1392
|
+
L.push(
|
|
1393
|
+
`结论:可用 ${s.ok || 0} 部分可用 ${s.partial || 0} 不兼容 ${s.broken || 0}` +
|
|
1394
|
+
(s.vacuousOk
|
|
1395
|
+
? `(其中 ${s.vacuousOk} 个「可用」没有已验证调用,不等于业务通过)`
|
|
1396
|
+
: ""),
|
|
1397
|
+
);
|
|
1398
|
+
L.push(`工具版本:${compat.toolVersion || "未知"}`);
|
|
1089
1399
|
L.push(...catalogLines(r.catalog));
|
|
1090
|
-
for(const note of r.observation?.notes||[])L.push(`采样说明:${note}`);
|
|
1091
|
-
if(s.limited)L.push(`证据覆盖有限:${s.limited} 个插件尚有未验证部分`);
|
|
1400
|
+
for (const note of r.observation?.notes || []) L.push(`采样说明:${note}`);
|
|
1401
|
+
if (s.limited) L.push(`证据覆盖有限:${s.limited} 个插件尚有未验证部分`);
|
|
1092
1402
|
L.push(`体检对象:${compat.nodeModules}(${compat.nodeModulesWhy})`);
|
|
1093
|
-
L.push(
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
+
|
|
1097
|
-
|
|
1403
|
+
L.push(
|
|
1404
|
+
r.hasRuntimeSurface
|
|
1405
|
+
? "方法级判定依据:宿主运行时面;具体确定性仍以每条发现和覆盖限制为准。"
|
|
1406
|
+
: "方法级判定依据:官方 API 目录(**契约目录,不是运行时面**)—— 因此方法级结论一律标「疑似」。" +
|
|
1407
|
+
"「疑似」不是缺陷,是诚实:判不准时降级标注,而不是升格成确定。",
|
|
1408
|
+
);
|
|
1409
|
+
L.push("");
|
|
1098
1410
|
|
|
1099
1411
|
const rank = { broken: 0, partial: 1, unknown: 2, ok: 3 };
|
|
1100
|
-
const names = Object.keys(r.plugins).sort(
|
|
1101
|
-
(
|
|
1412
|
+
const names = Object.keys(r.plugins).sort(
|
|
1413
|
+
(a, b) =>
|
|
1414
|
+
rank[r.plugins[a].verdict] - rank[r.plugins[b].verdict] ||
|
|
1415
|
+
a.localeCompare(b),
|
|
1416
|
+
);
|
|
1102
1417
|
|
|
1103
1418
|
for (const name of names) {
|
|
1104
1419
|
const p = r.plugins[name];
|
|
1105
|
-
const label =
|
|
1106
|
-
|
|
1107
|
-
|
|
1420
|
+
const label =
|
|
1421
|
+
{ broken: "不兼容", partial: "部分可用", unknown: "未知", ok: "可用" }[
|
|
1422
|
+
p.verdict
|
|
1423
|
+
] || p.verdict;
|
|
1424
|
+
L.push(
|
|
1425
|
+
`${label} ${name}@${p.version || "?"}${p.vacuous ? " [无已验证调用]" : ""}`,
|
|
1426
|
+
);
|
|
1427
|
+
if (p.coverage) {
|
|
1428
|
+
L.push(
|
|
1429
|
+
` 证据覆盖:${p.coverage.status === "complete" ? "完整" : "有限"};已验证调用 ${p.coverage.verifiedCalls}`,
|
|
1430
|
+
);
|
|
1431
|
+
for (const note of p.coverage.limitations)
|
|
1432
|
+
L.push(` 覆盖限制:${note}`);
|
|
1433
|
+
}
|
|
1108
1434
|
for (const f of p.findings || []) {
|
|
1109
|
-
const api = f.service
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1435
|
+
const api = f.service
|
|
1436
|
+
? f.method
|
|
1437
|
+
? `${f.service}.${f.method}`
|
|
1438
|
+
: f.service
|
|
1439
|
+
: "(包声明)";
|
|
1440
|
+
const loc = f.location ? `${f.location.file}:${f.location.line}` : "—";
|
|
1441
|
+
const cat =
|
|
1442
|
+
{
|
|
1443
|
+
"host-service-missing": "服务缺失",
|
|
1444
|
+
"host-method-missing": "方法缺失",
|
|
1445
|
+
"unprovided-dependency": "依赖服务待确认",
|
|
1446
|
+
"declaration-mismatch": "声明与实测不符",
|
|
1447
|
+
"client-entry-missing": "客户端入口不可交付",
|
|
1448
|
+
"host-entry-inactive": "宿主条目未激活",
|
|
1449
|
+
"declared-incompatible": "版本声明不支持",
|
|
1450
|
+
}[f.category] || f.category;
|
|
1451
|
+
const sev =
|
|
1452
|
+
{ fatal: "致命", "feature-dead": "功能失效", degraded: "降级" }[
|
|
1453
|
+
f.severity
|
|
1454
|
+
] || f.severity;
|
|
1455
|
+
L.push(
|
|
1456
|
+
` ${sev} / ${cat}${f.certainty === "certain" ? " [确定]" : " [疑似]"} ${api} @ ${loc}`,
|
|
1457
|
+
);
|
|
1458
|
+
if (f.impact)
|
|
1459
|
+
L.push(
|
|
1460
|
+
` 影响${f.impactBasis === "measured" ? "(实测)" : f.impactBasis === "inferred" ? "(推断,非实测)" : "(依据未提供)"}:${f.impact}`,
|
|
1461
|
+
);
|
|
1462
|
+
if (f.basis) L.push(` 依据:${f.basis}`);
|
|
1463
|
+
for (const step of nextSteps(f)) L.push(` 下一步:${step}`);
|
|
1122
1464
|
}
|
|
1123
1465
|
const cs = p.clientSide || [];
|
|
1124
|
-
if (cs.length)
|
|
1466
|
+
if (cs.length)
|
|
1467
|
+
L.push(
|
|
1468
|
+
` 客户端半边(命令行测不到,需界面验证):${cs.map((c) => c.service).join("、")}`,
|
|
1469
|
+
);
|
|
1125
1470
|
const noCalls = Object.values(p.resolved || {})
|
|
1126
|
-
.filter((c) => c.disposition ===
|
|
1127
|
-
|
|
1471
|
+
.filter((c) => c.disposition === "no-calls")
|
|
1472
|
+
.map((c) => c.service);
|
|
1473
|
+
if (noCalls.length)
|
|
1474
|
+
L.push(
|
|
1475
|
+
` 无证据可判(只出现名字,没有调用点):${noCalls.join("、")}`,
|
|
1476
|
+
);
|
|
1128
1477
|
}
|
|
1129
1478
|
|
|
1130
|
-
L.push(
|
|
1131
|
-
L.push(
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
L.push(
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1479
|
+
L.push("");
|
|
1480
|
+
L.push(
|
|
1481
|
+
"确定(certain)表示当前证据足以支持该条发现,可来自明确静态事实或合格运行时观测;疑似(suspected)表示当前证据不足以确定该条发现。",
|
|
1482
|
+
);
|
|
1483
|
+
L.push(
|
|
1484
|
+
"impactBasis=measured/inferred 分别表示影响有对应实测依据/影响由证据推断;确定发现不等于影响已实测。未观测不等于缺失。",
|
|
1485
|
+
);
|
|
1486
|
+
L.push(
|
|
1487
|
+
"没有已验证调用仍可能存在服务依赖或客户端功能;调用核验和覆盖完整均不等于业务验收通过。",
|
|
1488
|
+
);
|
|
1489
|
+
L.push("下一步是核实建议;本工具不会自动安装、升级或修改配置与源码。");
|
|
1490
|
+
L.push("读这个结果时注意:");
|
|
1491
|
+
L.push(
|
|
1492
|
+
' 1) 「可用」不等于"功能验证过"。它只说明本轮未发现冲突;请同时看证据覆盖与未验证项。' +
|
|
1493
|
+
"插件仍然可能因为自身逻辑、配置或第三方依赖而出问题。",
|
|
1494
|
+
);
|
|
1495
|
+
L.push(
|
|
1496
|
+
" 2) 客户端功能需在浏览器实际操作并核验结果;元素出现和没有报错均不等于业务成功。",
|
|
1497
|
+
);
|
|
1498
|
+
return L.map(terminalText).join("\n");
|
|
1140
1499
|
} catch (error) {
|
|
1141
|
-
return `渲染结论时出错(工具的问题):${errText(error)}`;
|
|
1500
|
+
return `渲染结论时出错(工具的问题):${terminalText(errText(error))}`;
|
|
1142
1501
|
}
|
|
1143
1502
|
}
|
|
1144
1503
|
|
|
1145
1504
|
/** 工具的服务集合:`tools`;`agents` 用来把相对路径解析到会话工作目录。 */
|
|
1146
1505
|
/** Static version comparisons never enter the ordinary report cache or baseline path. */
|
|
1147
1506
|
function unavailableUpgrade(error, options = {}) {
|
|
1148
|
-
let toolVersion =
|
|
1149
|
-
try {
|
|
1150
|
-
|
|
1507
|
+
let toolVersion = "unknown";
|
|
1508
|
+
try {
|
|
1509
|
+
toolVersion = JSON.parse(
|
|
1510
|
+
readFileSync(new URL("./package.json", import.meta.url), "utf8"),
|
|
1511
|
+
).version;
|
|
1512
|
+
} catch {}
|
|
1513
|
+
return upgradeFailure(error, {
|
|
1514
|
+
toolVersion,
|
|
1515
|
+
currentVersion: resolveDshVersion(),
|
|
1516
|
+
targetVersion: options.targetVersion,
|
|
1517
|
+
...options,
|
|
1518
|
+
});
|
|
1151
1519
|
}
|
|
1152
1520
|
|
|
1153
1521
|
async function serveUpgradePreview(state, options = {}) {
|
|
1154
|
-
const currentVersion = resolveDshVersion(),
|
|
1155
|
-
|
|
1522
|
+
const currentVersion = resolveDshVersion(),
|
|
1523
|
+
targetVersion = options.targetVersion || null;
|
|
1524
|
+
const failure = (error) =>
|
|
1525
|
+
unavailableUpgrade(error, { currentVersion, targetVersion });
|
|
1156
1526
|
try {
|
|
1157
|
-
if (state.disposed) return failure(
|
|
1527
|
+
if (state.disposed) return failure("Plugin generation disposed / 已卸载");
|
|
1158
1528
|
if (options.currentVersion && options.currentVersion !== currentVersion)
|
|
1159
|
-
return failure(
|
|
1529
|
+
return failure("当前 DSH 版本已变化,请刷新普通报告后重新预检");
|
|
1160
1530
|
if (options.invalidRequest) return failure(options.invalidRequest);
|
|
1161
|
-
if (
|
|
1162
|
-
|
|
1531
|
+
if (
|
|
1532
|
+
!SUPPORTED_PREVIEW_VERSIONS.includes(currentVersion) ||
|
|
1533
|
+
!SUPPORTED_PREVIEW_VERSIONS.includes(targetVersion)
|
|
1534
|
+
)
|
|
1535
|
+
return unavailableUpgrade(
|
|
1536
|
+
"版本未认证:仅支持有明确 API 目录的精确版本 " +
|
|
1537
|
+
SUPPORTED_PREVIEW_VERSIONS.join("、"),
|
|
1538
|
+
{ currentVersion, targetVersion, status: "unsupported-version" },
|
|
1539
|
+
);
|
|
1163
1540
|
const kernel = resolveKernelDir();
|
|
1164
|
-
if (!kernel) return failure(
|
|
1165
|
-
const engine = await import(
|
|
1166
|
-
|
|
1541
|
+
if (!kernel) return failure("找不到升级预检内核");
|
|
1542
|
+
const engine = await import(
|
|
1543
|
+
pathToFileURL(join(kernel, "lib/upgrade-preview.mjs")).href
|
|
1544
|
+
);
|
|
1545
|
+
if (state.disposed) return failure("Plugin generation disposed / 已卸载");
|
|
1167
1546
|
const nm = resolveNodeModules(options.profile || null);
|
|
1168
|
-
const canonical = nm.dir && await fsPromises.realpath(nm.dir);
|
|
1169
|
-
if (state.disposed) return failure(
|
|
1547
|
+
const canonical = nm.dir && (await fsPromises.realpath(nm.dir));
|
|
1548
|
+
if (state.disposed) return failure("Plugin generation disposed / 已卸载");
|
|
1170
1549
|
const key = JSON.stringify([canonical, currentVersion, targetVersion]);
|
|
1171
1550
|
if (state.upgradeFlight && state.upgradeFlight.key !== key)
|
|
1172
|
-
return failure(
|
|
1551
|
+
return failure("另一目标的升级预检正在运行,请稍后重试");
|
|
1173
1552
|
let flight = state.upgradeFlight;
|
|
1174
1553
|
if (!flight) {
|
|
1175
1554
|
const controller = new AbortController();
|
|
1176
|
-
flight = {
|
|
1555
|
+
flight = {
|
|
1556
|
+
key,
|
|
1557
|
+
controller,
|
|
1558
|
+
promise: engine.runUpgradePreview(
|
|
1559
|
+
{ nm: nm.dir, currentVersion, targetVersion },
|
|
1560
|
+
{ signal: controller.signal },
|
|
1561
|
+
),
|
|
1562
|
+
};
|
|
1177
1563
|
state.upgradeFlight = flight;
|
|
1178
1564
|
}
|
|
1179
1565
|
try {
|
|
1180
1566
|
const result = await flight.promise;
|
|
1181
1567
|
if (!state.disposed) return result;
|
|
1182
|
-
return failure(
|
|
1568
|
+
return failure(
|
|
1569
|
+
[result.ok ? null : result.error, "Plugin generation disposed / 已卸载"]
|
|
1570
|
+
.filter(Boolean)
|
|
1571
|
+
.join(";"),
|
|
1572
|
+
);
|
|
1183
1573
|
} finally {
|
|
1184
1574
|
if (state.upgradeFlight === flight) state.upgradeFlight = null;
|
|
1185
1575
|
}
|
|
1186
|
-
} catch (error) {
|
|
1576
|
+
} catch (error) {
|
|
1577
|
+
return failure(errText(error));
|
|
1578
|
+
}
|
|
1187
1579
|
}
|
|
1188
1580
|
|
|
1189
1581
|
async function upgradeToolResult(state, options) {
|
|
1190
1582
|
let preview, text;
|
|
1191
1583
|
try {
|
|
1192
1584
|
const kernel = resolveKernelDir();
|
|
1193
|
-
if (!kernel) throw Error(
|
|
1194
|
-
const {renderUpgradePreview} = await import(
|
|
1585
|
+
if (!kernel) throw Error("找不到升级预检内核");
|
|
1586
|
+
const { renderUpgradePreview } = await import(
|
|
1587
|
+
pathToFileURL(join(kernel, "lib/upgrade-preview.mjs")).href
|
|
1588
|
+
);
|
|
1195
1589
|
preview = await serveUpgradePreview(state, options);
|
|
1196
1590
|
text = renderUpgradePreview(preview);
|
|
1197
1591
|
} catch (error) {
|
|
1198
|
-
preview = unavailableUpgrade(errText(error),{
|
|
1199
|
-
|
|
1592
|
+
preview = unavailableUpgrade(errText(error), {
|
|
1593
|
+
targetVersion: options.targetVersion,
|
|
1594
|
+
});
|
|
1595
|
+
text =
|
|
1596
|
+
"升级预检无法比较:" +
|
|
1597
|
+
terminalText(preview.error) +
|
|
1598
|
+
";静态预测不等于目标版本业务通过。";
|
|
1200
1599
|
}
|
|
1201
|
-
return {
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1600
|
+
return {
|
|
1601
|
+
schemaVersion: 1,
|
|
1602
|
+
kind: "plugin-compat-check/tool-result",
|
|
1603
|
+
ok: preview.ok,
|
|
1604
|
+
error: preview.error,
|
|
1605
|
+
exitCode: preview.exitCode,
|
|
1606
|
+
toolVersion: preview.toolVersion,
|
|
1607
|
+
report: null,
|
|
1608
|
+
baseline: null,
|
|
1609
|
+
cache: null,
|
|
1610
|
+
nodeModules: "",
|
|
1611
|
+
hasRuntimeSurface: false,
|
|
1612
|
+
summary: preview.ok
|
|
1613
|
+
? `静态升级预检:新增 ${preview.summary.added} / 变重 ${preview.summary.worsened};不等于目标版本业务通过`
|
|
1614
|
+
: "升级预检无法比较",
|
|
1615
|
+
text,
|
|
1616
|
+
upgradePreview: preview,
|
|
1617
|
+
};
|
|
1205
1618
|
}
|
|
1206
1619
|
|
|
1207
1620
|
function registerUpgradeRoute(ctx, state) {
|
|
1208
|
-
if (typeof ctx.inject !==
|
|
1209
|
-
ctx.inject([
|
|
1621
|
+
if (typeof ctx.inject !== "function") return;
|
|
1622
|
+
ctx.inject(["webServer"], (scope) => {
|
|
1210
1623
|
try {
|
|
1211
|
-
return scope.webServer.register({
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
const
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1624
|
+
return scope.webServer.register({
|
|
1625
|
+
name: "dsh-plugin-compat-check/upgrade-preview",
|
|
1626
|
+
kind: "exact",
|
|
1627
|
+
path: "/plugin-compat-check/upgrade-preview",
|
|
1628
|
+
handler: async (req, res) => {
|
|
1629
|
+
const send = (status, body) => {
|
|
1630
|
+
res.writeHead(status, {
|
|
1631
|
+
"content-type": "application/json; charset=utf-8",
|
|
1632
|
+
"cache-control": "no-store",
|
|
1633
|
+
});
|
|
1634
|
+
res.end(JSON.stringify(body));
|
|
1635
|
+
};
|
|
1636
|
+
if (!isTrustedRequest(req))
|
|
1637
|
+
return send(403, { error: "refused: untrusted request" });
|
|
1638
|
+
if (req.method !== "GET")
|
|
1639
|
+
return send(405, { error: "升级预检仅支持 GET" });
|
|
1640
|
+
try {
|
|
1641
|
+
const url = new URL(req.url || "/", "http://127.0.0.1");
|
|
1642
|
+
const invalid =
|
|
1643
|
+
(req.url || "").length > 2048 ||
|
|
1644
|
+
[...url.searchParams.keys()].some(
|
|
1645
|
+
(k) => !["targetVersion", "currentVersion"].includes(k),
|
|
1646
|
+
);
|
|
1647
|
+
const out = await serveUpgradePreview(state, {
|
|
1648
|
+
targetVersion: url.searchParams.get("targetVersion"),
|
|
1649
|
+
currentVersion: url.searchParams.get("currentVersion"),
|
|
1650
|
+
invalidRequest: invalid ? "升级预检请求参数无效" : null,
|
|
1651
|
+
});
|
|
1652
|
+
send(200, out);
|
|
1653
|
+
} catch (error) {
|
|
1654
|
+
send(500, {
|
|
1655
|
+
error: "升级预检无法完成,请在本机核对插件与宿主版本",
|
|
1656
|
+
});
|
|
1657
|
+
}
|
|
1658
|
+
},
|
|
1659
|
+
});
|
|
1660
|
+
} catch (error) {
|
|
1661
|
+
log("Upgrade preview route registration failed: " + errText(error));
|
|
1662
|
+
}
|
|
1223
1663
|
});
|
|
1224
1664
|
}
|
|
1225
1665
|
|
|
1226
1666
|
/** Human review is an independently loaded sidecar, never a verdict override. */
|
|
1227
|
-
async function attachFindingReviews(state,out,key,kernel) {
|
|
1667
|
+
async function attachFindingReviews(state, out, key, kernel) {
|
|
1228
1668
|
try {
|
|
1229
|
-
const store=await import(
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
const
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1669
|
+
const store = await import(
|
|
1670
|
+
pathToFileURL(join(kernel, "lib", "finding-review-store.mjs")).href
|
|
1671
|
+
);
|
|
1672
|
+
const inputIdentity = state.reportService.identityForReport(
|
|
1673
|
+
key,
|
|
1674
|
+
out.report,
|
|
1675
|
+
);
|
|
1676
|
+
if (typeof inputIdentity !== "string")
|
|
1677
|
+
throw new Error("当前报告缺少可绑定的分析输入身份");
|
|
1678
|
+
const sourceIdentity = createHash("sha256")
|
|
1679
|
+
.update(inputIdentity)
|
|
1680
|
+
.digest("hex");
|
|
1681
|
+
const context = await store.reviewContext(out.report, {
|
|
1682
|
+
nodeModules: out.nodeModules,
|
|
1683
|
+
sourceIdentity,
|
|
1684
|
+
rulesIdentity: out.analysisIdentity?.rulesSha256,
|
|
1685
|
+
catalogIdentity: out.report.catalog?.sha256,
|
|
1686
|
+
toolVersion: out.toolVersion,
|
|
1687
|
+
});
|
|
1688
|
+
const reviews = await store.readReviews(context);
|
|
1689
|
+
if (state.disposed)
|
|
1690
|
+
return failureEnvelope("Plugin generation disposed / 已卸载", out);
|
|
1691
|
+
if (
|
|
1692
|
+
state.reportService.identityForReport(key, out.report) !== inputIdentity
|
|
1693
|
+
)
|
|
1694
|
+
throw new Error("报告缓存已回收或变化,请刷新后操作");
|
|
1236
1695
|
state.reviewTargets ||= new Map();
|
|
1237
|
-
state.reviewTargets.set(context.contextToken,{
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1696
|
+
state.reviewTargets.set(context.contextToken, {
|
|
1697
|
+
context,
|
|
1698
|
+
key,
|
|
1699
|
+
report: out.report,
|
|
1700
|
+
nodeModules: out.nodeModules,
|
|
1701
|
+
store,
|
|
1702
|
+
});
|
|
1703
|
+
while (state.reviewTargets.size > 32)
|
|
1704
|
+
state.reviewTargets.delete(state.reviewTargets.keys().next().value);
|
|
1705
|
+
return { ...out, reviews };
|
|
1706
|
+
} catch (error) {
|
|
1707
|
+
return {
|
|
1708
|
+
...out,
|
|
1709
|
+
reviews: {
|
|
1710
|
+
schemaVersion: 1,
|
|
1711
|
+
contextToken: null,
|
|
1712
|
+
revision: null,
|
|
1713
|
+
items: [],
|
|
1714
|
+
history: [],
|
|
1715
|
+
error: "人工处置记录不可用:" + errText(error),
|
|
1716
|
+
},
|
|
1717
|
+
};
|
|
1242
1718
|
}
|
|
1243
1719
|
}
|
|
1244
1720
|
|
|
1245
|
-
function readReviewRequest(req,signal) {
|
|
1246
|
-
return new Promise((resolve,reject)=>{
|
|
1247
|
-
const maxBytes=16384,
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
if(
|
|
1721
|
+
function readReviewRequest(req, signal) {
|
|
1722
|
+
return new Promise((resolve, reject) => {
|
|
1723
|
+
const maxBytes = 16384,
|
|
1724
|
+
chunks = [];
|
|
1725
|
+
let bytes = 0,
|
|
1726
|
+
settled = false;
|
|
1727
|
+
const finish = (error, value) => {
|
|
1728
|
+
if (settled) return;
|
|
1729
|
+
settled = true;
|
|
1730
|
+
clearTimeout(timer);
|
|
1731
|
+
req.removeListener("data", data);
|
|
1732
|
+
req.removeListener("end", end);
|
|
1733
|
+
req.removeListener("error", errorEvent);
|
|
1734
|
+
req.removeListener("aborted", abort);
|
|
1735
|
+
signal.removeEventListener("abort", abort);
|
|
1736
|
+
if (error) {
|
|
1737
|
+
req.pause?.();
|
|
1738
|
+
reject(error);
|
|
1739
|
+
} else resolve(value);
|
|
1740
|
+
};
|
|
1741
|
+
const invalid = (message, status) =>
|
|
1742
|
+
Object.assign(new Error(message), { status });
|
|
1743
|
+
const data = (chunk) => {
|
|
1744
|
+
const buffer = Buffer.from(chunk);
|
|
1745
|
+
bytes += buffer.length;
|
|
1746
|
+
if (bytes > maxBytes)
|
|
1747
|
+
return finish(invalid("人工处置请求超过大小限制", 413));
|
|
1748
|
+
chunks.push(buffer);
|
|
1749
|
+
};
|
|
1750
|
+
const end = () => {
|
|
1751
|
+
try {
|
|
1752
|
+
finish(
|
|
1753
|
+
null,
|
|
1754
|
+
JSON.parse(
|
|
1755
|
+
new TextDecoder("utf-8", { fatal: true }).decode(
|
|
1756
|
+
Buffer.concat(chunks),
|
|
1757
|
+
),
|
|
1758
|
+
),
|
|
1759
|
+
);
|
|
1760
|
+
} catch {
|
|
1761
|
+
finish(invalid("人工处置请求不是有效JSON", 400));
|
|
1762
|
+
}
|
|
1253
1763
|
};
|
|
1254
|
-
const
|
|
1255
|
-
const
|
|
1256
|
-
const
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1764
|
+
const errorEvent = () => finish(invalid("人工处置请求读取失败", 400));
|
|
1765
|
+
const abort = () => finish(invalid("人工处置请求已取消或插件已卸载", 409));
|
|
1766
|
+
const timer = setTimeout(
|
|
1767
|
+
() => finish(invalid("人工处置请求读取超时", 408)),
|
|
1768
|
+
5000,
|
|
1769
|
+
);
|
|
1770
|
+
timer.unref?.();
|
|
1771
|
+
req.on("data", data);
|
|
1772
|
+
req.once("end", end);
|
|
1773
|
+
req.once("error", errorEvent);
|
|
1774
|
+
req.once("aborted", abort);
|
|
1775
|
+
signal.addEventListener("abort", abort, { once: true });
|
|
1776
|
+
if (signal.aborted) abort();
|
|
1777
|
+
const length = req.headers?.["content-length"];
|
|
1778
|
+
if (
|
|
1779
|
+
length !== undefined &&
|
|
1780
|
+
(!/^\d+$/.test(String(length)) || Number(length) > maxBytes)
|
|
1781
|
+
)
|
|
1782
|
+
finish(invalid("人工处置请求超过大小限制", 413));
|
|
1264
1783
|
});
|
|
1265
1784
|
}
|
|
1266
|
-
function registerFindingReviewRoute(ctx,state) {
|
|
1267
|
-
if(typeof ctx.inject!==
|
|
1268
|
-
ctx.inject([
|
|
1269
|
-
try
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1785
|
+
function registerFindingReviewRoute(ctx, state) {
|
|
1786
|
+
if (typeof ctx.inject !== "function") return;
|
|
1787
|
+
ctx.inject(["webServer"], (scope) => {
|
|
1788
|
+
try {
|
|
1789
|
+
return scope.webServer.register({
|
|
1790
|
+
name: "dsh-plugin-compat-check/reviews",
|
|
1791
|
+
kind: "exact",
|
|
1792
|
+
path: "/plugin-compat-check/reviews",
|
|
1793
|
+
handler: async (req, res) => {
|
|
1794
|
+
const send = (status, body) => {
|
|
1795
|
+
res.writeHead(status, {
|
|
1796
|
+
"content-type": "application/json; charset=utf-8",
|
|
1797
|
+
"cache-control": "no-store",
|
|
1798
|
+
});
|
|
1799
|
+
res.end(JSON.stringify(body));
|
|
1800
|
+
};
|
|
1801
|
+
if (!isTrustedRequest(req))
|
|
1802
|
+
return send(403, {
|
|
1803
|
+
ok: false,
|
|
1804
|
+
error: "refused: untrusted request",
|
|
1805
|
+
});
|
|
1806
|
+
if (req.method !== "POST")
|
|
1807
|
+
return send(405, { ok: false, error: "人工处置仅支持POST" });
|
|
1808
|
+
if (
|
|
1809
|
+
!/^application\/json(?:\s*;|$)/i.test(
|
|
1810
|
+
req.headers?.["content-type"] || "",
|
|
1811
|
+
)
|
|
1812
|
+
)
|
|
1813
|
+
return send(415, {
|
|
1814
|
+
ok: false,
|
|
1815
|
+
error: "人工处置需要application/json",
|
|
1816
|
+
});
|
|
1817
|
+
if (state.disposed)
|
|
1818
|
+
return send(409, {
|
|
1819
|
+
ok: false,
|
|
1820
|
+
error: "Plugin generation disposed / 已卸载",
|
|
1821
|
+
});
|
|
1822
|
+
const controller = new AbortController();
|
|
1823
|
+
state.reviewRequests ||= new Set();
|
|
1824
|
+
state.reviewRequests.add(controller);
|
|
1825
|
+
try {
|
|
1826
|
+
const body = await readReviewRequest(req, controller.signal);
|
|
1827
|
+
const target = state.reviewTargets?.get(body?.contextToken);
|
|
1828
|
+
if (!target)
|
|
1829
|
+
return send(409, {
|
|
1830
|
+
ok: false,
|
|
1831
|
+
error: "报告已变化,请重新检查后核实此发现",
|
|
1832
|
+
});
|
|
1833
|
+
const canCommit = async () => {
|
|
1834
|
+
if (state.disposed || controller.signal.aborted) return false;
|
|
1835
|
+
const current = resolveNodeModules();
|
|
1836
|
+
if (
|
|
1837
|
+
!current.dir ||
|
|
1838
|
+
path.resolve(current.dir) !== path.resolve(target.nodeModules)
|
|
1839
|
+
)
|
|
1840
|
+
return false;
|
|
1841
|
+
return state.reportService.isCurrent(target.key, target.report);
|
|
1842
|
+
};
|
|
1843
|
+
if (!(await canCommit()))
|
|
1844
|
+
return send(409, {
|
|
1845
|
+
ok: false,
|
|
1846
|
+
error: "体检输入已变化,请重新检查后核实此发现",
|
|
1847
|
+
});
|
|
1848
|
+
const reviews = await target.store.saveReview(
|
|
1849
|
+
target.context,
|
|
1850
|
+
body,
|
|
1851
|
+
{ canCommit },
|
|
1852
|
+
);
|
|
1853
|
+
send(200, { ok: true, reviews });
|
|
1854
|
+
} catch (error) {
|
|
1855
|
+
const status =
|
|
1856
|
+
error.status ||
|
|
1857
|
+
(error.code === "REVIEW_STALE" || error.code === "REVIEW_BUSY"
|
|
1858
|
+
? 409
|
|
1859
|
+
: error.code === "REVIEW_STORAGE"
|
|
1860
|
+
? 422
|
|
1861
|
+
: 400);
|
|
1862
|
+
send(status, { ok: false, error: errText(error) });
|
|
1863
|
+
} finally {
|
|
1864
|
+
state.reviewRequests.delete(controller);
|
|
1865
|
+
}
|
|
1866
|
+
},
|
|
1867
|
+
});
|
|
1868
|
+
} catch (error) {
|
|
1869
|
+
log("Review route registration failed: " + errText(error));
|
|
1870
|
+
}
|
|
1294
1871
|
});
|
|
1295
1872
|
}
|
|
1296
1873
|
|
|
1297
|
-
const TOOL_SERVICES = [
|
|
1874
|
+
const TOOL_SERVICES = ["tools", "agents"];
|
|
1298
1875
|
|
|
1299
|
-
function toolResult(compat,text){
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1876
|
+
function toolResult(compat, text) {
|
|
1877
|
+
const s = compat.report?.summary || {};
|
|
1878
|
+
return {
|
|
1879
|
+
schemaVersion: 1,
|
|
1880
|
+
kind: "plugin-compat-check/tool-result",
|
|
1881
|
+
ok: compat.ok,
|
|
1882
|
+
error: compat.error,
|
|
1883
|
+
toolVersion: compat.toolVersion,
|
|
1884
|
+
exitCode: compat.exitCode,
|
|
1885
|
+
report: compat.report,
|
|
1886
|
+
baseline: compat.baseline,
|
|
1887
|
+
cache: compat.cache || null,
|
|
1888
|
+
reviews: compat.reviews || null,
|
|
1889
|
+
summary: compat.ok
|
|
1890
|
+
? `可用 ${s.ok || 0} / 部分可用 ${s.partial || 0} / 不兼容 ${s.broken || 0};覆盖有限 ${s.limited || 0};无已验证调用 ${s.vacuousOk || 0}`
|
|
1891
|
+
: "无法判定",
|
|
1892
|
+
nodeModules: compat.nodeModules || "",
|
|
1893
|
+
hasRuntimeSurface: !!compat.report?.hasRuntimeSurface,
|
|
1894
|
+
text,
|
|
1895
|
+
};
|
|
1303
1896
|
}
|
|
1304
1897
|
|
|
1305
1898
|
function registerTool(ctx, state) {
|
|
1306
1899
|
// 与 C2 路由同一个理由:走作用域 ctx.inject,headless/CLI 形态下 `tools` 不在场时
|
|
1307
1900
|
// 这段根本不注册,本插件的 fiber 也就不会去等一个不存在的服务(`inject` 保持 [])。
|
|
1308
|
-
if (typeof ctx.inject !==
|
|
1901
|
+
if (typeof ctx.inject !== "function") return;
|
|
1309
1902
|
ctx.inject(TOOL_SERVICES, (scope) => {
|
|
1310
1903
|
try {
|
|
1311
|
-
if (!scope.tools || typeof scope.tools.register !==
|
|
1312
|
-
log(
|
|
1904
|
+
if (!scope.tools || typeof scope.tools.register !== "function") {
|
|
1905
|
+
log("D1 tool skipped: scoped ctx has no tools.register");
|
|
1313
1906
|
return;
|
|
1314
1907
|
}
|
|
1315
1908
|
const dispose = scope.tools.register({
|
|
1316
|
-
name:
|
|
1909
|
+
name: "compat_check",
|
|
1317
1910
|
description:
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1911
|
+
"Assess compatibility risks for the DSH plugins installed in this profile against the " +
|
|
1912
|
+
"current DSH version, reporting evidence, coverage limits and file:line locations where available. " +
|
|
1913
|
+
"Use it when the user asks whether a plugin still works, why a feature disappeared after " +
|
|
1914
|
+
"updating DSH, or what compatibility evidence is available for an installed plugin. It reads the installed " +
|
|
1915
|
+
"profile; it does not install anything or check a plugin that is not yet installed. " +
|
|
1916
|
+
"Certainty is separate from impact evidence: certain means supported by explicit static facts or qualified runtime observations; " +
|
|
1917
|
+
"suspected means insufficient evidence to establish that finding, even when some runtime observations exist. " +
|
|
1918
|
+
"impactBasis=measured requires corresponding impact/state observations; inferred means an inferred impact, not a measured business outcome. " +
|
|
1919
|
+
"vacuous means no verified calls in an otherwise ok result; service dependencies or unverified client features may still exist. " +
|
|
1920
|
+
"Preserve coverage limits: unobserved does not mean absent, and no findings or verified calls do not certify business behavior. " +
|
|
1921
|
+
"The official API catalogue is a contract catalogue, not a runtime observation. " +
|
|
1922
|
+
"Provide upgradeTo for a separate static pre-upgrade comparison against an exact supported DSH version. It reuses no live observations, cache or baseline, performs no upgrade and does not certify future business behavior.",
|
|
1330
1923
|
parameters: {
|
|
1331
|
-
type:
|
|
1924
|
+
type: "object",
|
|
1332
1925
|
additionalProperties: false,
|
|
1333
1926
|
properties: {
|
|
1334
|
-
upgradeTo: {
|
|
1927
|
+
upgradeTo: {
|
|
1928
|
+
type: "string",
|
|
1929
|
+
description:
|
|
1930
|
+
"Optional exact target DSH version for static upgrade preflight; known catalogues: 0.2.0-rc.2 and 0.2.1-rc.2. Unknown versions are uncertified.",
|
|
1931
|
+
},
|
|
1335
1932
|
profile: {
|
|
1336
|
-
type:
|
|
1933
|
+
type: "string",
|
|
1337
1934
|
description:
|
|
1338
|
-
'Optional profile name to inspect (e.g. "desktop"). Defaults to the profile this '
|
|
1339
|
-
|
|
1935
|
+
'Optional profile name to inspect (e.g. "desktop"). Defaults to the profile this ' +
|
|
1936
|
+
"DSH instance is running when it can be determined. An explicit profile is required otherwise; no profile is guessed.",
|
|
1340
1937
|
},
|
|
1341
1938
|
},
|
|
1342
1939
|
},
|
|
@@ -1347,55 +1944,114 @@ function registerTool(ctx, state) {
|
|
|
1347
1944
|
// 实测踩过:注册失败、工具不存在,而**面板与路由照常工作** ——
|
|
1348
1945
|
// 所以只有真的去调一次工具才发现得了。
|
|
1349
1946
|
schema: {
|
|
1350
|
-
type:
|
|
1947
|
+
type: "object",
|
|
1351
1948
|
additionalProperties: false,
|
|
1352
1949
|
properties: {
|
|
1353
|
-
schemaVersion: {type:
|
|
1354
|
-
kind: {type:
|
|
1355
|
-
ok: {type:
|
|
1356
|
-
error: {oneOf:[{type:
|
|
1357
|
-
exitCode: {type:
|
|
1358
|
-
toolVersion: {oneOf:[{type:
|
|
1359
|
-
report: {
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1950
|
+
schemaVersion: { type: "integer", enum: [1] },
|
|
1951
|
+
kind: { type: "string" },
|
|
1952
|
+
ok: { type: "boolean" },
|
|
1953
|
+
error: { oneOf: [{ type: "string" }, { type: "null" }] },
|
|
1954
|
+
exitCode: { type: "integer", enum: [0, 1, 2, 3] },
|
|
1955
|
+
toolVersion: { oneOf: [{ type: "string" }, { type: "null" }] },
|
|
1956
|
+
report: {
|
|
1957
|
+
oneOf: [
|
|
1958
|
+
{ type: "object", additionalProperties: true },
|
|
1959
|
+
{ type: "null" },
|
|
1960
|
+
],
|
|
1961
|
+
},
|
|
1962
|
+
upgradePreview: { type: "object", additionalProperties: true },
|
|
1963
|
+
baseline: {
|
|
1964
|
+
oneOf: [
|
|
1965
|
+
{ type: "object", additionalProperties: true },
|
|
1966
|
+
{ type: "null" },
|
|
1967
|
+
],
|
|
1968
|
+
},
|
|
1969
|
+
cache: {
|
|
1970
|
+
oneOf: [
|
|
1971
|
+
{ type: "object", additionalProperties: true },
|
|
1972
|
+
{ type: "null" },
|
|
1973
|
+
],
|
|
1974
|
+
},
|
|
1975
|
+
reviews: {
|
|
1976
|
+
oneOf: [
|
|
1977
|
+
{ type: "object", additionalProperties: true },
|
|
1978
|
+
{ type: "null" },
|
|
1979
|
+
],
|
|
1980
|
+
},
|
|
1981
|
+
summary: { type: "string" },
|
|
1982
|
+
nodeModules: { type: "string" },
|
|
1983
|
+
hasRuntimeSurface: { type: "boolean" },
|
|
1984
|
+
text: { type: "string" },
|
|
1368
1985
|
},
|
|
1369
|
-
required: [
|
|
1986
|
+
required: [
|
|
1987
|
+
"schemaVersion",
|
|
1988
|
+
"kind",
|
|
1989
|
+
"ok",
|
|
1990
|
+
"error",
|
|
1991
|
+
"exitCode",
|
|
1992
|
+
"toolVersion",
|
|
1993
|
+
"report",
|
|
1994
|
+
"baseline",
|
|
1995
|
+
"summary",
|
|
1996
|
+
"nodeModules",
|
|
1997
|
+
"hasRuntimeSurface",
|
|
1998
|
+
"text",
|
|
1999
|
+
],
|
|
1370
2000
|
},
|
|
1371
|
-
render: (_args, value) => [
|
|
2001
|
+
render: (_args, value) => [
|
|
2002
|
+
{
|
|
2003
|
+
type: "text",
|
|
2004
|
+
text: value && typeof value.text === "string" ? value.text : "",
|
|
2005
|
+
},
|
|
2006
|
+
],
|
|
1372
2007
|
},
|
|
1373
2008
|
isConcurrencySafe: () => false,
|
|
1374
2009
|
presentCall: (args) => ({
|
|
1375
|
-
card:
|
|
1376
|
-
title:
|
|
1377
|
-
kind:
|
|
2010
|
+
card: "generic",
|
|
2011
|
+
title: "compat_check",
|
|
2012
|
+
kind: "read",
|
|
1378
2013
|
rawInput: args || {},
|
|
1379
2014
|
}),
|
|
1380
2015
|
async execute(args) {
|
|
1381
2016
|
try {
|
|
1382
|
-
const wanted =
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
2017
|
+
const wanted =
|
|
2018
|
+
args &&
|
|
2019
|
+
typeof args.profile === "string" &&
|
|
2020
|
+
args.profile.trim() !== ""
|
|
2021
|
+
? args.profile.trim()
|
|
2022
|
+
: null;
|
|
2023
|
+
if (
|
|
2024
|
+
args &&
|
|
2025
|
+
Object.prototype.hasOwnProperty.call(args, "upgradeTo")
|
|
2026
|
+
) {
|
|
2027
|
+
return await upgradeToolResult(state, {
|
|
2028
|
+
profile: wanted,
|
|
2029
|
+
targetVersion: args.upgradeTo,
|
|
2030
|
+
invalidRequest:
|
|
2031
|
+
typeof args.upgradeTo !== "string"
|
|
2032
|
+
? "upgradeTo 必须为确切 DSH 版本"
|
|
2033
|
+
: null,
|
|
2034
|
+
});
|
|
1387
2035
|
}
|
|
1388
|
-
const compat = await serveReport(
|
|
2036
|
+
const compat = await serveReport(
|
|
2037
|
+
state,
|
|
2038
|
+
ctx,
|
|
2039
|
+
false,
|
|
2040
|
+
wanted ? { profile: wanted } : {},
|
|
2041
|
+
);
|
|
1389
2042
|
const text = renderForModel(compat);
|
|
1390
|
-
return toolResult(compat,text);
|
|
2043
|
+
return toolResult(compat, text);
|
|
1391
2044
|
} catch (error) {
|
|
1392
2045
|
// 工具的 execute 抛错会把整轮对话变成错误 —— 而"体检失败"不是用户的错,
|
|
1393
2046
|
// 也不该让他重来一遍。所以这里**不抛**,把失败也变成一段可读的文本。
|
|
1394
|
-
return toolResult(
|
|
2047
|
+
return toolResult(
|
|
2048
|
+
failureEnvelope(errText(error)),
|
|
2049
|
+
`体检没跑成(这是工具的问题,不是插件的结论):${errText(error)}`,
|
|
2050
|
+
);
|
|
1395
2051
|
}
|
|
1396
2052
|
},
|
|
1397
2053
|
});
|
|
1398
|
-
log(
|
|
2054
|
+
log("D1 tool registered: compat_check");
|
|
1399
2055
|
return dispose;
|
|
1400
2056
|
} catch (error) {
|
|
1401
2057
|
log(`WARN D1 tool registration failed: ${errText(error)}`);
|
|
@@ -1421,8 +2077,8 @@ function registerTool(ctx, state) {
|
|
|
1421
2077
|
// provider 必须**等于** provider 自己的 name(否则报 returned skill "x" for provider "y");
|
|
1422
2078
|
// rank 必须是有限数,600 是官方 bundled skill 用的 BUNDLED_SKILL_RANK。
|
|
1423
2079
|
|
|
1424
|
-
const SKILL_NAME =
|
|
1425
|
-
const SKILL_PROVIDER =
|
|
2080
|
+
const SKILL_NAME = "dsh-plugin-compat-check";
|
|
2081
|
+
const SKILL_PROVIDER = "dsh-plugin-compat-check";
|
|
1426
2082
|
const SKILL_RANK = 600;
|
|
1427
2083
|
|
|
1428
2084
|
/**
|
|
@@ -1449,27 +2105,32 @@ const SKILL_RANK = 600;
|
|
|
1449
2105
|
function readPackagedSkill() {
|
|
1450
2106
|
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
1451
2107
|
const candidates = [
|
|
1452
|
-
join(here,
|
|
1453
|
-
join(here,
|
|
1454
|
-
join(here,
|
|
1455
|
-
join(here,
|
|
2108
|
+
join(here, "skill", "SKILL.md"), // **安装形态**:包内自带(files 里有 skill/)
|
|
2109
|
+
join(here, "..", "skill", "SKILL.md"), // 仓库形态:<repo>/plugin/index.js → <repo>/skill/SKILL.md
|
|
2110
|
+
join(here, "SKILL.md"),
|
|
2111
|
+
join(here, "assets", "SKILL.md"),
|
|
1456
2112
|
];
|
|
1457
2113
|
for (const file of candidates) {
|
|
1458
2114
|
try {
|
|
1459
2115
|
if (!existsSync(file)) continue;
|
|
1460
|
-
const raw = readFileSync(file,
|
|
2116
|
+
const raw = readFileSync(file, "utf8");
|
|
1461
2117
|
const fm = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/u.exec(raw);
|
|
1462
2118
|
if (fm === null) continue;
|
|
1463
2119
|
// 只按**单行标量**读这两个字段。写成多行 YAML 时这里读不到 ——
|
|
1464
2120
|
// 宁可读不到(打一条日志),也好过猜一个错的 description。
|
|
1465
2121
|
const field = (key) => {
|
|
1466
|
-
const m = new RegExp(`^${key}:[ \t]*(.+)$`,
|
|
1467
|
-
return m === null ? null : m[1].trim().replace(/^["']|["']$/g,
|
|
2122
|
+
const m = new RegExp(`^${key}:[ \t]*(.+)$`, "m").exec(fm[1]);
|
|
2123
|
+
return m === null ? null : m[1].trim().replace(/^["']|["']$/g, "");
|
|
1468
2124
|
};
|
|
1469
|
-
const skillName = field(
|
|
1470
|
-
const description = field(
|
|
2125
|
+
const skillName = field("name");
|
|
2126
|
+
const description = field("description");
|
|
1471
2127
|
if (skillName === null || description === null) continue;
|
|
1472
|
-
return {
|
|
2128
|
+
return {
|
|
2129
|
+
file,
|
|
2130
|
+
name: skillName,
|
|
2131
|
+
description,
|
|
2132
|
+
content: raw.slice(fm[0].length).trim(),
|
|
2133
|
+
};
|
|
1473
2134
|
} catch {
|
|
1474
2135
|
/* 读不到就试下一个候选 */
|
|
1475
2136
|
}
|
|
@@ -1479,19 +2140,24 @@ function readPackagedSkill() {
|
|
|
1479
2140
|
|
|
1480
2141
|
function registerSkill(ctx) {
|
|
1481
2142
|
try {
|
|
1482
|
-
if (typeof ctx.inject !==
|
|
1483
|
-
log(
|
|
2143
|
+
if (typeof ctx.inject !== "function") {
|
|
2144
|
+
log("SKILL registration skipped: ctx.inject is not a function");
|
|
1484
2145
|
return;
|
|
1485
2146
|
}
|
|
1486
|
-
ctx.inject([
|
|
2147
|
+
ctx.inject(["skills"], (skillCtx) => {
|
|
1487
2148
|
try {
|
|
1488
|
-
if (
|
|
1489
|
-
|
|
2149
|
+
if (
|
|
2150
|
+
!skillCtx.skills ||
|
|
2151
|
+
typeof skillCtx.skills.registerProvider !== "function"
|
|
2152
|
+
) {
|
|
2153
|
+
log(
|
|
2154
|
+
"SKILL registration skipped: scoped ctx has no skills.registerProvider",
|
|
2155
|
+
);
|
|
1490
2156
|
return;
|
|
1491
2157
|
}
|
|
1492
2158
|
const skill = readPackagedSkill();
|
|
1493
2159
|
if (skill === null) {
|
|
1494
|
-
log(
|
|
2160
|
+
log("SKILL registration skipped: 找不到随包的 skill/SKILL.md");
|
|
1495
2161
|
return;
|
|
1496
2162
|
}
|
|
1497
2163
|
// 正文在注册时读进内存:包内文件运行时不会变,这样 get() 永不抛。
|
|
@@ -1501,24 +2167,25 @@ function registerSkill(ctx) {
|
|
|
1501
2167
|
description: skill.description,
|
|
1502
2168
|
invocation,
|
|
1503
2169
|
provider: SKILL_PROVIDER,
|
|
1504
|
-
source:
|
|
2170
|
+
source: "bundled",
|
|
1505
2171
|
rank: SKILL_RANK,
|
|
1506
2172
|
locator: skill.file,
|
|
1507
2173
|
path: skill.file,
|
|
1508
|
-
resourceBase: { kind:
|
|
2174
|
+
resourceBase: { kind: "directory", path: path.dirname(skill.file) },
|
|
1509
2175
|
};
|
|
1510
2176
|
const dispose = skillCtx.skills.registerProvider(() => ({
|
|
1511
2177
|
name: SKILL_PROVIDER,
|
|
1512
2178
|
list: () => Promise.resolve([summary]),
|
|
1513
|
-
get: () =>
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
2179
|
+
get: () =>
|
|
2180
|
+
Promise.resolve({
|
|
2181
|
+
name: SKILL_NAME,
|
|
2182
|
+
description: skill.description,
|
|
2183
|
+
invocation,
|
|
2184
|
+
provider: SKILL_PROVIDER,
|
|
2185
|
+
source: summary.source,
|
|
2186
|
+
path: skill.file,
|
|
2187
|
+
content: skill.content,
|
|
2188
|
+
}),
|
|
1522
2189
|
}));
|
|
1523
2190
|
log(`SKILL registered: ${SKILL_NAME} <- ${skill.file}`);
|
|
1524
2191
|
return dispose;
|
|
@@ -1544,7 +2211,7 @@ export function apply(ctx) {
|
|
|
1544
2211
|
loaderVia: null,
|
|
1545
2212
|
loaderAccess: {
|
|
1546
2213
|
attempts: [],
|
|
1547
|
-
injectProbe: { registered: false, outcome:
|
|
2214
|
+
injectProbe: { registered: false, outcome: "not run" },
|
|
1548
2215
|
},
|
|
1549
2216
|
disposeInjectProbe: null,
|
|
1550
2217
|
// ── F2:结果缓存 ──
|
|
@@ -1555,9 +2222,15 @@ export function apply(ctx) {
|
|
|
1555
2222
|
/** 缓存有效期。到点不清空,而是**先返回旧的、后台再刷新** —— 见下面的 stale-while-revalidate。 */
|
|
1556
2223
|
cacheTtlMs: toPositiveInt(process.env.DSC_CACHE_TTL_MS, 5 * 60 * 1000),
|
|
1557
2224
|
// A caller may shorten the observation interval; never enlarge its default.
|
|
1558
|
-
cacheWatchMs: Math.min(
|
|
2225
|
+
cacheWatchMs: Math.min(
|
|
2226
|
+
5000,
|
|
2227
|
+
toPositiveInt(process.env.DSC_CACHE_WATCH_MS, 5000),
|
|
2228
|
+
),
|
|
1559
2229
|
/** 启动后多久自动跑第一次(默认落在最后一次 loader 扫描之后) */
|
|
1560
|
-
autoRunDelayMs: toPositiveInt(
|
|
2230
|
+
autoRunDelayMs: toPositiveInt(
|
|
2231
|
+
process.env.DSC_AUTORUN_MS,
|
|
2232
|
+
(SCAN_DELAYS[SCAN_DELAYS.length - 1] || 4000) + 2000,
|
|
2233
|
+
),
|
|
1561
2234
|
autoRunTimer: null,
|
|
1562
2235
|
};
|
|
1563
2236
|
|
|
@@ -1567,18 +2240,26 @@ export function apply(ctx) {
|
|
|
1567
2240
|
const resolved = resolveLoader(ctx);
|
|
1568
2241
|
state.loader = resolved.loader;
|
|
1569
2242
|
state.loaderVia = resolved.via;
|
|
1570
|
-
state.loaderAccess = {
|
|
2243
|
+
state.loaderAccess = {
|
|
2244
|
+
...state.loaderAccess,
|
|
2245
|
+
...publicLoaderAccess(resolved),
|
|
2246
|
+
};
|
|
1571
2247
|
log(
|
|
1572
|
-
`loader access: via=${resolved.via ??
|
|
2248
|
+
`loader access: via=${resolved.via ?? "UNREACHABLE"} attempts=${JSON.stringify(resolved.attempts)}`,
|
|
1573
2249
|
);
|
|
1574
2250
|
} catch (error) {
|
|
1575
|
-
state.loaderAccess.attempts.push(
|
|
2251
|
+
state.loaderAccess.attempts.push(
|
|
2252
|
+
`resolveLoader threw: ${errText(error)}`,
|
|
2253
|
+
);
|
|
1576
2254
|
log(`ERROR resolving loader: ${errText(error)}`);
|
|
1577
2255
|
}
|
|
1578
2256
|
|
|
1579
2257
|
// The `inject: ["loader"]` safety experiment (see startInjectProbe).
|
|
1580
2258
|
try {
|
|
1581
|
-
state.disposeInjectProbe = startInjectProbe(
|
|
2259
|
+
state.disposeInjectProbe = startInjectProbe(
|
|
2260
|
+
ctx,
|
|
2261
|
+
state.loaderAccess.injectProbe,
|
|
2262
|
+
);
|
|
1582
2263
|
} catch (error) {
|
|
1583
2264
|
state.loaderAccess.injectProbe.outcome = `probe setup threw: ${errText(error)}`;
|
|
1584
2265
|
}
|
|
@@ -1590,47 +2271,76 @@ export function apply(ctx) {
|
|
|
1590
2271
|
// 本插件的 fiber 也不会去等一个不存在的服务 —— `inject` 保持 [],
|
|
1591
2272
|
// 与本文件的整体设计一致(见文件头)。
|
|
1592
2273
|
try {
|
|
1593
|
-
if (typeof ctx.inject ===
|
|
1594
|
-
ctx.inject([
|
|
2274
|
+
if (typeof ctx.inject === "function") {
|
|
2275
|
+
ctx.inject(["webServer"], (scope) => {
|
|
1595
2276
|
try {
|
|
1596
2277
|
const dispose = scope.webServer.register({
|
|
1597
|
-
name:
|
|
1598
|
-
kind:
|
|
1599
|
-
path:
|
|
2278
|
+
name: "dsh-plugin-compat-check",
|
|
2279
|
+
kind: "exact",
|
|
2280
|
+
path: "/plugin-compat-check/report",
|
|
1600
2281
|
handler: async (req, res) => {
|
|
1601
2282
|
// 体检结论是本机信息(装了哪些插件、用了哪些 API),
|
|
1602
2283
|
// 不该被这台机器上的任意页面读到。
|
|
1603
2284
|
if (!isTrustedRequest(req)) {
|
|
1604
|
-
res.writeHead(403, {
|
|
1605
|
-
|
|
2285
|
+
res.writeHead(403, {
|
|
2286
|
+
"content-type": "application/json; charset=utf-8",
|
|
2287
|
+
});
|
|
2288
|
+
res.end(
|
|
2289
|
+
JSON.stringify({ error: "refused: untrusted request" }),
|
|
2290
|
+
);
|
|
2291
|
+
return;
|
|
2292
|
+
}
|
|
2293
|
+
if (req.method !== "GET") {
|
|
2294
|
+
res.writeHead(405, {
|
|
2295
|
+
"content-type": "application/json; charset=utf-8",
|
|
2296
|
+
"cache-control": "no-store",
|
|
2297
|
+
});
|
|
2298
|
+
res.end(JSON.stringify({ error: "体检报告仅支持 GET" }));
|
|
1606
2299
|
return;
|
|
1607
2300
|
}
|
|
1608
2301
|
try {
|
|
1609
|
-
const url = new URL(req.url ||
|
|
1610
|
-
const force = url.searchParams.get(
|
|
2302
|
+
const url = new URL(req.url || "/", "http://127.0.0.1");
|
|
2303
|
+
const force = url.searchParams.get("refresh") === "1";
|
|
1611
2304
|
const out = await serveReport(state, ctx, force);
|
|
1612
2305
|
res.writeHead(200, {
|
|
1613
|
-
|
|
1614
|
-
|
|
2306
|
+
"content-type": "application/json; charset=utf-8",
|
|
2307
|
+
"cache-control": "no-store",
|
|
1615
2308
|
});
|
|
1616
|
-
res.end(
|
|
1617
|
-
|
|
1618
|
-
|
|
2309
|
+
res.end(
|
|
2310
|
+
JSON.stringify(
|
|
2311
|
+
url.searchParams.get("metadata") === "1" && out.ok
|
|
2312
|
+
? {
|
|
2313
|
+
kind: "plugin-compat-check/report-metadata",
|
|
2314
|
+
ok: true,
|
|
2315
|
+
computedAt: out.computedAt,
|
|
2316
|
+
nodeModules: out.nodeModules,
|
|
2317
|
+
dshVersion: out.report.dshVersion,
|
|
2318
|
+
cache: out.cache || null,
|
|
2319
|
+
reviewsRevision: out.reviews?.revision || null,
|
|
2320
|
+
reviewsError: out.reviews?.error || null,
|
|
2321
|
+
reviewsContextToken:
|
|
2322
|
+
out.reviews?.contextToken || null,
|
|
2323
|
+
}
|
|
2324
|
+
: out,
|
|
2325
|
+
),
|
|
2326
|
+
);
|
|
1619
2327
|
} catch (error) {
|
|
1620
2328
|
// 连处理器里都不抛:把错误变成一条可读的 JSON,而不是 500 空响应。
|
|
1621
|
-
res.writeHead(200, {
|
|
2329
|
+
res.writeHead(200, {
|
|
2330
|
+
"content-type": "application/json; charset=utf-8",
|
|
2331
|
+
});
|
|
1622
2332
|
res.end(JSON.stringify(failureEnvelope(errText(error))));
|
|
1623
2333
|
}
|
|
1624
2334
|
},
|
|
1625
2335
|
});
|
|
1626
|
-
log(
|
|
2336
|
+
log("C2 route registered: GET /plugin-compat-check/report");
|
|
1627
2337
|
return dispose;
|
|
1628
2338
|
} catch (error) {
|
|
1629
2339
|
log(`WARN C2 route registration failed: ${errText(error)}`);
|
|
1630
2340
|
}
|
|
1631
2341
|
});
|
|
1632
2342
|
} else {
|
|
1633
|
-
log(
|
|
2343
|
+
log("C2 route skipped: ctx.inject is not a function");
|
|
1634
2344
|
}
|
|
1635
2345
|
} catch (error) {
|
|
1636
2346
|
log(`WARN C2 route setup threw: ${errText(error)}`);
|
|
@@ -1640,7 +2350,7 @@ export function apply(ctx) {
|
|
|
1640
2350
|
try {
|
|
1641
2351
|
registerFindingReviewRoute(ctx, state);
|
|
1642
2352
|
registerUpgradeRoute(ctx, state);
|
|
1643
|
-
|
|
2353
|
+
registerSkill(ctx);
|
|
1644
2354
|
} catch (error) {
|
|
1645
2355
|
log(`WARN SKILL registration threw: ${errText(error)}`);
|
|
1646
2356
|
}
|
|
@@ -1654,12 +2364,19 @@ export function apply(ctx) {
|
|
|
1654
2364
|
|
|
1655
2365
|
const finish = () => {
|
|
1656
2366
|
const probe = state.loaderAccess.injectProbe;
|
|
1657
|
-
if (
|
|
1658
|
-
probe.
|
|
2367
|
+
if (
|
|
2368
|
+
probe.registered &&
|
|
2369
|
+
probe.firedAfterMs === undefined &&
|
|
2370
|
+
probe.outcome === "not run"
|
|
2371
|
+
) {
|
|
2372
|
+
probe.outcome =
|
|
2373
|
+
"did not fire before the final scan (would have stayed PENDING)";
|
|
1659
2374
|
}
|
|
1660
2375
|
log(
|
|
1661
2376
|
`inject-probe: registered=${probe.registered} outcome=${probe.outcome}${
|
|
1662
|
-
probe.firedAfterMs !== undefined
|
|
2377
|
+
probe.firedAfterMs !== undefined
|
|
2378
|
+
? ` firedAfterMs=${probe.firedAfterMs}`
|
|
2379
|
+
: ""
|
|
1663
2380
|
}`,
|
|
1664
2381
|
);
|
|
1665
2382
|
// ⚠️ 这里**不要**动 `state.autoRunTimer`。它是 F2 的启动自检,与 loader 扫描
|
|
@@ -1669,12 +2386,12 @@ export function apply(ctx) {
|
|
|
1669
2386
|
// 教训:清理定时器必须由创建它的那一方负责。
|
|
1670
2387
|
// F2:最后一次扫描之后跑启动自检 —— 这时候条目状态才是稳的。
|
|
1671
2388
|
try {
|
|
1672
|
-
if (typeof state.runAutoCheck ===
|
|
2389
|
+
if (typeof state.runAutoCheck === "function") state.runAutoCheck();
|
|
1673
2390
|
} catch (error) {
|
|
1674
2391
|
log(`WARN F2 启动自检触发失败: ${errText(error)}`);
|
|
1675
2392
|
}
|
|
1676
2393
|
try {
|
|
1677
|
-
if (typeof state.disposeInjectProbe ===
|
|
2394
|
+
if (typeof state.disposeInjectProbe === "function") {
|
|
1678
2395
|
state.disposeInjectProbe();
|
|
1679
2396
|
state.disposeInjectProbe = null;
|
|
1680
2397
|
}
|
|
@@ -1687,18 +2404,20 @@ export function apply(ctx) {
|
|
|
1687
2404
|
// one can safely wait for it and the report file is never written twice
|
|
1688
2405
|
// concurrently.
|
|
1689
2406
|
const schedulePass = (index) => {
|
|
1690
|
-
if(state.disposed)return;
|
|
2407
|
+
if (state.disposed) return;
|
|
1691
2408
|
const previous = index === 0 ? 0 : SCAN_DELAYS[index - 1];
|
|
1692
2409
|
const delay = Math.max(0, SCAN_DELAYS[index] - previous);
|
|
1693
2410
|
const timer = setTimeout(() => {
|
|
1694
2411
|
state.scanTimers.delete(timer);
|
|
1695
|
-
if(state.disposed)return;
|
|
2412
|
+
if (state.disposed) return;
|
|
1696
2413
|
const pass = index + 1;
|
|
1697
2414
|
try {
|
|
1698
2415
|
scan(ctx, state, pass)
|
|
1699
|
-
.catch((error) =>
|
|
2416
|
+
.catch((error) =>
|
|
2417
|
+
log(`ERROR scan#${pass} failed: ${errText(error)}`),
|
|
2418
|
+
)
|
|
1700
2419
|
.finally(() => {
|
|
1701
|
-
if(state.disposed)return;
|
|
2420
|
+
if (state.disposed) return;
|
|
1702
2421
|
if (pass < SCAN_DELAYS.length) schedulePass(index + 1);
|
|
1703
2422
|
else finish();
|
|
1704
2423
|
});
|
|
@@ -1707,7 +2426,7 @@ export function apply(ctx) {
|
|
|
1707
2426
|
}
|
|
1708
2427
|
}, delay);
|
|
1709
2428
|
// unref: a scan timer must never keep the host process alive.
|
|
1710
|
-
if (timer && typeof timer.unref ===
|
|
2429
|
+
if (timer && typeof timer.unref === "function") timer.unref();
|
|
1711
2430
|
state.scanTimers.add(timer);
|
|
1712
2431
|
};
|
|
1713
2432
|
schedulePass(0);
|
|
@@ -1726,26 +2445,34 @@ export function apply(ctx) {
|
|
|
1726
2445
|
// 的清理顺手取消掉了 —— 启动自检从未运行、且**没有任何报错**。
|
|
1727
2446
|
// 一个"看起来做了其实没做"的定时器,比没有更糟。
|
|
1728
2447
|
state.runAutoCheck = () => {
|
|
1729
|
-
if(state.disposed)return;
|
|
1730
|
-
if (state.autoCheckStarted) return;
|
|
2448
|
+
if (state.disposed) return;
|
|
2449
|
+
if (state.autoCheckStarted) return; // 幂等:只跑一次
|
|
1731
2450
|
state.autoCheckStarted = true;
|
|
1732
2451
|
try {
|
|
1733
2452
|
serveReport(state, ctx, false)
|
|
1734
2453
|
.then((out) => {
|
|
1735
|
-
state.
|
|
1736
|
-
state.lastReportAt = Date.now();
|
|
2454
|
+
if (state.disposed) return;
|
|
1737
2455
|
if (out && out.ok) {
|
|
2456
|
+
if (!state.reportService?.ownsReport(out.report)) return;
|
|
2457
|
+
state.lastReport = out;
|
|
2458
|
+
state.lastReportAt = Date.now();
|
|
1738
2459
|
const sum = out.report.summary || {};
|
|
1739
2460
|
const b = out.baseline || {};
|
|
1740
2461
|
const badge = b.badge || {};
|
|
1741
|
-
log(
|
|
1742
|
-
|
|
2462
|
+
log(
|
|
2463
|
+
`F2 启动自检: 可用 ${sum.ok || 0} 部分可用 ${sum.partial || 0} 不兼容 ${sum.broken || 0}` +
|
|
2464
|
+
(sum.vacuousOk ? ` (${sum.vacuousOk} 个为空洞的可用)` : ""),
|
|
2465
|
+
);
|
|
1743
2466
|
// 有变化就打成一条显眼的日志;没变化也要有一条。
|
|
1744
2467
|
// 「没有变化」必须说出来 —— 沉默会被读成"没检查"或"坏消息被藏起来了"。
|
|
1745
|
-
if (badge.show)
|
|
1746
|
-
|
|
2468
|
+
if (badge.show)
|
|
2469
|
+
log(`F2 与上次相比: ${badge.text} —— ${badge.detail || ""}`);
|
|
2470
|
+
else
|
|
2471
|
+
log(
|
|
2472
|
+
`F2 与上次相比: ${badge.detail || b.note || "没有可比基线"}`,
|
|
2473
|
+
);
|
|
1747
2474
|
} else if (out) {
|
|
1748
|
-
log(`WARN F2 启动自检没跑成: ${out.error ||
|
|
2475
|
+
log(`WARN F2 启动自检没跑成: ${out.error || "未知原因"}`);
|
|
1749
2476
|
}
|
|
1750
2477
|
})
|
|
1751
2478
|
.catch((error) => log(`WARN F2 启动自检失败: ${errText(error)}`));
|
|
@@ -1758,8 +2485,14 @@ export function apply(ctx) {
|
|
|
1758
2485
|
// `scan` 会提前 return),启动自检也必须跑。两条路都调 `runAutoCheck`,
|
|
1759
2486
|
// 由它自己保证幂等 —— 谁先到都算数。
|
|
1760
2487
|
{
|
|
1761
|
-
const timer = setTimeout(() => {
|
|
1762
|
-
|
|
2488
|
+
const timer = setTimeout(() => {
|
|
2489
|
+
try {
|
|
2490
|
+
state.runAutoCheck();
|
|
2491
|
+
} catch {
|
|
2492
|
+
/* 忽略 */
|
|
2493
|
+
}
|
|
2494
|
+
}, state.autoRunDelayMs);
|
|
2495
|
+
if (timer && typeof timer.unref === "function") timer.unref();
|
|
1763
2496
|
state.fallbackTimer = timer;
|
|
1764
2497
|
}
|
|
1765
2498
|
|
|
@@ -1772,20 +2505,32 @@ export function apply(ctx) {
|
|
|
1772
2505
|
compatReport: (c) => computeReport(state, c || ctx),
|
|
1773
2506
|
};
|
|
1774
2507
|
globalThis.__DSH_COMPAT_CHECK__ = handle;
|
|
1775
|
-
if(typeof ctx.effect===
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
|
|
1783
|
-
|
|
1784
|
-
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
|
|
2508
|
+
if (typeof ctx.effect === "function")
|
|
2509
|
+
ctx.effect(
|
|
2510
|
+
() => () => {
|
|
2511
|
+
state.disposed = true;
|
|
2512
|
+
for (const request of state.reviewRequests || []) request.abort();
|
|
2513
|
+
state.reviewTargets?.clear();
|
|
2514
|
+
state.reportService?.dispose();
|
|
2515
|
+
state.lastReport = null;
|
|
2516
|
+
state.lastReportAt = 0;
|
|
2517
|
+
for (const off of state.cacheEventDisposers || []) {
|
|
2518
|
+
try {
|
|
2519
|
+
off();
|
|
2520
|
+
} catch {}
|
|
2521
|
+
}
|
|
2522
|
+
state.cacheEventDisposers = [];
|
|
2523
|
+
state.upgradeFlight?.controller?.abort();
|
|
2524
|
+
for (const timer of state.scanTimers) clearTimeout(timer);
|
|
2525
|
+
state.scanTimers.clear();
|
|
2526
|
+
clearTimeout(state.fallbackTimer);
|
|
2527
|
+
state.disposeInjectProbe?.();
|
|
2528
|
+
state.disposeInjectProbe = null;
|
|
2529
|
+
if (globalThis.__DSH_COMPAT_CHECK__ === handle)
|
|
2530
|
+
delete globalThis.__DSH_COMPAT_CHECK__;
|
|
2531
|
+
},
|
|
2532
|
+
"compat-check: generation cleanup",
|
|
2533
|
+
);
|
|
1789
2534
|
} catch {
|
|
1790
2535
|
/* 忽略 */
|
|
1791
2536
|
}
|