dsh-standards 1.3.0 → 1.4.1
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/40-checker/scripts/build-sidecar.mjs +238 -8
- package/40-checker/src/author-cli.mjs +274 -7
- package/40-checker/src/author-report.mjs +14 -3
- package/40-checker/src/bundle-static.mjs +181 -0
- package/40-checker/src/cli.mjs +27 -3
- package/40-checker/src/client-shape.mjs +33 -3
- package/40-checker/src/declarations.mjs +53 -8
- package/40-checker/src/index.mjs +3 -1
- package/40-checker/src/manifest.mjs +87 -14
- package/40-checker/src/remediation.mjs +109 -8
- package/40-checker/src/report.mjs +83 -7
- package/40-checker/src/semver.mjs +112 -65
- package/40-checker/src/util.mjs +51 -8
- package/CHANGELOG.md +27 -0
- package/README.md +28 -16
- package/docs/coverage.md +82 -0
- package/package.json +1 -1
- package/spec/coverage-contract.md +100 -0
- package/spec/dsc-clauses.json +766 -0
- package/spec/examples/boost-card.example.json +15 -0
- package/spec/examples/flow-evidence.example.json +113 -0
- package/spec/remediation.json +54 -54
- package/spec/schemas/boost-card.schema.json +107 -0
- package/spec/schemas/conformance.schema.json +848 -0
|
@@ -28,6 +28,28 @@ function inside(root, target) {
|
|
|
28
28
|
return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative))
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
+
/**
|
|
32
|
+
* Every local file target declared by `exports` (including conditional
|
|
33
|
+
* objects), labelled with the subpath that declares it. Wildcard patterns and
|
|
34
|
+
* non-relative targets cannot be checked statically and are skipped.
|
|
35
|
+
*/
|
|
36
|
+
function exportTargets(exportsValue) {
|
|
37
|
+
const targets = []
|
|
38
|
+
const visit = (node, label) => {
|
|
39
|
+
if (typeof node === 'string') { targets.push({ label, target: node }); return }
|
|
40
|
+
if (Array.isArray(node)) { for (const item of node) visit(item, label); return }
|
|
41
|
+
if (isObject(node)) { for (const item of Object.values(node)) visit(item, label) }
|
|
42
|
+
}
|
|
43
|
+
if (typeof exportsValue === 'string') {
|
|
44
|
+
visit(exportsValue, 'exports["."]')
|
|
45
|
+
} else if (isObject(exportsValue)) {
|
|
46
|
+
for (const [subpath, node] of Object.entries(exportsValue)) {
|
|
47
|
+
visit(node, subpath === '.' ? 'exports["."]' : `exports["${subpath}"]`)
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return targets
|
|
51
|
+
}
|
|
52
|
+
|
|
31
53
|
export function resolveEntry(dir, manifest) {
|
|
32
54
|
const candidates = []
|
|
33
55
|
const exportTarget = rootExportTarget(manifest)
|
|
@@ -85,9 +107,10 @@ function checkExportShape(entry, kind) {
|
|
|
85
107
|
|
|
86
108
|
function analyzePatch(dir, manifest) {
|
|
87
109
|
const raw = manifest?.dsh?.bundle?.patch
|
|
88
|
-
if (raw === undefined) return { present: false, files: [], problems: [], enabledPluginManagerTools: false }
|
|
110
|
+
if (raw === undefined) return { present: false, files: [], problems: [], rows: [], enabledPluginManagerTools: false }
|
|
89
111
|
const files = []
|
|
90
112
|
const problems = []
|
|
113
|
+
const rows = []
|
|
91
114
|
let enabledPluginManagerTools = false
|
|
92
115
|
for (const entry of asArray(raw)) {
|
|
93
116
|
if (typeof entry !== 'string' || entry.trim() === '') {
|
|
@@ -111,21 +134,27 @@ function analyzePatch(dir, manifest) {
|
|
|
111
134
|
let text = ''
|
|
112
135
|
try { text = readText(absolute) } catch { problems.push(`patch file is unreadable: ${entry}`); continue }
|
|
113
136
|
const lines = text.split('\n')
|
|
137
|
+
let lastId = null
|
|
114
138
|
for (let index = 0; index < lines.length; index += 1) {
|
|
115
139
|
const line = lines[index]
|
|
140
|
+
const idMatch = /^\s*-?\s*id:\s*['"]?([^'"\s#]+)['"]?/.exec(line)
|
|
141
|
+
if (idMatch) lastId = idMatch[1]
|
|
116
142
|
const match = /^\s*name:\s*['"]?([^'"\s#]+)['"]?/.exec(line)
|
|
117
143
|
if (!match) continue
|
|
118
144
|
const name = match[1]
|
|
119
145
|
if (path.isAbsolute(name) || name.startsWith('~') || name.includes('$') || name.split(/[\\/]/).includes('..')) {
|
|
120
146
|
problems.push(`patch row name escapes the package: ${name}`)
|
|
121
147
|
}
|
|
148
|
+
const window = lines.slice(Math.max(0, index - 3), index + 6).join('\n')
|
|
149
|
+
const disabledLine = /disabled:\s*([^\n#]+)/.exec(window)
|
|
150
|
+
const disabled = disabledLine !== null && /^true\b/.test(disabledLine[1].trim())
|
|
151
|
+
rows.push({ name, id: lastId, disabled, conditional: disabledLine !== null && !disabled })
|
|
122
152
|
if (name === '@deepseek-ai/dsh-plugin-manager/tools') {
|
|
123
|
-
const window = lines.slice(index, index + 4).join('\n')
|
|
124
153
|
if (!/disabled:\s*true/.test(window)) enabledPluginManagerTools = true
|
|
125
154
|
}
|
|
126
155
|
}
|
|
127
156
|
}
|
|
128
|
-
return { present: true, files, problems, enabledPluginManagerTools }
|
|
157
|
+
return { present: true, files, problems, rows, enabledPluginManagerTools }
|
|
129
158
|
}
|
|
130
159
|
|
|
131
160
|
function supplyChain(dir, manifest) {
|
|
@@ -180,7 +209,7 @@ export function runManifestChecks(dir, options = {}) {
|
|
|
180
209
|
const kindInfo = inferKind(manifest, entry.exists)
|
|
181
210
|
const kind = kindInfo.kind
|
|
182
211
|
const patch = analyzePatch(dir, manifest)
|
|
183
|
-
const g4 = checkClientShape(manifest)
|
|
212
|
+
const g4 = checkClientShape(manifest, dir)
|
|
184
213
|
|
|
185
214
|
const manifestDetails = []
|
|
186
215
|
const name = manifest.name
|
|
@@ -196,10 +225,13 @@ export function runManifestChecks(dir, options = {}) {
|
|
|
196
225
|
}
|
|
197
226
|
if (entry.escape) {
|
|
198
227
|
manifestDetails.push(detail('C-1.2', 'FAIL', `entry escapes the package: ${entry.target}`, ['package.json#exports']))
|
|
199
|
-
} else if (entry.declaredMissing && entry.buildHint) {
|
|
200
|
-
manifestDetails.push(detail('C-1.2', 'WARN', `exports["."] target missing; build hint exists (${entry.sourceEntry ?? 'build script'})`, ['package.json#exports']))
|
|
201
228
|
} else if (entry.declaredMissing) {
|
|
202
|
-
|
|
229
|
+
// A declared entry that is absent is a packaging defect. A build hint only
|
|
230
|
+
// explains it; it does not make the package loadable from the tarball.
|
|
231
|
+
const hint = entry.buildHint
|
|
232
|
+
? ` (build hint: ${entry.sourceEntry ?? 'build script'}; publish the built file)`
|
|
233
|
+
: ''
|
|
234
|
+
manifestDetails.push(detail('C-1.2', 'FAIL', `exports["."] points to a missing file: ${entry.target}${hint}`, ['package.json#exports']))
|
|
203
235
|
} else if (entry.exists) {
|
|
204
236
|
manifestDetails.push(detail('C-1.2', 'PASS', `entry resolves via ${entry.source}: ${entry.relative}`, [entry.relative]))
|
|
205
237
|
} else if (entry.buildHint) {
|
|
@@ -216,6 +248,37 @@ export function runManifestChecks(dir, options = {}) {
|
|
|
216
248
|
} else {
|
|
217
249
|
manifestDetails.push(detail('C-1.2', 'FAIL', 'no resolvable runtime entry', ['package.json#exports', 'package.json#main']))
|
|
218
250
|
}
|
|
251
|
+
|
|
252
|
+
// Declared `exports` subpaths and `main` must point at files that ship with
|
|
253
|
+
// the package. This is a pure file-existence check: it works for bundles
|
|
254
|
+
// (which may have no root entry) and for client subpaths that the host
|
|
255
|
+
// resolves through the package root.
|
|
256
|
+
const checkedTargets = new Map()
|
|
257
|
+
const missingTargetDetails = []
|
|
258
|
+
const noteTarget = (label, target, source, { allowBare = false } = {}) => {
|
|
259
|
+
if (typeof target !== 'string' || target.trim() === '') return
|
|
260
|
+
if (!allowBare && !target.startsWith('./')) return
|
|
261
|
+
if (target.includes('*')) return
|
|
262
|
+
const key = `${label}|${target}`
|
|
263
|
+
if (checkedTargets.has(key)) return
|
|
264
|
+
checkedTargets.set(key, true)
|
|
265
|
+
const absolute = path.resolve(dir, target)
|
|
266
|
+
if (!inside(dir, absolute)) {
|
|
267
|
+
missingTargetDetails.push(detail('C-1.2', 'FAIL', `${label} target escapes the package: ${target}`, [source]))
|
|
268
|
+
return
|
|
269
|
+
}
|
|
270
|
+
if (!isFile(absolute)) {
|
|
271
|
+
missingTargetDetails.push(detail('C-1.2', 'FAIL', `${label} points to a missing file: ${target}`, [source]))
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
for (const item of exportTargets(manifest.exports)) {
|
|
275
|
+
if (item.label === 'exports["."]') continue // resolveEntry() already judged the root export
|
|
276
|
+
noteTarget(item.label, item.target, 'package.json#exports')
|
|
277
|
+
}
|
|
278
|
+
if (typeof manifest.main === 'string' && manifest.main.trim() !== '') {
|
|
279
|
+
noteTarget('main', manifest.main, 'package.json#main', { allowBare: true })
|
|
280
|
+
}
|
|
281
|
+
manifestDetails.push(...missingTargetDetails.slice(0, 8))
|
|
219
282
|
const shape = checkExportShape(entry, kind)
|
|
220
283
|
manifestDetails.push(detail('C-1.3', shape.result, shape.reason, shape.evidence))
|
|
221
284
|
if (!patch.present) {
|
|
@@ -255,7 +318,7 @@ export function runManifestChecks(dir, options = {}) {
|
|
|
255
318
|
} else if (!validRange(dshEngine)) {
|
|
256
319
|
compatDetails.push(detail('C-2.1', 'FAIL', `engines.dsh is not a SemVer range: ${dshEngine}`, ['package.json#engines.dsh']))
|
|
257
320
|
} else if (!satisfies(runtimeVersion, dshEngine)) {
|
|
258
|
-
compatDetails.push(detail('C-2.2', 'FAIL', `engines.dsh ${dshEngine} does not satisfy runtime ${runtimeVersion}`, ['package.json#engines.dsh']))
|
|
321
|
+
compatDetails.push(detail('C-2.2', 'FAIL', `engines.dsh ${dshEngine} is valid but does not satisfy runtime ${runtimeVersion}`, ['package.json#engines.dsh']))
|
|
259
322
|
} else {
|
|
260
323
|
compatDetails.push(detail('C-2.1', 'PASS', `engines.dsh=${dshEngine}`))
|
|
261
324
|
compatDetails.push(detail('C-2.2', 'PASS', `runtime ${runtimeVersion} satisfies ${dshEngine}`))
|
|
@@ -267,23 +330,33 @@ export function runManifestChecks(dir, options = {}) {
|
|
|
267
330
|
}
|
|
268
331
|
}
|
|
269
332
|
} else if (dshPeers.length > 0) {
|
|
270
|
-
let
|
|
333
|
+
let invalid = false
|
|
334
|
+
let unsatisfied = false
|
|
271
335
|
for (const [peer, rangeValue] of dshPeers) {
|
|
272
336
|
if (typeof rangeValue !== 'string') {
|
|
273
337
|
compatDetails.push(detail('C-2.1', 'FAIL', `peer ${peer} must be a string range`, [`package.json#peerDependencies.${peer}`]))
|
|
274
|
-
|
|
338
|
+
invalid = true
|
|
275
339
|
continue
|
|
276
340
|
}
|
|
277
341
|
const effective = /^workspace:(?:\^|~|\*)$/.test(rangeValue) ? runtimeVersion : rangeValue
|
|
278
342
|
if (!validRange(effective)) {
|
|
279
343
|
compatDetails.push(detail('C-2.1', 'FAIL', `peer ${peer} is not a SemVer range: ${rangeValue}`, [`package.json#peerDependencies.${peer}`]))
|
|
280
|
-
|
|
344
|
+
invalid = true
|
|
281
345
|
} else if (!satisfies(runtimeVersion, effective)) {
|
|
282
|
-
|
|
283
|
-
|
|
346
|
+
// A syntactically valid range that does not cover this runtime is a
|
|
347
|
+
// compatibility finding (C-2.2), never an invalid declaration (C-2.1).
|
|
348
|
+
compatDetails.push(detail('C-2.2', 'FAIL', `peer ${peer} range ${rangeValue} is valid but does not satisfy runtime ${runtimeVersion}`, [`package.json#peerDependencies.${peer}`]))
|
|
349
|
+
unsatisfied = true
|
|
284
350
|
}
|
|
285
351
|
}
|
|
286
|
-
|
|
352
|
+
if (!invalid) {
|
|
353
|
+
compatDetails.push(detail('C-2.1', 'PASS', 'dsh peer range(s) are valid SemVer; no engines.dsh declared'))
|
|
354
|
+
}
|
|
355
|
+
if (invalid) {
|
|
356
|
+
compatDetails.push(detail('C-2.1', 'FAIL', 'one or more dsh peer ranges are invalid'))
|
|
357
|
+
} else if (!unsatisfied) {
|
|
358
|
+
compatDetails.push(detail('C-2.2', 'PASS', `runtime ${runtimeVersion} satisfies the dsh peer range(s)`))
|
|
359
|
+
}
|
|
287
360
|
} else {
|
|
288
361
|
compatDetails.push(detail('C-2.1', 'FAIL', 'neither engines.dsh nor a @deepseek-ai/dsh* peer range is declared', ['package.json#engines', 'package.json#peerDependencies']))
|
|
289
362
|
}
|
|
@@ -1,20 +1,30 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Join a dsh-conformance/1 report with the remediation rule table.
|
|
3
3
|
*
|
|
4
|
-
* This module is
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
4
|
+
* This module is additive: it never writes, and it never changes the report it
|
|
5
|
+
* receives. Its one file access is best-effort: it reads the subject's own
|
|
6
|
+
* `package.json` to turn symbolic `package.json#field` references into a
|
|
7
|
+
* `package.json:<line>` locator. When the subject, the file, or the field is
|
|
8
|
+
* absent the reference is left exactly as the check emitted it.
|
|
8
9
|
*/
|
|
9
10
|
|
|
11
|
+
import fs from 'node:fs'
|
|
12
|
+
import path from 'node:path'
|
|
10
13
|
import packageJson from '../../package.json' with { type: 'json' }
|
|
11
14
|
|
|
12
15
|
/** Release-pinned guide base. `docs` in the rule table is a relative path. */
|
|
13
|
-
export const DOCS_BASE = 'https://github.com/PerryLink/dsh-plugin-guide/blob/v0.4.
|
|
16
|
+
export const DOCS_BASE = 'https://github.com/PerryLink/dsh-plugin-guide/blob/v0.4.6/'
|
|
14
17
|
|
|
15
18
|
const LIVE = new Set(['FAIL', 'WARN'])
|
|
16
19
|
const SEVERITY_RANK = { FAIL: 2, WARN: 1 }
|
|
17
20
|
|
|
21
|
+
/**
|
|
22
|
+
* Rule pairs that can report the same literal from two different checks. When
|
|
23
|
+
* their evidence arrays are identical they are one finding: one advice entry
|
|
24
|
+
* survives (the higher severity, then the first key) and `reason` names both.
|
|
25
|
+
*/
|
|
26
|
+
export const EQUIVALENT_RULES = [['C-11.4', 'C-12.4']]
|
|
27
|
+
|
|
18
28
|
function tableOf(rules) {
|
|
19
29
|
if (!rules || typeof rules !== 'object') return {}
|
|
20
30
|
// Accept either the whole spec/1 object or a bare key→rule map.
|
|
@@ -48,6 +58,94 @@ function uniqueEvidence(...lists) {
|
|
|
48
58
|
return out.slice(0, 10)
|
|
49
59
|
}
|
|
50
60
|
|
|
61
|
+
/** Read the subject's package.json once; null when it cannot be located. */
|
|
62
|
+
function packageJsonText(subjectPath) {
|
|
63
|
+
if (typeof subjectPath !== 'string' || subjectPath === '') return null
|
|
64
|
+
const candidates = path.extname(subjectPath).toLowerCase() === '.json'
|
|
65
|
+
? [subjectPath]
|
|
66
|
+
: [path.join(subjectPath, 'package.json')]
|
|
67
|
+
for (const candidate of candidates) {
|
|
68
|
+
try {
|
|
69
|
+
if (fs.statSync(candidate).isFile()) return fs.readFileSync(candidate, 'utf8')
|
|
70
|
+
} catch {
|
|
71
|
+
// Best effort only: a missing/unreadable file is not an advice error.
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return null
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** 1-based line of `"<leaf>":` in the manifest text, or null. */
|
|
78
|
+
function fieldLine(text, ref) {
|
|
79
|
+
if (typeof text !== 'string' || !ref.startsWith('package.json#')) return null
|
|
80
|
+
const leaf = ref.slice('package.json#'.length).split('.').filter(Boolean).pop()
|
|
81
|
+
if (!leaf) return null
|
|
82
|
+
const needle = JSON.stringify(leaf)
|
|
83
|
+
let index = text.indexOf(needle)
|
|
84
|
+
while (index !== -1) {
|
|
85
|
+
if (/^\s*:/.test(text.slice(index + needle.length))) {
|
|
86
|
+
return text.slice(0, index).split('\n').length
|
|
87
|
+
}
|
|
88
|
+
index = text.indexOf(needle, index + needle.length)
|
|
89
|
+
}
|
|
90
|
+
return null
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Prefix each `package.json#field` reference with a concrete
|
|
95
|
+
* `package.json:<line>` alongside it. Nothing is dropped; unresolvable
|
|
96
|
+
* references are kept verbatim.
|
|
97
|
+
*/
|
|
98
|
+
function withLineLocators(evidence, text) {
|
|
99
|
+
if (!text) return evidence
|
|
100
|
+
const out = []
|
|
101
|
+
const seen = new Set()
|
|
102
|
+
for (const value of evidence) {
|
|
103
|
+
if (typeof value !== 'string' || value === '' || seen.has(value)) continue
|
|
104
|
+
seen.add(value)
|
|
105
|
+
out.push(value)
|
|
106
|
+
const line = fieldLine(text, value)
|
|
107
|
+
if (line === null) continue
|
|
108
|
+
const locator = `package.json:${line}`
|
|
109
|
+
if (!seen.has(locator)) {
|
|
110
|
+
seen.add(locator)
|
|
111
|
+
out.push(locator)
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return out.slice(0, 10)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function sameEvidenceSet(left, right) {
|
|
118
|
+
if (!Array.isArray(left) || !Array.isArray(right)) return false
|
|
119
|
+
if (left.length === 0 || left.length !== right.length) return false
|
|
120
|
+
const a = [...left].sort()
|
|
121
|
+
const b = [...right].sort()
|
|
122
|
+
return a.every((value, index) => value === b[index])
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Collapse the pairs in EQUIVALENT_RULES when both checks reported the same
|
|
127
|
+
* evidence. The surviving entry keeps its own key/docs/severity; `reason`
|
|
128
|
+
* records the merged key, and `occurrences` sums both findings.
|
|
129
|
+
*/
|
|
130
|
+
function mergeEquivalentAdvice(advice) {
|
|
131
|
+
const dropped = new Set()
|
|
132
|
+
for (const [leftKey, rightKey] of EQUIVALENT_RULES) {
|
|
133
|
+
const left = advice.find(item => item.key === leftKey && !dropped.has(item))
|
|
134
|
+
const right = advice.find(item => item.key === rightKey && !dropped.has(item))
|
|
135
|
+
if (!left || !right || !sameEvidenceSet(left.evidence, right.evidence)) continue
|
|
136
|
+
const keepLeft = (SEVERITY_RANK[left.gate] ?? 0) >= (SEVERITY_RANK[right.gate] ?? 0)
|
|
137
|
+
const keep = keepLeft ? left : right
|
|
138
|
+
const drop = keepLeft ? right : left
|
|
139
|
+
keep.occurrences += drop.occurrences
|
|
140
|
+
keep.evidence = uniqueEvidence(keep.evidence, drop.evidence)
|
|
141
|
+
const aliasReason = drop.reason && drop.reason !== keep.reason ? `: ${drop.reason}` : ''
|
|
142
|
+
const note = `identical evidence to ${drop.key} (${drop.source})${aliasReason}; merged into ${keep.key}`
|
|
143
|
+
keep.reason = keep.reason ? `${keep.reason} | ${note}` : note
|
|
144
|
+
dropped.add(drop)
|
|
145
|
+
}
|
|
146
|
+
return advice.filter(item => !dropped.has(item))
|
|
147
|
+
}
|
|
148
|
+
|
|
51
149
|
/**
|
|
52
150
|
* Build additive advice from a report.
|
|
53
151
|
*
|
|
@@ -59,7 +157,8 @@ function uniqueEvidence(...lists) {
|
|
|
59
157
|
* detail's own reason stays in `reason` for diagnostics.
|
|
60
158
|
*
|
|
61
159
|
* Duplicate details are merged: evidence is unioned, occurrences counted, and
|
|
62
|
-
* the specific reasons are joined for diagnostics.
|
|
160
|
+
* the specific reasons are joined for diagnostics. Duplicate findings that the
|
|
161
|
+
* same literal produces from two checks are merged by EQUIVALENT_RULES.
|
|
63
162
|
*
|
|
64
163
|
* @param {object} report dsh-conformance/1 report
|
|
65
164
|
* @param {object} rules flat key→rule map (also accepts a { rules } or { items } wrapper)
|
|
@@ -69,6 +168,8 @@ function uniqueEvidence(...lists) {
|
|
|
69
168
|
export function joinAdvice(report, rules, docsBase = DOCS_BASE) {
|
|
70
169
|
const table = tableOf(rules)
|
|
71
170
|
const version = versionOf(rules)
|
|
171
|
+
const subjectPath = typeof report?.subject?.path === 'string' ? report.subject.path : null
|
|
172
|
+
const manifestText = packageJsonText(subjectPath)
|
|
72
173
|
const merged = new Map()
|
|
73
174
|
|
|
74
175
|
const add = (check, key, gate, reason, evidence) => {
|
|
@@ -118,7 +219,7 @@ export function joinAdvice(report, rules, docsBase = DOCS_BASE) {
|
|
|
118
219
|
docsUrl: docsUrlOf(rule, docsBase),
|
|
119
220
|
source: entry.check.id,
|
|
120
221
|
version,
|
|
121
|
-
evidence: entry.evidence,
|
|
222
|
+
evidence: withLineLocators(entry.evidence, manifestText),
|
|
122
223
|
reason: entry.reasons.slice(0, 3).join(' | '),
|
|
123
224
|
occurrences: entry.occurrences,
|
|
124
225
|
advisory: entry.check.advisory === true,
|
|
@@ -133,5 +234,5 @@ export function joinAdvice(report, rules, docsBase = DOCS_BASE) {
|
|
|
133
234
|
|| a.key.localeCompare(b.key)
|
|
134
235
|
|| a.source.localeCompare(b.source)
|
|
135
236
|
))
|
|
136
|
-
return advice
|
|
237
|
+
return mergeEquivalentAdvice(advice)
|
|
137
238
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import path from 'node:path'
|
|
2
2
|
import remediationSpec from '../../spec/remediation.json' with { type: 'json' }
|
|
3
3
|
import { DOCS_BASE, joinAdvice } from './remediation.mjs'
|
|
4
|
+
import { parseVersion } from './semver.mjs'
|
|
4
5
|
|
|
5
6
|
export const PACKAGE_MANDATORY = [
|
|
6
7
|
'DSC-01', 'DSC-02', 'DSC-04', 'DSC-05', 'DSC-06', 'DSC-07', 'DSC-08', 'DSC-09', 'DSC-11', 'DSC-12', 'DSC-13',
|
|
@@ -46,6 +47,34 @@ function bucketRow(item, extra = {}) {
|
|
|
46
47
|
}
|
|
47
48
|
}
|
|
48
49
|
|
|
50
|
+
/**
|
|
51
|
+
* Explicit "how to make this check runnable" text for every row that is not
|
|
52
|
+
* fully checked. NA/ERROR findings must explain themselves; they are not
|
|
53
|
+
* silently empty.
|
|
54
|
+
*/
|
|
55
|
+
export function howToEnableFor(item) {
|
|
56
|
+
const id = item?.id
|
|
57
|
+
const reason = String(item?.reason ?? '')
|
|
58
|
+
if (id === 'DSC-03') return 'pass --install-spec <spec>, or --market-index <file> --plugin-name <name>'
|
|
59
|
+
if (['DSC-04', 'DSC-05', 'DSC-06', 'DSC-07', 'DSC-08', 'DSC-09'].includes(id)) {
|
|
60
|
+
if (item?.coverage === 'partial') return 'omit --no-exec; pass --treat-as-plugin --runtime <path> for L1-L3, and --host <dir> for bundle G0-G3'
|
|
61
|
+
return 'pass --runtime <path> (plugin L1-L3) or --host <dir> (bundle G0-G3); do not use --no-exec'
|
|
62
|
+
}
|
|
63
|
+
if (id === 'DSC-10') {
|
|
64
|
+
if (/truncated at \d+ files/.test(reason)) return 'raise the source scan budget with --max-files <n> / --max-bytes <n>; the entry may be outside the current scan'
|
|
65
|
+
if (/entry module/.test(reason)) return 'fix package.json exports/main so the entry is inside the package, or adjust --max-files/--max-bytes'
|
|
66
|
+
return 'provide a readable entry and sources; re-run with a larger --max-files/--max-bytes if the scan is bounded'
|
|
67
|
+
}
|
|
68
|
+
if (id === 'DSC-14') {
|
|
69
|
+
return 'pass --host <app-boot/lib/index.js> for bundle G0-G3, or --runtime <path> for a plugin; omit --no-exec and --entry-claim static'
|
|
70
|
+
}
|
|
71
|
+
if (['DSC-11', 'DSC-12', 'DSC-13'].includes(id)) {
|
|
72
|
+
if (/manifest unavailable/.test(reason)) return 'fix package.json; these declaration checks cannot run without a manifest'
|
|
73
|
+
return 'run the full profile without --no-exec; declaration checks need source files'
|
|
74
|
+
}
|
|
75
|
+
return 'run the full 14-check profile (see --help) so this check can produce a result'
|
|
76
|
+
}
|
|
77
|
+
|
|
49
78
|
/**
|
|
50
79
|
* `entryBlocking` is only sound when every prerequisite was actually evaluated.
|
|
51
80
|
* C-1.x always produce details; C-3.1/3.2/3.5 only produce them when an install
|
|
@@ -132,9 +161,12 @@ export function buildReport({ subject, anchor, checks, g4 = null, scope, profile
|
|
|
132
161
|
level = staticOnly ? `DS-C static${status === 'warn' ? ' (warnings)' : ''}` : profile === 'listing' || profile === 'both' ? `DS-C (listing${suffix})` : `DS-C (package${suffix})`
|
|
133
162
|
}
|
|
134
163
|
|
|
164
|
+
const partialIds = selected.filter(item => item.coverage === 'partial').map(item => item.id)
|
|
165
|
+
const fullyNotRun = notRunIds.filter(id => !partialIds.includes(id))
|
|
135
166
|
const reasonParts = []
|
|
136
167
|
if (failed.length > 0) reasonParts.push(`FAIL: ${failed.map(item => item.id).join(', ')}`)
|
|
137
|
-
if (
|
|
168
|
+
if (fullyNotRun.length > 0) reasonParts.push(`not run: ${fullyNotRun.join(', ')}`)
|
|
169
|
+
if (partialIds.length > 0) reasonParts.push(`partial: ${partialIds.join(', ')}`)
|
|
138
170
|
if (warnings.length > 0) reasonParts.push(`WARN: ${warnings.map(item => item.id).join(', ')}`)
|
|
139
171
|
if (advisoryWarnings.length > 0) reasonParts.push(`advisory: ${advisoryWarnings.map(item => item.id).join(', ')}`)
|
|
140
172
|
|
|
@@ -147,11 +179,29 @@ export function buildReport({ subject, anchor, checks, g4 = null, scope, profile
|
|
|
147
179
|
}))
|
|
148
180
|
const notCheckedBucket = effectiveChecks
|
|
149
181
|
.filter(item => item.result === 'NA' || item.result === 'ERROR')
|
|
150
|
-
.map(item => bucketRow(item))
|
|
182
|
+
.map(item => bucketRow(item, { howToEnable: howToEnableFor(item) }))
|
|
151
183
|
for (const id of missing) {
|
|
152
|
-
notCheckedBucket.push({ id, clause: null, result: 'NOT-RUN', coverage: 'not-run', reason: 'mandatory check missing' })
|
|
184
|
+
notCheckedBucket.push({ id, clause: null, result: 'NOT-RUN', coverage: 'not-run', reason: 'mandatory check missing', howToEnable: howToEnableFor({ id, reason: 'mandatory check missing' }) })
|
|
153
185
|
}
|
|
154
|
-
|
|
186
|
+
// A coverage view, not a verdict view: a partial check may also be blocking
|
|
187
|
+
// or hygiene. It is listed here so "what was only statically covered" is
|
|
188
|
+
// machine-readable instead of disappearing into a NA count. NA/ERROR rows
|
|
189
|
+
// already live in notChecked; do not duplicate them.
|
|
190
|
+
const partialBucket = effectiveChecks
|
|
191
|
+
.filter(item => item.coverage === 'partial' && item.result !== 'NA' && item.result !== 'ERROR')
|
|
192
|
+
.map(item => bucketRow(item, { howToEnable: howToEnableFor(item) }))
|
|
193
|
+
const partialCount = effectiveChecks.filter(item => item.coverage === 'partial').length
|
|
194
|
+
const buckets = { blocking: blockingBucket, hygiene: hygieneBucket, partial: partialBucket, notChecked: notCheckedBucket }
|
|
195
|
+
const notChecked = [...notCheckedBucket, ...partialBucket]
|
|
196
|
+
.map(row => ({
|
|
197
|
+
id: row.id,
|
|
198
|
+
clause: row.clause,
|
|
199
|
+
result: row.result,
|
|
200
|
+
coverage: row.coverage,
|
|
201
|
+
reason: row.reason || 'not checked',
|
|
202
|
+
howToEnable: row.howToEnable ?? howToEnableFor(row),
|
|
203
|
+
}))
|
|
204
|
+
.filter((row, index, list) => list.findIndex(item => item.id === row.id) === index)
|
|
155
205
|
const declaredFields = Array.isArray(declarations?.present) ? declarations.present.length : 0
|
|
156
206
|
const canMarkSuspect = anchor?.suspect === true
|
|
157
207
|
|
|
@@ -161,7 +211,7 @@ export function buildReport({ subject, anchor, checks, g4 = null, scope, profile
|
|
|
161
211
|
declaredFields,
|
|
162
212
|
}
|
|
163
213
|
const prereq = entryBlocking ? ` (prereq ${entryBlocking.checked.length}/${entryBlocking.required})` : ''
|
|
164
|
-
const headline = `${scope}/${profile} · runtime ${anchor?.dsh ?? 'unknown'}${anchor?.runtimeSource ? ` ${anchor.runtimeSource}` : ''}${canMarkSuspect ? ' SUSPECT' : ''} · blocking ${buckets.blocking.length}${prereq} · hygiene ${buckets.hygiene.length} · not checked ${buckets.notChecked.length}/${effectiveChecks.length}`
|
|
214
|
+
const headline = `${scope}/${profile} · runtime ${anchor?.dsh ?? 'unknown'}${anchor?.runtimeSource ? ` ${anchor.runtimeSource}` : ''}${canMarkSuspect ? ' SUSPECT' : ''} · blocking ${buckets.blocking.length}${prereq} · hygiene ${buckets.hygiene.length} · partial ${partialCount} · not checked ${buckets.notChecked.length}/${effectiveChecks.length}`
|
|
165
215
|
|
|
166
216
|
const report = {
|
|
167
217
|
schema: 'dsh-conformance/1',
|
|
@@ -180,10 +230,12 @@ export function buildReport({ subject, anchor, checks, g4 = null, scope, profile
|
|
|
180
230
|
fail: evaluatedAll.filter(item => item.result === 'FAIL').length,
|
|
181
231
|
na: naAll.length,
|
|
182
232
|
notRun: notRunAllIds.length,
|
|
233
|
+
partial: partialCount,
|
|
183
234
|
error: errorAll.length,
|
|
184
235
|
entryBlocking,
|
|
185
236
|
},
|
|
186
237
|
buckets,
|
|
238
|
+
notChecked,
|
|
187
239
|
metrics,
|
|
188
240
|
conclusion: {
|
|
189
241
|
headline,
|
|
@@ -202,11 +254,32 @@ export function buildReport({ subject, anchor, checks, g4 = null, scope, profile
|
|
|
202
254
|
// Additive only: `advice` is a sibling of `checks`, never part of a check,
|
|
203
255
|
// and the verdict is computed above without reading it.
|
|
204
256
|
if (advice !== false) {
|
|
205
|
-
report.advice = joinAdvice(report, remediationSpec, DOCS_BASE)
|
|
257
|
+
report.advice = retargetCompatibilityAdvice(joinAdvice(report, remediationSpec, DOCS_BASE), anchor)
|
|
206
258
|
}
|
|
207
259
|
return report
|
|
208
260
|
}
|
|
209
261
|
|
|
262
|
+
/**
|
|
263
|
+
* C-2.2 advice is a compatibility finding: the declared range is valid but does
|
|
264
|
+
* not cover the runtime the report was run against. The static spec example
|
|
265
|
+
* (`>=0.2.2-0`) can itself be outside an older host, so append the exact floor
|
|
266
|
+
* that would include this anchor. This keeps advice from contradicting the
|
|
267
|
+
* FAIL it is attached to.
|
|
268
|
+
*/
|
|
269
|
+
function retargetCompatibilityAdvice(advice, anchor) {
|
|
270
|
+
const version = parseVersion(anchor?.dsh)
|
|
271
|
+
if (!version) return advice
|
|
272
|
+
const floor = `${version.major}.${version.minor}.${version.patch}-0`
|
|
273
|
+
const upper = `${version.major}.${version.minor + 1}.0`
|
|
274
|
+
const hint = `当前锚是 ${anchor.dsh}:要兼容它,engines.dsh/peer 下限不得高于 ${floor}(例如 >=${floor} <${upper}),并用 --runtime-version ${anchor.dsh} 复跑。`
|
|
275
|
+
for (const item of advice) {
|
|
276
|
+
if (item.key !== 'C-2.2') continue
|
|
277
|
+
item.fix = item.fix ? `${item.fix} ${hint}` : hint
|
|
278
|
+
item.reason = item.reason ? `${item.reason} | ${hint}` : hint
|
|
279
|
+
}
|
|
280
|
+
return advice
|
|
281
|
+
}
|
|
282
|
+
|
|
210
283
|
const ICON = { PASS: 'PASS', WARN: 'WARN', FAIL: 'FAIL', NA: 'NA ', ERROR: 'ERR ' }
|
|
211
284
|
|
|
212
285
|
function renderBucket(lines, title, rows, byId) {
|
|
@@ -219,6 +292,7 @@ function renderBucket(lines, title, rows, byId) {
|
|
|
219
292
|
const tags = [row.declaration ? 'declaration' : null, row.advisory ? 'advisory' : null].filter(Boolean)
|
|
220
293
|
const reason = String(row.reason ?? '').replace(/\s+/g, ' ').slice(0, 150)
|
|
221
294
|
lines.push(` ${(ICON[row.result] ?? row.result).padEnd(4)} ${String(row.id).padEnd(7)} ${String(row.clause ?? '-').padEnd(6)} ${String(row.coverage ?? '-').padEnd(8)} ${reason}${tags.length > 0 ? ` [${tags.join(',')}]` : ''}`)
|
|
295
|
+
if (row.howToEnable) lines.push(` enable: ${String(row.howToEnable).replace(/\s+/g, ' ').slice(0, 180)}`)
|
|
222
296
|
const item = byId.get(row.id)
|
|
223
297
|
if (!item || (row.result !== 'FAIL' && row.result !== 'WARN')) continue
|
|
224
298
|
for (const detail of item.details ?? []) {
|
|
@@ -242,11 +316,13 @@ export function formatText(report) {
|
|
|
242
316
|
lines.push('')
|
|
243
317
|
renderBucket(lines, 'Hygiene (WARN)', report.buckets?.hygiene ?? [], byId)
|
|
244
318
|
lines.push('')
|
|
319
|
+
renderBucket(lines, 'Partly checked (static subset)', report.buckets?.partial ?? [], byId)
|
|
320
|
+
lines.push('')
|
|
245
321
|
renderBucket(lines, 'Not checked (NA / not-run / ERROR)', report.buckets?.notChecked ?? [], byId)
|
|
246
322
|
lines.push('')
|
|
247
323
|
const passed = (report.checks ?? []).filter(item => item.result === 'PASS')
|
|
248
324
|
lines.push(`passed (${passed.length}): ${passed.map(item => item.id).join(', ') || 'none'}`)
|
|
249
|
-
lines.push(`coverage: total=${coverage.total ?? report.checks.length} checked=${coverage.checked ?? coverage.pass + coverage.warn + coverage.fail} pass=${coverage.pass} warn=${coverage.warn} fail=${coverage.fail} na=${coverage.na} notRun=${coverage.notRun} error=${coverage.error} advisory=${coverage.advisory}`)
|
|
325
|
+
lines.push(`coverage: total=${coverage.total ?? report.checks.length} checked=${coverage.checked ?? coverage.pass + coverage.warn + coverage.fail} pass=${coverage.pass} warn=${coverage.warn} fail=${coverage.fail} na=${coverage.na} notRun=${coverage.notRun} partial=${coverage.partial ?? 0} error=${coverage.error} advisory=${coverage.advisory}`)
|
|
250
326
|
if (coverage.entryBlocking) {
|
|
251
327
|
const blocking = coverage.entryBlocking
|
|
252
328
|
lines.push(`entryBlocking: checked=${blocking.checked?.length ?? '?'}/${blocking.required} failed=[${(blocking.failed ?? []).join(', ')}] notChecked=[${(blocking.notChecked ?? []).join(', ')}] pass=${blocking.pass}`)
|