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.
Files changed (46) hide show
  1. package/README.md +17 -30
  2. package/THIRD_PARTY_NOTICES.md +1 -1
  3. package/index.js +1295 -550
  4. package/kernel/lib/api-semantics.mjs +223 -0
  5. package/kernel/lib/author-ci-budget.mjs +25 -0
  6. package/kernel/lib/author-ci-output.mjs +22 -0
  7. package/kernel/lib/author-ci-policy.mjs +6 -1
  8. package/kernel/lib/baseline.mjs +109 -38
  9. package/kernel/lib/catalog.mjs +11 -3
  10. package/kernel/lib/contracts.mjs +16 -1
  11. package/kernel/lib/execution-environment.mjs +52 -0
  12. package/kernel/lib/extract.mjs +57 -13
  13. package/kernel/lib/finding-guidance.mjs +6 -0
  14. package/kernel/lib/finding-review-store.mjs +11 -23
  15. package/kernel/lib/issue-report.mjs +2 -1
  16. package/kernel/lib/patch-mounts.mjs +232 -0
  17. package/kernel/lib/report-identity-worker.mjs +47 -26
  18. package/kernel/lib/report-identity.mjs +4 -3
  19. package/kernel/lib/report-service.mjs +118 -38
  20. package/kernel/lib/report.mjs +13 -17
  21. package/kernel/lib/source-analysis.mjs +258 -42
  22. package/kernel/lib/source-graph.mjs +353 -75
  23. package/kernel/lib/state-lock.mjs +206 -0
  24. package/kernel/lib/state-store.mjs +41 -30
  25. package/kernel/lib/static-input.mjs +111 -0
  26. package/kernel/lib/syntax.mjs +242 -20
  27. package/kernel/lib/upgrade-contract.mjs +1 -0
  28. package/kernel/lib/upgrade-input.mjs +9 -4
  29. package/kernel/lib/upgrade-preview.mjs +11 -15
  30. package/kernel/lib/upgrade-worker.mjs +10 -1
  31. package/kernel/lib/verdict.mjs +28 -5
  32. package/kernel/lib/version.mjs +2 -34
  33. package/kernel/lib/worker-analysis.mjs +74 -3
  34. package/kernel/rules/README.md +30 -208
  35. package/kernel/rules/dsh-0.2.1-rc.3-review.json +63 -0
  36. package/kernel/rules/dsh-api-catalog-0.2.1-rc.3.json +743 -0
  37. package/kernel/rules/severity.json +77 -0
  38. package/lib/client.js +4668 -3423
  39. package/lib/finding-guidance.js +6 -0
  40. package/lib/local-request.js +44 -0
  41. package/lib/report-contract.js +16 -1
  42. package/lib/upgrade-contract.js +1 -0
  43. package/package.json +3 -2
  44. package/scripts/prepublish-check.mjs +1 -22
  45. package/scripts/stage-contract.mjs +7 -0
  46. 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 { existsSync, mkdirSync, readFileSync, writeFileSync, realpathSync, promises as fsPromises } from 'node:fs';
25
- import { homedir, tmpdir } from 'node:os';
26
- import path, { join } from 'node:path';
27
- import { fileURLToPath, pathToFileURL } from 'node:url';
28
- import { createRequire as __createRequire } from 'node:module';
29
- import { createHash } from 'node:crypto';
30
-
31
- import {loaderObservations,privateDiagnostic} from './lib/host-resources.js';
32
- import {assertEnvelope,failureEnvelope,catalogLines} from './lib/report-contract.js';
33
- import {nextSteps} from './lib/finding-guidance.js';
34
- import {createUpgradeFailure as upgradeFailure, SUPPORTED_PREVIEW_VERSIONS} from './lib/upgrade-contract.js';
35
-
36
- export const name = 'dsh-plugin-compat-check';
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: 'PENDING',
55
- 1: 'LOADING',
56
- 2: 'ACTIVE',
57
- 3: 'FAILED',
58
- 4: 'DISPOSED',
59
- 5: 'UNLOADING',
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, 'loader-introspect.json') : null;
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 !== 'string' || value.trim() === '') return fallback;
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 === 'string') return error;
113
+ if (typeof error === "string") return error;
105
114
  if (error instanceof Error) {
106
- const stack = typeof error.stack === 'string' ? 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 === '[object Object]' ? safeJson(error) : text;
119
+ return text === "[object Object]" ? safeJson(error) : text;
111
120
  } catch {
112
- return '<unprintable error>';
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 '<unserializable>';
129
+ return "<unserializable>";
121
130
  }
122
131
  }
123
132
 
124
133
  function describe(value) {
125
134
  try {
126
- if (value === undefined) return 'undefined';
127
- if (value === null) return 'null';
135
+ if (value === undefined) return "undefined";
136
+ if (value === null) return "null";
128
137
  return typeof value;
129
138
  } catch {
130
- return '<unreadable>';
139
+ return "<unreadable>";
131
140
  }
132
141
  }
133
142
 
