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.
@@ -0,0 +1,493 @@
1
+ /** Browser-safe runtime type contracts. No imports: staged into the prebuilt
2
+ * client and plugin host from this single source. Unknown additive fields are
3
+ * preserved. Version changes or missing evidence fail closed as tool errors.
4
+ * @typedef {{status:'complete'|'limited',verifiedCalls:number,limitations:string[]}} Coverage
5
+ * @typedef {{category:string,severity:'fatal'|'feature-dead'|'degraded',certainty:'certain'|'suspected',impactBasis:'measured'|'inferred',location:object|null}} Finding
6
+ * @typedef {{mode:'catalogue'|'runtime',bound:boolean,usable:boolean,notes:string[],profileId:string|null,dshVersion:string|null,probedAt:string|null}} Observation
7
+ */
8
+ export const REPORT_SCHEMA = 1;
9
+ export const REPORT_KIND = "plugin-compat-check/result";
10
+ export const ENVELOPE_KIND = "plugin-compat-check/report";
11
+ const record = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
12
+ const text = (v) => typeof v === "string" && v.length > 0;
13
+ const nullableText = (v) => v === null || typeof v === "string";
14
+ const integer = (v) => Number.isSafeInteger(v) && v >= 0;
15
+ const strings = (v) =>
16
+ Array.isArray(v) && v.every((x) => typeof x === "string");
17
+ const verdicts = ["ok", "partial", "broken", "unknown"];
18
+ const severities = ["fatal", "feature-dead", "degraded"];
19
+ const certainties = ["certain", "suspected"];
20
+ const dispositions = [
21
+ "finding",
22
+ "client-side",
23
+ "self-provided",
24
+ "runtime-facility",
25
+ "filtered",
26
+ "abstained",
27
+ "guarded",
28
+ "no-calls",
29
+ "unknown",
30
+ ];
31
+ function coherentVerdict(p) {
32
+ if (!Array.isArray(p.findings)) return false;
33
+ return (
34
+ p.verdict === "unknown" ||
35
+ p.verdict ===
36
+ (p.findings.some((f) => f?.severity === "fatal")
37
+ ? "broken"
38
+ : p.findings.length
39
+ ? "partial"
40
+ : "ok")
41
+ );
42
+ }
43
+ function snapshotFinding(f) {
44
+ return (
45
+ record(f) &&
46
+ text(f.key) &&
47
+ text(f.category) &&
48
+ severities.includes(f.severity) &&
49
+ certainties.includes(f.certainty) &&
50
+ nullableText(f.location)
51
+ );
52
+ }
53
+ function checkedEntry(v) {
54
+ return record(v) && text(v.service) && dispositions.includes(v.disposition);
55
+ }
56
+ // Producers resolve relative observation paths before serialization. Lexical
57
+ // normalization here is platform independent and needs no browser Node shim.
58
+ function pathIdentity(v) {
59
+ const normalized = v.replace(/\\/g, "/");
60
+ const parts = [];
61
+ for (const part of normalized.split("/")) {
62
+ if (part === "..") parts.pop();
63
+ else if (part && part !== ".") parts.push(part);
64
+ }
65
+ return (normalized.startsWith("/") ? "/" : "") + parts.join("/");
66
+ }
67
+ function need(errors, condition, path) {
68
+ if (!condition) errors.push(path);
69
+ }
70
+ export function exitCodeForSummary(summary) {
71
+ return summary.broken > 0
72
+ ? 2
73
+ : summary.partial > 0 || summary.unknown > 0
74
+ ? 1
75
+ : 0;
76
+ }
77
+ export function createFailure(error, options = {}) {
78
+ return {
79
+ ...options,
80
+ schemaVersion: REPORT_SCHEMA,
81
+ kind: REPORT_KIND,
82
+ ok: false,
83
+ exitCode: 3,
84
+ toolVersion: options.toolVersion ?? null,
85
+ error: String(error || "Unknown tool error"),
86
+ inputs: options.inputs || {},
87
+ };
88
+ }
89
+ export function validateObservation(value) {
90
+ const e = [];
91
+ if (!record(value)) return ["observation"];
92
+ need(e, ["runtime", "catalogue"].includes(value.mode), "observation.mode");
93
+ need(e, typeof value.bound === "boolean", "observation.bound");
94
+ need(e, typeof value.usable === "boolean", "observation.usable");
95
+ need(e, strings(value.notes), "observation.notes");
96
+ for (const key of ["profileId", "dshVersion", "probedAt"])
97
+ need(e, nullableText(value[key]), "observation." + key);
98
+ if (value.probedAt !== null)
99
+ need(
100
+ e,
101
+ Number.isFinite(Date.parse(value.probedAt)),
102
+ "observation.probedAt",
103
+ );
104
+ need(
105
+ e,
106
+ (value.mode === "runtime") === value.usable,
107
+ "observation.mode/usable",
108
+ );
109
+ return e;
110
+ }
111
+ export function validateFinding(f) {
112
+ const e = [];
113
+ if (!record(f)) return ["finding"];
114
+ need(e, text(f.category), "finding.category");
115
+ need(e, severities.includes(f.severity), "finding.severity");
116
+ need(e, certainties.includes(f.certainty), "finding.certainty");
117
+ need(
118
+ e,
119
+ ["inferred", "measured"].includes(f.impactBasis),
120
+ "finding.impactBasis",
121
+ );
122
+ need(e, text(f.impact), "finding.impact");
123
+ need(e, text(f.basis), "finding.basis");
124
+ for (const key of ["service", "method"])
125
+ if (f[key] !== undefined) need(e, nullableText(f[key]), "finding." + key);
126
+ need(
127
+ e,
128
+ f.location === null ||
129
+ (record(f.location) &&
130
+ text(f.location.file) &&
131
+ Number.isSafeInteger(f.location.line) &&
132
+ f.location.line > 0),
133
+ "finding.location",
134
+ );
135
+ if (f.evidence !== undefined) need(e, record(f.evidence), "finding.evidence");
136
+ if (record(f.location)) {
137
+ for (const key of ["column", "start", "end"])
138
+ if (f.location[key] !== undefined && f.location[key] !== null)
139
+ need(e, integer(f.location[key]), "finding.location." + key);
140
+ if (integer(f.location.start) && integer(f.location.end))
141
+ need(e, f.location.end >= f.location.start, "finding.location.span");
142
+ }
143
+ return e;
144
+ }
145
+ export function validateBaseline(value) {
146
+ const e = [];
147
+ if (!record(value) || value.schemaVersion !== 2)
148
+ return ["baseline.schemaVersion"];
149
+ need(e, nullableText(value.dshVersion), "baseline.dshVersion");
150
+ need(
151
+ e,
152
+ record(value.identity) && text(value.identity.mode),
153
+ "baseline.identity",
154
+ );
155
+ if (!record(value.plugins)) return [...e, "baseline.plugins"];
156
+ for (const [name, p] of Object.entries(value.plugins)) {
157
+ if (!record(p)) {
158
+ e.push("baseline.plugin:" + name);
159
+ continue;
160
+ }
161
+ need(
162
+ e,
163
+ nullableText(p.version) && verdicts.includes(p.verdict),
164
+ "baseline.plugin:" + name,
165
+ );
166
+ need(e, Array.isArray(p.findings), "baseline.findings:" + name);
167
+ need(e, coherentVerdict(p), "baseline.verdict/findings:" + name);
168
+ if (p.vacuous !== undefined)
169
+ need(e, typeof p.vacuous === "boolean", "baseline.vacuous:" + name);
170
+ for (const f of Array.isArray(p.findings) ? p.findings : []) {
171
+ if (!record(f)) {
172
+ e.push("baseline.finding:" + name);
173
+ continue;
174
+ }
175
+ need(e, snapshotFinding(f), "baseline.finding:" + name);
176
+ }
177
+ }
178
+ return e;
179
+ }
180
+ function validateBaselineResult(b) {
181
+ if (b === null) return [];
182
+ const e = [];
183
+ if (!record(b)) return ["baseline result"];
184
+ if (b.error !== undefined) {
185
+ need(e, text(b.error) && text(b.note), "baseline.error");
186
+ return e;
187
+ }
188
+ need(e, typeof b.wrote === "boolean", "baseline.wrote");
189
+ need(e, record(b.badge), "baseline.badge");
190
+ if (record(b.badge)) {
191
+ need(
192
+ e,
193
+ typeof b.badge.show === "boolean" &&
194
+ nullableText(b.badge.text) &&
195
+ nullableText(b.badge.detail) &&
196
+ [null, "worse", "better", "mixed", "neutral"].includes(
197
+ b.badge.direction,
198
+ ),
199
+ "baseline.badge.fields",
200
+ );
201
+ }
202
+ if (b.diff !== null) {
203
+ need(
204
+ e,
205
+ record(b.diff) && typeof b.diff.comparable === "boolean",
206
+ "baseline.diff",
207
+ );
208
+ if (record(b.diff)) {
209
+ for (const key of [
210
+ "added",
211
+ "removed",
212
+ "worsened",
213
+ "improved",
214
+ "updated",
215
+ ]) {
216
+ need(e, Array.isArray(b.diff[key]), "baseline.diff." + key);
217
+ for (const entry of Array.isArray(b.diff[key]) ? b.diff[key] : []) {
218
+ const at = "baseline.diff." + key;
219
+ if (!record(entry)) {
220
+ e.push(at + ".entry");
221
+ continue;
222
+ }
223
+ need(e, text(entry.name), at + ".name");
224
+ if (key === "added" || key === "removed") {
225
+ need(e, nullableText(entry.version), at + ".version");
226
+ continue;
227
+ }
228
+ need(
229
+ e,
230
+ nullableText(entry.versionFrom) &&
231
+ nullableText(entry.versionTo) &&
232
+ typeof entry.versionChanged === "boolean" &&
233
+ verdicts.includes(entry.verdictFrom) &&
234
+ verdicts.includes(entry.verdictTo) &&
235
+ text(entry.reason),
236
+ at + ".fields",
237
+ );
238
+ for (const field of ["newFindings", "goneFindings"]) {
239
+ need(
240
+ e,
241
+ Array.isArray(entry[field]) &&
242
+ entry[field].every(snapshotFinding),
243
+ at + "." + field,
244
+ );
245
+ }
246
+ need(
247
+ e,
248
+ Array.isArray(entry.changedFindings) &&
249
+ entry.changedFindings.every(
250
+ (c) =>
251
+ record(c) && snapshotFinding(c.from) && snapshotFinding(c.to),
252
+ ),
253
+ at + ".changedFindings",
254
+ );
255
+ }
256
+ }
257
+ for (const key of ["dshVersionFrom", "dshVersionTo", "reason"])
258
+ need(e, nullableText(b.diff[key]), "baseline.diff." + key);
259
+ need(
260
+ e,
261
+ typeof b.diff.dshVersionChanged === "boolean",
262
+ "baseline.diff.dshVersionChanged",
263
+ );
264
+ }
265
+ }
266
+ return e;
267
+ }
268
+ export function validateReportBody(body) {
269
+ const e = [];
270
+ if (!record(body)) return ["report"];
271
+ need(
272
+ e,
273
+ body.schemaVersion === REPORT_SCHEMA && body.kind === REPORT_KIND,
274
+ "report.schemaVersion/kind",
275
+ );
276
+ need(e, typeof body.ok === "boolean", "report.ok");
277
+ need(e, nullableText(body.toolVersion), "report.toolVersion");
278
+ need(e, record(body.inputs), "report.inputs");
279
+ if (body.ok === false) {
280
+ need(e, body.exitCode === 3 && text(body.error), "report.error/exitCode");
281
+ return e;
282
+ }
283
+ need(e, body.error === null, "report.error");
284
+ need(e, nullableText(body.dshVersion), "report.dshVersion");
285
+ e.push(...validateObservation(body.observation));
286
+ if (
287
+ record(body.observation) &&
288
+ body.observation.bound &&
289
+ body.observation.usable
290
+ ) {
291
+ const o = body.observation;
292
+ if (text(body.dshVersion) && text(o.dshVersion))
293
+ need(
294
+ e,
295
+ o.dshVersion === body.dshVersion,
296
+ "observation/report.dshVersion",
297
+ );
298
+ if (text(body.inputs?.nodeModules) && text(o.profileId))
299
+ need(
300
+ e,
301
+ pathIdentity(o.profileId) === pathIdentity(body.inputs.nodeModules),
302
+ "observation/report.profileId",
303
+ );
304
+ }
305
+ e.push(...validateBaselineResult(body.baseline));
306
+ const summary = body.summary,
307
+ plugins = body.plugins;
308
+ if (!record(summary) || !record(plugins))
309
+ return [...e, "report.summary/plugins"];
310
+ for (const key of [...verdicts, "limited", "vacuousOk"])
311
+ need(e, integer(summary[key]), "summary." + key);
312
+ const counts = {
313
+ ok: 0,
314
+ partial: 0,
315
+ broken: 0,
316
+ unknown: 0,
317
+ limited: 0,
318
+ vacuousOk: 0,
319
+ };
320
+ for (const [name, p] of Object.entries(plugins)) {
321
+ if (!record(p)) {
322
+ e.push("plugin:" + name);
323
+ continue;
324
+ }
325
+ need(
326
+ e,
327
+ p.name === name &&
328
+ nullableText(p.version) &&
329
+ verdicts.includes(p.verdict),
330
+ "plugin.identity/verdict:" + name,
331
+ );
332
+ need(e, typeof p.vacuous === "boolean", "plugin.vacuous:" + name);
333
+ if (verdicts.includes(p.verdict)) counts[p.verdict]++;
334
+ if (p.vacuous && p.verdict === "ok") counts.vacuousOk++;
335
+ const c = p.coverage;
336
+ need(
337
+ e,
338
+ record(c) &&
339
+ ["complete", "limited"].includes(c.status) &&
340
+ integer(c.verifiedCalls) &&
341
+ strings(c.limitations),
342
+ "plugin.coverage:" + name,
343
+ );
344
+ if (c?.status === "limited") counts.limited++;
345
+ for (const key of [
346
+ "findings",
347
+ "checked",
348
+ "clientSide",
349
+ "filtered",
350
+ "abstained",
351
+ "runtimeFacilities",
352
+ ])
353
+ need(e, Array.isArray(p[key]), "plugin." + key + ":" + name);
354
+ if (Array.isArray(p.findings))
355
+ for (const f of p.findings)
356
+ e.push(...validateFinding(f).map((x) => name + ":" + x));
357
+ need(e, coherentVerdict(p), "plugin.verdict/findings:" + name);
358
+ if (record(c) && integer(c.verifiedCalls))
359
+ need(
360
+ e,
361
+ p.vacuous === (p.verdict === "ok" && c.verifiedCalls === 0),
362
+ "plugin.vacuous/coverage:" + name,
363
+ );
364
+ if (record(c) && strings(c.limitations) && Array.isArray(p.clientSide))
365
+ need(
366
+ e,
367
+ (c.status === "limited") ===
368
+ (c.limitations.length > 0 ||
369
+ p.clientSide.length > 0 ||
370
+ p.vacuous === true),
371
+ "plugin.coverage/status:" + name,
372
+ );
373
+ need(e, record(p.resolved), "plugin.resolved:" + name);
374
+ if (record(p.resolved))
375
+ for (const [service, value] of Object.entries(p.resolved))
376
+ need(
377
+ e,
378
+ checkedEntry(value) && value.service === service,
379
+ "plugin.resolved.entry:" + name + "." + service,
380
+ );
381
+ for (const [key, valid] of [
382
+ ["checked", checkedEntry],
383
+ ["clientSide", (v) => record(v) && text(v.service) && text(v.note)],
384
+ [
385
+ "filtered",
386
+ (v) => record(v) && text(v.service) && text(v.method) && text(v.reason),
387
+ ],
388
+ ["abstained", (v) => record(v) && text(v.service) && text(v.basis)],
389
+ ["runtimeFacilities", (v) => record(v) && text(v.name) && text(v.reason)],
390
+ ])
391
+ if (Array.isArray(p[key]))
392
+ need(e, p[key].every(valid), "plugin." + key + ".entry:" + name);
393
+ }
394
+ for (const key of Object.keys(counts))
395
+ need(e, counts[key] === summary[key], "summary.count:" + key);
396
+ need(e, body.exitCode === exitCodeForSummary(summary), "report.exitCode");
397
+ need(
398
+ e,
399
+ record(body.extraction) &&
400
+ typeof body.extraction.scanComplete === "boolean" &&
401
+ Array.isArray(body.extraction.diagnostics) &&
402
+ Array.isArray(body.extraction.manifests),
403
+ "report.extraction",
404
+ );
405
+ return e;
406
+ }
407
+ export function assertReportBody(body) {
408
+ const errors = validateReportBody(body);
409
+ if (errors.length) throw new Error("报告契约不匹配: " + errors.join(", "));
410
+ return body;
411
+ }
412
+ export function sealReportBody(input) {
413
+ const observation = input.observation || {};
414
+ const plugins = Object.fromEntries(
415
+ Object.entries(input.plugins || {}).map(([name, p]) => [
416
+ name,
417
+ {
418
+ ...p,
419
+ findings: p.findings.map((f) => ({
420
+ ...f,
421
+ location: f.location ?? null,
422
+ })),
423
+ },
424
+ ]),
425
+ );
426
+ return assertReportBody({
427
+ ...input,
428
+ schemaVersion: REPORT_SCHEMA,
429
+ kind: REPORT_KIND,
430
+ ok: true,
431
+ error: null,
432
+ exitCode: exitCodeForSummary(input.summary),
433
+ summary: { limited: 0, vacuousOk: 0, ...input.summary },
434
+ plugins,
435
+ observation: {
436
+ profileId: null,
437
+ dshVersion: null,
438
+ probedAt: null,
439
+ ...observation,
440
+ },
441
+ baseline: input.baseline ?? null,
442
+ });
443
+ }
444
+ export function failureEnvelope(error, options = {}) {
445
+ const report = createFailure(error, {
446
+ toolVersion: options.toolVersion ?? null,
447
+ inputs: options.report?.inputs || {},
448
+ });
449
+ return {
450
+ ...options,
451
+ schemaVersion: REPORT_SCHEMA,
452
+ kind: ENVELOPE_KIND,
453
+ ok: false,
454
+ error: report.error,
455
+ exitCode: 3,
456
+ toolVersion: report.toolVersion,
457
+ baseline: null,
458
+ report,
459
+ };
460
+ }
461
+ export function validateEnvelope(data) {
462
+ if (
463
+ !record(data) ||
464
+ data.schemaVersion !== REPORT_SCHEMA ||
465
+ data.kind !== ENVELOPE_KIND
466
+ )
467
+ return ["envelope.schemaVersion/kind"];
468
+ const e = validateReportBody(data.report);
469
+ need(
470
+ e,
471
+ typeof data.ok === "boolean" && data.ok === data.report?.ok,
472
+ "envelope.ok",
473
+ );
474
+ need(e, data.exitCode === data.report?.exitCode, "envelope.exitCode");
475
+ need(e, data.error === data.report?.error, "envelope.error");
476
+ need(
477
+ e,
478
+ data.toolVersion === data.report?.toolVersion,
479
+ "envelope.toolVersion",
480
+ );
481
+ need(
482
+ e,
483
+ JSON.stringify(data.baseline) ===
484
+ JSON.stringify(data.report?.baseline ?? null),
485
+ "envelope.baseline",
486
+ );
487
+ return e;
488
+ }
489
+ export function assertEnvelope(data) {
490
+ const errors = validateEnvelope(data);
491
+ if (errors.length) throw new Error("报告契约不匹配: " + errors.join(", "));
492
+ return data;
493
+ }
@@ -0,0 +1,50 @@
1
+ import semver from "semver";
2
+
3
+ /** DSH app-boot 0.2.0-rc.2 checks every official dsh peer, including prereleases. */
4
+ export function declaredSupport(pkg, version) {
5
+ if (!version || !semver.valid(version)) return null;
6
+ const requirements = [];
7
+ for (const [name, range] of Object.entries(pkg.peerDependencies || {})) {
8
+ if (name === "@deepseek-ai/dsh" || name.startsWith("@deepseek-ai/dsh-"))
9
+ requirements.push({ name, range, source: "peerDependencies" });
10
+ }
11
+ if (typeof pkg.engines?.dsh === "string")
12
+ requirements.push({
13
+ name: "dsh",
14
+ range: pkg.engines.dsh,
15
+ source: "engines (advisory)",
16
+ });
17
+ const exact =
18
+ pkg.compatibility?.[version] ?? pkg.compatibility?.dshReleases?.[version];
19
+ let exactClaim =
20
+ exact === "compatible" ? true : exact === "incompatible" ? false : null;
21
+ const checks = requirements.map((r) => {
22
+ const range = ["workspace:^", "workspace:~", "workspace:*"].includes(
23
+ r.range,
24
+ )
25
+ ? version
26
+ : r.range;
27
+ return {
28
+ ...r,
29
+ valid: typeof range === "string" && semver.validRange(range) !== null,
30
+ compatible:
31
+ typeof range === "string" &&
32
+ range.trim() !== "" &&
33
+ semver.satisfies(version, range, { includePrerelease: true }),
34
+ };
35
+ });
36
+ if (!checks.length && exactClaim === null) return null;
37
+ return {
38
+ claimsCompatible: checks.length
39
+ ? checks.every((r) => r.valid && r.compatible) && exactClaim !== false
40
+ : exactClaim,
41
+ raw: {
42
+ compatibility: pkg.compatibility ?? null,
43
+ engines: pkg.engines ?? null,
44
+ peers: checks,
45
+ },
46
+ notes: checks
47
+ .filter((r) => !r.valid)
48
+ .map((r) => `Invalid declared range for ${r.name}`),
49
+ };
50
+ }
@@ -0,0 +1,10 @@
1
+ import { parentPort, workerData } from "node:worker_threads";
2
+ import { extractWithProvenance } from "./extract.mjs";
3
+ try {
4
+ parentPort.postMessage({
5
+ ok: true,
6
+ value: extractWithProvenance(workerData.nm, workerData.options),
7
+ });
8
+ } catch (error) {
9
+ parentPort.postMessage({ ok: false, error: error.message });
10
+ }