dsh-plugin-compat-check 0.1.0 → 0.1.2
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/LICENSE +21 -0
- package/README.md +21 -57
- package/index.js +93 -155
- package/kernel/lib/baseline.mjs +110 -34
- package/kernel/lib/contracts.mjs +493 -0
- package/kernel/lib/declarations.mjs +50 -0
- package/kernel/lib/extract-worker.mjs +10 -0
- package/kernel/lib/extract.mjs +235 -1515
- package/kernel/lib/flow-values.mjs +53 -0
- package/kernel/lib/observation.mjs +182 -0
- package/kernel/lib/report-service.mjs +55 -0
- package/kernel/lib/report.mjs +237 -0
- package/kernel/lib/source-analysis.mjs +1913 -0
- package/kernel/lib/source-graph.mjs +297 -0
- package/kernel/lib/state-store.mjs +126 -0
- package/kernel/lib/syntax.mjs +73 -0
- package/kernel/lib/verdict.mjs +492 -587
- package/kernel/lib/version.mjs +2 -2
- package/kernel/lib/worker-analysis.mjs +82 -0
- package/kernel/rules/README.md +46 -58
- package/kernel/rules/severity.json +161 -35
- package/kernel/rules/usage-inventory.json +369 -61
- package/lib/client.js +523 -10
- package/lib/host-resources.js +92 -0
- package/lib/report-contract.js +494 -0
- package/package.json +14 -5
- package/scripts/prepublish-check.mjs +14 -9
- package/scripts/stage-contract.mjs +43 -0
- package/scripts/stage-kernel.mjs +9 -21
- package/skill/SKILL.md +40 -39
package/lib/client.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
// Client half of dsh-plugin-compat-check — the overview table (Plan.md §5 C2).
|
|
1
|
+
// Client half of dsh-plugin-compat-check — the overview table (docs/Plan.md §5 C2).
|
|
2
2
|
//
|
|
3
3
|
// This file is a PREBUILT artifact: the host does not build plugin client code.
|
|
4
4
|
// If it moves to src/ the entry fails to activate with "run pnpm build before
|
|
5
5
|
// launch", so it stays here as plain, dependency-free JS.
|
|
6
6
|
//
|
|
7
|
-
// Contract (Plan.md §7.3, verified on 0.2.0-rc.2):
|
|
7
|
+
// Contract (docs/Plan.md §7.3, verified on 0.2.0-rc.2):
|
|
8
8
|
// window.__ModuleLoader__.load({ id: "<package name>", factory })
|
|
9
9
|
// factory(require) -> { name, inject: ["slots"], apply(ctx) }
|
|
10
10
|
//
|
|
@@ -27,12 +27,512 @@ window.__ModuleLoader__.load({
|
|
|
27
27
|
var exports = module.exports;
|
|
28
28
|
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
29
29
|
|
|
30
|
+
// BEGIN GENERATED REPORT CONTRACT
|
|
31
|
+
var ReportContract = (function () {
|
|
32
|
+
/** Browser-safe runtime type contracts. No imports: staged into the prebuilt
|
|
33
|
+
* client and plugin host from this single source. Unknown additive fields are
|
|
34
|
+
* preserved. Version changes or missing evidence fail closed as tool errors.
|
|
35
|
+
* @typedef {{status:'complete'|'limited',verifiedCalls:number,limitations:string[]}} Coverage
|
|
36
|
+
* @typedef {{category:string,severity:'fatal'|'feature-dead'|'degraded',certainty:'certain'|'suspected',impactBasis:'measured'|'inferred',location:object|null}} Finding
|
|
37
|
+
* @typedef {{mode:'catalogue'|'runtime',bound:boolean,usable:boolean,notes:string[],profileId:string|null,dshVersion:string|null,probedAt:string|null}} Observation
|
|
38
|
+
*/
|
|
39
|
+
const REPORT_SCHEMA = 1;
|
|
40
|
+
const REPORT_KIND = "plugin-compat-check/result";
|
|
41
|
+
const ENVELOPE_KIND = "plugin-compat-check/report";
|
|
42
|
+
const record = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
|
|
43
|
+
const text = (v) => typeof v === "string" && v.length > 0;
|
|
44
|
+
const nullableText = (v) => v === null || typeof v === "string";
|
|
45
|
+
const integer = (v) => Number.isSafeInteger(v) && v >= 0;
|
|
46
|
+
const strings = (v) =>
|
|
47
|
+
Array.isArray(v) && v.every((x) => typeof x === "string");
|
|
48
|
+
const verdicts = ["ok", "partial", "broken", "unknown"];
|
|
49
|
+
const severities = ["fatal", "feature-dead", "degraded"];
|
|
50
|
+
const certainties = ["certain", "suspected"];
|
|
51
|
+
const dispositions = [
|
|
52
|
+
"finding",
|
|
53
|
+
"client-side",
|
|
54
|
+
"self-provided",
|
|
55
|
+
"runtime-facility",
|
|
56
|
+
"filtered",
|
|
57
|
+
"abstained",
|
|
58
|
+
"guarded",
|
|
59
|
+
"no-calls",
|
|
60
|
+
"unknown",
|
|
61
|
+
];
|
|
62
|
+
function coherentVerdict(p) {
|
|
63
|
+
if (!Array.isArray(p.findings)) return false;
|
|
64
|
+
return (
|
|
65
|
+
p.verdict === "unknown" ||
|
|
66
|
+
p.verdict ===
|
|
67
|
+
(p.findings.some((f) => f?.severity === "fatal")
|
|
68
|
+
? "broken"
|
|
69
|
+
: p.findings.length
|
|
70
|
+
? "partial"
|
|
71
|
+
: "ok")
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
function snapshotFinding(f) {
|
|
75
|
+
return (
|
|
76
|
+
record(f) &&
|
|
77
|
+
text(f.key) &&
|
|
78
|
+
text(f.category) &&
|
|
79
|
+
severities.includes(f.severity) &&
|
|
80
|
+
certainties.includes(f.certainty) &&
|
|
81
|
+
nullableText(f.location)
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
function checkedEntry(v) {
|
|
85
|
+
return record(v) && text(v.service) && dispositions.includes(v.disposition);
|
|
86
|
+
}
|
|
87
|
+
// Producers resolve relative observation paths before serialization. Lexical
|
|
88
|
+
// normalization here is platform independent and needs no browser Node shim.
|
|
89
|
+
function pathIdentity(v) {
|
|
90
|
+
const normalized = v.replace(/\\/g, "/");
|
|
91
|
+
const parts = [];
|
|
92
|
+
for (const part of normalized.split("/")) {
|
|
93
|
+
if (part === "..") parts.pop();
|
|
94
|
+
else if (part && part !== ".") parts.push(part);
|
|
95
|
+
}
|
|
96
|
+
return (normalized.startsWith("/") ? "/" : "") + parts.join("/");
|
|
97
|
+
}
|
|
98
|
+
function need(errors, condition, path) {
|
|
99
|
+
if (!condition) errors.push(path);
|
|
100
|
+
}
|
|
101
|
+
function exitCodeForSummary(summary) {
|
|
102
|
+
return summary.broken > 0
|
|
103
|
+
? 2
|
|
104
|
+
: summary.partial > 0 || summary.unknown > 0
|
|
105
|
+
? 1
|
|
106
|
+
: 0;
|
|
107
|
+
}
|
|
108
|
+
function createFailure(error, options = {}) {
|
|
109
|
+
return {
|
|
110
|
+
...options,
|
|
111
|
+
schemaVersion: REPORT_SCHEMA,
|
|
112
|
+
kind: REPORT_KIND,
|
|
113
|
+
ok: false,
|
|
114
|
+
exitCode: 3,
|
|
115
|
+
toolVersion: options.toolVersion ?? null,
|
|
116
|
+
error: String(error || "Unknown tool error"),
|
|
117
|
+
inputs: options.inputs || {},
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
function validateObservation(value) {
|
|
121
|
+
const e = [];
|
|
122
|
+
if (!record(value)) return ["observation"];
|
|
123
|
+
need(e, ["runtime", "catalogue"].includes(value.mode), "observation.mode");
|
|
124
|
+
need(e, typeof value.bound === "boolean", "observation.bound");
|
|
125
|
+
need(e, typeof value.usable === "boolean", "observation.usable");
|
|
126
|
+
need(e, strings(value.notes), "observation.notes");
|
|
127
|
+
for (const key of ["profileId", "dshVersion", "probedAt"])
|
|
128
|
+
need(e, nullableText(value[key]), "observation." + key);
|
|
129
|
+
if (value.probedAt !== null)
|
|
130
|
+
need(
|
|
131
|
+
e,
|
|
132
|
+
Number.isFinite(Date.parse(value.probedAt)),
|
|
133
|
+
"observation.probedAt",
|
|
134
|
+
);
|
|
135
|
+
need(
|
|
136
|
+
e,
|
|
137
|
+
(value.mode === "runtime") === value.usable,
|
|
138
|
+
"observation.mode/usable",
|
|
139
|
+
);
|
|
140
|
+
return e;
|
|
141
|
+
}
|
|
142
|
+
function validateFinding(f) {
|
|
143
|
+
const e = [];
|
|
144
|
+
if (!record(f)) return ["finding"];
|
|
145
|
+
need(e, text(f.category), "finding.category");
|
|
146
|
+
need(e, severities.includes(f.severity), "finding.severity");
|
|
147
|
+
need(e, certainties.includes(f.certainty), "finding.certainty");
|
|
148
|
+
need(
|
|
149
|
+
e,
|
|
150
|
+
["inferred", "measured"].includes(f.impactBasis),
|
|
151
|
+
"finding.impactBasis",
|
|
152
|
+
);
|
|
153
|
+
need(e, text(f.impact), "finding.impact");
|
|
154
|
+
need(e, text(f.basis), "finding.basis");
|
|
155
|
+
for (const key of ["service", "method"])
|
|
156
|
+
if (f[key] !== undefined) need(e, nullableText(f[key]), "finding." + key);
|
|
157
|
+
need(
|
|
158
|
+
e,
|
|
159
|
+
f.location === null ||
|
|
160
|
+
(record(f.location) &&
|
|
161
|
+
text(f.location.file) &&
|
|
162
|
+
Number.isSafeInteger(f.location.line) &&
|
|
163
|
+
f.location.line > 0),
|
|
164
|
+
"finding.location",
|
|
165
|
+
);
|
|
166
|
+
if (f.evidence !== undefined) need(e, record(f.evidence), "finding.evidence");
|
|
167
|
+
if (record(f.location)) {
|
|
168
|
+
for (const key of ["column", "start", "end"])
|
|
169
|
+
if (f.location[key] !== undefined && f.location[key] !== null)
|
|
170
|
+
need(e, integer(f.location[key]), "finding.location." + key);
|
|
171
|
+
if (integer(f.location.start) && integer(f.location.end))
|
|
172
|
+
need(e, f.location.end >= f.location.start, "finding.location.span");
|
|
173
|
+
}
|
|
174
|
+
return e;
|
|
175
|
+
}
|
|
176
|
+
function validateBaseline(value) {
|
|
177
|
+
const e = [];
|
|
178
|
+
if (!record(value) || value.schemaVersion !== 2)
|
|
179
|
+
return ["baseline.schemaVersion"];
|
|
180
|
+
need(e, nullableText(value.dshVersion), "baseline.dshVersion");
|
|
181
|
+
need(
|
|
182
|
+
e,
|
|
183
|
+
record(value.identity) && text(value.identity.mode),
|
|
184
|
+
"baseline.identity",
|
|
185
|
+
);
|
|
186
|
+
if (!record(value.plugins)) return [...e, "baseline.plugins"];
|
|
187
|
+
for (const [name, p] of Object.entries(value.plugins)) {
|
|
188
|
+
if (!record(p)) {
|
|
189
|
+
e.push("baseline.plugin:" + name);
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
need(
|
|
193
|
+
e,
|
|
194
|
+
nullableText(p.version) && verdicts.includes(p.verdict),
|
|
195
|
+
"baseline.plugin:" + name,
|
|
196
|
+
);
|
|
197
|
+
need(e, Array.isArray(p.findings), "baseline.findings:" + name);
|
|
198
|
+
need(e, coherentVerdict(p), "baseline.verdict/findings:" + name);
|
|
199
|
+
if (p.vacuous !== undefined)
|
|
200
|
+
need(e, typeof p.vacuous === "boolean", "baseline.vacuous:" + name);
|
|
201
|
+
for (const f of Array.isArray(p.findings) ? p.findings : []) {
|
|
202
|
+
if (!record(f)) {
|
|
203
|
+
e.push("baseline.finding:" + name);
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
need(e, snapshotFinding(f), "baseline.finding:" + name);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
return e;
|
|
210
|
+
}
|
|
211
|
+
function validateBaselineResult(b) {
|
|
212
|
+
if (b === null) return [];
|
|
213
|
+
const e = [];
|
|
214
|
+
if (!record(b)) return ["baseline result"];
|
|
215
|
+
if (b.error !== undefined) {
|
|
216
|
+
need(e, text(b.error) && text(b.note), "baseline.error");
|
|
217
|
+
return e;
|
|
218
|
+
}
|
|
219
|
+
need(e, typeof b.wrote === "boolean", "baseline.wrote");
|
|
220
|
+
need(e, record(b.badge), "baseline.badge");
|
|
221
|
+
if (record(b.badge)) {
|
|
222
|
+
need(
|
|
223
|
+
e,
|
|
224
|
+
typeof b.badge.show === "boolean" &&
|
|
225
|
+
nullableText(b.badge.text) &&
|
|
226
|
+
nullableText(b.badge.detail) &&
|
|
227
|
+
[null, "worse", "better", "mixed", "neutral"].includes(
|
|
228
|
+
b.badge.direction,
|
|
229
|
+
),
|
|
230
|
+
"baseline.badge.fields",
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
if (b.diff !== null) {
|
|
234
|
+
need(
|
|
235
|
+
e,
|
|
236
|
+
record(b.diff) && typeof b.diff.comparable === "boolean",
|
|
237
|
+
"baseline.diff",
|
|
238
|
+
);
|
|
239
|
+
if (record(b.diff)) {
|
|
240
|
+
for (const key of [
|
|
241
|
+
"added",
|
|
242
|
+
"removed",
|
|
243
|
+
"worsened",
|
|
244
|
+
"improved",
|
|
245
|
+
"updated",
|
|
246
|
+
]) {
|
|
247
|
+
need(e, Array.isArray(b.diff[key]), "baseline.diff." + key);
|
|
248
|
+
for (const entry of Array.isArray(b.diff[key]) ? b.diff[key] : []) {
|
|
249
|
+
const at = "baseline.diff." + key;
|
|
250
|
+
if (!record(entry)) {
|
|
251
|
+
e.push(at + ".entry");
|
|
252
|
+
continue;
|
|
253
|
+
}
|
|
254
|
+
need(e, text(entry.name), at + ".name");
|
|
255
|
+
if (key === "added" || key === "removed") {
|
|
256
|
+
need(e, nullableText(entry.version), at + ".version");
|
|
257
|
+
continue;
|
|
258
|
+
}
|
|
259
|
+
need(
|
|
260
|
+
e,
|
|
261
|
+
nullableText(entry.versionFrom) &&
|
|
262
|
+
nullableText(entry.versionTo) &&
|
|
263
|
+
typeof entry.versionChanged === "boolean" &&
|
|
264
|
+
verdicts.includes(entry.verdictFrom) &&
|
|
265
|
+
verdicts.includes(entry.verdictTo) &&
|
|
266
|
+
text(entry.reason),
|
|
267
|
+
at + ".fields",
|
|
268
|
+
);
|
|
269
|
+
for (const field of ["newFindings", "goneFindings"]) {
|
|
270
|
+
need(
|
|
271
|
+
e,
|
|
272
|
+
Array.isArray(entry[field]) &&
|
|
273
|
+
entry[field].every(snapshotFinding),
|
|
274
|
+
at + "." + field,
|
|
275
|
+
);
|
|
276
|
+
}
|
|
277
|
+
need(
|
|
278
|
+
e,
|
|
279
|
+
Array.isArray(entry.changedFindings) &&
|
|
280
|
+
entry.changedFindings.every(
|
|
281
|
+
(c) =>
|
|
282
|
+
record(c) && snapshotFinding(c.from) && snapshotFinding(c.to),
|
|
283
|
+
),
|
|
284
|
+
at + ".changedFindings",
|
|
285
|
+
);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
for (const key of ["dshVersionFrom", "dshVersionTo", "reason"])
|
|
289
|
+
need(e, nullableText(b.diff[key]), "baseline.diff." + key);
|
|
290
|
+
need(
|
|
291
|
+
e,
|
|
292
|
+
typeof b.diff.dshVersionChanged === "boolean",
|
|
293
|
+
"baseline.diff.dshVersionChanged",
|
|
294
|
+
);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
return e;
|
|
298
|
+
}
|
|
299
|
+
function validateReportBody(body) {
|
|
300
|
+
const e = [];
|
|
301
|
+
if (!record(body)) return ["report"];
|
|
302
|
+
need(
|
|
303
|
+
e,
|
|
304
|
+
body.schemaVersion === REPORT_SCHEMA && body.kind === REPORT_KIND,
|
|
305
|
+
"report.schemaVersion/kind",
|
|
306
|
+
);
|
|
307
|
+
need(e, typeof body.ok === "boolean", "report.ok");
|
|
308
|
+
need(e, nullableText(body.toolVersion), "report.toolVersion");
|
|
309
|
+
need(e, record(body.inputs), "report.inputs");
|
|
310
|
+
if (body.ok === false) {
|
|
311
|
+
need(e, body.exitCode === 3 && text(body.error), "report.error/exitCode");
|
|
312
|
+
return e;
|
|
313
|
+
}
|
|
314
|
+
need(e, body.error === null, "report.error");
|
|
315
|
+
need(e, nullableText(body.dshVersion), "report.dshVersion");
|
|
316
|
+
e.push(...validateObservation(body.observation));
|
|
317
|
+
if (
|
|
318
|
+
record(body.observation) &&
|
|
319
|
+
body.observation.bound &&
|
|
320
|
+
body.observation.usable
|
|
321
|
+
) {
|
|
322
|
+
const o = body.observation;
|
|
323
|
+
if (text(body.dshVersion) && text(o.dshVersion))
|
|
324
|
+
need(
|
|
325
|
+
e,
|
|
326
|
+
o.dshVersion === body.dshVersion,
|
|
327
|
+
"observation/report.dshVersion",
|
|
328
|
+
);
|
|
329
|
+
if (text(body.inputs?.nodeModules) && text(o.profileId))
|
|
330
|
+
need(
|
|
331
|
+
e,
|
|
332
|
+
pathIdentity(o.profileId) === pathIdentity(body.inputs.nodeModules),
|
|
333
|
+
"observation/report.profileId",
|
|
334
|
+
);
|
|
335
|
+
}
|
|
336
|
+
e.push(...validateBaselineResult(body.baseline));
|
|
337
|
+
const summary = body.summary,
|
|
338
|
+
plugins = body.plugins;
|
|
339
|
+
if (!record(summary) || !record(plugins))
|
|
340
|
+
return [...e, "report.summary/plugins"];
|
|
341
|
+
for (const key of [...verdicts, "limited", "vacuousOk"])
|
|
342
|
+
need(e, integer(summary[key]), "summary." + key);
|
|
343
|
+
const counts = {
|
|
344
|
+
ok: 0,
|
|
345
|
+
partial: 0,
|
|
346
|
+
broken: 0,
|
|
347
|
+
unknown: 0,
|
|
348
|
+
limited: 0,
|
|
349
|
+
vacuousOk: 0,
|
|
350
|
+
};
|
|
351
|
+
for (const [name, p] of Object.entries(plugins)) {
|
|
352
|
+
if (!record(p)) {
|
|
353
|
+
e.push("plugin:" + name);
|
|
354
|
+
continue;
|
|
355
|
+
}
|
|
356
|
+
need(
|
|
357
|
+
e,
|
|
358
|
+
p.name === name &&
|
|
359
|
+
nullableText(p.version) &&
|
|
360
|
+
verdicts.includes(p.verdict),
|
|
361
|
+
"plugin.identity/verdict:" + name,
|
|
362
|
+
);
|
|
363
|
+
need(e, typeof p.vacuous === "boolean", "plugin.vacuous:" + name);
|
|
364
|
+
if (verdicts.includes(p.verdict)) counts[p.verdict]++;
|
|
365
|
+
if (p.vacuous && p.verdict === "ok") counts.vacuousOk++;
|
|
366
|
+
const c = p.coverage;
|
|
367
|
+
need(
|
|
368
|
+
e,
|
|
369
|
+
record(c) &&
|
|
370
|
+
["complete", "limited"].includes(c.status) &&
|
|
371
|
+
integer(c.verifiedCalls) &&
|
|
372
|
+
strings(c.limitations),
|
|
373
|
+
"plugin.coverage:" + name,
|
|
374
|
+
);
|
|
375
|
+
if (c?.status === "limited") counts.limited++;
|
|
376
|
+
for (const key of [
|
|
377
|
+
"findings",
|
|
378
|
+
"checked",
|
|
379
|
+
"clientSide",
|
|
380
|
+
"filtered",
|
|
381
|
+
"abstained",
|
|
382
|
+
"runtimeFacilities",
|
|
383
|
+
])
|
|
384
|
+
need(e, Array.isArray(p[key]), "plugin." + key + ":" + name);
|
|
385
|
+
if (Array.isArray(p.findings))
|
|
386
|
+
for (const f of p.findings)
|
|
387
|
+
e.push(...validateFinding(f).map((x) => name + ":" + x));
|
|
388
|
+
need(e, coherentVerdict(p), "plugin.verdict/findings:" + name);
|
|
389
|
+
if (record(c) && integer(c.verifiedCalls))
|
|
390
|
+
need(
|
|
391
|
+
e,
|
|
392
|
+
p.vacuous === (p.verdict === "ok" && c.verifiedCalls === 0),
|
|
393
|
+
"plugin.vacuous/coverage:" + name,
|
|
394
|
+
);
|
|
395
|
+
if (record(c) && strings(c.limitations) && Array.isArray(p.clientSide))
|
|
396
|
+
need(
|
|
397
|
+
e,
|
|
398
|
+
(c.status === "limited") ===
|
|
399
|
+
(c.limitations.length > 0 ||
|
|
400
|
+
p.clientSide.length > 0 ||
|
|
401
|
+
p.vacuous === true),
|
|
402
|
+
"plugin.coverage/status:" + name,
|
|
403
|
+
);
|
|
404
|
+
need(e, record(p.resolved), "plugin.resolved:" + name);
|
|
405
|
+
if (record(p.resolved))
|
|
406
|
+
for (const [service, value] of Object.entries(p.resolved))
|
|
407
|
+
need(
|
|
408
|
+
e,
|
|
409
|
+
checkedEntry(value) && value.service === service,
|
|
410
|
+
"plugin.resolved.entry:" + name + "." + service,
|
|
411
|
+
);
|
|
412
|
+
for (const [key, valid] of [
|
|
413
|
+
["checked", checkedEntry],
|
|
414
|
+
["clientSide", (v) => record(v) && text(v.service) && text(v.note)],
|
|
415
|
+
[
|
|
416
|
+
"filtered",
|
|
417
|
+
(v) => record(v) && text(v.service) && text(v.method) && text(v.reason),
|
|
418
|
+
],
|
|
419
|
+
["abstained", (v) => record(v) && text(v.service) && text(v.basis)],
|
|
420
|
+
["runtimeFacilities", (v) => record(v) && text(v.name) && text(v.reason)],
|
|
421
|
+
])
|
|
422
|
+
if (Array.isArray(p[key]))
|
|
423
|
+
need(e, p[key].every(valid), "plugin." + key + ".entry:" + name);
|
|
424
|
+
}
|
|
425
|
+
for (const key of Object.keys(counts))
|
|
426
|
+
need(e, counts[key] === summary[key], "summary.count:" + key);
|
|
427
|
+
need(e, body.exitCode === exitCodeForSummary(summary), "report.exitCode");
|
|
428
|
+
need(
|
|
429
|
+
e,
|
|
430
|
+
record(body.extraction) &&
|
|
431
|
+
typeof body.extraction.scanComplete === "boolean" &&
|
|
432
|
+
Array.isArray(body.extraction.diagnostics) &&
|
|
433
|
+
Array.isArray(body.extraction.manifests),
|
|
434
|
+
"report.extraction",
|
|
435
|
+
);
|
|
436
|
+
return e;
|
|
437
|
+
}
|
|
438
|
+
function assertReportBody(body) {
|
|
439
|
+
const errors = validateReportBody(body);
|
|
440
|
+
if (errors.length) throw new Error("报告契约不匹配: " + errors.join(", "));
|
|
441
|
+
return body;
|
|
442
|
+
}
|
|
443
|
+
function sealReportBody(input) {
|
|
444
|
+
const observation = input.observation || {};
|
|
445
|
+
const plugins = Object.fromEntries(
|
|
446
|
+
Object.entries(input.plugins || {}).map(([name, p]) => [
|
|
447
|
+
name,
|
|
448
|
+
{
|
|
449
|
+
...p,
|
|
450
|
+
findings: p.findings.map((f) => ({
|
|
451
|
+
...f,
|
|
452
|
+
location: f.location ?? null,
|
|
453
|
+
})),
|
|
454
|
+
},
|
|
455
|
+
]),
|
|
456
|
+
);
|
|
457
|
+
return assertReportBody({
|
|
458
|
+
...input,
|
|
459
|
+
schemaVersion: REPORT_SCHEMA,
|
|
460
|
+
kind: REPORT_KIND,
|
|
461
|
+
ok: true,
|
|
462
|
+
error: null,
|
|
463
|
+
exitCode: exitCodeForSummary(input.summary),
|
|
464
|
+
summary: { limited: 0, vacuousOk: 0, ...input.summary },
|
|
465
|
+
plugins,
|
|
466
|
+
observation: {
|
|
467
|
+
profileId: null,
|
|
468
|
+
dshVersion: null,
|
|
469
|
+
probedAt: null,
|
|
470
|
+
...observation,
|
|
471
|
+
},
|
|
472
|
+
baseline: input.baseline ?? null,
|
|
473
|
+
});
|
|
474
|
+
}
|
|
475
|
+
function failureEnvelope(error, options = {}) {
|
|
476
|
+
const report = createFailure(error, {
|
|
477
|
+
toolVersion: options.toolVersion ?? null,
|
|
478
|
+
inputs: options.report?.inputs || {},
|
|
479
|
+
});
|
|
480
|
+
return {
|
|
481
|
+
...options,
|
|
482
|
+
schemaVersion: REPORT_SCHEMA,
|
|
483
|
+
kind: ENVELOPE_KIND,
|
|
484
|
+
ok: false,
|
|
485
|
+
error: report.error,
|
|
486
|
+
exitCode: 3,
|
|
487
|
+
toolVersion: report.toolVersion,
|
|
488
|
+
baseline: null,
|
|
489
|
+
report,
|
|
490
|
+
};
|
|
491
|
+
}
|
|
492
|
+
function validateEnvelope(data) {
|
|
493
|
+
if (
|
|
494
|
+
!record(data) ||
|
|
495
|
+
data.schemaVersion !== REPORT_SCHEMA ||
|
|
496
|
+
data.kind !== ENVELOPE_KIND
|
|
497
|
+
)
|
|
498
|
+
return ["envelope.schemaVersion/kind"];
|
|
499
|
+
const e = validateReportBody(data.report);
|
|
500
|
+
need(
|
|
501
|
+
e,
|
|
502
|
+
typeof data.ok === "boolean" && data.ok === data.report?.ok,
|
|
503
|
+
"envelope.ok",
|
|
504
|
+
);
|
|
505
|
+
need(e, data.exitCode === data.report?.exitCode, "envelope.exitCode");
|
|
506
|
+
need(e, data.error === data.report?.error, "envelope.error");
|
|
507
|
+
need(
|
|
508
|
+
e,
|
|
509
|
+
data.toolVersion === data.report?.toolVersion,
|
|
510
|
+
"envelope.toolVersion",
|
|
511
|
+
);
|
|
512
|
+
need(
|
|
513
|
+
e,
|
|
514
|
+
JSON.stringify(data.baseline) ===
|
|
515
|
+
JSON.stringify(data.report?.baseline ?? null),
|
|
516
|
+
"envelope.baseline",
|
|
517
|
+
);
|
|
518
|
+
return e;
|
|
519
|
+
}
|
|
520
|
+
function assertEnvelope(data) {
|
|
521
|
+
const errors = validateEnvelope(data);
|
|
522
|
+
if (errors.length) throw new Error("报告契约不匹配: " + errors.join(", "));
|
|
523
|
+
return data;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
return { assertEnvelope: assertEnvelope };
|
|
527
|
+
})();
|
|
528
|
+
// END GENERATED REPORT CONTRACT
|
|
529
|
+
|
|
30
530
|
var React = require("react");
|
|
31
531
|
|
|
32
532
|
var SLOT = "settings.plugins.tab";
|
|
33
533
|
var LABEL = "兼容性";
|
|
34
534
|
var ROUTE = "/plugin-compat-check/report";
|
|
35
|
-
// C3:单插件视图的两个槽位。实测(Plan.md §5 C3):
|
|
535
|
+
// C3:单插件视图的两个槽位。实测(docs/Plan.md §5 C3):
|
|
36
536
|
// - 详情页不是「抽屉」,是侧栏插件面板**换了 React 视图状态**:
|
|
37
537
|
// 侧栏轨道按钮 [class*="panelRow"][aria-label="插件"] → 卡片标题
|
|
38
538
|
// button[class*="cardOpen"][aria-label="查看 <名字>"] → 返回用 button[class*="crumb"]。
|
|
@@ -56,10 +556,11 @@ window.__ModuleLoader__.load({
|
|
|
56
556
|
"host-method-missing": "方法缺失",
|
|
57
557
|
"unprovided-dependency": "无人提供的依赖",
|
|
58
558
|
"declaration-mismatch": "声明与实测不符",
|
|
559
|
+
"declared-incompatible": "版本声明不支持",
|
|
59
560
|
};
|
|
60
561
|
|
|
61
562
|
// 渲染侧标记:只在我们的组件体里设置。页面上出现同名文字**不能**证明
|
|
62
|
-
// 是我们渲染的,这个标记能 —— 验收要经得起这个追问(Plan.md §7.2.1 ③)。
|
|
563
|
+
// 是我们渲染的,这个标记能 —— 验收要经得起这个追问(docs/Plan.md §7.2.1 ③)。
|
|
63
564
|
function mark(extra) {
|
|
64
565
|
try {
|
|
65
566
|
var prev = window.__COMPAT_CHECK__ || {};
|
|
@@ -91,6 +592,14 @@ window.__ModuleLoader__.load({
|
|
|
91
592
|
}
|
|
92
593
|
|
|
93
594
|
/** C3 · 详情页 section:这个插件的完整结论 + 每条发现的证据 */
|
|
595
|
+
function acceptPayload(data) {
|
|
596
|
+
try { return ReportContract.assertEnvelope(data); }
|
|
597
|
+
catch (error) {
|
|
598
|
+
console.warn('[dsh-plugin-compat-check] invalid report', String(error && error.message || error));
|
|
599
|
+
throw new Error('无法读取体检报告:报告格式或版本不受支持,请刷新页面并确认宿主与插件版本一致。');
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
|
|
94
603
|
function useCompatibility() {
|
|
95
604
|
var state = React.useState({ phase: "loading", data: null, error: null });
|
|
96
605
|
var value = state[0];
|
|
@@ -109,6 +618,7 @@ window.__ModuleLoader__.load({
|
|
|
109
618
|
return res.json();
|
|
110
619
|
})
|
|
111
620
|
.then(function (data) {
|
|
621
|
+
data = acceptPayload(data);
|
|
112
622
|
if (!alive.current) return;
|
|
113
623
|
set({ phase: "done", data: data, error: null });
|
|
114
624
|
})
|
|
@@ -199,7 +709,9 @@ window.__ModuleLoader__.load({
|
|
|
199
709
|
f.certainty !== "certain"
|
|
200
710
|
? React.createElement("span", { style: styles.suspect, title: "没有运行时面,只能靠官方目录推断" }, " [疑似]")
|
|
201
711
|
: null,
|
|
202
|
-
f.impact ? React.createElement("div", { style: styles.dim },
|
|
712
|
+
f.impact ? React.createElement("div", { style: styles.dim },
|
|
713
|
+
(f.impactBasis === "inferred" ? "影响(推断,非实测):" : "影响:") + f.impact) : null,
|
|
714
|
+
f.basis ? React.createElement("div", { style: styles.dim }, "依据:" + f.basis) : null);
|
|
203
715
|
}));
|
|
204
716
|
}
|
|
205
717
|
|
|
@@ -289,7 +801,7 @@ window.__ModuleLoader__.load({
|
|
|
289
801
|
/**
|
|
290
802
|
* C4 · 基线角标:回答「**和上次比变了没有**」,并给出方向。
|
|
291
803
|
*
|
|
292
|
-
* 规则是 Plan.md §2 第 5 题的落地:**任何变化都打**,不只是变差。
|
|
804
|
+
* 规则是 docs/Plan.md §2 第 5 题的落地:**任何变化都打**,不只是变差。
|
|
293
805
|
* 只有变差才亮会漏掉"某个插件被修好了、你可以放心留着了" —— 那同样是用户要知道的事。
|
|
294
806
|
* 没有基线时**不打**角标,但要说清是「还没比过」而不是「没问题」。
|
|
295
807
|
*/
|
|
@@ -459,10 +971,11 @@ window.__ModuleLoader__.load({
|
|
|
459
971
|
React.createElement("td", { style: styles.td }, React.createElement(Findings, { plugin: pl })));
|
|
460
972
|
}))),
|
|
461
973
|
React.createElement("div", { style: styles.note },
|
|
462
|
-
report.hasRuntimeSurface
|
|
463
|
-
? "
|
|
974
|
+
"工具版本:" + (data.toolVersion || "未知") + " · " + (report.summary.limited ? "部分证据尚未验证。 " : "") + (report.hasRuntimeSurface
|
|
975
|
+
? "方法级判定依据:宿主运行时面;确定性以每条证据为准。"
|
|
464
976
|
: "方法级判定依据:官方 API 目录(契约目录,不是运行时面)—— 因此方法级结论标为「疑似」。"
|
|
465
|
-
+ "「疑似」不是缺陷,是诚实:判不准时降级标注,而不是升格成确定。"),
|
|
977
|
+
+ "「疑似」不是缺陷,是诚实:判不准时降级标注,而不是升格成确定。")),
|
|
978
|
+
React.createElement("div", { style: styles.note }, (report.observation && report.observation.notes || []).join(";")),
|
|
466
979
|
React.createElement("div", { style: styles.note },
|
|
467
980
|
"判定内核:" + (data.kernelDir || "?")
|
|
468
981
|
+ " · 体检对象:" + (data.nodeModules || "?") + "(" + (data.nodeModulesWhy || "?") + ")"
|
|
@@ -538,7 +1051,7 @@ window.__ModuleLoader__.load({
|
|
|
538
1051
|
* 注册一个槽位,并把结果**记在标记里**。
|
|
539
1052
|
*
|
|
540
1053
|
* 为什么要记账:注册失败必须是**响的**。静默失败会让人以为"这个槽位不渲染",
|
|
541
|
-
* 而真相是"我们没注册上" —— 两者要查的方向完全不同。实测(Plan.md §5 C3)
|
|
1054
|
+
* 而真相是"我们没注册上" —— 两者要查的方向完全不同。实测(docs/Plan.md §5 C3)
|
|
542
1055
|
* 三个 detail 槽位都真的渲染,所以一旦它们不出现,第一个该怀疑的是注册。
|
|
543
1056
|
*/
|
|
544
1057
|
function registerSlot(ctx, slot, spec, Comp, tag) {
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
/** Iterate without awaiting inactive fibers, and map only verified package identities. */
|
|
5
|
+
export function loaderObservations(ctx, manifests, items = []) {
|
|
6
|
+
const rows = [],
|
|
7
|
+
unmapped = [];
|
|
8
|
+
const names = new Set(manifests.map((m) => m.name));
|
|
9
|
+
let loader;
|
|
10
|
+
try {
|
|
11
|
+
loader = ctx.get?.("loader", false) || ctx.loader;
|
|
12
|
+
} catch {}
|
|
13
|
+
if (!loader?.entries) return { rows, unmapped, available: false };
|
|
14
|
+
try {
|
|
15
|
+
let count = 0;
|
|
16
|
+
for (const entry of loader.entries()) {
|
|
17
|
+
if (count++ >= 20000) {
|
|
18
|
+
unmapped.push("(entry limit)");
|
|
19
|
+
break;
|
|
20
|
+
}
|
|
21
|
+
const opts = entry.options || {},
|
|
22
|
+
id = entry.id ?? opts.id;
|
|
23
|
+
const packageName =
|
|
24
|
+
[
|
|
25
|
+
entry.packageName,
|
|
26
|
+
opts.packageName,
|
|
27
|
+
opts.name,
|
|
28
|
+
items.find((i) => i.id === id)?.packageName,
|
|
29
|
+
id,
|
|
30
|
+
].find((v) => typeof v === "string" && names.has(v)) || null;
|
|
31
|
+
const state =
|
|
32
|
+
["PENDING", "LOADING", "ACTIVE", "FAILED", "DISPOSED", "UNLOADING"][
|
|
33
|
+
entry.fiber?.state
|
|
34
|
+
] || "UNREADABLE";
|
|
35
|
+
if (!packageName) {
|
|
36
|
+
unmapped.push(String(id ?? "(unknown)"));
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
rows.push({
|
|
40
|
+
packageName,
|
|
41
|
+
id,
|
|
42
|
+
state,
|
|
43
|
+
disabled: entry.disabled === true,
|
|
44
|
+
group: opts.group === true,
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
return { rows, unmapped, available: true };
|
|
48
|
+
} catch {
|
|
49
|
+
return {
|
|
50
|
+
rows,
|
|
51
|
+
unmapped: [...unmapped, "(loader unreadable)"],
|
|
52
|
+
available: false,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Diagnostics are opt-in, user-owned and refuse symlink output. */
|
|
58
|
+
export function privateDiagnostic(file, data) {
|
|
59
|
+
if (!file) return false;
|
|
60
|
+
const dir = path.dirname(file);
|
|
61
|
+
try {
|
|
62
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
63
|
+
const stat = fs.lstatSync(dir);
|
|
64
|
+
if (
|
|
65
|
+
!stat.isDirectory() ||
|
|
66
|
+
stat.isSymbolicLink() ||
|
|
67
|
+
(stat.mode & 0o077) !== 0 ||
|
|
68
|
+
(typeof process.getuid === "function" && stat.uid !== process.getuid())
|
|
69
|
+
)
|
|
70
|
+
throw new Error(
|
|
71
|
+
"Diagnostic directory must be private and owned by this user",
|
|
72
|
+
);
|
|
73
|
+
if (fs.existsSync(file) && fs.lstatSync(file).isSymbolicLink())
|
|
74
|
+
throw new Error("Diagnostic output is a symlink");
|
|
75
|
+
const fd = fs.openSync(
|
|
76
|
+
file,
|
|
77
|
+
fs.constants.O_WRONLY |
|
|
78
|
+
fs.constants.O_CREAT |
|
|
79
|
+
fs.constants.O_TRUNC |
|
|
80
|
+
(fs.constants.O_NOFOLLOW || 0),
|
|
81
|
+
0o600,
|
|
82
|
+
);
|
|
83
|
+
try {
|
|
84
|
+
fs.writeFileSync(fd, JSON.stringify(data, null, 2) + "\n");
|
|
85
|
+
} finally {
|
|
86
|
+
fs.closeSync(fd);
|
|
87
|
+
}
|
|
88
|
+
return true;
|
|
89
|
+
} catch {
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
92
|
+
}
|