dsh-plugin-compat-check 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +78 -2
- package/cordis.patch.yml +3 -0
- package/index.js +1584 -0
- package/kernel/lib/baseline.mjs +233 -0
- package/kernel/lib/extract.mjs +1643 -0
- package/kernel/lib/verdict.mjs +620 -0
- package/kernel/lib/version.mjs +37 -0
- package/kernel/rules/README.md +232 -0
- package/kernel/rules/dsh-api-catalog.json +689 -0
- package/kernel/rules/severity.json +1056 -0
- package/kernel/rules/usage-inventory.json +144 -0
- package/lib/client.js +562 -0
- package/package.json +55 -4
- package/scripts/prepublish-check.mjs +84 -0
- package/scripts/stage-kernel.mjs +68 -0
- package/scripts/stage-skill.mjs +53 -0
- package/scripts/unstage-kernel.mjs +21 -0
- package/skill/SKILL.md +149 -0
package/index.js
ADDED
|
@@ -0,0 +1,1584 @@
|
|
|
1
|
+
// Host half of the dsh-plugin-compat-check plugin — loader self-introspection
|
|
2
|
+
// (Plan.md §5 B1/B2/B3: read ctx.loader.entries(), recover failure reasons,
|
|
3
|
+
// never throw).
|
|
4
|
+
//
|
|
5
|
+
// HARD RULE (Plan.md §4 第 6 条:绝不抛、绝不拖累宿主): this plugin must never
|
|
6
|
+
// throw and must never keep the host from booting. A health-check plugin that
|
|
7
|
+
// breaks host startup becomes an instance of the very class of problem it is
|
|
8
|
+
// meant to detect. Every host object in here is touched behind try/catch, and
|
|
9
|
+
// the one call that can hang (`fiber.await()`) is raced against a timeout.
|
|
10
|
+
//
|
|
11
|
+
// Why `inject` stays empty: `ctx.loader` is a Cordis service, and the context
|
|
12
|
+
// proxy resolves it by walking the fiber chain — declaring it in `inject` is
|
|
13
|
+
// not required to read it. Declaring a service we do not strictly need is the
|
|
14
|
+
// exact way to park this plugin's own fiber in PENDING (Plan.md §6 地雷清单).
|
|
15
|
+
// `resolveLoader()` below therefore tries the inject-free lookup first, and the
|
|
16
|
+
// plugin additionally runs a one-shot child-fiber probe (`ctx.inject`) to
|
|
17
|
+
// record, empirically, whether `inject: ["loader"]` *would* have been safe —
|
|
18
|
+
// without risking its own activation.
|
|
19
|
+
//
|
|
20
|
+
// Output (the verifiable form required before any UI exists):
|
|
21
|
+
// ${DSC_OUT_DIR:-$TMPDIR/dsh-plugin-compat-check}/loader-introspect.json
|
|
22
|
+
// plus structured console lines prefixed with [dsh-plugin-compat-check].
|
|
23
|
+
|
|
24
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } 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
|
+
|
|
30
|
+
export const name = 'dsh-plugin-compat-check';
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Deliberately empty: every service this plugin reads is read through
|
|
34
|
+
* `ctx.get(...)`, which returns `undefined` instead of throwing when a service
|
|
35
|
+
* is absent. See the header comment.
|
|
36
|
+
*
|
|
37
|
+
* Measured 2026-10-10 on DSH 0.2.0-rc.2: `inject: ["loader"]` ALSO activates
|
|
38
|
+
* (the fiber ends up ACTIVE with `loader` present), so this is the safe choice
|
|
39
|
+
* rather than the only workable one — declaring nothing keeps the health-check
|
|
40
|
+
* plugin independent of the very service it audits.
|
|
41
|
+
*/
|
|
42
|
+
export const inject = [];
|
|
43
|
+
|
|
44
|
+
const SCHEMA_VERSION = 1;
|
|
45
|
+
|
|
46
|
+
/** Cordis FiberState (const enum in the host, so mirrored here as data). */
|
|
47
|
+
const FIBER_STATES = {
|
|
48
|
+
0: 'PENDING',
|
|
49
|
+
1: 'LOADING',
|
|
50
|
+
2: 'ACTIVE',
|
|
51
|
+
3: 'FAILED',
|
|
52
|
+
4: 'DISPOSED',
|
|
53
|
+
5: 'UNLOADING',
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const OUT_DIR = process.env.DSC_OUT_DIR || join(tmpdir(), 'dsh-plugin-compat-check');
|
|
57
|
+
const OUT_JSON = join(OUT_DIR, 'loader-introspect.json');
|
|
58
|
+
|
|
59
|
+
/** Defensive cap: a pathological iterable must not exhaust the host's heap. */
|
|
60
|
+
const MAX_ENTRIES = 20000;
|
|
61
|
+
|
|
62
|
+
/** `fiber.await()` may never settle for a fiber stuck mid-load: always race it. */
|
|
63
|
+
const AWAIT_TIMEOUT_MS = toPositiveInt(process.env.DSC_AWAIT_TIMEOUT_MS, 1500);
|
|
64
|
+
/** Extra passes catch late transitions (entries import asynchronously). */
|
|
65
|
+
const SCAN_DELAYS = parseDelays(process.env.DSC_SCAN_DELAYS, [4000, 9000]);
|
|
66
|
+
|
|
67
|
+
function toPositiveInt(value, fallback) {
|
|
68
|
+
const n = Number(value);
|
|
69
|
+
return Number.isFinite(n) && n > 0 ? Math.trunc(n) : fallback;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function parseDelays(value, fallback) {
|
|
73
|
+
if (typeof value !== 'string' || value.trim() === '') return fallback;
|
|
74
|
+
const parsed = value
|
|
75
|
+
.split(',')
|
|
76
|
+
.map((part) => toPositiveInt(part.trim(), 0))
|
|
77
|
+
.filter((n) => n > 0);
|
|
78
|
+
return parsed.length > 0 ? parsed : fallback;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** `createRequire` 的小包装:同步可用,不要用 await import(apply 不是 async)。 */
|
|
82
|
+
function reqNode() {
|
|
83
|
+
return { createRequire: __createRequire };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function log(message) {
|
|
87
|
+
try {
|
|
88
|
+
console.log(`[dsh-plugin-compat-check] ${message}`);
|
|
89
|
+
} catch {
|
|
90
|
+
/* 打日志失败也绝不能影响宿主 */
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Never let an unknown throw escape as itself. */
|
|
95
|
+
function errText(error) {
|
|
96
|
+
try {
|
|
97
|
+
if (error === null || error === undefined) return String(error);
|
|
98
|
+
if (typeof error === 'string') return error;
|
|
99
|
+
if (error instanceof Error) {
|
|
100
|
+
const stack = typeof error.stack === 'string' ? error.stack : '';
|
|
101
|
+
return stack || `${error.name}: ${error.message}`;
|
|
102
|
+
}
|
|
103
|
+
const text = String(error);
|
|
104
|
+
return text === '[object Object]' ? safeJson(error) : text;
|
|
105
|
+
} catch {
|
|
106
|
+
return '<unprintable error>';
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function safeJson(value) {
|
|
111
|
+
try {
|
|
112
|
+
return JSON.stringify(value);
|
|
113
|
+
} catch {
|
|
114
|
+
return '<unserializable>';
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function describe(value) {
|
|
119
|
+
try {
|
|
120
|
+
if (value === undefined) return 'undefined';
|
|
121
|
+
if (value === null) return 'null';
|
|
122
|
+
return typeof value;
|
|
123
|
+
} catch {
|
|
124
|
+
return '<unreadable>';
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function writeReport(report) {
|
|
129
|
+
try {
|
|
130
|
+
mkdirSync(OUT_DIR, { recursive: true });
|
|
131
|
+
} catch {
|
|
132
|
+
/* 目录已存在或不可写 */
|
|
133
|
+
}
|
|
134
|
+
let written = false;
|
|
135
|
+
try {
|
|
136
|
+
writeFileSync(OUT_JSON, `${JSON.stringify(report, null, 2)}\n`);
|
|
137
|
+
written = true;
|
|
138
|
+
} catch (error) {
|
|
139
|
+
log(`WARN could not write ${OUT_JSON}: ${errText(error)}`);
|
|
140
|
+
}
|
|
141
|
+
return written;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// ---------------------------------------------------------------------------
|
|
145
|
+
// B1: loader access
|
|
146
|
+
// ---------------------------------------------------------------------------
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Obtain the loader without declaring `inject: ["loader"]`.
|
|
150
|
+
*
|
|
151
|
+
* Cordis resolves service reads by walking the fiber chain, so
|
|
152
|
+
* `ctx.get("loader")` works from a plugin fiber; `ctx.loader` is the same
|
|
153
|
+
* lookup through the proxy trap. Both are tried and the route that worked is
|
|
154
|
+
* recorded, so the answer is evidence rather than assumption.
|
|
155
|
+
*
|
|
156
|
+
* The returned object is JSON-safe: the live loader instance is never included.
|
|
157
|
+
*/
|
|
158
|
+
function resolveLoader(ctx) {
|
|
159
|
+
const attempts = [];
|
|
160
|
+
let loader;
|
|
161
|
+
let via;
|
|
162
|
+
|
|
163
|
+
const looksLikeLoader = (value) => value !== undefined && value !== null && typeof value.entries === 'function';
|
|
164
|
+
|
|
165
|
+
try {
|
|
166
|
+
const value = ctx.get('loader');
|
|
167
|
+
attempts.push(`ctx.get('loader') -> ${describe(value)}`);
|
|
168
|
+
if (looksLikeLoader(value)) {
|
|
169
|
+
loader = value;
|
|
170
|
+
via = "ctx.get('loader')";
|
|
171
|
+
}
|
|
172
|
+
} catch (error) {
|
|
173
|
+
attempts.push(`ctx.get('loader') threw: ${errText(error)}`);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (loader === undefined) {
|
|
177
|
+
try {
|
|
178
|
+
const value = ctx.get('loader', false);
|
|
179
|
+
attempts.push(`ctx.get('loader', false) -> ${describe(value)}`);
|
|
180
|
+
if (looksLikeLoader(value)) {
|
|
181
|
+
loader = value;
|
|
182
|
+
via = "ctx.get('loader', false)";
|
|
183
|
+
}
|
|
184
|
+
} catch (error) {
|
|
185
|
+
attempts.push(`ctx.get('loader', false) threw: ${errText(error)}`);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
let direct;
|
|
190
|
+
let directError = null;
|
|
191
|
+
try {
|
|
192
|
+
direct = ctx.loader;
|
|
193
|
+
attempts.push(`ctx.loader -> ${describe(direct)}`);
|
|
194
|
+
if (loader === undefined && looksLikeLoader(direct)) {
|
|
195
|
+
loader = direct;
|
|
196
|
+
via = 'ctx.loader';
|
|
197
|
+
}
|
|
198
|
+
} catch (error) {
|
|
199
|
+
directError = errText(error);
|
|
200
|
+
attempts.push(`ctx.loader threw: ${errText(error)}`);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
return {
|
|
204
|
+
loader,
|
|
205
|
+
via: via ?? null,
|
|
206
|
+
attempts,
|
|
207
|
+
directPropertyReadable: directError === null && looksLikeLoader(direct),
|
|
208
|
+
directSameInstance: loader !== undefined && direct === loader,
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** Drop the live loader instance so the access record stays JSON-safe. */
|
|
213
|
+
function publicLoaderAccess(resolved) {
|
|
214
|
+
return {
|
|
215
|
+
via: resolved.via ?? null,
|
|
216
|
+
attempts: resolved.attempts ?? [],
|
|
217
|
+
directPropertyReadable: resolved.directPropertyReadable === true,
|
|
218
|
+
directSameInstance: resolved.directSameInstance === true,
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* One-shot, side-effect-free probe: would `inject: ["loader"]` have kept this
|
|
224
|
+
* plugin PENDING? `ctx.inject` spawns a *child* fiber, so a loader that cannot
|
|
225
|
+
* be resolved leaves that child PENDING while this plugin stays ACTIVE — the
|
|
226
|
+
* exact question, answered without risking our own activation.
|
|
227
|
+
*
|
|
228
|
+
* Returns a disposer for the child fiber (or null).
|
|
229
|
+
*/
|
|
230
|
+
function startInjectProbe(ctx, record) {
|
|
231
|
+
const startedAt = Date.now();
|
|
232
|
+
record.startedAt = new Date(startedAt).toISOString();
|
|
233
|
+
try {
|
|
234
|
+
if (typeof ctx.inject !== 'function') {
|
|
235
|
+
record.outcome = 'ctx.inject is not a function';
|
|
236
|
+
return null;
|
|
237
|
+
}
|
|
238
|
+
record.registered = true;
|
|
239
|
+
const dispose = ctx.inject(['loader'], (scoped) => {
|
|
240
|
+
try {
|
|
241
|
+
record.outcome = 'fired (child fiber activated)';
|
|
242
|
+
record.firedAfterMs = Date.now() - startedAt;
|
|
243
|
+
const value = scoped?.loader ?? scoped?.get?.('loader');
|
|
244
|
+
record.scopedLoader = describe(value);
|
|
245
|
+
record.scopedLoaderHasEntries = typeof value?.entries === 'function';
|
|
246
|
+
} catch (error) {
|
|
247
|
+
record.outcome = `callback threw: ${errText(error)}`;
|
|
248
|
+
}
|
|
249
|
+
});
|
|
250
|
+
return typeof dispose === 'function' ? dispose : null;
|
|
251
|
+
} catch (error) {
|
|
252
|
+
record.outcome = `ctx.inject threw: ${errText(error)}`;
|
|
253
|
+
return null;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// ---------------------------------------------------------------------------
|
|
258
|
+
// B2: failure reasons
|
|
259
|
+
// ---------------------------------------------------------------------------
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* `await fiber.await()` without ever being able to hang the host.
|
|
263
|
+
*
|
|
264
|
+
* Resolves to `{ ok, reason, timedOut }`. The underlying promise gets a
|
|
265
|
+
* rejection handler attached immediately so a late rejection after the timeout
|
|
266
|
+
* cannot surface as an unhandled rejection (which Node would turn into a fatal
|
|
267
|
+
* error — the opposite of "绝不抛").
|
|
268
|
+
*/
|
|
269
|
+
async function awaitFiber(fiber, timeoutMs) {
|
|
270
|
+
let promise;
|
|
271
|
+
try {
|
|
272
|
+
promise = fiber.await();
|
|
273
|
+
} catch (error) {
|
|
274
|
+
return { ok: false, reason: errText(error), timedOut: false };
|
|
275
|
+
}
|
|
276
|
+
if (promise === undefined || promise === null || typeof promise.then !== 'function') {
|
|
277
|
+
return { ok: true, reason: null, timedOut: false };
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const settled = Promise.resolve(promise).then(
|
|
281
|
+
() => ({ ok: true, reason: null, timedOut: false }),
|
|
282
|
+
(error) => ({ ok: false, reason: errText(error), timedOut: false }),
|
|
283
|
+
);
|
|
284
|
+
|
|
285
|
+
let timer;
|
|
286
|
+
try {
|
|
287
|
+
const timeout = new Promise((resolve) => {
|
|
288
|
+
timer = setTimeout(() => resolve({ ok: false, reason: null, timedOut: true }), timeoutMs);
|
|
289
|
+
if (timer && typeof timer.unref === 'function') timer.unref();
|
|
290
|
+
});
|
|
291
|
+
return await Promise.race([settled, timeout]);
|
|
292
|
+
} catch (error) {
|
|
293
|
+
return { ok: false, reason: errText(error), timedOut: false };
|
|
294
|
+
} finally {
|
|
295
|
+
try {
|
|
296
|
+
clearTimeout(timer);
|
|
297
|
+
} catch {
|
|
298
|
+
/* 忽略 */
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// ---------------------------------------------------------------------------
|
|
304
|
+
// B1/B2: per-entry introspection
|
|
305
|
+
// ---------------------------------------------------------------------------
|
|
306
|
+
|
|
307
|
+
function declaredInjectKeys(fiber) {
|
|
308
|
+
try {
|
|
309
|
+
const inject = fiber?.inject;
|
|
310
|
+
if (inject === undefined || inject === null) return [];
|
|
311
|
+
if (Array.isArray(inject)) return inject.map(String);
|
|
312
|
+
if (typeof inject === 'object') return Object.keys(inject);
|
|
313
|
+
if (inject instanceof Set) return [...inject].map(String);
|
|
314
|
+
return [];
|
|
315
|
+
} catch (error) {
|
|
316
|
+
return [`<unreadable inject: ${errText(error)}>`];
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/** 缺服务 = fiber.ctx.get(key) === undefined (Plan.md §5 B1). */
|
|
321
|
+
function missingServices(fiber, keys) {
|
|
322
|
+
const missing = [];
|
|
323
|
+
const present = [];
|
|
324
|
+
const unreadable = [];
|
|
325
|
+
for (const key of keys) {
|
|
326
|
+
try {
|
|
327
|
+
const value = fiber.ctx.get(key);
|
|
328
|
+
if (value === undefined) missing.push(key);
|
|
329
|
+
else present.push(key);
|
|
330
|
+
} catch (error) {
|
|
331
|
+
unreadable.push({ key, error: errText(error) });
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
return { missing, present, unreadable };
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function optionsOf(entry) {
|
|
338
|
+
try {
|
|
339
|
+
const options = entry?.options;
|
|
340
|
+
return options !== undefined && options !== null && typeof options === 'object' ? options : {};
|
|
341
|
+
} catch (error) {
|
|
342
|
+
return { __unreadable: errText(error) };
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
async function introspectEntry(entry) {
|
|
347
|
+
const options = optionsOf(entry);
|
|
348
|
+
const row = {
|
|
349
|
+
id: options.id,
|
|
350
|
+
name: options.name,
|
|
351
|
+
group: options.group === true,
|
|
352
|
+
disabled: null,
|
|
353
|
+
disabledError: null,
|
|
354
|
+
fiberState: 'NO_FIBER',
|
|
355
|
+
fiberStateCode: null,
|
|
356
|
+
declaredInject: [],
|
|
357
|
+
declaredInjectCount: 0,
|
|
358
|
+
missingServices: [],
|
|
359
|
+
presentServices: [],
|
|
360
|
+
unreadableServices: [],
|
|
361
|
+
failureReason: null,
|
|
362
|
+
awaitTimedOut: false,
|
|
363
|
+
notes: [],
|
|
364
|
+
};
|
|
365
|
+
|
|
366
|
+
try {
|
|
367
|
+
row.id = entry?.id;
|
|
368
|
+
} catch (error) {
|
|
369
|
+
row.notes.push(`entry.id unreadable: ${errText(error)}`);
|
|
370
|
+
}
|
|
371
|
+
if (row.id === undefined) row.id = options.id;
|
|
372
|
+
|
|
373
|
+
// `entry.disabled` can throw for a `!!js` expression (the host wraps it too).
|
|
374
|
+
try {
|
|
375
|
+
row.disabled = Boolean(entry.disabled);
|
|
376
|
+
} catch (error) {
|
|
377
|
+
row.disabledError = errText(error);
|
|
378
|
+
row.notes.push('disabled expression threw');
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
let fiber;
|
|
382
|
+
try {
|
|
383
|
+
fiber = entry?.fiber;
|
|
384
|
+
} catch (error) {
|
|
385
|
+
row.notes.push(`entry.fiber unreadable: ${errText(error)}`);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
if (fiber === undefined || fiber === null) {
|
|
389
|
+
row.fiberState = 'NO_FIBER';
|
|
390
|
+
row.notes.push('no fiber (loader did not import this entry)');
|
|
391
|
+
if (row.group) row.notes.push('options.group is true');
|
|
392
|
+
return row;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
try {
|
|
396
|
+
const code = fiber.state;
|
|
397
|
+
row.fiberStateCode = typeof code === 'number' ? code : null;
|
|
398
|
+
row.fiberState = FIBER_STATES[code] ?? `UNKNOWN(${String(code)})`;
|
|
399
|
+
} catch (error) {
|
|
400
|
+
row.fiberState = 'UNREADABLE';
|
|
401
|
+
row.notes.push(`fiber.state threw: ${errText(error)}`);
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
try {
|
|
405
|
+
row.declaredInject = declaredInjectKeys(fiber);
|
|
406
|
+
row.declaredInjectCount = row.declaredInject.length;
|
|
407
|
+
const { missing, present, unreadable } = missingServices(fiber, row.declaredInject);
|
|
408
|
+
row.missingServices = missing;
|
|
409
|
+
row.presentServices = present;
|
|
410
|
+
row.unreadableServices = unreadable;
|
|
411
|
+
} catch (error) {
|
|
412
|
+
row.notes.push(`inject/missing-service scan threw: ${errText(error)}`);
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// Only a fiber that is not ACTIVE can carry an unobserved rejection.
|
|
416
|
+
if (row.fiberState !== 'ACTIVE' && row.fiberState !== 'DISPOSED') {
|
|
417
|
+
const awaited = await awaitFiber(fiber, AWAIT_TIMEOUT_MS);
|
|
418
|
+
if (awaited.timedOut) {
|
|
419
|
+
row.awaitTimedOut = true;
|
|
420
|
+
row.notes.push(`fiber.await() exceeded ${AWAIT_TIMEOUT_MS}ms`);
|
|
421
|
+
} else if (!awaited.ok) {
|
|
422
|
+
row.failureReason = awaited.reason;
|
|
423
|
+
} else if (row.fiberState === 'PENDING') {
|
|
424
|
+
row.notes.push('fiber.await() resolved without error (PENDING: waiting for services)');
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
if (row.disabled === true) row.notes.push('entry is disabled');
|
|
429
|
+
return row;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/**
|
|
433
|
+
* `loader.entries()` is an iterable, not necessarily an array (measured: a
|
|
434
|
+
* plain `object` whose iterator yields the entries). Accept everything that can
|
|
435
|
+
* be walked, never assume a shape, and never walk an unbounded sequence — a
|
|
436
|
+
* pathological iterator must not be able to exhaust the host's heap.
|
|
437
|
+
*
|
|
438
|
+
* @returns {{entries: any[]|null, truncated: boolean, error: string|null}}
|
|
439
|
+
*/
|
|
440
|
+
function toEntryArray(value) {
|
|
441
|
+
const fail = (error) => ({ entries: null, truncated: false, error });
|
|
442
|
+
if (value === null || value === undefined) return fail(`entries() returned ${describe(value)}`);
|
|
443
|
+
if (Array.isArray(value)) return { entries: value, truncated: false, error: null };
|
|
444
|
+
|
|
445
|
+
let iterable = false;
|
|
446
|
+
try {
|
|
447
|
+
iterable = typeof value[Symbol.iterator] === 'function';
|
|
448
|
+
} catch (error) {
|
|
449
|
+
return fail(`probing Symbol.iterator threw: ${errText(error)}`);
|
|
450
|
+
}
|
|
451
|
+
if (iterable) {
|
|
452
|
+
const out = [];
|
|
453
|
+
try {
|
|
454
|
+
for (const item of value) {
|
|
455
|
+
out.push(item);
|
|
456
|
+
if (out.length >= MAX_ENTRIES) return { entries: out, truncated: true, error: null };
|
|
457
|
+
}
|
|
458
|
+
return { entries: out, truncated: false, error: null };
|
|
459
|
+
} catch (error) {
|
|
460
|
+
return { entries: out, truncated: false, error: `iterating entries threw: ${errText(error)}` };
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
try {
|
|
465
|
+
if (typeof value.length === 'number') {
|
|
466
|
+
const length = Math.min(value.length, MAX_ENTRIES);
|
|
467
|
+
return { entries: Array.prototype.slice.call(value, 0, length), truncated: value.length > length, error: null };
|
|
468
|
+
}
|
|
469
|
+
} catch (error) {
|
|
470
|
+
return fail(`reading array-like entries threw: ${errText(error)}`);
|
|
471
|
+
}
|
|
472
|
+
return fail(`entries() returned a non-iterable ${describe(value)}`);
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
async function scan(ctx, state, pass) {
|
|
476
|
+
const report = {
|
|
477
|
+
schemaVersion: SCHEMA_VERSION,
|
|
478
|
+
plugin: name,
|
|
479
|
+
pid: process.pid,
|
|
480
|
+
node: process.version,
|
|
481
|
+
platform: process.platform,
|
|
482
|
+
scannedAt: new Date().toISOString(),
|
|
483
|
+
pass,
|
|
484
|
+
passCount: SCAN_DELAYS.length,
|
|
485
|
+
applyAt: state.applyAt,
|
|
486
|
+
loaderAccess: state.loaderAccess,
|
|
487
|
+
outPath: OUT_JSON,
|
|
488
|
+
awaitTimeoutMs: AWAIT_TIMEOUT_MS,
|
|
489
|
+
entryCount: 0,
|
|
490
|
+
stateHistogram: {},
|
|
491
|
+
problems: [],
|
|
492
|
+
entries: [],
|
|
493
|
+
errors: [],
|
|
494
|
+
};
|
|
495
|
+
|
|
496
|
+
let loader = state.loader;
|
|
497
|
+
let via = state.loaderVia;
|
|
498
|
+
if (loader === undefined) {
|
|
499
|
+
const resolved = resolveLoader(ctx);
|
|
500
|
+
loader = resolved.loader;
|
|
501
|
+
via = resolved.via;
|
|
502
|
+
state.loader = loader;
|
|
503
|
+
state.loaderVia = via;
|
|
504
|
+
report.loaderAccess = { ...state.loaderAccess, ...publicLoaderAccess(resolved) };
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
if (loader === undefined) {
|
|
508
|
+
report.errors.push('loader service not reachable from this plugin context');
|
|
509
|
+
log(`ERROR loader not reachable (${JSON.stringify(state.loaderAccess.attempts ?? [])})`);
|
|
510
|
+
writeReport(report);
|
|
511
|
+
return report;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
let entries;
|
|
515
|
+
let rawEntries;
|
|
516
|
+
try {
|
|
517
|
+
rawEntries = loader.entries();
|
|
518
|
+
const walked = toEntryArray(rawEntries);
|
|
519
|
+
report.entriesSource = {
|
|
520
|
+
typeof: describe(rawEntries),
|
|
521
|
+
isArray: Array.isArray(rawEntries),
|
|
522
|
+
constructorName: safeJson(rawEntries?.constructor?.name ?? null),
|
|
523
|
+
iterable: typeof rawEntries?.[Symbol.iterator] === 'function',
|
|
524
|
+
truncated: walked.truncated,
|
|
525
|
+
};
|
|
526
|
+
entries = walked.entries;
|
|
527
|
+
if (walked.error !== null) report.errors.push(`loader.entries(): ${walked.error}`);
|
|
528
|
+
} catch (error) {
|
|
529
|
+
report.errors.push(`loader.entries() threw: ${errText(error)}`);
|
|
530
|
+
log(`ERROR scan#${pass}: loader.entries() threw: ${errText(error)}`);
|
|
531
|
+
writeReport(report);
|
|
532
|
+
return report;
|
|
533
|
+
}
|
|
534
|
+
if (entries === null) {
|
|
535
|
+
const detail = report.errors.length > 0 ? report.errors.join('; ') : 'unknown reason';
|
|
536
|
+
log(`ERROR scan#${pass}: could not read entries (${detail})`);
|
|
537
|
+
writeReport(report);
|
|
538
|
+
return report;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
report.entryCount = entries.length;
|
|
542
|
+
for (const entry of entries) {
|
|
543
|
+
let row;
|
|
544
|
+
try {
|
|
545
|
+
row = await introspectEntry(entry);
|
|
546
|
+
} catch (error) {
|
|
547
|
+
row = {
|
|
548
|
+
id: '<unreadable entry>',
|
|
549
|
+
fiberState: 'UNREADABLE',
|
|
550
|
+
notes: [`entry introspection threw: ${errText(error)}`],
|
|
551
|
+
};
|
|
552
|
+
report.errors.push(`entry introspection threw: ${errText(error)}`);
|
|
553
|
+
}
|
|
554
|
+
report.entries.push(row);
|
|
555
|
+
report.stateHistogram[row.fiberState] = (report.stateHistogram[row.fiberState] ?? 0) + 1;
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
report.problems = report.entries.filter((row) => {
|
|
559
|
+
if (row.disabled === true) return false;
|
|
560
|
+
if (row.fiberState === 'ACTIVE') return false;
|
|
561
|
+
return true;
|
|
562
|
+
});
|
|
563
|
+
|
|
564
|
+
const written = writeReport(report);
|
|
565
|
+
log(
|
|
566
|
+
`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)'}`,
|
|
567
|
+
);
|
|
568
|
+
for (const row of report.problems) {
|
|
569
|
+
log(
|
|
570
|
+
` PROBLEM id=${safeJson(row.id)} name=${safeJson(row.name)} state=${row.fiberState}` +
|
|
571
|
+
` missing=[${row.missingServices.join(', ')}]` +
|
|
572
|
+
(row.failureReason ? ` reason=${firstLine(row.failureReason)}` : '') +
|
|
573
|
+
(row.awaitTimedOut ? ' (await timed out)' : ''),
|
|
574
|
+
);
|
|
575
|
+
}
|
|
576
|
+
return report;
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
function firstLine(text) {
|
|
580
|
+
try {
|
|
581
|
+
const line = String(text).split('\n')[0];
|
|
582
|
+
return line.length > 300 ? `${line.slice(0, 300)}…` : line;
|
|
583
|
+
} catch {
|
|
584
|
+
return '<unprintable>';
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
// ---------------------------------------------------------------------------
|
|
589
|
+
// apply
|
|
590
|
+
// ---------------------------------------------------------------------------
|
|
591
|
+
|
|
592
|
+
// ---------------------------------------------------------------------------
|
|
593
|
+
// C2: 面板数据通道(宿主半边算结论,客户端半边渲染)
|
|
594
|
+
// ---------------------------------------------------------------------------
|
|
595
|
+
|
|
596
|
+
/**
|
|
597
|
+
* 内核的位置。插件半边**不自己实现判定** —— 它 import 内核。
|
|
598
|
+
*
|
|
599
|
+
* 四条候选路径,按可靠性排序:
|
|
600
|
+
* 1. 环境变量 `DSC_KERNEL`(打包/发布形态下由外层指定)
|
|
601
|
+
* 2. **本包自己就是自足包**(`here` 下有 `lib/` 与 `rules/`)—— npm 发布形态
|
|
602
|
+
* 3. 本包在仓库里的位置(`plugin/` 的上一级)—— 开发与 GitHub 分发时的形态
|
|
603
|
+
*
|
|
604
|
+
* ## ⚠️ 为什么 `here` 必须排在 `here/..` 前面
|
|
605
|
+
*
|
|
606
|
+
* 这两种形态在**文件系统上长得一样但含义相反**:
|
|
607
|
+
*
|
|
608
|
+
* | 形态 | `here` | 内核在 |
|
|
609
|
+
* |---|---|---|
|
|
610
|
+
* | 仓库检出 | `<repo>/plugin` | `here/..` = `<repo>` |
|
|
611
|
+
* | npm 装好的包 | `<...>/node_modules/dsh-plugin-compat-check` | **`here/kernel`** |
|
|
612
|
+
*
|
|
613
|
+
* 在 npm 形态下,`here/..` 是 `node_modules/` —— 那里当然没有 `lib/extract.mjs`,
|
|
614
|
+
* 于是**内核永远找不到,插件静默变成一个只会说"找不到判定内核"的壳**。
|
|
615
|
+
* 而 `here` 先试的代价是零(仓库形态下 `plugin/lib/` 不存在,直接落到下一条)。
|
|
616
|
+
*
|
|
617
|
+
* 找不到就返回 null,**由调用方决定怎么说**,这里不抛。
|
|
618
|
+
*/
|
|
619
|
+
function resolveKernelDir() {
|
|
620
|
+
const { dirname: dir } = path;
|
|
621
|
+
const here = dir(fileURLToPath(import.meta.url));
|
|
622
|
+
const candidates = [
|
|
623
|
+
process.env.DSC_KERNEL,
|
|
624
|
+
path.join(here, 'kernel'), // npm 自足包形态(`npm pack` 时由 prepack 暂存到此)
|
|
625
|
+
here, // 另一种自足布局:内核与 index.js 同级
|
|
626
|
+
path.resolve(here, '..'), // 仓库形态:plugin/ → 仓库根
|
|
627
|
+
].filter(Boolean);
|
|
628
|
+
for (const c of candidates) {
|
|
629
|
+
try {
|
|
630
|
+
if (existsSync(path.join(c, 'lib', 'extract.mjs')) && existsSync(path.join(c, 'lib', 'verdict.mjs'))) {
|
|
631
|
+
return c;
|
|
632
|
+
}
|
|
633
|
+
} catch { /* 下一个候选 */ }
|
|
634
|
+
}
|
|
635
|
+
return null;
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
/**
|
|
639
|
+
* 当前 profile 的 node_modules。
|
|
640
|
+
*
|
|
641
|
+
* 优先级:显式覆盖 → DSH 正在跑的 profile → `desktop`。
|
|
642
|
+
* 「正在跑的是哪个 profile」没有正式服务暴露,只能靠环境变量与目录推断 ——
|
|
643
|
+
* 所以这里把推断的**依据**一起放进报告,让结论可追溯,而不是猜一个就不吭声。
|
|
644
|
+
*/
|
|
645
|
+
/**
|
|
646
|
+
* 当前 DSH 的版本。
|
|
647
|
+
*
|
|
648
|
+
* ## 为什么必须拿到它
|
|
649
|
+
*
|
|
650
|
+
* `dshVersion` 是基线的三个字段之一(Plan.md §5 F1),也是「**声明与实测不符**」
|
|
651
|
+
* 这类结论的**前提** —— 没有它就没法把插件的 `dsh.compatibility` 声明与实际版本对上,
|
|
652
|
+
* 于是那一类发现(本工具相对既有方案唯一不可替代的结论)在插件形态下根本不成立。
|
|
653
|
+
*
|
|
654
|
+
* ## 实测过的四条路(按可靠性排序)
|
|
655
|
+
*
|
|
656
|
+
* 在真实桌面端里把宿主进程打出来(`--profile vprobe`,VERPROBE 那一次):
|
|
657
|
+
*
|
|
658
|
+
* process.env.DSH_VERSION → null (不存在)
|
|
659
|
+
* process.versions.electron → "44.0.0" (说明宿主跑在 Electron 里)
|
|
660
|
+
* argv 里指向 `…/dsh/node_modules/…` 的路径,从中反推 dsh 根
|
|
661
|
+
* createRequire(dshRoot).resolve('@deepseek-ai/dsh-desktop-host/package.json')
|
|
662
|
+
* → @deepseek-ai/dsh-desktop-host@0.2.0-rc.2 ✅ 与 `dsh --version` 一致
|
|
663
|
+
* createRequire(dshRoot).resolve('@deepseek-ai/dsh/package.json')
|
|
664
|
+
* → @deepseek-ai/dsh@0.2.0-rc.2 ✅ 两个都行
|
|
665
|
+
*
|
|
666
|
+
* ### 被否掉的方案,以及为什么
|
|
667
|
+
*
|
|
668
|
+
* - **`process.env.DSH_VERSION`**:不存在(就是这次要解决的缺口本身)。
|
|
669
|
+
* - **`dsh --version` 起子进程**:不行,而且**不是**能力问题 —— Plan.md §2 第 1 题锁定了
|
|
670
|
+
* 「插件严格只读:不装包、不起子进程、不联网」。为拿一个版本号破这条边界不值。
|
|
671
|
+
* - **`Info.plist`**:macOS 专有(`CFBundleShortVersionString` = 0.2.0-rc.2,能读到)。
|
|
672
|
+
* 本产品要跨平台(§2 第 6 题),不能把它当主路。
|
|
673
|
+
*
|
|
674
|
+
* ## 拿不到时返回 null,而不是猜
|
|
675
|
+
*
|
|
676
|
+
* `dshVersion: null` 会让「声明与实测不符」这类结论自动不成立(判定层已经这样处理)。
|
|
677
|
+
* 猜一个版本字符进去,会**凭空造出**声明不符的结论 —— 那比缺一条结论糟得多。
|
|
678
|
+
*/
|
|
679
|
+
function resolveDshVersion() {
|
|
680
|
+
// ① 显式覆盖(CLI 形态与测试用)
|
|
681
|
+
if (process.env.DSH_VERSION) return process.env.DSH_VERSION;
|
|
682
|
+
|
|
683
|
+
// ② 从 argv 反推 dsh 根,再用 Node 自己的解析器问它
|
|
684
|
+
//
|
|
685
|
+
// 真实桌面端的 argv(实测):
|
|
686
|
+
// [ …/MacOS/DeepSeek Harness, --expose-internals,
|
|
687
|
+
// …/app.asar/dsh/node_modules/@deepseek-ai/dsh-desktop-host/lib/index.js,
|
|
688
|
+
// …/app.asar/dsh, ← dsh 根
|
|
689
|
+
// /Users/…/.dsh/profiles/vprobe, … ]
|
|
690
|
+
//
|
|
691
|
+
// 注意 **不写死 macOS 路径**:任何 `<…>/dsh/node_modules/<…>` 都能反推出根,
|
|
692
|
+
// Windows 的 `\dsh\node_modules\` 形态也一并处理。
|
|
693
|
+
try {
|
|
694
|
+
const argv = (process.argv || []).map(String);
|
|
695
|
+
const sep = argv.find((a) => /[\\/]dsh[\\/]node_modules[\\/]/.test(a));
|
|
696
|
+
if (sep) {
|
|
697
|
+
const m = /^(.*[\\/]dsh)[\\/]node_modules[\\/]/.exec(sep);
|
|
698
|
+
if (m && m[1]) {
|
|
699
|
+
const dshRoot = m[1];
|
|
700
|
+
const req = __createRequire(join(dshRoot, 'probe.js'));
|
|
701
|
+
// 两个包实测都能解析;都试一遍是为了不依赖某一个包的存在
|
|
702
|
+
for (const spec of ['@deepseek-ai/dsh-desktop-host/package.json', '@deepseek-ai/dsh/package.json']) {
|
|
703
|
+
try {
|
|
704
|
+
const meta = req(req.resolve(spec));
|
|
705
|
+
if (meta && typeof meta.version === 'string' && meta.version) {
|
|
706
|
+
return meta.version;
|
|
707
|
+
}
|
|
708
|
+
} catch { /* 试下一个 */ }
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
} catch { /* 继续 */ }
|
|
713
|
+
|
|
714
|
+
// ③ 都没有 → **null,不猜**(判定层会据此不产出声明不符的结论)
|
|
715
|
+
return null;
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
/**
|
|
719
|
+
* 当前正在跑的是哪个 profile。
|
|
720
|
+
*
|
|
721
|
+
* ## 为什么这里必须小心:环境变量会**主动骗人**
|
|
722
|
+
*
|
|
723
|
+
* 实测(在 `--profile gverify` 下启动宿主,让插件把进程环境打出来):
|
|
724
|
+
*
|
|
725
|
+
* ```
|
|
726
|
+
* DSH_PROFILE=desktop ← 过期值!
|
|
727
|
+
* DSH_PROFILE_DIR=…/profiles/desktop
|
|
728
|
+
* process.argv = [ …/dsh-desktop-host/lib/cli.js, "--profile", "gverify" ]
|
|
729
|
+
* ```
|
|
730
|
+
*
|
|
731
|
+
* 那两个 `DSH_PROFILE*` 是从**启动这个 app 的那个 shell** 继承来的,与实际加载的
|
|
732
|
+
* profile 无关。信它们会把 gverify 的结论写进 `baseline.desktop.json`,
|
|
733
|
+
* 于是差分会报出"移除了 17 个插件"这种**完全是编出来的变化**。
|
|
734
|
+
*
|
|
735
|
+
* 所以顺序是:argv(宿主自己声明的)> 显式覆盖 > 环境变量(可能是陈的)> 回退。
|
|
736
|
+
*/
|
|
737
|
+
function resolveActiveProfile() {
|
|
738
|
+
// ① 宿主自己的启动参数 —— 最权威。**两种形态都要认**:
|
|
739
|
+
//
|
|
740
|
+
// CLI: … dsh-desktop-host/lib/cli.js --profile gverify
|
|
741
|
+
// 桌面 app:… dsh-desktop-host/lib/index.js <dsh 根> /Users/…/profiles/desktop <runtime> …
|
|
742
|
+
//
|
|
743
|
+
// 桌面端把 **profile 目录路径按位置传**,没有 `--profile` 标志。
|
|
744
|
+
// 只认 `--profile` 时,真实 app 里会走"回退到 desktop" —— 幸亏这次的结论恰好
|
|
745
|
+
// 就是 desktop,否则会把别的 profile 的结论写进 desktop 的基线。**那次是运气,不是正确。**
|
|
746
|
+
try {
|
|
747
|
+
const argv = (process.argv || []).map(String);
|
|
748
|
+
for (let i = argv.length - 1; i >= 0; i--) {
|
|
749
|
+
const inline = /^--profile=(.+)$/.exec(argv[i]);
|
|
750
|
+
if (inline) return { name: inline[1], why: `process.argv 的 --profile=${inline[1]}` };
|
|
751
|
+
if (argv[i] === '--profile' && argv[i + 1] && !argv[i + 1].startsWith('-')) {
|
|
752
|
+
return { name: argv[i + 1], why: `process.argv 的 --profile ${argv[i + 1]}` };
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
// 位置形态:任何指向 `<…>/profiles/<名字>`(或它下面的 node_modules)的绝对路径
|
|
756
|
+
for (const a of argv) {
|
|
757
|
+
if (!a.startsWith('/')) continue;
|
|
758
|
+
const m = /\/profiles\/([^/]+)(?:\/|$)/.exec(a);
|
|
759
|
+
if (m && m[1]) return { name: m[1], why: `process.argv 里的 profile 路径 ${a}` };
|
|
760
|
+
}
|
|
761
|
+
} catch { /* 继续 */ }
|
|
762
|
+
// ② 显式覆盖(工具/测试用)
|
|
763
|
+
if (process.env.DSC_PROFILE) return { name: process.env.DSC_PROFILE, why: `DSC_PROFILE=${process.env.DSC_PROFILE}` };
|
|
764
|
+
// ③ 环境变量 —— **可能是陈的**,所以带上警示写进 why
|
|
765
|
+
for (const key of ['DSH_PROFILE', 'DSH_ACTIVE_PROFILE', 'DSH_PROFILE_NAME']) {
|
|
766
|
+
const v = process.env[key];
|
|
767
|
+
if (v) return { name: v, why: `${key}=${v}(环境变量可能来自启动 app 的那个 shell,未必是当前加载的 profile)` };
|
|
768
|
+
}
|
|
769
|
+
return { name: null, why: '无法判断' };
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
function resolveNodeModules() {
|
|
773
|
+
const dshHome = process.env.DSH_HOME || join(homedir(), '.dsh');
|
|
774
|
+
const candidates = [];
|
|
775
|
+
|
|
776
|
+
const inferred = resolveActiveProfile();
|
|
777
|
+
if (inferred.name) {
|
|
778
|
+
candidates.push({ dir: join(dshHome, 'profiles', inferred.name, 'node_modules'), why: inferred.why });
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
// 兜底:从 `DSH_PROFILE_DIR` 直接拿目录(它指向 profile 根,不是 node_modules)
|
|
782
|
+
if (process.env.DSH_PROFILE_DIR) {
|
|
783
|
+
candidates.push({ dir: join(process.env.DSH_PROFILE_DIR, 'node_modules'), why: `DSH_PROFILE_DIR=${process.env.DSH_PROFILE_DIR}` });
|
|
784
|
+
}
|
|
785
|
+
// 最后回退。到这一步说明前面都没中(例如插件是从仓库直接跑的、或发布形态装在别处),
|
|
786
|
+
// 那就只能挑一个 —— 但 why 里会**写明这是回退**,否则用户会以为它检查的是当前 profile。
|
|
787
|
+
candidates.push({ dir: join(dshHome, 'profiles', 'desktop', 'node_modules'), why: '回退到 desktop profile(没能判断出正在跑哪一个)' });
|
|
788
|
+
|
|
789
|
+
for (const c of candidates) {
|
|
790
|
+
try {
|
|
791
|
+
if (existsSync(c.dir)) return { dir: c.dir, why: c.why };
|
|
792
|
+
} catch { /* 下一个 */ }
|
|
793
|
+
}
|
|
794
|
+
return { dir: null, why: '没有任何候选目录存在' };
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
/**
|
|
798
|
+
* 把结论交给面板 —— **F2 的缓存策略集中在这里**。
|
|
799
|
+
*
|
|
800
|
+
* ## 策略:stale-while-revalidate(先给旧的,后台再刷)
|
|
801
|
+
*
|
|
802
|
+
* 为什么不是"到点就重算":一次体检 0.5–1.5 秒(desktop profile 21 个插件)。
|
|
803
|
+
* 让面板的请求**等**这 1.5 秒,用户看到的是转圈;而结论晚 1.5 秒到完全无所谓 ——
|
|
804
|
+
* 这份数据说的是"你装的插件跟这版 DSH 兼不兼容",它不是秒级指标。
|
|
805
|
+
* 所以:有缓存就先给,同时在后台刷新的。**响应速度与正确性都不牺牲**。
|
|
806
|
+
*
|
|
807
|
+
* 三种情况:
|
|
808
|
+
* - `?refresh=1` 或没有缓存 → **同步算**(用户明确要新的,或者根本没有旧的)
|
|
809
|
+
* - 缓存在有效期内 → 直接给
|
|
810
|
+
* - 缓存过期 → 先给旧的,并触发一次后台刷新(同一时刻只跑一个)
|
|
811
|
+
*
|
|
812
|
+
* 并发安全:`state.computing` 保存"正在跑的那一个 promise",所以十个面板请求
|
|
813
|
+
* 同时到达也只会算一次 —— 否则它们会各自算一遍,白烧 1.5 秒 × 10。
|
|
814
|
+
*/
|
|
815
|
+
async function serveReport(state, ctx, force) {
|
|
816
|
+
const now = Date.now();
|
|
817
|
+
const age = now - state.lastReportAt;
|
|
818
|
+
|
|
819
|
+
if (force || !state.lastReport) {
|
|
820
|
+
// 没有缓存时不并发去重:第一次访问就该拿到真结果。
|
|
821
|
+
const out = await computeReport(state, ctx);
|
|
822
|
+
state.lastReport = out;
|
|
823
|
+
state.lastReportAt = Date.now();
|
|
824
|
+
return out;
|
|
825
|
+
}
|
|
826
|
+
if (age <= state.cacheTtlMs) {
|
|
827
|
+
return { ...state.lastReport, cache: { hit: true, ageMs: age, refreshing: false } };
|
|
828
|
+
}
|
|
829
|
+
// 过期:**不等它**。给旧的,后台刷。
|
|
830
|
+
if (!state.computing) {
|
|
831
|
+
state.computing = computeReport(state, ctx)
|
|
832
|
+
.then((out) => {
|
|
833
|
+
state.lastReport = out;
|
|
834
|
+
state.lastReportAt = Date.now();
|
|
835
|
+
return out;
|
|
836
|
+
})
|
|
837
|
+
.catch((error) => {
|
|
838
|
+
// 后台刷新失败**不能影响已经发出去的那份**,但必须留下痕迹:
|
|
839
|
+
// 静默失败的刷新会让面板一直显示过期数据,而用户以为那是当前的。
|
|
840
|
+
try { log(`WARN F2 background refresh failed: ${errText(error)}`); } catch { /* 忽略 */ }
|
|
841
|
+
state.lastRefreshError = errText(error);
|
|
842
|
+
return null;
|
|
843
|
+
})
|
|
844
|
+
.finally(() => { state.computing = null; });
|
|
845
|
+
}
|
|
846
|
+
return { ...state.lastReport, cache: { hit: true, ageMs: age, refreshing: true } };
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
/**
|
|
850
|
+
* 基线文件的位置。**必须与 CLI 的 `bin/compat-check.mjs` 推导出同一个路径** ——
|
|
851
|
+
* 两份基线会立刻分叉,而用户在面板和命令行看到的"变化"就会是两件事。
|
|
852
|
+
* 按 profile 分文件:不同 profile 的插件集合不同,混在一起会让差分变成噪音。
|
|
853
|
+
*/
|
|
854
|
+
function baselineFileFor(nmDir) {
|
|
855
|
+
const dshHome = process.env.DSH_HOME || join(homedir(), '.dsh');
|
|
856
|
+
let key = 'unknown';
|
|
857
|
+
try {
|
|
858
|
+
const parts = String(nmDir).split(path.sep);
|
|
859
|
+
const i = parts.lastIndexOf('profiles');
|
|
860
|
+
if (i >= 0 && parts[i + 1]) key = parts[i + 1];
|
|
861
|
+
} catch { /* 保持 unknown */ }
|
|
862
|
+
return join(dshHome, 'dsh-plugin-compat-check', `baseline.${key.replace(/[^\w.-]/g, '_')}.json`);
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
/**
|
|
866
|
+
* 跑一次体检。**同步、纯计算、不抛**。
|
|
867
|
+
*
|
|
868
|
+
* 为什么宿主半边要算而不是把原始数据丢给客户端:
|
|
869
|
+
* - 客户端跑在浏览器里,读不了文件系统;
|
|
870
|
+
* - 结论(五元组 + 确定性)是内核的产物,算出唯一一份才不会两边漂移。
|
|
871
|
+
*/
|
|
872
|
+
async function computeReport(state, ctx) {
|
|
873
|
+
const started = Date.now();
|
|
874
|
+
const out = {
|
|
875
|
+
schemaVersion: 1,
|
|
876
|
+
kind: 'plugin-compat-check/report',
|
|
877
|
+
computedAt: new Date().toISOString(),
|
|
878
|
+
ok: false,
|
|
879
|
+
error: null,
|
|
880
|
+
kernelDir: null,
|
|
881
|
+
// 「这份结论是哪一版工具给的」。面板是本插件的主要入口,
|
|
882
|
+
// 少了它,用户来问"凭什么"时第一句就卡住 —— 而同一插件在不同版本的
|
|
883
|
+
// 规则表/提取器下结论会变(见 lib/version.mjs 与 Plan.md §5.2.8)。
|
|
884
|
+
toolVersion: null,
|
|
885
|
+
nodeModules: null,
|
|
886
|
+
nodeModulesWhy: null,
|
|
887
|
+
// C4:与上次体检的差分包(没有基线时为 null,并带 note 说明为什么)
|
|
888
|
+
baseline: null,
|
|
889
|
+
report: null,
|
|
890
|
+
};
|
|
891
|
+
try {
|
|
892
|
+
const kernel = resolveKernelDir();
|
|
893
|
+
out.kernelDir = kernel;
|
|
894
|
+
// 版本要**尽早**拿到:连"找不到内核"这条错误路径上,用户也需要知道
|
|
895
|
+
// 是哪一版工具报的这个错。`version.mjs` 只在有内核时才读得到 ——
|
|
896
|
+
// 读不到就保持 null,**不猜**。
|
|
897
|
+
if (kernel) {
|
|
898
|
+
try {
|
|
899
|
+
const vmod = await import(pathToFileURL(join(kernel, 'lib', 'version.mjs')).href);
|
|
900
|
+
out.toolVersion = vmod.TOOL_VERSION ?? null;
|
|
901
|
+
} catch (error) {
|
|
902
|
+
log(`版本读不到(${errText(error)})—— 报告里的 toolVersion 将是 null`);
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
if (!kernel) {
|
|
906
|
+
out.error = '找不到判定内核(lib/extract.mjs + lib/verdict.mjs)。'
|
|
907
|
+
+ '插件形态下它们是同仓库的相邻目录;发布形态请设 DSC_KERNEL。';
|
|
908
|
+
return out;
|
|
909
|
+
}
|
|
910
|
+
const nm = resolveNodeModules();
|
|
911
|
+
out.nodeModules = nm.dir;
|
|
912
|
+
out.nodeModulesWhy = nm.why;
|
|
913
|
+
if (!nm.dir) {
|
|
914
|
+
out.error = `找不到要体检的 node_modules:${nm.why}`;
|
|
915
|
+
return out;
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
const { extractWithProvenance } = await import(pathToFileURL(join(kernel, 'lib', 'extract.mjs')).href);
|
|
919
|
+
const { buildVerdicts } = await import(pathToFileURL(join(kernel, 'lib', 'verdict.mjs')).href);
|
|
920
|
+
|
|
921
|
+
const catalogPath = join(kernel, 'rules', 'dsh-api-catalog.json');
|
|
922
|
+
const rulesPath = join(kernel, 'rules', 'severity.json');
|
|
923
|
+
const catalog = JSON.parse(readFileSync(catalogPath, 'utf8'));
|
|
924
|
+
const rules = JSON.parse(readFileSync(rulesPath, 'utf8'));
|
|
925
|
+
|
|
926
|
+
// 运行时面:宿主半边**自己就在进程里**,所以它能拿到比探针更准的东西。
|
|
927
|
+
// 第一版先读探针报告(如果外层给了路径),没有就退回目录 —— 并如实标注。
|
|
928
|
+
let hostSurface = null;
|
|
929
|
+
let surfaceWhy = '没有运行时面:结论中的方法级判定降为「疑似」';
|
|
930
|
+
const probePath = process.env.DSC_PROBE_REPORT;
|
|
931
|
+
if (probePath && existsSync(probePath)) {
|
|
932
|
+
try {
|
|
933
|
+
hostSurface = JSON.parse(readFileSync(probePath, 'utf8'));
|
|
934
|
+
surfaceWhy = `运行时面来自探针报告 ${probePath}`;
|
|
935
|
+
} catch (error) {
|
|
936
|
+
surfaceWhy = `探针报告读不动(${errText(error)}),退回目录`;
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
out.surfaceWhy = surfaceWhy;
|
|
940
|
+
|
|
941
|
+
const extracted = extractWithProvenance(nm.dir, { catalog });
|
|
942
|
+
const verdict = buildVerdicts({
|
|
943
|
+
report: extracted.report,
|
|
944
|
+
hostSurface,
|
|
945
|
+
rules,
|
|
946
|
+
catalog,
|
|
947
|
+
dshVersion: resolveDshVersion(),
|
|
948
|
+
});
|
|
949
|
+
|
|
950
|
+
// ── C4:基线差分 ──
|
|
951
|
+
// 用**与 CLI 完全相同**的 lib/baseline.mjs。面板说"变差了"而命令行说"没变"
|
|
952
|
+
// 这种分叉在结构上就不可能发生。
|
|
953
|
+
let baseline = null;
|
|
954
|
+
try {
|
|
955
|
+
const { snapshotForBaseline, diffAgainstBaseline, badgeFor } =
|
|
956
|
+
await import(pathToFileURL(join(kernel, 'lib', 'baseline.mjs')).href);
|
|
957
|
+
const snap = snapshotForBaseline(verdict, resolveDshVersion());
|
|
958
|
+
const bfile = baselineFileFor(nm.dir);
|
|
959
|
+
let prev = null;
|
|
960
|
+
let readNote = null;
|
|
961
|
+
try {
|
|
962
|
+
if (existsSync(bfile)) prev = JSON.parse(readFileSync(bfile, 'utf8'));
|
|
963
|
+
else readNote = '还没有基线(这是第一次体检)';
|
|
964
|
+
} catch (error) {
|
|
965
|
+
// 读不出来**不是**"没有变化"。必须区分"你没跑过"与"你的基线坏了" ——
|
|
966
|
+
// 后者会让用户以为一切照旧,继续用着已经变坏的环境。
|
|
967
|
+
readNote = `基线读不出来(${errText(error)})—— 这次不做比较`;
|
|
968
|
+
}
|
|
969
|
+
const diff = diffAgainstBaseline(prev, snap);
|
|
970
|
+
baseline = { file: bfile, note: readNote, diff, badge: badgeFor(diff), wrote: false };
|
|
971
|
+
// 只有这一次真的算出了结论才写基线:一次失败的体检写下的基线,
|
|
972
|
+
// 会把"读不到"记成"这些插件都没了",下次比较全是假变化。
|
|
973
|
+
try {
|
|
974
|
+
mkdirSync(path.dirname(bfile), { recursive: true });
|
|
975
|
+
writeFileSync(bfile, `${JSON.stringify(snap, null, 2)}\n`);
|
|
976
|
+
baseline.wrote = true;
|
|
977
|
+
} catch (error) {
|
|
978
|
+
baseline.writeError = `基线写不进去(${errText(error)})`;
|
|
979
|
+
}
|
|
980
|
+
} catch (error) {
|
|
981
|
+
baseline = { error: errText(error), note: '基线差分没算成(不影响本次结论)' };
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
// 只把面板要用的部分送过去:完整 report 在 desktop profile 上是 ~1 MB,
|
|
985
|
+
// 而面板要的只是每个插件的结论与去处。
|
|
986
|
+
// ── 面板条目台账:id → 包名 ──
|
|
987
|
+
// `plugins.detail.*` 只收到 `{subject:{kind,id}}`,**没有包名**,而 id 常常
|
|
988
|
+
// 不等于包名(插件面板自己那个条目的 id 就叫 `plugins`)。所以从面板管理器
|
|
989
|
+
// 自己的只读服务里取这张表,让客户端能解析出"这是哪个插件"。
|
|
990
|
+
// 取不到不算失败:客户端会退回到"id 就是包名"的判断,并如实不渲染角标。
|
|
991
|
+
try {
|
|
992
|
+
const pm = typeof ctx.get === 'function' ? ctx.get('pluginManager', false) : undefined;
|
|
993
|
+
const listing = pm && typeof pm.list === 'function' ? await pm.list() : null;
|
|
994
|
+
if (listing) {
|
|
995
|
+
const arr = Array.isArray(listing) ? listing : (listing.items || listing.plugins || []);
|
|
996
|
+
out.items = arr
|
|
997
|
+
.map((it) => {
|
|
998
|
+
if (!it || typeof it !== 'object') return null;
|
|
999
|
+
const id = it.id ?? it.entryId ?? it.slotId ?? null;
|
|
1000
|
+
const pkg = it.packageName ?? it.package ?? it.name ?? null;
|
|
1001
|
+
return (id && pkg) ? { id, packageName: String(pkg), label: it.label ?? null } : null;
|
|
1002
|
+
})
|
|
1003
|
+
.filter(Boolean);
|
|
1004
|
+
}
|
|
1005
|
+
} catch (error) {
|
|
1006
|
+
out.itemsError = errText(error);
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
out.baseline = baseline;
|
|
1010
|
+
out.dshVersion = resolveDshVersion();
|
|
1011
|
+
out.report = {
|
|
1012
|
+
summary: verdict.summary,
|
|
1013
|
+
dshVersion: verdict.dshVersion,
|
|
1014
|
+
hasRuntimeSurface: Boolean(hostSurface),
|
|
1015
|
+
surfaceWhy,
|
|
1016
|
+
plugins: verdict.plugins,
|
|
1017
|
+
// 逐条发现的证据形态:面板要能回答「凭什么这么判」
|
|
1018
|
+
// 条目台账(id → 包名),客户端解析角标归属时用
|
|
1019
|
+
items: out.items || [],
|
|
1020
|
+
itemsError: out.itemsError || null,
|
|
1021
|
+
manifests: extracted.report.manifests,
|
|
1022
|
+
serviceCount: extracted.report.services.length,
|
|
1023
|
+
siteTotal: (extracted.report.callSites || []).length,
|
|
1024
|
+
};
|
|
1025
|
+
out.ok = true;
|
|
1026
|
+
out.elapsedMs = Date.now() - started;
|
|
1027
|
+
} catch (error) {
|
|
1028
|
+
out.error = errText(error);
|
|
1029
|
+
}
|
|
1030
|
+
return out;
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
/** 本地信任栅栏:只有本机的 DSH 界面该读到这份数据。 */
|
|
1034
|
+
function isTrustedRequest(req) {
|
|
1035
|
+
try {
|
|
1036
|
+
const host = String((req.headers && (req.headers.host || req.headers.Host)) || '');
|
|
1037
|
+
const origin = String((req.headers && (req.headers.origin || req.headers.Origin)) || '');
|
|
1038
|
+
const hostOk = /^(127\.0\.0\.1|localhost|\[::1\])(:\d+)?$/i.test(host);
|
|
1039
|
+
if (!origin) return hostOk;
|
|
1040
|
+
return hostOk && /^https?:\/\/(127\.0\.0\.1|localhost|\[::1\])(:\d+)?$/i.test(origin);
|
|
1041
|
+
} catch {
|
|
1042
|
+
return false;
|
|
1043
|
+
}
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
|
|
1047
|
+
// ---------------------------------------------------------------------------
|
|
1048
|
+
// D1: 对话工具 —— 让 agent 能直接答「我这插件兼容吗」
|
|
1049
|
+
// ---------------------------------------------------------------------------
|
|
1050
|
+
|
|
1051
|
+
/**
|
|
1052
|
+
* 把结论渲染成一段**给模型读**的文本。
|
|
1053
|
+
*
|
|
1054
|
+
* 与 CLI 的 `lib/report.mjs` 是同一口径:本函数只负责把已经算好的结论翻译成
|
|
1055
|
+
* 文本,不做任何判定。这样"面板说可用、工具说部分可用"在结构上不可能发生。
|
|
1056
|
+
*
|
|
1057
|
+
* 为什么工具**必须**能给出结论而不是只丢一个文件名:
|
|
1058
|
+
* 用户问的是"兼不兼容",一个"报告写好了,你自己去看"的回答等于没答。
|
|
1059
|
+
*/
|
|
1060
|
+
function renderForModel(compat) {
|
|
1061
|
+
try {
|
|
1062
|
+
if (!compat || compat.ok !== true) {
|
|
1063
|
+
return `体检没跑成(这是工具的问题,不是插件的结论):${(compat && compat.error) || '未知原因'}`;
|
|
1064
|
+
}
|
|
1065
|
+
const r = compat.report;
|
|
1066
|
+
const L = [];
|
|
1067
|
+
const s = r.summary || {};
|
|
1068
|
+
L.push(`结论:可用 ${s.ok || 0} 部分可用 ${s.partial || 0} 不兼容 ${s.broken || 0}`
|
|
1069
|
+
+ (s.vacuousOk ? `(其中 ${s.vacuousOk} 个「可用」是空洞的:该插件没有用过任何 DSH 服务,因此没有可检查的东西)` : ''));
|
|
1070
|
+
L.push(`体检对象:${compat.nodeModules}(${compat.nodeModulesWhy})`);
|
|
1071
|
+
L.push(r.hasRuntimeSurface
|
|
1072
|
+
? '方法级判定依据:宿主运行时面 —— 这类结论是确定的。'
|
|
1073
|
+
: '方法级判定依据:官方 API 目录(**契约目录,不是运行时面**)—— 因此方法级结论一律标「疑似」。'
|
|
1074
|
+
+ '「疑似」不是缺陷,是诚实:判不准时降级标注,而不是升格成确定。');
|
|
1075
|
+
L.push('');
|
|
1076
|
+
|
|
1077
|
+
const rank = { broken: 0, partial: 1, unknown: 2, ok: 3 };
|
|
1078
|
+
const names = Object.keys(r.plugins).sort((a, b) =>
|
|
1079
|
+
(rank[r.plugins[a].verdict] - rank[r.plugins[b].verdict]) || a.localeCompare(b));
|
|
1080
|
+
|
|
1081
|
+
for (const name of names) {
|
|
1082
|
+
const p = r.plugins[name];
|
|
1083
|
+
const label = { broken: '不兼容', partial: '部分可用', unknown: '未知', ok: '可用' }[p.verdict] || p.verdict;
|
|
1084
|
+
L.push(`${label} ${name}@${p.version || '?'}${p.vacuous ? ' [空洞的结论:无可检查对象]' : ''}`);
|
|
1085
|
+
for (const f of p.findings || []) {
|
|
1086
|
+
const api = f.service ? (f.method ? `${f.service}.${f.method}` : f.service) : '(包声明)';
|
|
1087
|
+
const loc = f.location ? `${f.location.file}:${f.location.line}` : '—';
|
|
1088
|
+
const cat = {
|
|
1089
|
+
'host-service-missing': '服务缺失', 'host-method-missing': '方法缺失',
|
|
1090
|
+
'unprovided-dependency': '无人提供的依赖', 'declaration-mismatch': '声明与实测不符',
|
|
1091
|
+
}[f.category] || f.category;
|
|
1092
|
+
const sev = { fatal: '致命', 'feature-dead': '功能失效', degraded: '降级' }[f.severity] || f.severity;
|
|
1093
|
+
L.push(` ${sev} / ${cat}${f.certainty === 'certain' ? '' : ' [疑似]'} ${api} @ ${loc}`);
|
|
1094
|
+
if (f.impact) L.push(` 影响:${f.impact}`);
|
|
1095
|
+
}
|
|
1096
|
+
const cs = p.clientSide || [];
|
|
1097
|
+
if (cs.length) L.push(` 客户端半边(命令行测不到,需界面验证):${cs.map((c) => c.service).join('、')}`);
|
|
1098
|
+
const noCalls = Object.values(p.resolved || {})
|
|
1099
|
+
.filter((c) => c.disposition === 'no-calls').map((c) => c.service);
|
|
1100
|
+
if (noCalls.length) L.push(` 无证据可判(只出现名字,没有调用点):${noCalls.join('、')}`);
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
L.push('');
|
|
1104
|
+
L.push('读这个结果时注意两点:');
|
|
1105
|
+
L.push(' 1) 「可用」不等于"功能验证过"。它只说明**用到的 DSH 服务与 API 都在**。'
|
|
1106
|
+
+ '插件仍然可能因为自身逻辑、配置或第三方依赖而出问题。');
|
|
1107
|
+
L.push(' 2) 「客户端半边」的那些服务命令行永远测不到 —— 它们活在浏览器里,'
|
|
1108
|
+
+ '只能靠"界面上有没有出现该插件的 UI"来验证。别把"没报错"当成"没问题"。');
|
|
1109
|
+
return L.join('\n');
|
|
1110
|
+
} catch (error) {
|
|
1111
|
+
return `渲染结论时出错(工具的问题):${errText(error)}`;
|
|
1112
|
+
}
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
/** 工具的服务集合:`tools`;`agents` 用来把相对路径解析到会话工作目录。 */
|
|
1116
|
+
const TOOL_SERVICES = ['tools', 'agents'];
|
|
1117
|
+
|
|
1118
|
+
function registerTool(ctx, state) {
|
|
1119
|
+
// 与 C2 路由同一个理由:走作用域 ctx.inject,headless/CLI 形态下 `tools` 不在场时
|
|
1120
|
+
// 这段根本不注册,本插件的 fiber 也就不会去等一个不存在的服务(`inject` 保持 [])。
|
|
1121
|
+
if (typeof ctx.inject !== 'function') return;
|
|
1122
|
+
ctx.inject(TOOL_SERVICES, (scope) => {
|
|
1123
|
+
try {
|
|
1124
|
+
if (!scope.tools || typeof scope.tools.register !== 'function') {
|
|
1125
|
+
log('D1 tool skipped: scoped ctx has no tools.register');
|
|
1126
|
+
return;
|
|
1127
|
+
}
|
|
1128
|
+
scope.tools.register({
|
|
1129
|
+
name: 'compat_check',
|
|
1130
|
+
description:
|
|
1131
|
+
'Check whether the DSH plugins installed in this profile are actually compatible with the '
|
|
1132
|
+
+ 'current DSH version, and report the exact file:line and reason for anything that is not. '
|
|
1133
|
+
+ 'Use it when the user asks whether a plugin still works, why a feature disappeared after '
|
|
1134
|
+
+ 'updating DSH, or whether something they installed is safe to keep. It reads the installed '
|
|
1135
|
+
+ 'profile; it does not install anything or check a plugin that is not yet installed. '
|
|
1136
|
+
+ 'Findings are labelled "certain" (backed by the live runtime surface) or "suspected" '
|
|
1137
|
+
+ '(inferred from the official API catalogue, which is a contract catalogue and not the '
|
|
1138
|
+
+ 'runtime surface) — report that distinction to the user instead of flattening it.',
|
|
1139
|
+
parameters: {
|
|
1140
|
+
type: 'object',
|
|
1141
|
+
additionalProperties: false,
|
|
1142
|
+
properties: {
|
|
1143
|
+
profile: {
|
|
1144
|
+
type: 'string',
|
|
1145
|
+
description:
|
|
1146
|
+
'Optional profile name to inspect (e.g. "desktop"). Defaults to the profile this '
|
|
1147
|
+
+ 'DSH instance is running, or desktop if it cannot be determined.',
|
|
1148
|
+
},
|
|
1149
|
+
},
|
|
1150
|
+
},
|
|
1151
|
+
output: {
|
|
1152
|
+
// ⚠️ **工具 DSL 不是 JSON Schema**:`required` 是**顶层数组**,
|
|
1153
|
+
// 不是每个属性上的 `required: true`。写成后者会被宿主直接拒掉,
|
|
1154
|
+
// 报 `schema.properties.X.required is not supported on type "string"`。
|
|
1155
|
+
// 实测踩过:注册失败、工具不存在,而**面板与路由照常工作** ——
|
|
1156
|
+
// 所以只有真的去调一次工具才发现得了。
|
|
1157
|
+
schema: {
|
|
1158
|
+
type: 'object',
|
|
1159
|
+
additionalProperties: false,
|
|
1160
|
+
properties: {
|
|
1161
|
+
summary: { type: 'string' },
|
|
1162
|
+
nodeModules: { type: 'string' },
|
|
1163
|
+
hasRuntimeSurface: { type: 'boolean' },
|
|
1164
|
+
text: { type: 'string' },
|
|
1165
|
+
},
|
|
1166
|
+
required: ['summary', 'nodeModules', 'hasRuntimeSurface', 'text'],
|
|
1167
|
+
},
|
|
1168
|
+
render: (_args, value) => [{ type: 'text', text: value && typeof value.text === 'string' ? value.text : '' }],
|
|
1169
|
+
},
|
|
1170
|
+
isConcurrencySafe: () => false,
|
|
1171
|
+
presentCall: (args) => ({
|
|
1172
|
+
card: 'generic',
|
|
1173
|
+
title: 'compat_check',
|
|
1174
|
+
kind: 'read',
|
|
1175
|
+
rawInput: args || {},
|
|
1176
|
+
}),
|
|
1177
|
+
async execute(args) {
|
|
1178
|
+
try {
|
|
1179
|
+
const wanted = args && typeof args.profile === 'string' && args.profile.trim() !== ''
|
|
1180
|
+
? args.profile.trim()
|
|
1181
|
+
: null;
|
|
1182
|
+
const prev = process.env.DSC_PROFILE;
|
|
1183
|
+
if (wanted) process.env.DSC_PROFILE = wanted;
|
|
1184
|
+
let compat;
|
|
1185
|
+
try {
|
|
1186
|
+
compat = await computeReport(state, ctx);
|
|
1187
|
+
} finally {
|
|
1188
|
+
if (wanted) {
|
|
1189
|
+
if (prev === undefined) delete process.env.DSC_PROFILE;
|
|
1190
|
+
else process.env.DSC_PROFILE = prev;
|
|
1191
|
+
}
|
|
1192
|
+
}
|
|
1193
|
+
const text = renderForModel(compat);
|
|
1194
|
+
const s = (compat.report && compat.report.summary) || {};
|
|
1195
|
+
return {
|
|
1196
|
+
summary: `可用 ${s.ok || 0} / 部分可用 ${s.partial || 0} / 不兼容 ${s.broken || 0}`,
|
|
1197
|
+
nodeModules: compat.nodeModules || '',
|
|
1198
|
+
hasRuntimeSurface: Boolean(compat.report && compat.report.hasRuntimeSurface),
|
|
1199
|
+
text,
|
|
1200
|
+
};
|
|
1201
|
+
} catch (error) {
|
|
1202
|
+
// 工具的 execute 抛错会把整轮对话变成错误 —— 而"体检失败"不是用户的错,
|
|
1203
|
+
// 也不该让他重来一遍。所以这里**不抛**,把失败也变成一段可读的文本。
|
|
1204
|
+
return {
|
|
1205
|
+
summary: '无法判定',
|
|
1206
|
+
nodeModules: '',
|
|
1207
|
+
hasRuntimeSurface: false,
|
|
1208
|
+
text: `体检没跑成(这是工具的问题,不是插件的结论):${errText(error)}\n`
|
|
1209
|
+
+ '请不要据此判断兼容性。',
|
|
1210
|
+
};
|
|
1211
|
+
}
|
|
1212
|
+
},
|
|
1213
|
+
});
|
|
1214
|
+
log('D1 tool registered: compat_check');
|
|
1215
|
+
} catch (error) {
|
|
1216
|
+
log(`WARN D1 tool registration failed: ${errText(error)}`);
|
|
1217
|
+
}
|
|
1218
|
+
});
|
|
1219
|
+
}
|
|
1220
|
+
|
|
1221
|
+
// ---------------------------------------------------------------------------
|
|
1222
|
+
// E1: skill 半边 —— 把 skill/SKILL.md 注册进 ctx.skills
|
|
1223
|
+
// ---------------------------------------------------------------------------
|
|
1224
|
+
//
|
|
1225
|
+
// **放在包里不算数。** DSH 不扫描插件包内的文件(实测见 skill/README.md):
|
|
1226
|
+
// `@liustack/modlens` 发了 `skills/modlens/SKILL.md` 却没注册,运行时根本没有这个 skill。
|
|
1227
|
+
// 两条路能进 skill 目录:① 文件落进文件系统扫描根(那是脚本 --install-skill 的路);
|
|
1228
|
+
// ② 运行时注册 —— 就是下面这段。
|
|
1229
|
+
//
|
|
1230
|
+
// 为什么用 `ctx.inject` 而不是静态 `inject = ['skills']`:与本文件开头同一条理由 ——
|
|
1231
|
+
// 静态 inject 会让本插件的 fiber 依赖 skills 服务,缺服务时整条条目停在 PENDING;
|
|
1232
|
+
// 而这个插件的职责正是"宿主缺了什么",它自己更不能因为缺服务而消失。
|
|
1233
|
+
//
|
|
1234
|
+
// 字段约束读自运行时源码(`@deepseek-ai/dsh-skill` 的 validateCandidate),不是照抄:
|
|
1235
|
+
// name 必须 kebab-case;description 必须非空;
|
|
1236
|
+
// provider 必须**等于** provider 自己的 name(否则报 returned skill "x" for provider "y");
|
|
1237
|
+
// rank 必须是有限数,600 是官方 bundled skill 用的 BUNDLED_SKILL_RANK。
|
|
1238
|
+
|
|
1239
|
+
const SKILL_NAME = 'dsh-plugin-compat-check';
|
|
1240
|
+
const SKILL_PROVIDER = 'dsh-plugin-compat-check';
|
|
1241
|
+
const SKILL_RANK = 600;
|
|
1242
|
+
|
|
1243
|
+
/**
|
|
1244
|
+
* 随包的 SKILL.md 在哪。
|
|
1245
|
+
*
|
|
1246
|
+
* ## ⚠️ 候选顺序是**实测换来的**,不要随手调
|
|
1247
|
+
*
|
|
1248
|
+
* 原来只有 `join(here, '..', 'skill', 'SKILL.md')`(仓库形态)。
|
|
1249
|
+
* 而**装进 profile 之后**它是 `<profile>/node_modules/dsh-plugin-compat-check`,
|
|
1250
|
+
* 上一层是 `node_modules/` —— 那里当然没有 `skill/`。
|
|
1251
|
+
*
|
|
1252
|
+
* 实测的后果是**静默失效**:宿主日志只有一句
|
|
1253
|
+
* `SKILL registration skipped: 找不到随包的 skill/SKILL.md`,
|
|
1254
|
+
* 而 skill 是本项目三类产物之一。E1 当年报"跑通"是因为那次**在仓库里**跑的。
|
|
1255
|
+
*
|
|
1256
|
+
* 根因是 pnpm 按 `package.json` 的 `files` 字段**复制**目录(不是符号链接),
|
|
1257
|
+
* 而当时 `files` 里没有 `skill/`。所以现在两件事一起做:
|
|
1258
|
+
* 1. `files` 加了 `skill/`,且 `plugin/skill/SKILL.md` 是**提交的副本**
|
|
1259
|
+
* (`file:` 安装路径**不跑 `prepack`**,所以不能像 kernel/ 那样只在打包时暂存);
|
|
1260
|
+
* 2. 候选顺序把**包内**的放第一位 —— 那才是安装后的真实位置。
|
|
1261
|
+
*
|
|
1262
|
+
* 副本会漂移,所以 `test/skill.test.mjs` 有一条断言:两份必须逐字节相同。
|
|
1263
|
+
*/
|
|
1264
|
+
function readPackagedSkill() {
|
|
1265
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
1266
|
+
const candidates = [
|
|
1267
|
+
join(here, 'skill', 'SKILL.md'), // **安装形态**:包内自带(files 里有 skill/)
|
|
1268
|
+
join(here, '..', 'skill', 'SKILL.md'), // 仓库形态:<repo>/plugin/index.js → <repo>/skill/SKILL.md
|
|
1269
|
+
join(here, 'SKILL.md'),
|
|
1270
|
+
join(here, 'assets', 'SKILL.md'),
|
|
1271
|
+
];
|
|
1272
|
+
for (const file of candidates) {
|
|
1273
|
+
try {
|
|
1274
|
+
if (!existsSync(file)) continue;
|
|
1275
|
+
const raw = readFileSync(file, 'utf8');
|
|
1276
|
+
const fm = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/u.exec(raw);
|
|
1277
|
+
if (fm === null) continue;
|
|
1278
|
+
// 只按**单行标量**读这两个字段。写成多行 YAML 时这里读不到 ——
|
|
1279
|
+
// 宁可读不到(打一条日志),也好过猜一个错的 description。
|
|
1280
|
+
const field = (key) => {
|
|
1281
|
+
const m = new RegExp(`^${key}:[ \t]*(.+)$`, 'm').exec(fm[1]);
|
|
1282
|
+
return m === null ? null : m[1].trim().replace(/^["']|["']$/g, '');
|
|
1283
|
+
};
|
|
1284
|
+
const skillName = field('name');
|
|
1285
|
+
const description = field('description');
|
|
1286
|
+
if (skillName === null || description === null) continue;
|
|
1287
|
+
return { file, name: skillName, description, content: raw.slice(fm[0].length).trim() };
|
|
1288
|
+
} catch {
|
|
1289
|
+
/* 读不到就试下一个候选 */
|
|
1290
|
+
}
|
|
1291
|
+
}
|
|
1292
|
+
return null;
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1295
|
+
function registerSkill(ctx) {
|
|
1296
|
+
try {
|
|
1297
|
+
if (typeof ctx.inject !== 'function') {
|
|
1298
|
+
log('SKILL registration skipped: ctx.inject is not a function');
|
|
1299
|
+
return;
|
|
1300
|
+
}
|
|
1301
|
+
ctx.inject(['skills'], (skillCtx) => {
|
|
1302
|
+
try {
|
|
1303
|
+
if (!skillCtx.skills || typeof skillCtx.skills.registerProvider !== 'function') {
|
|
1304
|
+
log('SKILL registration skipped: scoped ctx has no skills.registerProvider');
|
|
1305
|
+
return;
|
|
1306
|
+
}
|
|
1307
|
+
const skill = readPackagedSkill();
|
|
1308
|
+
if (skill === null) {
|
|
1309
|
+
log('SKILL registration skipped: 找不到随包的 skill/SKILL.md');
|
|
1310
|
+
return;
|
|
1311
|
+
}
|
|
1312
|
+
// 正文在注册时读进内存:包内文件运行时不会变,这样 get() 永不抛。
|
|
1313
|
+
const invocation = { modelInvocable: true, userInvocable: true };
|
|
1314
|
+
const summary = {
|
|
1315
|
+
name: SKILL_NAME,
|
|
1316
|
+
description: skill.description,
|
|
1317
|
+
invocation,
|
|
1318
|
+
provider: SKILL_PROVIDER,
|
|
1319
|
+
source: 'bundled',
|
|
1320
|
+
rank: SKILL_RANK,
|
|
1321
|
+
locator: skill.file,
|
|
1322
|
+
path: skill.file,
|
|
1323
|
+
resourceBase: { kind: 'directory', path: path.dirname(skill.file) },
|
|
1324
|
+
};
|
|
1325
|
+
skillCtx.skills.registerProvider(() => ({
|
|
1326
|
+
name: SKILL_PROVIDER,
|
|
1327
|
+
list: () => Promise.resolve([summary]),
|
|
1328
|
+
get: () => Promise.resolve({
|
|
1329
|
+
name: SKILL_NAME,
|
|
1330
|
+
description: skill.description,
|
|
1331
|
+
invocation,
|
|
1332
|
+
provider: SKILL_PROVIDER,
|
|
1333
|
+
source: summary.source,
|
|
1334
|
+
path: skill.file,
|
|
1335
|
+
content: skill.content,
|
|
1336
|
+
}),
|
|
1337
|
+
}));
|
|
1338
|
+
log(`SKILL registered: ${SKILL_NAME} <- ${skill.file}`);
|
|
1339
|
+
} catch (error) {
|
|
1340
|
+
log(`SKILL registration failed: ${errText(error)}`);
|
|
1341
|
+
}
|
|
1342
|
+
});
|
|
1343
|
+
} catch (error) {
|
|
1344
|
+
log(`SKILL registration subscribe failed: ${errText(error)}`);
|
|
1345
|
+
}
|
|
1346
|
+
}
|
|
1347
|
+
|
|
1348
|
+
export function apply(ctx) {
|
|
1349
|
+
// Nothing below may throw: a throw here fails this plugin's own fiber.
|
|
1350
|
+
try {
|
|
1351
|
+
log(`HOST-HALF-ACTIVATED pid=${process.pid} node=${process.version}`);
|
|
1352
|
+
|
|
1353
|
+
const state = {
|
|
1354
|
+
applyAt: new Date().toISOString(),
|
|
1355
|
+
loader: undefined,
|
|
1356
|
+
loaderVia: null,
|
|
1357
|
+
loaderAccess: {
|
|
1358
|
+
attempts: [],
|
|
1359
|
+
injectProbe: { registered: false, outcome: 'not run' },
|
|
1360
|
+
},
|
|
1361
|
+
disposeInjectProbe: null,
|
|
1362
|
+
// ── F2:结果缓存 ──
|
|
1363
|
+
// 面板打开时可能连着拉几次,而一次体检是几百毫秒到 1.5 秒的活。
|
|
1364
|
+
// 更重要的是**启动时就先跑一次**(见下面的预热),这样用户点开面板是即时的。
|
|
1365
|
+
lastReport: null,
|
|
1366
|
+
lastReportAt: 0,
|
|
1367
|
+
computing: null,
|
|
1368
|
+
/** 缓存有效期。到点不清空,而是**先返回旧的、后台再刷新** —— 见下面的 stale-while-revalidate。 */
|
|
1369
|
+
cacheTtlMs: toPositiveInt(process.env.DSC_CACHE_TTL_MS, 5 * 60 * 1000),
|
|
1370
|
+
/** 启动后多久自动跑第一次(默认落在最后一次 loader 扫描之后) */
|
|
1371
|
+
autoRunDelayMs: toPositiveInt(process.env.DSC_AUTORUN_MS, (SCAN_DELAYS[SCAN_DELAYS.length - 1] || 4000) + 2000),
|
|
1372
|
+
autoRunTimer: null,
|
|
1373
|
+
};
|
|
1374
|
+
|
|
1375
|
+
// Resolve the loader immediately: it is a plain service read and must not
|
|
1376
|
+
// depend on the entry tree having settled.
|
|
1377
|
+
try {
|
|
1378
|
+
const resolved = resolveLoader(ctx);
|
|
1379
|
+
state.loader = resolved.loader;
|
|
1380
|
+
state.loaderVia = resolved.via;
|
|
1381
|
+
state.loaderAccess = { ...state.loaderAccess, ...publicLoaderAccess(resolved) };
|
|
1382
|
+
log(
|
|
1383
|
+
`loader access: via=${resolved.via ?? 'UNREACHABLE'} attempts=${JSON.stringify(resolved.attempts)}`,
|
|
1384
|
+
);
|
|
1385
|
+
} catch (error) {
|
|
1386
|
+
state.loaderAccess.attempts.push(`resolveLoader threw: ${errText(error)}`);
|
|
1387
|
+
log(`ERROR resolving loader: ${errText(error)}`);
|
|
1388
|
+
}
|
|
1389
|
+
|
|
1390
|
+
// The `inject: ["loader"]` safety experiment (see startInjectProbe).
|
|
1391
|
+
try {
|
|
1392
|
+
state.disposeInjectProbe = startInjectProbe(ctx, state.loaderAccess.injectProbe);
|
|
1393
|
+
} catch (error) {
|
|
1394
|
+
state.loaderAccess.injectProbe.outcome = `probe setup threw: ${errText(error)}`;
|
|
1395
|
+
}
|
|
1396
|
+
|
|
1397
|
+
// ── C2 数据通道 ──
|
|
1398
|
+
//
|
|
1399
|
+
// 路由挂在**作用域 ctx** 上,而不是本插件自己的 ctx 上:这样
|
|
1400
|
+
// webServer 缺席时(headless / CLI 形态)这一段根本不注册,
|
|
1401
|
+
// 本插件的 fiber 也不会去等一个不存在的服务 —— `inject` 保持 [],
|
|
1402
|
+
// 与本文件的整体设计一致(见文件头)。
|
|
1403
|
+
try {
|
|
1404
|
+
if (typeof ctx.inject === 'function') {
|
|
1405
|
+
ctx.inject(['webServer'], (scope) => {
|
|
1406
|
+
try {
|
|
1407
|
+
scope.webServer.register({
|
|
1408
|
+
name: 'dsh-plugin-compat-check',
|
|
1409
|
+
kind: 'exact',
|
|
1410
|
+
path: '/plugin-compat-check/report',
|
|
1411
|
+
handler: async (req, res) => {
|
|
1412
|
+
// 体检结论是本机信息(装了哪些插件、用了哪些 API),
|
|
1413
|
+
// 不该被这台机器上的任意页面读到。
|
|
1414
|
+
if (!isTrustedRequest(req)) {
|
|
1415
|
+
res.writeHead(403, { 'content-type': 'application/json; charset=utf-8' });
|
|
1416
|
+
res.end(JSON.stringify({ error: 'refused: untrusted request' }));
|
|
1417
|
+
return;
|
|
1418
|
+
}
|
|
1419
|
+
try {
|
|
1420
|
+
const url = new URL(req.url || '/', 'http://127.0.0.1');
|
|
1421
|
+
const force = url.searchParams.get('refresh') === '1';
|
|
1422
|
+
const out = await serveReport(state, ctx, force);
|
|
1423
|
+
res.writeHead(200, {
|
|
1424
|
+
'content-type': 'application/json; charset=utf-8',
|
|
1425
|
+
'cache-control': 'no-store',
|
|
1426
|
+
});
|
|
1427
|
+
res.end(JSON.stringify(out));
|
|
1428
|
+
} catch (error) {
|
|
1429
|
+
// 连处理器里都不抛:把错误变成一条可读的 JSON,而不是 500 空响应。
|
|
1430
|
+
res.writeHead(200, { 'content-type': 'application/json; charset=utf-8' });
|
|
1431
|
+
res.end(JSON.stringify({ ok: false, error: errText(error) }));
|
|
1432
|
+
}
|
|
1433
|
+
},
|
|
1434
|
+
});
|
|
1435
|
+
log('C2 route registered: GET /plugin-compat-check/report');
|
|
1436
|
+
} catch (error) {
|
|
1437
|
+
log(`WARN C2 route registration failed: ${errText(error)}`);
|
|
1438
|
+
}
|
|
1439
|
+
});
|
|
1440
|
+
} else {
|
|
1441
|
+
log('C2 route skipped: ctx.inject is not a function');
|
|
1442
|
+
}
|
|
1443
|
+
} catch (error) {
|
|
1444
|
+
log(`WARN C2 route setup threw: ${errText(error)}`);
|
|
1445
|
+
}
|
|
1446
|
+
|
|
1447
|
+
// E1:注册随包的 skill。**失败只留日志,绝不抛** —— 见文件顶部硬规矩。
|
|
1448
|
+
try {
|
|
1449
|
+
registerSkill(ctx);
|
|
1450
|
+
} catch (error) {
|
|
1451
|
+
log(`WARN SKILL registration threw: ${errText(error)}`);
|
|
1452
|
+
}
|
|
1453
|
+
|
|
1454
|
+
// D1:对话工具(同一个 computeReport,所以工具与面板不可能给出不同答案)
|
|
1455
|
+
try {
|
|
1456
|
+
registerTool(ctx, state);
|
|
1457
|
+
} catch (error) {
|
|
1458
|
+
log(`WARN D1 tool setup threw: ${errText(error)}`);
|
|
1459
|
+
}
|
|
1460
|
+
|
|
1461
|
+
const finish = () => {
|
|
1462
|
+
const probe = state.loaderAccess.injectProbe;
|
|
1463
|
+
if (probe.registered && probe.firedAfterMs === undefined && probe.outcome === 'not run') {
|
|
1464
|
+
probe.outcome = 'did not fire before the final scan (would have stayed PENDING)';
|
|
1465
|
+
}
|
|
1466
|
+
log(
|
|
1467
|
+
`inject-probe: registered=${probe.registered} outcome=${probe.outcome}${
|
|
1468
|
+
probe.firedAfterMs !== undefined ? ` firedAfterMs=${probe.firedAfterMs}` : ''
|
|
1469
|
+
}`,
|
|
1470
|
+
);
|
|
1471
|
+
// ⚠️ 这里**不要**动 `state.autoRunTimer`。它是 F2 的启动自检,与 loader 扫描
|
|
1472
|
+
// 是两件事 —— 但它们**时间上相邻**:默认时机就落在最后一次扫描之后 2 秒。
|
|
1473
|
+
// 早先写成了 `finish()` 里 clearTimeout(autoRunTimer),于是最后一次扫描一结束
|
|
1474
|
+
// 就把启动自检取消了:**它从来没跑过,也没有任何报错**(性能测量看不见的静默失效)。
|
|
1475
|
+
// 教训:清理定时器必须由创建它的那一方负责。
|
|
1476
|
+
// F2:最后一次扫描之后跑启动自检 —— 这时候条目状态才是稳的。
|
|
1477
|
+
try {
|
|
1478
|
+
if (typeof state.runAutoCheck === 'function') state.runAutoCheck();
|
|
1479
|
+
} catch (error) {
|
|
1480
|
+
log(`WARN F2 启动自检触发失败: ${errText(error)}`);
|
|
1481
|
+
}
|
|
1482
|
+
try {
|
|
1483
|
+
if (typeof state.disposeInjectProbe === 'function') {
|
|
1484
|
+
state.disposeInjectProbe();
|
|
1485
|
+
state.disposeInjectProbe = null;
|
|
1486
|
+
}
|
|
1487
|
+
} catch (error) {
|
|
1488
|
+
log(`WARN disposing inject probe failed: ${errText(error)}`);
|
|
1489
|
+
}
|
|
1490
|
+
};
|
|
1491
|
+
|
|
1492
|
+
// Passes are serialized: each scan is time-bounded internally, so the next
|
|
1493
|
+
// one can safely wait for it and the report file is never written twice
|
|
1494
|
+
// concurrently.
|
|
1495
|
+
const schedulePass = (index) => {
|
|
1496
|
+
const previous = index === 0 ? 0 : SCAN_DELAYS[index - 1];
|
|
1497
|
+
const delay = Math.max(0, SCAN_DELAYS[index] - previous);
|
|
1498
|
+
const timer = setTimeout(() => {
|
|
1499
|
+
const pass = index + 1;
|
|
1500
|
+
try {
|
|
1501
|
+
scan(ctx, state, pass)
|
|
1502
|
+
.catch((error) => log(`ERROR scan#${pass} failed: ${errText(error)}`))
|
|
1503
|
+
.finally(() => {
|
|
1504
|
+
if (pass < SCAN_DELAYS.length) schedulePass(index + 1);
|
|
1505
|
+
else finish();
|
|
1506
|
+
});
|
|
1507
|
+
} catch (error) {
|
|
1508
|
+
log(`ERROR scan#${pass} threw synchronously: ${errText(error)}`);
|
|
1509
|
+
}
|
|
1510
|
+
}, delay);
|
|
1511
|
+
// unref: a scan timer must never keep the host process alive.
|
|
1512
|
+
if (timer && typeof timer.unref === 'function') timer.unref();
|
|
1513
|
+
};
|
|
1514
|
+
schedulePass(0);
|
|
1515
|
+
|
|
1516
|
+
// ── F2:启动后自动跑一次,并把「变了没有」直接打进启动日志 ──
|
|
1517
|
+
//
|
|
1518
|
+
// 为什么值得单独做:本产品最大的风险不是"算错",而是**用户想不起来看**。
|
|
1519
|
+
// 角标只在他点开面板时才存在;而"上次是好的、这次不行了"这件事,
|
|
1520
|
+
// 最该被知道的时间点就是**启动时**。
|
|
1521
|
+
//
|
|
1522
|
+
// 时机:**由最后一次 loader 扫描完成来触发**,而不是另起一个定时器。
|
|
1523
|
+
// 两个理由:
|
|
1524
|
+
// 1. 插件条目的状态在启动后几秒内还会变(异步 import),太早跑会把
|
|
1525
|
+
// "还在加载"误记成"没起来";
|
|
1526
|
+
// 2. 早先用独立定时器,结果它和 `finish()` 的时间**相邻**,被 `finish()`
|
|
1527
|
+
// 的清理顺手取消掉了 —— 启动自检从未运行、且**没有任何报错**。
|
|
1528
|
+
// 一个"看起来做了其实没做"的定时器,比没有更糟。
|
|
1529
|
+
state.runAutoCheck = () => {
|
|
1530
|
+
if (state.autoCheckStarted) return; // 幂等:只跑一次
|
|
1531
|
+
state.autoCheckStarted = true;
|
|
1532
|
+
try {
|
|
1533
|
+
computeReport(state, ctx)
|
|
1534
|
+
.then((out) => {
|
|
1535
|
+
state.lastReport = out;
|
|
1536
|
+
state.lastReportAt = Date.now();
|
|
1537
|
+
if (out && out.ok) {
|
|
1538
|
+
const sum = out.report.summary || {};
|
|
1539
|
+
const b = out.baseline || {};
|
|
1540
|
+
const badge = b.badge || {};
|
|
1541
|
+
log(`F2 启动自检: 可用 ${sum.ok || 0} 部分可用 ${sum.partial || 0} 不兼容 ${sum.broken || 0}`
|
|
1542
|
+
+ (sum.vacuousOk ? ` (${sum.vacuousOk} 个为空洞的可用)` : ''));
|
|
1543
|
+
// 有变化就打成一条显眼的日志;没变化也要有一条。
|
|
1544
|
+
// 「没有变化」必须说出来 —— 沉默会被读成"没检查"或"坏消息被藏起来了"。
|
|
1545
|
+
if (badge.show) log(`F2 与上次相比: ${badge.text} —— ${badge.detail || ''}`);
|
|
1546
|
+
else log(`F2 与上次相比: ${badge.detail || b.note || '没有可比基线'}`);
|
|
1547
|
+
} else if (out) {
|
|
1548
|
+
log(`WARN F2 启动自检没跑成: ${out.error || '未知原因'}`);
|
|
1549
|
+
}
|
|
1550
|
+
})
|
|
1551
|
+
.catch((error) => log(`WARN F2 启动自检失败: ${errText(error)}`));
|
|
1552
|
+
} catch (error) {
|
|
1553
|
+
log(`WARN F2 启动自检同步失败: ${errText(error)}`);
|
|
1554
|
+
}
|
|
1555
|
+
};
|
|
1556
|
+
|
|
1557
|
+
// 兜底:万一 loader 扫描那条路没走到(例如 loader 完全不可达,
|
|
1558
|
+
// `scan` 会提前 return),启动自检也必须跑。两条路都调 `runAutoCheck`,
|
|
1559
|
+
// 由它自己保证幂等 —— 谁先到都算数。
|
|
1560
|
+
{
|
|
1561
|
+
const timer = setTimeout(() => { try { state.runAutoCheck(); } catch { /* 忽略 */ } }, state.autoRunDelayMs);
|
|
1562
|
+
if (timer && typeof timer.unref === 'function') timer.unref();
|
|
1563
|
+
state.fallbackTimer = timer;
|
|
1564
|
+
}
|
|
1565
|
+
|
|
1566
|
+
// Manual re-scan handle for tooling/experiments (no host service needed).
|
|
1567
|
+
try {
|
|
1568
|
+
globalThis.__DSH_COMPAT_CHECK__ = {
|
|
1569
|
+
rescan: () => scan(ctx, state, 0),
|
|
1570
|
+
reportPath: OUT_JSON,
|
|
1571
|
+
// 面板用的那份结论(同一个函数,所以界面与工具不可能给出不同答案)
|
|
1572
|
+
compatReport: (c) => computeReport(state, c || ctx),
|
|
1573
|
+
};
|
|
1574
|
+
} catch {
|
|
1575
|
+
/* 忽略 */
|
|
1576
|
+
}
|
|
1577
|
+
} catch (error) {
|
|
1578
|
+
try {
|
|
1579
|
+
log(`FATAL-SWALLOWED apply() failed: ${errText(error)}`);
|
|
1580
|
+
} catch {
|
|
1581
|
+
/* 连打日志都失败就只能咽下去 */
|
|
1582
|
+
}
|
|
1583
|
+
}
|
|
1584
|
+
}
|