dsh-plugin-compat-check 0.1.3 → 0.1.5
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 +36 -20
- package/THIRD_PARTY_NOTICES.md +139 -0
- package/index.js +288 -55
- package/kernel/lib/author-ci-output.mjs +188 -0
- package/kernel/lib/author-ci-policy.mjs +285 -0
- package/kernel/lib/catalog.mjs +38 -0
- package/kernel/lib/contracts.mjs +88 -0
- package/kernel/lib/dependency-graph.mjs +650 -0
- package/kernel/lib/extract.mjs +14 -0
- package/kernel/lib/finding-guidance.mjs +72 -0
- package/kernel/lib/finding-review-store.mjs +245 -0
- package/kernel/lib/finding-review.mjs +338 -0
- package/kernel/lib/functional-health-contract.mjs +337 -0
- package/kernel/lib/issue-report.mjs +217 -0
- package/kernel/lib/observation.mjs +19 -9
- package/kernel/lib/owned-process.mjs +287 -0
- package/kernel/lib/report-identity-worker.mjs +153 -0
- package/kernel/lib/report-identity.mjs +15 -0
- package/kernel/lib/report-service.mjs +196 -11
- package/kernel/lib/report.mjs +76 -11
- package/kernel/lib/source-analysis.mjs +24 -6
- package/kernel/lib/upgrade-capture-worker.mjs +20 -0
- package/kernel/lib/upgrade-cleanup-worker.mjs +28 -0
- package/kernel/lib/upgrade-contract.mjs +471 -0
- package/kernel/lib/upgrade-input.mjs +309 -0
- package/kernel/lib/upgrade-preview.mjs +256 -0
- package/kernel/lib/upgrade-worker.mjs +50 -0
- package/kernel/lib/verdict.mjs +8 -1
- package/kernel/lib/version.mjs +1 -1
- package/kernel/lib/worker-analysis.mjs +16 -1
- package/kernel/lib/workflow-panels.mjs +706 -0
- package/kernel/rules/README.md +8 -6
- package/lib/client.js +2974 -69
- package/lib/dependency-graph.js +651 -0
- package/lib/finding-guidance.js +73 -0
- package/lib/finding-review.js +339 -0
- package/lib/functional-health-contract.js +338 -0
- package/lib/report-contract.js +88 -0
- package/lib/upgrade-contract.js +472 -0
- package/lib/workflow-panels.js +707 -0
- package/package.json +5 -3
- package/scripts/prepublish-check.mjs +28 -13
- package/scripts/stage-contract.mjs +91 -29
- package/scripts/stage-kernel.mjs +4 -3
- package/skill/SKILL.md +92 -39
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
/** Pure declarations/results contract. Author declarations never imply execution or compatibility. */
|
|
2
|
+
export const FUNCTIONAL_HEALTH_KIND = "plugin-compat-check/functional-health";
|
|
3
|
+
export const FUNCTIONAL_HEALTH_LIMITS = Object.freeze({
|
|
4
|
+
checks: 32,
|
|
5
|
+
path: 256,
|
|
6
|
+
selector: 160,
|
|
7
|
+
responseBytes: 65536,
|
|
8
|
+
requestMs: 3000,
|
|
9
|
+
totalMs: 180000,
|
|
10
|
+
});
|
|
11
|
+
const object = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
|
|
12
|
+
const keys = (v, allowed) =>
|
|
13
|
+
object(v) && Object.keys(v).every((k) => allowed.includes(k));
|
|
14
|
+
const exactVersion = (v) =>
|
|
15
|
+
typeof v === "string" &&
|
|
16
|
+
/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z]+(?:[.-][0-9A-Za-z]+)*)?$/.test(
|
|
17
|
+
v,
|
|
18
|
+
);
|
|
19
|
+
const digest = (v) => typeof v === "string" && /^[a-f0-9]{64}$/.test(v);
|
|
20
|
+
const bounded = (v, n) =>
|
|
21
|
+
typeof v === "string" && v.length > 0 && v.length <= n;
|
|
22
|
+
const artifactValid = (a) =>
|
|
23
|
+
keys(a, ["name", "version", "sha256"]) &&
|
|
24
|
+
typeof a.name === "string" &&
|
|
25
|
+
/^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/.test(a.name) &&
|
|
26
|
+
a.name.length <= 214 &&
|
|
27
|
+
exactVersion(a.version) &&
|
|
28
|
+
digest(a.sha256);
|
|
29
|
+
export function validFunctionalPath(v) {
|
|
30
|
+
return (
|
|
31
|
+
bounded(v, FUNCTIONAL_HEALTH_LIMITS.path) &&
|
|
32
|
+
/^\/(?!\/)[A-Za-z0-9/_.,~-]*$/.test(v) &&
|
|
33
|
+
!v.split("/").some((p) => p === ".." || p === ".")
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
export function validateFunctionalManifest(value) {
|
|
37
|
+
const errors = [];
|
|
38
|
+
if (
|
|
39
|
+
!keys(value, ["schemaVersion", "artifact", "dshVersion", "checks"]) ||
|
|
40
|
+
value.schemaVersion !== 1 ||
|
|
41
|
+
!artifactValid(value.artifact) ||
|
|
42
|
+
!exactVersion(value.dshVersion)
|
|
43
|
+
)
|
|
44
|
+
errors.push("Invalid manifest schema or exact artifact/DSH identity");
|
|
45
|
+
if (
|
|
46
|
+
!Array.isArray(value?.checks) ||
|
|
47
|
+
value.checks.length < 1 ||
|
|
48
|
+
value.checks.length > FUNCTIONAL_HEALTH_LIMITS.checks
|
|
49
|
+
)
|
|
50
|
+
errors.push("Expected 1..32 bounded checks");
|
|
51
|
+
else {
|
|
52
|
+
const seen = new Set();
|
|
53
|
+
for (const c of value.checks) {
|
|
54
|
+
if (
|
|
55
|
+
!object(c) ||
|
|
56
|
+
!bounded(c.id, 64) ||
|
|
57
|
+
!/^[A-Za-z0-9_-]+$/.test(c.id) ||
|
|
58
|
+
seen.has(c.id) ||
|
|
59
|
+
!validFunctionalPath(c.path)
|
|
60
|
+
) {
|
|
61
|
+
errors.push("Invalid check id/path or duplicate id");
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
seen.add(c.id);
|
|
65
|
+
if (c.type === "http") {
|
|
66
|
+
if (
|
|
67
|
+
!keys(c, ["id", "type", "path", "status", "json"]) ||
|
|
68
|
+
!Number.isInteger(c.status) ||
|
|
69
|
+
c.status < 200 ||
|
|
70
|
+
c.status > 599 ||
|
|
71
|
+
(c.status >= 300 && c.status < 400)
|
|
72
|
+
)
|
|
73
|
+
errors.push("Invalid HTTP check");
|
|
74
|
+
if (
|
|
75
|
+
c.json !== undefined &&
|
|
76
|
+
(!keys(c.json, ["pointer", "equals"]) ||
|
|
77
|
+
typeof c.json.pointer !== "string" ||
|
|
78
|
+
c.json.pointer.length > 256 ||
|
|
79
|
+
!/^(?:\/(?:[^~\u0000-\u001f]|~[01])*)*$/.test(c.json.pointer) ||
|
|
80
|
+
!Object.hasOwn(c.json, "equals") ||
|
|
81
|
+
![null, "string", "boolean", "number"].includes(
|
|
82
|
+
c.json.equals === null ? null : typeof c.json.equals,
|
|
83
|
+
) ||
|
|
84
|
+
(typeof c.json.equals === "string" && c.json.equals.length > 256) ||
|
|
85
|
+
(typeof c.json.equals === "number" &&
|
|
86
|
+
!Number.isFinite(c.json.equals)))
|
|
87
|
+
)
|
|
88
|
+
errors.push(
|
|
89
|
+
"JSON assertion must be a bounded JSON pointer and scalar value",
|
|
90
|
+
);
|
|
91
|
+
} else if (c.type === "dom") {
|
|
92
|
+
if (
|
|
93
|
+
!keys(c, [
|
|
94
|
+
"id",
|
|
95
|
+
"type",
|
|
96
|
+
"path",
|
|
97
|
+
"selector",
|
|
98
|
+
"minCount",
|
|
99
|
+
"maxCount",
|
|
100
|
+
]) ||
|
|
101
|
+
!bounded(c.selector, FUNCTIONAL_HEALTH_LIMITS.selector) ||
|
|
102
|
+
!/^[A-Za-z0-9_#.*\[\]="'\- >]+$/.test(c.selector) ||
|
|
103
|
+
!Number.isInteger(c.minCount) ||
|
|
104
|
+
!Number.isInteger(c.maxCount) ||
|
|
105
|
+
c.minCount < 0 ||
|
|
106
|
+
c.maxCount < c.minCount ||
|
|
107
|
+
c.maxCount > 1000
|
|
108
|
+
)
|
|
109
|
+
errors.push("Invalid bounded DOM selector/count check");
|
|
110
|
+
} else errors.push("Only HTTP GET and DOM selector counts are supported");
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return errors.length ? { valid: false, errors } : { valid: true, value };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Imported evidence is an offline assertion, not a signature or compatibility verdict. */
|
|
117
|
+
export function validateFunctionalHealthResult(value, expectedIdentity = {}) {
|
|
118
|
+
const errors = [];
|
|
119
|
+
const iso = (v) =>
|
|
120
|
+
typeof v === "string" &&
|
|
121
|
+
Number.isFinite(Date.parse(v)) &&
|
|
122
|
+
new Date(v).toISOString() === v;
|
|
123
|
+
if (
|
|
124
|
+
!keys(value, [
|
|
125
|
+
"schemaVersion",
|
|
126
|
+
"kind",
|
|
127
|
+
"status",
|
|
128
|
+
"identity",
|
|
129
|
+
"startedAt",
|
|
130
|
+
"completedAt",
|
|
131
|
+
"checks",
|
|
132
|
+
"cleanup",
|
|
133
|
+
"limitations",
|
|
134
|
+
"error",
|
|
135
|
+
"declaration",
|
|
136
|
+
]) ||
|
|
137
|
+
value.schemaVersion !== 1 ||
|
|
138
|
+
value.kind !== FUNCTIONAL_HEALTH_KIND ||
|
|
139
|
+
!["tested-pass", "tested-failed", "error"].includes(value.status)
|
|
140
|
+
)
|
|
141
|
+
errors.push("Invalid functional health result schema/status");
|
|
142
|
+
const declarationValidation = validateFunctionalManifest(value?.declaration);
|
|
143
|
+
if (!declarationValidation.valid)
|
|
144
|
+
errors.push("Missing or invalid executed declaration");
|
|
145
|
+
const id = value?.identity;
|
|
146
|
+
if (
|
|
147
|
+
declarationValidation.valid &&
|
|
148
|
+
(value.declaration.dshVersion !== id?.dshVersion ||
|
|
149
|
+
["name", "version", "sha256"].some(
|
|
150
|
+
(k) => value.declaration.artifact[k] !== id?.artifact?.[k],
|
|
151
|
+
))
|
|
152
|
+
)
|
|
153
|
+
errors.push("Executed declaration identity differs from result");
|
|
154
|
+
if (
|
|
155
|
+
!keys(id, ["artifact", "dshVersion", "manifestSha256"]) ||
|
|
156
|
+
!artifactValid(id.artifact) ||
|
|
157
|
+
!exactVersion(id.dshVersion) ||
|
|
158
|
+
!digest(id.manifestSha256)
|
|
159
|
+
)
|
|
160
|
+
errors.push("Invalid exact evidence identity");
|
|
161
|
+
if (
|
|
162
|
+
!iso(value?.startedAt) ||
|
|
163
|
+
!iso(value?.completedAt) ||
|
|
164
|
+
value.completedAt < value.startedAt ||
|
|
165
|
+
Date.parse(value.completedAt) - Date.parse(value.startedAt) >
|
|
166
|
+
FUNCTIONAL_HEALTH_LIMITS.totalMs + 30000
|
|
167
|
+
)
|
|
168
|
+
errors.push("Invalid bounded measurement times");
|
|
169
|
+
if (
|
|
170
|
+
!keys(value?.cleanup, ["stopped", "removed", "errors"]) ||
|
|
171
|
+
typeof value.cleanup.stopped !== "boolean" ||
|
|
172
|
+
typeof value.cleanup.removed !== "boolean" ||
|
|
173
|
+
(value.cleanup.errors !== undefined &&
|
|
174
|
+
(!Array.isArray(value.cleanup.errors) ||
|
|
175
|
+
value.cleanup.errors.length > 8 ||
|
|
176
|
+
value.cleanup.errors.some((x) => !bounded(x, 512))))
|
|
177
|
+
)
|
|
178
|
+
errors.push("Invalid cleanup evidence");
|
|
179
|
+
if (
|
|
180
|
+
!Array.isArray(value?.limitations) ||
|
|
181
|
+
value.limitations.length < 1 ||
|
|
182
|
+
value.limitations.length > 8 ||
|
|
183
|
+
value.limitations.some((x) => !bounded(x, 512)) ||
|
|
184
|
+
(value.error !== undefined && !bounded(value.error, 1024))
|
|
185
|
+
)
|
|
186
|
+
errors.push("Invalid limitations/error summary");
|
|
187
|
+
const seen = new Set();
|
|
188
|
+
if (!Array.isArray(value?.checks) || value.checks.length > 32)
|
|
189
|
+
errors.push("Invalid check evidence");
|
|
190
|
+
else
|
|
191
|
+
for (const c of value.checks) {
|
|
192
|
+
const e = c?.evidence;
|
|
193
|
+
if (
|
|
194
|
+
!keys(c, ["id", "type", "status", "evidence"]) ||
|
|
195
|
+
!bounded(c.id, 64) ||
|
|
196
|
+
!/^[A-Za-z0-9_-]+$/.test(c.id) ||
|
|
197
|
+
seen.has(c.id) ||
|
|
198
|
+
!["http", "dom"].includes(c.type) ||
|
|
199
|
+
!["pass", "failed", "error"].includes(c.status) ||
|
|
200
|
+
!keys(e, [
|
|
201
|
+
"httpStatus",
|
|
202
|
+
"responseBytes",
|
|
203
|
+
"jsonMatched",
|
|
204
|
+
"count",
|
|
205
|
+
"elapsedMs",
|
|
206
|
+
"error",
|
|
207
|
+
]) ||
|
|
208
|
+
!Number.isFinite(e.elapsedMs) ||
|
|
209
|
+
e.elapsedMs < 0 ||
|
|
210
|
+
e.elapsedMs > FUNCTIONAL_HEALTH_LIMITS.totalMs
|
|
211
|
+
) {
|
|
212
|
+
errors.push("Invalid check row");
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
seen.add(c.id);
|
|
216
|
+
if (c.status === "error") {
|
|
217
|
+
if (!bounded(e.error, 256)) errors.push("Missing check error");
|
|
218
|
+
} else if (c.type === "http") {
|
|
219
|
+
if (
|
|
220
|
+
!Number.isInteger(e.httpStatus) ||
|
|
221
|
+
e.httpStatus < 200 ||
|
|
222
|
+
e.httpStatus > 599 ||
|
|
223
|
+
!Number.isInteger(e.responseBytes) ||
|
|
224
|
+
e.responseBytes < 0 ||
|
|
225
|
+
e.responseBytes > 65536 ||
|
|
226
|
+
(e.jsonMatched !== undefined && typeof e.jsonMatched !== "boolean") ||
|
|
227
|
+
e.count !== undefined ||
|
|
228
|
+
e.error !== undefined
|
|
229
|
+
)
|
|
230
|
+
errors.push("Invalid HTTP evidence");
|
|
231
|
+
} else if (
|
|
232
|
+
!Number.isInteger(e.count) ||
|
|
233
|
+
e.count < 0 ||
|
|
234
|
+
e.httpStatus !== undefined ||
|
|
235
|
+
e.responseBytes !== undefined ||
|
|
236
|
+
e.jsonMatched !== undefined ||
|
|
237
|
+
e.error !== undefined
|
|
238
|
+
)
|
|
239
|
+
errors.push("Invalid DOM evidence");
|
|
240
|
+
}
|
|
241
|
+
if (errors.length) return { valid: false, errors };
|
|
242
|
+
if (declarationValidation.valid && Array.isArray(value?.checks)) {
|
|
243
|
+
const expected = value.declaration.checks;
|
|
244
|
+
if (
|
|
245
|
+
(value.status !== "error" && value.checks.length !== expected.length) ||
|
|
246
|
+
value.checks.length > expected.length
|
|
247
|
+
)
|
|
248
|
+
errors.push("Evidence must cover every declared check in order");
|
|
249
|
+
for (let i = 0; i < value.checks.length; i++) {
|
|
250
|
+
const row = value.checks[i],
|
|
251
|
+
check = expected[i];
|
|
252
|
+
if (!row || !check || row.id !== check.id || row.type !== check.type) {
|
|
253
|
+
errors.push("Evidence check identity/order differs from declaration");
|
|
254
|
+
continue;
|
|
255
|
+
}
|
|
256
|
+
if (row.status === "error") continue;
|
|
257
|
+
const e = row.evidence;
|
|
258
|
+
if (!object(e)) continue;
|
|
259
|
+
const passed =
|
|
260
|
+
check.type === "http"
|
|
261
|
+
? e.httpStatus === check.status &&
|
|
262
|
+
(!check.json || e.jsonMatched === true)
|
|
263
|
+
: e.count >= check.minCount && e.count <= check.maxCount;
|
|
264
|
+
if (
|
|
265
|
+
row.status !== (passed ? "pass" : "failed") ||
|
|
266
|
+
(check.type === "http" &&
|
|
267
|
+
Boolean(check.json) !== (e.jsonMatched !== undefined))
|
|
268
|
+
)
|
|
269
|
+
errors.push("Evidence outcome conflicts with executed expectation");
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
if (
|
|
273
|
+
value?.status === "tested-pass" &&
|
|
274
|
+
(!value.checks?.length ||
|
|
275
|
+
value.checks.some((c) => c?.status !== "pass") ||
|
|
276
|
+
value.cleanup?.stopped !== true ||
|
|
277
|
+
value.cleanup?.removed !== true ||
|
|
278
|
+
value.cleanup?.errors?.length ||
|
|
279
|
+
value.error !== undefined)
|
|
280
|
+
)
|
|
281
|
+
errors.push("Pass conflicts with checks or cleanup");
|
|
282
|
+
if (
|
|
283
|
+
value?.status === "tested-failed" &&
|
|
284
|
+
(!value.checks?.some((c) => c?.status === "failed") ||
|
|
285
|
+
value.checks.some((c) => c?.status === "error") ||
|
|
286
|
+
value.cleanup?.stopped !== true ||
|
|
287
|
+
value.cleanup?.removed !== true ||
|
|
288
|
+
value.error !== undefined)
|
|
289
|
+
)
|
|
290
|
+
errors.push("Failed measurement conflicts with checks/cleanup");
|
|
291
|
+
if (value?.status === "error" && !bounded(value.error, 1024))
|
|
292
|
+
errors.push("Missing run error");
|
|
293
|
+
if (errors.length) return { valid: false, errors };
|
|
294
|
+
const staleReasons = [];
|
|
295
|
+
if (
|
|
296
|
+
!expectedIdentity.artifact ||
|
|
297
|
+
!["name", "version", "sha256"].every(
|
|
298
|
+
(k) => typeof expectedIdentity.artifact[k] === "string",
|
|
299
|
+
)
|
|
300
|
+
)
|
|
301
|
+
staleReasons.push("Current artifact identity unavailable");
|
|
302
|
+
else
|
|
303
|
+
for (const key of ["name", "version", "sha256"])
|
|
304
|
+
if (id.artifact[key] !== expectedIdentity.artifact[key])
|
|
305
|
+
staleReasons.push("Artifact " + key + " changed");
|
|
306
|
+
if (!expectedIdentity.dshVersion)
|
|
307
|
+
staleReasons.push("Current DSH version unavailable");
|
|
308
|
+
else if (id.dshVersion !== expectedIdentity.dshVersion)
|
|
309
|
+
staleReasons.push("DSH version changed");
|
|
310
|
+
if (!expectedIdentity.manifestSha256)
|
|
311
|
+
staleReasons.push("Current declaration identity unavailable");
|
|
312
|
+
else if (id.manifestSha256 !== expectedIdentity.manifestSha256)
|
|
313
|
+
staleReasons.push("Declaration changed");
|
|
314
|
+
return {
|
|
315
|
+
valid: true,
|
|
316
|
+
value,
|
|
317
|
+
current: staleReasons.length === 0,
|
|
318
|
+
staleReasons,
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/** SHA256 identity rule: UTF-8 of JSON with recursively sorted object keys and preserved array order, no whitespace. */
|
|
323
|
+
export function canonicalFunctionalManifest(value) {
|
|
324
|
+
const validation = validateFunctionalManifest(value);
|
|
325
|
+
if (!validation.valid) throw Error(validation.errors.join("; "));
|
|
326
|
+
const ordered = (v) =>
|
|
327
|
+
Array.isArray(v)
|
|
328
|
+
? v.map(ordered)
|
|
329
|
+
: object(v)
|
|
330
|
+
? Object.fromEntries(
|
|
331
|
+
Object.keys(v)
|
|
332
|
+
.sort()
|
|
333
|
+
.map((k) => [k, ordered(v[k])]),
|
|
334
|
+
)
|
|
335
|
+
: v;
|
|
336
|
+
return JSON.stringify(ordered(value));
|
|
337
|
+
}
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
/** Shared CLI/panel share view. Never serialize raw inputs, logs, evidence or baseline. */
|
|
2
|
+
import { TOOL_VERSION } from "./version.mjs";
|
|
3
|
+
import { nextSteps } from "./finding-guidance.mjs";
|
|
4
|
+
|
|
5
|
+
function safe(value) {
|
|
6
|
+
const raw = String(value ?? "未提供");
|
|
7
|
+
// Hide the whole field: token-by-token replacement leaks the tail of a path
|
|
8
|
+
// containing spaces and can turn a multi-line log into apparently safe prose.
|
|
9
|
+
if (
|
|
10
|
+
/[\x00-\x1f\x7f\u202a-\u202e\u2066-\u2069]/.test(raw) ||
|
|
11
|
+
/(?:[a-z]:[\\/]|\\\\|~[\\/]|(?:^|[\s("'`=::,;])\/|\.\.[\\/]|\/[^\s/]+\/)/i.test(
|
|
12
|
+
raw,
|
|
13
|
+
) ||
|
|
14
|
+
/(?:https?|file):|Bearer\s|(?:token|password|passwd|secret|api[_-]?key|authorization)\s*[:=]|(?:sk-|ghp_|github_pat_|npm_)[a-z0-9_]+/i.test(
|
|
15
|
+
raw,
|
|
16
|
+
)
|
|
17
|
+
)
|
|
18
|
+
return "[说明包含路径、链接、凭据或控制字符,已隐藏;请在本机查看]";
|
|
19
|
+
return raw
|
|
20
|
+
.replace(
|
|
21
|
+
/[&<>@]/g,
|
|
22
|
+
(c) => ({ "&": "&", "<": "<", ">": ">", "@": "@" })[c],
|
|
23
|
+
)
|
|
24
|
+
.replace(/[\\`*_{}\[\]()#+.!|~-]/g, "\\$&");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Keep public package identifiers and relative source positions useful. Invalid
|
|
28
|
+
// metadata is omitted wholesale rather than partially echoing arbitrary input.
|
|
29
|
+
function identity(value) {
|
|
30
|
+
return typeof value === "string" &&
|
|
31
|
+
/^(?:@[a-z0-9._-]+\/)?[a-z0-9._-]+$/i.test(value)
|
|
32
|
+
? "`" + value + "`"
|
|
33
|
+
: "未提供或已隐藏";
|
|
34
|
+
}
|
|
35
|
+
function version(value) {
|
|
36
|
+
return typeof value === "string" &&
|
|
37
|
+
/^\d+\.\d+\.\d+(?:-[a-z0-9.-]+)?(?:\+[a-z0-9.-]+)?$/i.test(value)
|
|
38
|
+
? "`" + value + "`"
|
|
39
|
+
: "未知或已隐藏";
|
|
40
|
+
}
|
|
41
|
+
function location(value) {
|
|
42
|
+
if (!value) return "源码位置未提供(声明或 loader 事实可无位置)";
|
|
43
|
+
const file = value.file;
|
|
44
|
+
if (
|
|
45
|
+
typeof file !== "string" ||
|
|
46
|
+
!/^(?:[a-z0-9_@.-]+\/)*[a-z0-9_@.-]+$/i.test(file) ||
|
|
47
|
+
file.split("/").some((p) => p === ".." || p === ".") ||
|
|
48
|
+
!Number.isSafeInteger(value.line) ||
|
|
49
|
+
value.line < 1
|
|
50
|
+
)
|
|
51
|
+
return "源码位置已隐藏(非安全的包内相对路径)";
|
|
52
|
+
return "`" + file + ":" + value.line + "`";
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Render the canonical JSON body, without changing its verdict or exit code. */
|
|
56
|
+
export function renderIssueReport(body, options = {}) {
|
|
57
|
+
const lines = [
|
|
58
|
+
"# DSH 插件兼容性故障报告",
|
|
59
|
+
"",
|
|
60
|
+
"- 工具版本: " + version(body.toolVersion ?? TOOL_VERSION),
|
|
61
|
+
"- DSH 目标版本: " + version(body.dshVersion),
|
|
62
|
+
"- 退出码: " + (body.ok ? body.exitCode : 3),
|
|
63
|
+
"",
|
|
64
|
+
"绝对路径、原始参数、日志、源码片段、原始探针/evidence 与基线均不导出;保留插件名、包内相对位置及分析说明。常见凭据会隐藏,但不能保证任意业务文本无敏感信息;请预览后再分享。",
|
|
65
|
+
"",
|
|
66
|
+
];
|
|
67
|
+
if (!body.ok) {
|
|
68
|
+
lines.push(
|
|
69
|
+
"## 工具无法判定",
|
|
70
|
+
"",
|
|
71
|
+
"本次未产生可信插件结论。原始错误已省略,避免泄露路径、配置或日志;请在本机移除 --issue-report 排查。用法错误可先运行 --help。",
|
|
72
|
+
"",
|
|
73
|
+
);
|
|
74
|
+
} else {
|
|
75
|
+
const o = body.observation;
|
|
76
|
+
if (body.catalog) {
|
|
77
|
+
const c = body.catalog;
|
|
78
|
+
lines.push(
|
|
79
|
+
"- 参考目录: " +
|
|
80
|
+
version(c.referenceVersion) +
|
|
81
|
+
";SHA-256: " +
|
|
82
|
+
(/^[a-f0-9]{64}$/.test(c.sha256) ? c.sha256 : "未提供"),
|
|
83
|
+
"- 目录认证范围: " +
|
|
84
|
+
(c.certifiedVersions?.map(version).join("、") || "无") +
|
|
85
|
+
";仅内置目录与精确版本集成,不代表业务验收或运行时观测。",
|
|
86
|
+
...(c.verified
|
|
87
|
+
? []
|
|
88
|
+
: [
|
|
89
|
+
"- 目录警告: 目标版本或自定义目录未认证;当前结果不能证明目标版本已验收,请在实际宿主复核。",
|
|
90
|
+
]),
|
|
91
|
+
);
|
|
92
|
+
} else lines.push("- API目录身份未提供,版本范围未认证;请重新体检。");
|
|
93
|
+
lines.push(
|
|
94
|
+
"- 证据模式: " + o.mode,
|
|
95
|
+
"- 观测绑定: " + o.bound + ";可用: " + o.usable,
|
|
96
|
+
"- 观测 DSH 版本: " + version(o.dshVersion),
|
|
97
|
+
"- 扫描完整: " +
|
|
98
|
+
body.extraction.scanComplete +
|
|
99
|
+
";诊断数: " +
|
|
100
|
+
body.extraction.diagnostics.length,
|
|
101
|
+
"- 汇总: " +
|
|
102
|
+
["ok", "partial", "broken", "unknown", "limited", "vacuousOk"]
|
|
103
|
+
.map((k) => k + "=" + body.summary[k])
|
|
104
|
+
.join(", "),
|
|
105
|
+
"",
|
|
106
|
+
);
|
|
107
|
+
for (const note of o.notes) lines.push("- 观测限制: " + safe(note));
|
|
108
|
+
for (const [name, p] of Object.entries(body.plugins)) {
|
|
109
|
+
lines.push(
|
|
110
|
+
"",
|
|
111
|
+
"## 插件 " + identity(name),
|
|
112
|
+
"",
|
|
113
|
+
"- 插件版本: " + version(p.version),
|
|
114
|
+
"- 结论: " + p.verdict + ";vacuous=" + p.vacuous,
|
|
115
|
+
"- coverage=" +
|
|
116
|
+
p.coverage.status +
|
|
117
|
+
";verifiedCalls=" +
|
|
118
|
+
p.coverage.verifiedCalls,
|
|
119
|
+
);
|
|
120
|
+
for (const note of p.coverage.limitations)
|
|
121
|
+
lines.push("- 覆盖限制: " + safe(note));
|
|
122
|
+
if (!p.findings.length)
|
|
123
|
+
lines.push("- 未报告发现;不等于业务功能通过验收。");
|
|
124
|
+
for (const f of p.findings) {
|
|
125
|
+
lines.push(
|
|
126
|
+
"",
|
|
127
|
+
"- 发现: " +
|
|
128
|
+
safe(f.category) +
|
|
129
|
+
";API: " +
|
|
130
|
+
identity([f.service, f.method].filter(Boolean).join(".")),
|
|
131
|
+
" - severity=" +
|
|
132
|
+
f.severity +
|
|
133
|
+
";certainty=" +
|
|
134
|
+
f.certainty +
|
|
135
|
+
";impactBasis=" +
|
|
136
|
+
f.impactBasis,
|
|
137
|
+
" - 位置: " + location(f.location),
|
|
138
|
+
" - 影响: " + safe(f.impact),
|
|
139
|
+
" - 依据: " + safe(f.basis),
|
|
140
|
+
);
|
|
141
|
+
for (const step of nextSteps(f))
|
|
142
|
+
lines.push(" - 下一步: " + safe(step));
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
const flags = [
|
|
147
|
+
"--nm <profile-or-node_modules>",
|
|
148
|
+
"--no-baseline",
|
|
149
|
+
"--issue-report",
|
|
150
|
+
];
|
|
151
|
+
const runtimeEvidence =
|
|
152
|
+
body.observation?.usable === true ||
|
|
153
|
+
body.observation?.mode === "runtime" ||
|
|
154
|
+
body.hasRuntimeSurface === true ||
|
|
155
|
+
body.inputs?.hasRuntimeSurface === true;
|
|
156
|
+
const fileProbe =
|
|
157
|
+
options.probe || (body.inputs?.probe && !body.inputs?.probeBoot);
|
|
158
|
+
const explicitBoot =
|
|
159
|
+
!fileProbe && (options.probeBoot || body.inputs?.probeBoot);
|
|
160
|
+
const loaderFindings = Object.values(body.plugins || {}).some((plugin) =>
|
|
161
|
+
(plugin.findings || []).some(
|
|
162
|
+
(finding) => finding.category === "host-entry-inactive",
|
|
163
|
+
),
|
|
164
|
+
);
|
|
165
|
+
if (fileProbe || (runtimeEvidence && !explicitBoot))
|
|
166
|
+
flags.push("--probe <probe-report.json>");
|
|
167
|
+
else if (explicitBoot) flags.push("--probe-boot");
|
|
168
|
+
if (options.dshVersion || body.dshVersion)
|
|
169
|
+
flags.push("--dsh-version <dsh-version>");
|
|
170
|
+
if (options.rules || body.inputs?.rules) flags.push("--rules <rules.json>");
|
|
171
|
+
if (options.catalog || body.inputs?.catalog)
|
|
172
|
+
flags.push("--catalog <catalog.json>");
|
|
173
|
+
lines.push("", "## 复核(在仓库根目录,先替换占位符)", "");
|
|
174
|
+
if (runtimeEvidence)
|
|
175
|
+
lines.push(
|
|
176
|
+
"本报告含运行时观测;仅静态扫描可能给出不同结论,不能复现这些运行时发现。",
|
|
177
|
+
"",
|
|
178
|
+
);
|
|
179
|
+
if (loaderFindings)
|
|
180
|
+
lines.push(
|
|
181
|
+
"本报告包含 loader 状态事实。FAILED/PENDING 必须回到原来的同一宿主刷新报告并核对条目身份、状态和时间;静态扫描或文件 probe 不能复现这些状态。",
|
|
182
|
+
"",
|
|
183
|
+
);
|
|
184
|
+
if (explicitBoot)
|
|
185
|
+
lines.push(
|
|
186
|
+
"原 CLI 显式请求了 --probe-boot;下面命令会安装依赖、启动并执行目标插件,临时 profile 不是 OS 沙箱。这是一次新宿主检查,不能复现原宿主的 loader 或业务事实。原有超时/留存参数未复制,必要时在本机补回;导出文本本身不启动宿主。",
|
|
187
|
+
"",
|
|
188
|
+
);
|
|
189
|
+
else if (fileProbe || runtimeEvidence)
|
|
190
|
+
lines.push(
|
|
191
|
+
"下面命令仅适用于已另行取得同一 profile、DSH 版本且采样时间有效的合格 probe 文件后;本报告未导出观测,也不会自动创建 probe 或启动宿主。文件 probe 只能补充其明确记录的服务结构,不能代替原宿主状态或业务验收。",
|
|
192
|
+
"",
|
|
193
|
+
);
|
|
194
|
+
else
|
|
195
|
+
lines.push(
|
|
196
|
+
"下面命令只用于复核文件、声明与目录;CLI 仍可能读取目标旁置的 probe-report.json,运行后须核对输出中的实际证据来源。",
|
|
197
|
+
"",
|
|
198
|
+
);
|
|
199
|
+
lines.push(
|
|
200
|
+
"```sh",
|
|
201
|
+
"node bin/compat-check.mjs " + flags.join(" "),
|
|
202
|
+
"```",
|
|
203
|
+
"",
|
|
204
|
+
"使用相同工具版本/源码提交、目标插件制品、规则与 API 目录;规则/目录占位符须替换为本报告对应的输入,自定义输入需自行保存并核对身份。命令不保证复现原观测时刻或相同结论,也不补齐未导出的观测。",
|
|
205
|
+
"",
|
|
206
|
+
"## 判读限制",
|
|
207
|
+
"",
|
|
208
|
+
"certain 表示当前证据足以支持该条发现,可来自明确静态事实或合格运行时观测;suspected 表示当前证据不足以确定该条发现。impactBasis=measured/inferred 分别表示影响有对应实测依据/影响由证据推断,确定发现不等于影响已实测。vacuous 表示本轮未发现冲突但没有已验证调用,仍可能存在服务依赖或客户端功能。catalogue 不是运行时观测,未观测不等于缺失;limited/vacuous 或客户端未验证均不能省略。声明放行、激活、调用核验或覆盖完整不证明业务成功。",
|
|
209
|
+
"",
|
|
210
|
+
"普通体检按目标版本选择已知目录:0.2.1-rc.2及以后用rc.2目录,其余/未知用旧目录;显式--catalog优先,未来版本未经认证。声明从0.2.1-rc.1起优先engines.dsh,否则回退官方peer;旧目标保留旧规则。精确豁免只表示声明获准,不证明业务成功;请以实际宿主复核。",
|
|
211
|
+
"",
|
|
212
|
+
"下一步是核实建议;本工具不会自动安装、升级或修改配置与源码。",
|
|
213
|
+
"",
|
|
214
|
+
"本报告只生成文本,不自动上传、创建 issue 或回复他人。",
|
|
215
|
+
);
|
|
216
|
+
return lines.join("\n");
|
|
217
|
+
}
|
|
@@ -2,6 +2,7 @@ import path from "node:path";
|
|
|
2
2
|
|
|
3
3
|
const object = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
|
|
4
4
|
const samePath = (a, b) => path.resolve(a) === path.resolve(b);
|
|
5
|
+
export const MAX_OBSERVATION_FUTURE_SKEW_MS = 5 * 60 * 1000;
|
|
5
6
|
|
|
6
7
|
/** Legacy reports authorize only their explicit keys, never missing keys. */
|
|
7
8
|
export function normalizeObservation(surface, target = {}) {
|
|
@@ -22,15 +23,24 @@ export function normalizeObservation(surface, target = {}) {
|
|
|
22
23
|
notes.push("探针的 profile/DSH 版本与体检对象不匹配,忽略运行时证据");
|
|
23
24
|
return { root, notes, bound, usable: false };
|
|
24
25
|
}
|
|
25
|
-
if (
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
(!
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
26
|
+
if (bound) {
|
|
27
|
+
const sampledAt =
|
|
28
|
+
typeof surface.probedAt === "string" ? Date.parse(surface.probedAt) : NaN;
|
|
29
|
+
if (!Number.isFinite(sampledAt)) {
|
|
30
|
+
notes.push("探针采样时间缺失或无效,忽略运行时证据");
|
|
31
|
+
return { root, notes, bound, usable: false };
|
|
32
|
+
}
|
|
33
|
+
const ageMs = Date.now() - sampledAt;
|
|
34
|
+
if (ageMs < -MAX_OBSERVATION_FUTURE_SKEW_MS) {
|
|
35
|
+
notes.push(
|
|
36
|
+
"探针采样时间超出允许的未来时钟偏差(5 分钟),忽略运行时证据",
|
|
37
|
+
);
|
|
38
|
+
return { root, notes, bound, usable: false };
|
|
39
|
+
}
|
|
40
|
+
if (target.maxAgeMs && ageMs > target.maxAgeMs) {
|
|
41
|
+
notes.push("探针采样时间已过期,忽略运行时证据");
|
|
42
|
+
return { root, notes, bound, usable: false };
|
|
43
|
+
}
|
|
34
44
|
}
|
|
35
45
|
if (!bound)
|
|
36
46
|
notes.push(
|