134
- function writeReport(report) { return privateDiagnostic(OUT_JSON, 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) => value !== undefined && value !== null && typeof value.entries === 'function';
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('loader');
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('loader', false);
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 = 'ctx.loader';
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 !== 'function') {
227
- record.outcome = 'ctx.inject is not a function';
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(['loader'], (scoped) => {
245
+ const dispose = ctx.inject(["loader"], (scoped) => {
232
246
  try {
233
- record.outcome = 'fired (child fiber activated)';
247
+ record.outcome = "fired (child fiber activated)";
234
248
  record.firedAfterMs = Date.now() - startedAt;
235
- const value = scoped?.loader ?? scoped?.get?.('loader');
249
+ const value = scoped?.loader ?? scoped?.get?.("loader");
236
250
  record.scopedLoader = describe(value);
237
- record.scopedLoaderHasEntries = typeof value?.entries === 'function';
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 === 'function' ? dispose : null;
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 (promise === undefined || promise === null || typeof promise.then !== 'function') {
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(() => resolve({ ok: false, reason: null, timedOut: true }), timeoutMs);
281
- if (timer && typeof timer.unref === 'function') timer.unref();
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 === 'object') return Object.keys(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 && options !== null && typeof options === 'object' ? options : {};
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: 'NO_FIBER',
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('disabled expression threw');
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 = 'NO_FIBER';
382
- row.notes.push('no fiber (loader did not import this entry)');
383
- if (row.group) row.notes.push('options.group is true');
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 === 'number' ? code : null;
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 = 'UNREADABLE';
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(fiber, row.declaredInject);
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 !== 'ACTIVE' && row.fiberState !== 'DISPOSED') {
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 === 'PENDING') {
416
- row.notes.push('fiber.await() resolved without error (PENDING: waiting for services)');
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('entry is disabled');
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) return fail(`entries() returned ${describe(value)}`);
435
- if (Array.isArray(value)) return { entries: value, truncated: false, error: null };
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] === 'function';
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) return { entries: out, truncated: true, error: null };
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 { entries: out, truncated: false, error: `iterating entries threw: ${errText(error)}` };
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 === 'number') {
494
+ if (typeof value.length === "number") {
458
495
  const length = Math.min(value.length, MAX_ENTRIES);
459
- return { entries: Array.prototype.slice.call(value, 0, length), truncated: value.length > length, error: null };
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 = { ...state.loaderAccess, ...publicLoaderAccess(resolved) };
538
+ report.loaderAccess = {
539
+ ...state.loaderAccess,
540
+ ...publicLoaderAccess(resolved),
541
+ };
498
542
  }
499
543
 
500
544
  if (loader === undefined) {
501
- report.errors.push('loader service not reachable from this plugin context');
502
- log(`ERROR loader not reachable (${JSON.stringify(state.loaderAccess.attempts ?? [])})`);
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] === 'function',
562
+ iterable: typeof rawEntries?.[Symbol.iterator] === "function",
517
563
  truncated: walked.truncated,
518
564
  };
519
565
  entries = walked.entries;
520
- if (walked.error !== null) report.errors.push(`loader.entries(): ${walked.error}`);
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 = report.errors.length > 0 ? report.errors.join('; ') : 'unknown reason';
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: '<unreadable entry>',
542
- fiberState: 'UNREADABLE',
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] = (report.stateHistogram[row.fiberState] ?? 0) + 1;
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 === 'ACTIVE') return false;
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 ? '' : ' (WRITE FAILED)'}`,
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 ? ' (await timed out)' : ''),
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('\n')[0];
624
+ const line = String(text).split("\n")[0];
576
625
  return line.length > 300 ? `${line.slice(0, 300)}…` : line;
577
626
  } catch {
578
- return '<unprintable>';
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, 'kernel'), // npm 自足包形态(`npm pack` 时由 prepack 暂存到此)
667
+ path.join(here, "kernel"), // npm 自足包形态(`npm pack` 时由 prepack 暂存到此)
619
668
  here, // 另一种自足布局:内核与 index.js 同级
620
- path.resolve(here, '..'), // 仓库形态:plugin/ → 仓库根
669
+ path.resolve(here, ".."), // 仓库形态:plugin/ → 仓库根
621
670
  ].filter(Boolean);
622
671
  for (const c of candidates) {
623
672
  try {
624
- if (existsSync(path.join(c, 'lib', 'extract.mjs')) && existsSync(path.join(c, 'lib', 'verdict.mjs'))) {
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, 'probe.js'));
748
+ const req = __createRequire(join(dshRoot, "probe.js"));
695
749
  // 两个包实测都能解析;都试一遍是为了不依赖某一个包的存在
696
- for (const spec of ['@deepseek-ai/dsh-desktop-host/package.json', '@deepseek-ai/dsh/package.json']) {
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 === 'string' && 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(readFileSync(join(dir,'package.json'),'utf8'));
715
- if(['@deepseek-ai/dsh','@deepseek-ai/dsh-desktop-host','@deepseek-ai/dsh-app-boot'].includes(meta.name)&&typeof meta.version==='string'&&meta.version)return meta.version;
716
- }catch{}
717
- const parent=path.dirname(dir);if(parent===dir)break;dir=parent;
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) return { name: inline[1], why: `process.argv 的 --profile=${inline[1]}` };
757
- if (argv[i] === '--profile' && argv[i + 1] && !argv[i + 1].startsWith('-')) {
758
- return { name: argv[i + 1], why: `process.argv 的 --profile ${argv[i + 1]}` };
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('/')) continue;
849
+ if (!a.startsWith("/")) continue;
764
850
  const m = /\/profiles\/([^/]+)(?:\/|$)/.exec(a);
765
- if (m && m[1]) return { name: m[1], why: `process.argv 里的 profile 路径 ${a}` };
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) return { name: process.env.DSC_PROFILE, why: `DSC_PROFILE=${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 ['DSH_PROFILE', 'DSH_ACTIVE_PROFILE', 'DSH_PROFILE_NAME']) {
864
+ for (const key of ["DSH_PROFILE", "DSH_ACTIVE_PROFILE", "DSH_PROFILE_NAME"]) {
772
865
  const v = process.env[key];
773
- if (v) return { name: v, why: `${key}=${v}(环境变量可能来自启动 app 的那个 shell,未必是当前加载的 profile)` };
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(),'.dsh');
780
- const inferred=resolveActiveProfile();
781
- const selected=requestedProfile || inferred.name;
782
- if(selected){
783
- if(!/^[\w.-]+$/.test(selected) || selected==='.' || selected==='..')return {dir:null,why:'profile 名称非法'};
784
- const dir=join(dshHome,'profiles',selected,'node_modules');
785
- return existsSync(dir)?{dir,why:requestedProfile?'请求明确指定的 profile '+selected:inferred.why}:{dir:null,why:'指定 profile 不存在: '+selected};
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
- if(process.env.DSH_PROFILE_DIR){const dir=join(process.env.DSH_PROFILE_DIR,'node_modules');return existsSync(dir)?{dir,why:'显式 DSH_PROFILE_DIR'}:{dir:null,why:'DSH_PROFILE_DIR 不存在'};}
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)return failureEnvelope('Plugin generation disposed / 已卸载');
802
- const kernel=resolveKernelDir(),nm=resolveNodeModules(options.profile || null);
803
- if(!kernel)return failureEnvelope('找不到判定内核');
804
- if(!nm.dir){
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),version=resolveDshVersion();
810
- const context={nodeModules:canonical,observationProfile:path.resolve(nm.dir),kernel:await fsPromises.realpath(kernel),dshVersion:version,probe:process.env.DSC_PROBE_REPORT || null};
811
- if(!state.reportService){
812
- const [{ReportService},{reportIdentity},{catalogPathFor},{sampleServices}]=await Promise.all([
813
- import(pathToFileURL(join(kernel,'lib','report-service.mjs')).href),
814
- import(pathToFileURL(join(kernel,'lib','report-identity.mjs')).href),
815
- import(pathToFileURL(join(kernel,'lib','catalog.mjs')).href),
816
- import(pathToFileURL(join(kernel,'lib','observation.mjs')).href),
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)return failureEnvelope('Plugin generation disposed / 已卸载');
819
- if(!state.reportService)state.reportService=new ReportService(async (options,guard)=>{
820
- const out=await computeReport(state,ctx,{...options,_cacheGuard:guard});
821
- return out.ok?assertEnvelope(out):failureEnvelope(out.error,out);
822
- },{ttlMs:state.cacheTtlMs,watchMs:state.cacheWatchMs,identity:async options=>{
823
- const scope=options._cacheContext,current=resolveNodeModules(),currentVersion=resolveDshVersion();
824
- const active=current.dir?await fsPromises.realpath(current.dir):null;
825
- const requestedTarget=await fsPromises.realpath(options._nodeModules.dir);
826
- const identity=await reportIdentity({nodeModules:scope.nodeModules,context:{...scope,currentVersion,active,requestedTarget,probe:process.env.DSC_PROBE_REPORT || null},files:[join(scope.kernel,'rules','severity.json'),catalogPathFor(scope.kernel,currentVersion),process.env.DSC_PROBE_REPORT,join(path.dirname(options._nodeModules.dir),'compatibility.json')]});
827
- const sameHost=active===scope.nodeModules;
828
- const itemListing=sameHost?await (async()=>{try{const listing=await ctx.get?.('pluginManager',false)?.list?.();return (Array.isArray(listing)?listing:listing?.items||listing?.plugins||[]).map(i=>({id:i.id??i.entryId??i.slotId,packageName:i.packageName??i.package??i.name}));}catch{return [];}})():[];
829
- const loader=sameHost?loaderObservations(ctx,identity.manifests,itemListing):null;
830
- const observation=sameHost?sampleServices(ctx,[...identity.services,...(options._cacheInputs.services || [])],{profileId:scope.nodeModules,dshVersion:currentVersion}):null;
831
- // Sampling timestamps are evidence, not a changing service identity.
832
- return JSON.stringify([identity.digest,loader,observation?.root || null]);
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==='function'){
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 ['plugin-manager/changed','plugin-manager/install-state','loader/entry-init','loader/partial-dispose','loader/volatile-update']){
840
- try{const off=ctx.on(event,()=>state.reportService?.notify());if(typeof off==='function')state.cacheEventDisposers.push(off);}catch{}
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
- state.reportInputs ||= new Map();
845
- if(!state.reportInputs.has(key))state.reportInputs.set(key,{services:[]});
846
- const inputs=state.reportInputs.get(key);
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(key,{...options,_cacheContext:context,_cacheInputs:inputs,_nodeModules:{dir:nm.dir,why:nm.why}},force);
850
- if(state.disposed)return failureEnvelope('Plugin generation disposed / 已卸载');
851
- if (!out?.ok) return failureEnvelope(out?.error,out||{});
852
- return attachFindingReviews(state,assertEnvelope(out),key,kernel);
853
- }
854
-
855
- /**
856
- * 基线文件的位置。**必须与 CLI 的 `bin/compat-check.mjs` 推导出同一个路径** ——
857
- * 两份基线会立刻分叉,而用户在面板和命令行看到的"变化"就会是两件事。
858
- * 按 profile 分文件:不同 profile 的插件集合不同,混在一起会让差分变成噪音。
859
- */
860
- function baselineFileFor(nmDir) {
861
- const dshHome = process.env.DSH_HOME || join(homedir(), '.dsh');
862
- let key = 'unknown';
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)return failureEnvelope('Plugin generation 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: 'plugin-compat-check/report',
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(options._cacheContext && (options._cacheContext.dshVersion!==resolveDshVersion() || options._cacheContext.probe!==(process.env.DSC_PROBE_REPORT || null) || options._cacheContext.nodeModules!==(await fsPromises.realpath(options._nodeModules.dir)) || options._cacheContext.kernel!==(kernel && await fsPromises.realpath(kernel))))return failureEnvelope('报告上下文已变化,等待目标报告刷新');
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(pathToFileURL(join(kernel, 'lib', 'version.mjs')).href);
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 = '找不到判定内核(lib/extract.mjs + lib/verdict.mjs)。'
915
- + '插件形态下它们是同仓库的相邻目录;发布形态请设 DSC_KERNEL。';
1132
+ out.error =
1133
+ "找不到判定内核(lib/extract.mjs + lib/verdict.mjs)。" +
1134
+ "插件形态下它们是同仓库的相邻目录;发布形态请设 DSC_KERNEL。";
916
1135
  return out;
917
1136
  }
918
- const nm = options._nodeModules || resolveNodeModules(options.profile || null);
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(pathToFileURL(join(kernel, 'lib', 'worker-analysis.mjs')).href);
927
- const { buildVerdicts } = await import(pathToFileURL(join(kernel, 'lib', 'verdict.mjs')).href);
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(pathToFileURL(join(kernel,'lib','catalog.mjs')).href);
930
- const {file:catalogPath,catalog,evidence:catalogEvidence} = loadCatalog(kernel,resolveDshVersion());
931
- const rulesPath = join(kernel, 'rules', 'severity.json');
932
- const rulesBytes = readFileSync(rulesPath);
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={rulesSha256:createHash('sha256').update(rulesBytes).digest('hex'),catalogSha256:catalogEvidence.sha256};
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
- let surfaceWhy = '没有运行时面:结论中的方法级判定降为「疑似」';
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(readFileSync(probePath, 'utf8'));
1180
+ hostSurface = JSON.parse(
1181
+ readStaticFile(probePath, 8 * 1024 * 1024).toString("utf8"),
1182
+ );
944
1183
  surfaceWhy = `运行时面来自探针报告 ${probePath}`;
945
1184
  } catch (error) {
946
- surfaceWhy = `探针报告读不动(${errText(error)}),退回目录`;
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
- if(state.disposed)return failureEnvelope('Plugin generation disposed / 已卸载');
953
- if(options._cacheInputs)options._cacheInputs.services=extracted.report.services;
954
- const { sampleServices } = await import(pathToFileURL(join(kernel,'lib','observation.mjs')).href);
955
- const currentTarget=resolveNodeModules();
956
- const sameHost=currentTarget.dir&&(await fsPromises.realpath(currentTarget.dir))===(await fsPromises.realpath(nm.dir));
957
- if(!hostSurface && sameHost){
958
- hostSurface=sampleServices(ctx,extracted.report.services,{profileId:nm.dir,dshVersion:resolveDshVersion()});
959
- surfaceWhy='当前宿主进程内采集的服务结构;未采集或读取失败不等于缺失';
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()=>{try{const listing=await ctx.get?.('pluginManager',false)?.list?.();return (Array.isArray(listing)?listing:listing?.items||listing?.plugins||[]).map(i=>({id:i.id??i.entryId??i.slotId,packageName:i.packageName??i.package??i.name}));}catch{return [];}})();
962
- const loaderState=sameHost?loaderObservations(ctx,extracted.report.manifests,itemListing):{rows:[],unmapped:[],available:false};
963
- out.loaderCoverage=loaderState;
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(pathToFileURL(join(kernel,'lib','profile-compatibility.mjs')).href);
967
- const compatibility=readProfileCompatibility(nm.dir);
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, finalizeBaseline = null;
1255
+ let baseline = null,
1256
+ finalizeBaseline = null;
984
1257
  try {
985
1258
  const { snapshotForBaseline, diffAgainstBaseline, badgeFor } =
986
- await import(pathToFileURL(join(kernel, 'lib', 'baseline.mjs')).href);
987
- const store=await import(pathToFileURL(join(kernel,'lib','state-store.mjs')).href);
988
- const identity=store.baselineIdentity(nm.dir,verdict.observation.mode,rules,catalog);
989
- const snap = snapshotForBaseline(verdict, resolveDshVersion(),identity);
990
- const bfile = store.baselinePath(nm.dir,verdict.observation.mode);
991
- if(state.disposed)return failureEnvelope('Plugin generation disposed / 已卸载');
992
- finalizeBaseline=()=>store.baselineTransaction(bfile,snap,{advance:extracted.report.scanComplete});
993
- if(!options._cacheGuard)baseline=finalizeBaseline();
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 = { error: errText(error), note: '基线差分没算成(不影响本次结论)' };
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 = typeof ctx.get === 'function' ? ctx.get('pluginManager', false) : undefined;
1007
- const listing = pm && typeof pm.list === 'function' ? await pm.list() : null;
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) ? listing : (listing.items || listing.plugins || []);
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 !== 'object') return null;
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 (id && pkg) ? { id, packageName: String(pkg), label: it.label ?? null } : null;
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(pathToFileURL(join(kernel,'lib','report.mjs')).href);
1024
- const {renderIssueReport}=await import(pathToFileURL(join(kernel,'lib','issue-report.mjs')).href);
1025
- if(state.disposed)return failureEnvelope('Plugin generation disposed / 已卸载');
1026
- const canonical=toJSON({error:null,extract:extracted,verdict,entryStates:loaderState.rows,baseline,catalog:catalogEvidence,inputs:{nodeModules:nm.dir,hasRuntimeSurface:verdict.observation.usable,catalog:catalogPath,rules:rulesPath}});
1027
- if(options._cacheGuard && finalizeBaseline)options._cacheGuard.commit(()=>{
1028
- baseline=finalizeBaseline();
1029
- canonical.baseline=baseline;
1030
- out.baseline=baseline;
1031
- out.report.baseline=baseline;
1032
- out.issueReport=renderIssueReport(canonical);
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
- out.issueReport=renderIssueReport(canonical);
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 = {...canonical,hasRuntimeSurface:verdict.observation.usable,loaderCoverage:out.loaderCoverage,surfaceWhy,items:out.items||[],itemsError:out.itemsError||null,manifests:extracted.report.manifests,serviceCount:extracted.report.services.length,siteTotal:(extracted.report.callSites||[]).length};
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(`结论:可用 ${s.ok || 0} 部分可用 ${s.partial || 0} 不兼容 ${s.broken || 0}`
1087
- + (s.vacuousOk ? `(其中 ${s.vacuousOk} 个「可用」没有已验证调用,不等于业务通过)` : ''));
1088
- L.push(`工具版本:${compat.toolVersion || '未知'}`);
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(r.hasRuntimeSurface
1094
- ? '方法级判定依据:宿主运行时面;具体确定性仍以每条发现和覆盖限制为准。'
1095
- : '方法级判定依据:官方 API 目录(**契约目录,不是运行时面**)—— 因此方法级结论一律标「疑似」。'
1096
- + '「疑似」不是缺陷,是诚实:判不准时降级标注,而不是升格成确定。');
1097
- L.push('');
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((a, b) =>
1101
- (rank[r.plugins[a].verdict] - rank[r.plugins[b].verdict]) || a.localeCompare(b));
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 = { broken: '不兼容', partial: '部分可用', unknown: '未知', ok: '可用' }[p.verdict] || p.verdict;
1106
- L.push(`${label} ${name}@${p.version || '?'}${p.vacuous ? ' [无已验证调用]' : ''}`);
1107
- if(p.coverage){L.push(` 证据覆盖:${p.coverage.status==='complete'?'完整':'有限'};已验证调用 ${p.coverage.verifiedCalls}`);for(const note of p.coverage.limitations)L.push(` 覆盖限制:${note}`);}
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 ? (f.method ? `${f.service}.${f.method}` : f.service) : '(包声明)';
1110
- const loc = f.location ? `${f.location.file}:${f.location.line}` : '—';
1111
- const cat = {
1112
- 'host-service-missing': '服务缺失', 'host-method-missing': '方法缺失',
1113
- 'unprovided-dependency': '依赖服务待确认', 'declaration-mismatch': '声明与实测不符',
1114
- 'client-entry-missing': '客户端入口不可交付', 'host-entry-inactive': '宿主条目未激活',
1115
- 'declared-incompatible':'版本声明不支持',
1116
- }[f.category] || f.category;
1117
- const sev = { fatal: '致命', 'feature-dead': '功能失效', degraded: '降级' }[f.severity] || f.severity;
1118
- L.push(` ${sev} / ${cat}${f.certainty === 'certain' ? ' [确定]' : ' [疑似]'} ${api} @ ${loc}`);
1119
- if (f.impact) L.push(` 影响${f.impactBasis==='measured'?'(实测)':f.impactBasis==='inferred'?'(推断,非实测)':'(依据未提供)'}:${f.impact}`);
1120
- if(f.basis)L.push(` 依据:${f.basis}`);
1121
- for(const step of nextSteps(f))L.push(` 下一步:${step}`);
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) L.push(` 客户端半边(命令行测不到,需界面验证):${cs.map((c) => c.service).join('、')}`);
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 === 'no-calls').map((c) => c.service);
1127
- if (noCalls.length) L.push(` 无证据可判(只出现名字,没有调用点):${noCalls.join('、')}`);
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('确定(certain)表示当前证据足以支持该条发现,可来自明确静态事实或合格运行时观测;疑似(suspected)表示当前证据不足以确定该条发现。');
1132
- L.push('impactBasis=measured/inferred 分别表示影响有对应实测依据/影响由证据推断;确定发现不等于影响已实测。未观测不等于缺失。');
1133
- L.push('没有已验证调用仍可能存在服务依赖或客户端功能;调用核验和覆盖完整均不等于业务验收通过。');
1134
- L.push('下一步是核实建议;本工具不会自动安装、升级或修改配置与源码。');
1135
- L.push('读这个结果时注意:');
1136
- L.push(' 1) 「可用」不等于"功能验证过"。它只说明本轮未发现冲突;请同时看证据覆盖与未验证项。'
1137
- + '插件仍然可能因为自身逻辑、配置或第三方依赖而出问题。');
1138
- L.push(' 2) 客户端功能需在浏览器实际操作并核验结果;元素出现和没有报错均不等于业务成功。');
1139
- return L.join('\n');
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 = 'unknown';
1149
- try { toolVersion = JSON.parse(readFileSync(new URL('./package.json', import.meta.url), 'utf8')).version; } catch {}
1150
- return upgradeFailure(error, {toolVersion, currentVersion:resolveDshVersion(), targetVersion:options.targetVersion, ...options});
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(), targetVersion = options.targetVersion || null;
1155
- const failure = error => unavailableUpgrade(error,{currentVersion,targetVersion});
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('Plugin generation disposed / 已卸载');
1527
+ if (state.disposed) return failure("Plugin generation disposed / 已卸载");
1158
1528
  if (options.currentVersion && options.currentVersion !== currentVersion)
1159
- return failure('当前 DSH 版本已变化,请刷新普通报告后重新预检');
1529
+ return failure("当前 DSH 版本已变化,请刷新普通报告后重新预检");
1160
1530
  if (options.invalidRequest) return failure(options.invalidRequest);
1161
- if (!SUPPORTED_PREVIEW_VERSIONS.includes(currentVersion) || !SUPPORTED_PREVIEW_VERSIONS.includes(targetVersion))
1162
- return unavailableUpgrade('版本未认证:仅支持有明确 API 目录的精确版本 '+SUPPORTED_PREVIEW_VERSIONS.join('、'),{currentVersion,targetVersion,status:'unsupported-version'});
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(pathToFileURL(join(kernel, 'lib/upgrade-preview.mjs')).href);
1166
- if (state.disposed) return failure('Plugin generation disposed / 已卸载');
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('Plugin generation disposed / 已卸载');
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 = {key, controller, promise: engine.runUpgradePreview({nm:nm.dir, currentVersion, targetVersion},{signal:controller.signal})};
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([result.ok ? null : result.error, 'Plugin generation disposed / 已卸载'].filter(Boolean).join(';'));
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) {return failure(errText(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(pathToFileURL(join(kernel, 'lib/upgrade-preview.mjs')).href);
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),{targetVersion:options.targetVersion});
1199
- text = '升级预检无法比较:'+preview.error+';静态预测不等于目标版本业务通过。';
1592
+ preview = unavailableUpgrade(errText(error), {
1593
+ targetVersion: options.targetVersion,
1594
+ });
1595
+ text =
1596
+ "升级预检无法比较:" +
1597
+ terminalText(preview.error) +
1598
+ ";静态预测不等于目标版本业务通过。";
1200
1599
  }
1201
- return {schemaVersion:1,kind:'plugin-compat-check/tool-result',ok:preview.ok,error:preview.error,exitCode:preview.exitCode,toolVersion:preview.toolVersion,
1202
- report:null,baseline:null,cache:null,nodeModules:'',hasRuntimeSurface:false,
1203
- summary:preview.ok?`静态升级预检:新增 ${preview.summary.added} / 变重 ${preview.summary.worsened};不等于目标版本业务通过`:'升级预检无法比较',
1204
- text,upgradePreview:preview};
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 !== 'function') return;
1209
- ctx.inject(['webServer'], scope => {
1621
+ if (typeof ctx.inject !== "function") return;
1622
+ ctx.inject(["webServer"], (scope) => {
1210
1623
  try {
1211
- return scope.webServer.register({name:'dsh-plugin-compat-check/upgrade-preview',kind:'exact',path:'/plugin-compat-check/upgrade-preview',handler:async(req,res)=>{
1212
- const send=(status,body)=>{res.writeHead(status,{'content-type':'application/json; charset=utf-8','cache-control':'no-store'});res.end(JSON.stringify(body));};
1213
- if (!isTrustedRequest(req)) return send(403,{error:'refused: untrusted request'});
1214
- if (req.method !== 'GET') return send(405,{error:'升级预检仅支持 GET'});
1215
- try {
1216
- const url=new URL(req.url||'/', 'http://127.0.0.1');
1217
- const invalid=(req.url||'').length>2048 || [...url.searchParams.keys()].some(k=>!['targetVersion','currentVersion'].includes(k));
1218
- const out=await serveUpgradePreview(state,{targetVersion:url.searchParams.get('targetVersion'),currentVersion:url.searchParams.get('currentVersion'),invalidRequest:invalid?'升级预检请求参数无效':null});
1219
- send(200,out);
1220
- } catch (error) {send(500,{error:'升级预检无法完成,请在本机核对插件与宿主版本'});}
1221
- }});
1222
- } catch (error) {log('Upgrade preview route registration failed: '+errText(error));}
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(pathToFileURL(join(kernel,'lib','finding-review-store.mjs')).href);
1230
- const inputIdentity=state.reportService.identityForReport(key,out.report);
1231
- if(typeof inputIdentity!=='string')throw new Error('当前报告缺少可绑定的分析输入身份');
1232
- const sourceIdentity=createHash('sha256').update(inputIdentity).digest('hex');
1233
- const context=await store.reviewContext(out.report,{nodeModules:out.nodeModules,sourceIdentity,rulesIdentity:out.analysisIdentity?.rulesSha256,catalogIdentity:out.report.catalog?.sha256,toolVersion:out.toolVersion});
1234
- const reviews=await store.readReviews(context);
1235
- if(state.disposed)return failureEnvelope('Plugin generation disposed / 已卸载',out);
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,{context,key,report:out.report,nodeModules:out.nodeModules,store});
1238
- while(state.reviewTargets.size>32)state.reviewTargets.delete(state.reviewTargets.keys().next().value);
1239
- return {...out,reviews};
1240
- } catch(error) {
1241
- return {...out,reviews:{schemaVersion:1,contextToken:null,revision:null,items:[],history:[],error:'人工处置记录不可用:'+errText(error)}};
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,chunks=[];let bytes=0,settled=false;
1248
- const finish=(error,value)=>{
1249
- if(settled)return;settled=true;clearTimeout(timer);
1250
- req.removeListener('data',data);req.removeListener('end',end);req.removeListener('error',errorEvent);req.removeListener('aborted',abort);
1251
- signal.removeEventListener('abort',abort);
1252
- if(error){req.pause?.();reject(error);}else resolve(value);
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 invalid=(message,status)=>Object.assign(new Error(message),{status});
1255
- const data=chunk=>{const buffer=Buffer.from(chunk);bytes+=buffer.length;if(bytes>maxBytes)return finish(invalid('人工处置请求超过大小限制',413));chunks.push(buffer);};
1256
- const end=()=>{try{finish(null,JSON.parse(new TextDecoder('utf-8',{fatal:true}).decode(Buffer.concat(chunks))));}catch{finish(invalid('人工处置请求不是有效JSON',400));}};
1257
- const errorEvent=()=>finish(invalid('人工处置请求读取失败',400));
1258
- const abort=()=>finish(invalid('人工处置请求已取消或插件已卸载',409));
1259
- const timer=setTimeout(()=>finish(invalid('人工处置请求读取超时',408)),5000);timer.unref?.();
1260
- req.on('data',data);req.once('end',end);req.once('error',errorEvent);req.once('aborted',abort);signal.addEventListener('abort',abort,{once:true});
1261
- if(signal.aborted)abort();
1262
- const length=req.headers?.['content-length'];
1263
- if(length!==undefined&&(!/^\d+$/.test(String(length))||Number(length)>maxBytes))finish(invalid('人工处置请求超过大小限制',413));
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!=='function')return;
1268
- ctx.inject(['webServer'],scope=>{
1269
- try{return scope.webServer.register({name:'dsh-plugin-compat-check/reviews',kind:'exact',path:'/plugin-compat-check/reviews',handler:async(req,res)=>{
1270
- const send=(status,body)=>{res.writeHead(status,{'content-type':'application/json; charset=utf-8','cache-control':'no-store'});res.end(JSON.stringify(body));};
1271
- if(!isTrustedRequest(req))return send(403,{ok:false,error:'refused: untrusted request'});
1272
- if(req.method!=='POST')return send(405,{ok:false,error:'人工处置仅支持POST'});
1273
- if(!/^application\/json(?:\s*;|$)/i.test(req.headers?.['content-type']||''))return send(415,{ok:false,error:'人工处置需要application/json'});
1274
- if(state.disposed)return send(409,{ok:false,error:'Plugin generation disposed / 已卸载'});
1275
- const controller=new AbortController();state.reviewRequests ||= new Set();state.reviewRequests.add(controller);
1276
- try {
1277
- const body=await readReviewRequest(req,controller.signal);
1278
- const target=state.reviewTargets?.get(body?.contextToken);
1279
- if(!target) return send(409,{ok:false,error:'报告已变化,请重新检查后核实此发现'});
1280
- const canCommit=async()=>{
1281
- if(state.disposed||controller.signal.aborted)return false;
1282
- const current=resolveNodeModules();
1283
- if(!current.dir||path.resolve(current.dir)!==path.resolve(target.nodeModules))return false;
1284
- return state.reportService.isCurrent(target.key,target.report);
1285
- };
1286
- if(!await canCommit())return send(409,{ok:false,error:'体检输入已变化,请重新检查后核实此发现'});
1287
- const reviews=await target.store.saveReview(target.context,body,{canCommit});
1288
- send(200,{ok:true,reviews});
1289
- } catch(error) {
1290
- const status=error.status||(error.code==='REVIEW_STALE'||error.code==='REVIEW_BUSY'?409:error.code==='REVIEW_STORAGE'?422:400);
1291
- send(status,{ok:false,error:errText(error)});
1292
- } finally {state.reviewRequests.delete(controller);}
1293
- }});}catch(error){log('Review route registration failed: '+errText(error));}
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 = ['tools', 'agents'];
1874
+ const TOOL_SERVICES = ["tools", "agents"];
1298
1875
 
1299
- function toolResult(compat,text){
1300
- const s=compat.report?.summary||{};
1301
- return {schemaVersion:1,kind:'plugin-compat-check/tool-result',ok:compat.ok,error:compat.error,toolVersion:compat.toolVersion,exitCode:compat.exitCode,report:compat.report,baseline:compat.baseline,cache:compat.cache||null,reviews:compat.reviews||null,
1302
- summary:compat.ok?`可用 ${s.ok||0} / 部分可用 ${s.partial||0} / 不兼容 ${s.broken||0};覆盖有限 ${s.limited||0};无已验证调用 ${s.vacuousOk||0}`:'无法判定',nodeModules:compat.nodeModules||'',hasRuntimeSurface:!!compat.report?.hasRuntimeSurface,text};
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 !== 'function') return;
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 !== 'function') {
1312
- log('D1 tool skipped: scoped ctx has no tools.register');
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: 'compat_check',
1909
+ name: "compat_check",
1317
1910
  description:
1318
- 'Assess compatibility risks for the DSH plugins installed in this profile against the '
1319
- + 'current DSH version, reporting evidence, coverage limits and file:line locations where available. '
1320
- + 'Use it when the user asks whether a plugin still works, why a feature disappeared after '
1321
- + 'updating DSH, or what compatibility evidence is available for an installed plugin. It reads the installed '
1322
- + 'profile; it does not install anything or check a plugin that is not yet installed. '
1323
- + 'Certainty is separate from impact evidence: certain means supported by explicit static facts or qualified runtime observations; '
1324
- + 'suspected means insufficient evidence to establish that finding, even when some runtime observations exist. '
1325
- + 'impactBasis=measured requires corresponding impact/state observations; inferred means an inferred impact, not a measured business outcome. '
1326
- + 'vacuous means no verified calls in an otherwise ok result; service dependencies or unverified client features may still exist. '
1327
- + 'Preserve coverage limits: unobserved does not mean absent, and no findings or verified calls do not certify business behavior. '
1328
- + 'The official API catalogue is a contract catalogue, not a runtime observation. '
1329
- + '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.',
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: 'object',
1924
+ type: "object",
1332
1925
  additionalProperties: false,
1333
1926
  properties: {
1334
- upgradeTo: {type:'string',description:'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.'},
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: 'string',
1933
+ type: "string",
1337
1934
  description:
1338
- 'Optional profile name to inspect (e.g. "desktop"). Defaults to the profile this '
1339
- + 'DSH instance is running, or desktop if it cannot be determined.',
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: 'object',
1947
+ type: "object",
1351
1948
  additionalProperties: false,
1352
1949
  properties: {
1353
- schemaVersion: {type:'integer',enum:[1]},
1354
- kind: {type:'string'},
1355
- ok: {type:'boolean'},
1356
- error: {oneOf:[{type:'string'},{type:'null'}]},
1357
- exitCode: {type:'integer',enum:[0,1,2,3]},
1358
- toolVersion: {oneOf:[{type:'string'},{type:'null'}]},
1359
- report: {oneOf:[{type:'object',additionalProperties:true},{type:'null'}]},
1360
- upgradePreview: {type:'object',additionalProperties:true},
1361
- baseline: {oneOf:[{type:'object',additionalProperties:true},{type:'null'}]},
1362
- cache: {oneOf:[{type:'object',additionalProperties:true},{type:'null'}]},
1363
- reviews: {oneOf:[{type:'object',additionalProperties:true},{type:'null'}]},
1364
- summary: { type: 'string' },
1365
- nodeModules: { type: 'string' },
1366
- hasRuntimeSurface: { type: 'boolean' },
1367
- text: { type: 'string' },
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: ['schemaVersion','kind','ok','error','exitCode','toolVersion','report','baseline','summary','nodeModules','hasRuntimeSurface','text'],
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) => [{ type: 'text', text: value && typeof value.text === 'string' ? value.text : '' }],
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: 'generic',
1376
- title: 'compat_check',
1377
- kind: 'read',
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 = args && typeof args.profile === 'string' && args.profile.trim() !== ''
1383
- ? args.profile.trim()
1384
- : null;
1385
- if (args && Object.prototype.hasOwnProperty.call(args,'upgradeTo')) {
1386
- return await upgradeToolResult(state,{profile:wanted,targetVersion:args.upgradeTo,invalidRequest:typeof args.upgradeTo !== 'string'?'upgradeTo 必须为确切 DSH 版本':null});
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(state,ctx,false,wanted?{profile:wanted}:{});
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(failureEnvelope(errText(error)),`体检没跑成(这是工具的问题,不是插件的结论):${errText(error)}`);
2047
+ return toolResult(
2048
+ failureEnvelope(errText(error)),
2049
+ `体检没跑成(这是工具的问题,不是插件的结论):${errText(error)}`,
2050
+ );
1395
2051
  }
1396
2052
  },
1397
2053
  });
1398
- log('D1 tool registered: compat_check');
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 = 'dsh-plugin-compat-check';
1425
- const SKILL_PROVIDER = 'dsh-plugin-compat-check';
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, 'skill', 'SKILL.md'), // **安装形态**:包内自带(files 里有 skill/)
1453
- join(here, '..', 'skill', 'SKILL.md'), // 仓库形态:<repo>/plugin/index.js → <repo>/skill/SKILL.md
1454
- join(here, 'SKILL.md'),
1455
- join(here, 'assets', 'SKILL.md'),
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, 'utf8');
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]*(.+)$`, 'm').exec(fm[1]);
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('name');
1470
- const description = field('description');
2125
+ const skillName = field("name");
2126
+ const description = field("description");
1471
2127
  if (skillName === null || description === null) continue;
1472
- return { file, name: skillName, description, content: raw.slice(fm[0].length).trim() };
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 !== 'function') {
1483
- log('SKILL registration skipped: ctx.inject is not a function');
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(['skills'], (skillCtx) => {
2147
+ ctx.inject(["skills"], (skillCtx) => {
1487
2148
  try {
1488
- if (!skillCtx.skills || typeof skillCtx.skills.registerProvider !== 'function') {
1489
- log('SKILL registration skipped: scoped ctx has no skills.registerProvider');
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('SKILL registration skipped: 找不到随包的 skill/SKILL.md');
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: 'bundled',
2170
+ source: "bundled",
1505
2171
  rank: SKILL_RANK,
1506
2172
  locator: skill.file,
1507
2173
  path: skill.file,
1508
- resourceBase: { kind: 'directory', path: path.dirname(skill.file) },
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: () => Promise.resolve({
1514
- name: SKILL_NAME,
1515
- description: skill.description,
1516
- invocation,
1517
- provider: SKILL_PROVIDER,
1518
- source: summary.source,
1519
- path: skill.file,
1520
- content: skill.content,
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: 'not run' },
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(5000,toPositiveInt(process.env.DSC_CACHE_WATCH_MS,5000)),
2225
+ cacheWatchMs: Math.min(
2226
+ 5000,
2227
+ toPositiveInt(process.env.DSC_CACHE_WATCH_MS, 5000),
2228
+ ),
1559
2229
  /** 启动后多久自动跑第一次(默认落在最后一次 loader 扫描之后) */
1560
- autoRunDelayMs: toPositiveInt(process.env.DSC_AUTORUN_MS, (SCAN_DELAYS[SCAN_DELAYS.length - 1] || 4000) + 2000),
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 = { ...state.loaderAccess, ...publicLoaderAccess(resolved) };
2243
+ state.loaderAccess = {
2244
+ ...state.loaderAccess,
2245
+ ...publicLoaderAccess(resolved),
2246
+ };
1571
2247
  log(
1572
- `loader access: via=${resolved.via ?? 'UNREACHABLE'} attempts=${JSON.stringify(resolved.attempts)}`,
2248
+ `loader access: via=${resolved.via ?? "UNREACHABLE"} attempts=${JSON.stringify(resolved.attempts)}`,
1573
2249
  );
1574
2250
  } catch (error) {
1575
- state.loaderAccess.attempts.push(`resolveLoader threw: ${errText(error)}`);
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(ctx, state.loaderAccess.injectProbe);
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 === 'function') {
1594
- ctx.inject(['webServer'], (scope) => {
2274
+ if (typeof ctx.inject === "function") {
2275
+ ctx.inject(["webServer"], (scope) => {
1595
2276
  try {
1596
2277
  const dispose = scope.webServer.register({
1597
- name: 'dsh-plugin-compat-check',
1598
- kind: 'exact',
1599
- path: '/plugin-compat-check/report',
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, { 'content-type': 'application/json; charset=utf-8' });
1605
- res.end(JSON.stringify({ error: 'refused: untrusted request' }));
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 || '/', 'http://127.0.0.1');
1610
- const force = url.searchParams.get('refresh') === '1';
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
- 'content-type': 'application/json; charset=utf-8',
1614
- 'cache-control': 'no-store',
2306
+ "content-type": "application/json; charset=utf-8",
2307
+ "cache-control": "no-store",
1615
2308
  });
1616
- res.end(JSON.stringify(url.searchParams.get('metadata')==='1' && out.ok ? {
1617
- kind:'plugin-compat-check/report-metadata',ok:true,computedAt:out.computedAt,nodeModules:out.nodeModules,dshVersion:out.report.dshVersion,cache:out.cache || null,reviewsRevision:out.reviews?.revision || null,reviewsError:out.reviews?.error || null,reviewsContextToken:out.reviews?.contextToken || null,
1618
- } : out));
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, { 'content-type': 'application/json; charset=utf-8' });
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('C2 route registered: GET /plugin-compat-check/report');
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('C2 route skipped: ctx.inject is not a function');
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
- registerSkill(ctx);
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 (probe.registered && probe.firedAfterMs === undefined && probe.outcome === 'not run') {
1658
- probe.outcome = 'did not fire before the final scan (would have stayed PENDING)';
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 ? ` firedAfterMs=${probe.firedAfterMs}` : ''
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 === 'function') 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 === 'function') {
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) => log(`ERROR scan#${pass} failed: ${errText(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 === 'function') 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.lastReport = out;
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(`F2 启动自检: 可用 ${sum.ok || 0} 部分可用 ${sum.partial || 0} 不兼容 ${sum.broken || 0}`
1742
- + (sum.vacuousOk ? ` (${sum.vacuousOk} 个为空洞的可用)` : ''));
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) log(`F2 与上次相比: ${badge.text} —— ${badge.detail || ''}`);
1746
- else log(`F2 与上次相比: ${badge.detail || b.note || '没有可比基线'}`);
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(() => { try { state.runAutoCheck(); } catch { /* 忽略 */ } }, state.autoRunDelayMs);
1762
- if (timer && typeof timer.unref === 'function') timer.unref();
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==='function')ctx.effect(()=>()=>{
1776
- state.disposed=true;
1777
- for (const request of state.reviewRequests || []) request.abort();
1778
- state.reviewTargets?.clear();
1779
- state.reportService?.dispose();
1780
- for(const off of state.cacheEventDisposers || []){try{off();}catch{}}
1781
- state.cacheEventDisposers=[];
1782
- state.upgradeFlight?.controller?.abort();
1783
- for(const timer of state.scanTimers)clearTimeout(timer);
1784
- state.scanTimers.clear();
1785
- clearTimeout(state.fallbackTimer);
1786
- state.disposeInjectProbe?.();state.disposeInjectProbe=null;
1787
- if(globalThis.__DSH_COMPAT_CHECK__===handle)delete globalThis.__DSH_COMPAT_CHECK__;
1788
- },'compat-check: generation cleanup');
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
  }