dsh-standards 1.2.2 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/40-checker/README.md +18 -1
- package/40-checker/scripts/build-offline-kit.mjs +284 -0
- package/40-checker/scripts/build-sidecar.mjs +714 -0
- package/40-checker/scripts/flow-collect.mjs +7 -1
- package/40-checker/scripts/level-audit.mjs +45 -7
- package/40-checker/scripts/run-tests.mjs +3 -7
- package/40-checker/src/author-cli.mjs +406 -0
- package/40-checker/src/author-report.mjs +271 -0
- package/40-checker/src/bundle-static.mjs +181 -0
- package/40-checker/src/cli.mjs +100 -11
- package/40-checker/src/client-shape.mjs +33 -3
- package/40-checker/src/declarations.mjs +53 -8
- package/40-checker/src/flow-evidence.mjs +106 -1
- package/40-checker/src/index.mjs +6 -4
- package/40-checker/src/manifest.mjs +87 -14
- package/40-checker/src/remediation.mjs +108 -7
- package/40-checker/src/report.mjs +203 -26
- package/40-checker/src/semver.mjs +112 -65
- package/40-checker/src/util.mjs +51 -8
- package/CHANGELOG.md +44 -0
- package/README.en.md +7 -2
- package/README.es.md +7 -2
- package/README.hi.md +7 -2
- package/README.md +35 -18
- package/README.pt.md +7 -2
- package/bin/dsh-standards.mjs +13 -2
- package/docs/assets/dsh-standards-flow-v3-dark.svg +87 -0
- package/docs/assets/dsh-standards-flow-v3-light.svg +87 -0
- package/docs/assets/dsh-standards-flow-v4-dark.svg +79 -0
- package/docs/assets/dsh-standards-flow-v4-light.svg +79 -0
- package/docs/assets/dsh-standards-hero-en-dark.webp +0 -0
- package/docs/assets/dsh-standards-hero-en-light.webp +0 -0
- package/docs/assets/dsh-standards-hero-zh-dark.webp +0 -0
- package/docs/assets/dsh-standards-hero-zh-light.webp +0 -0
- package/docs/coverage.md +82 -0
- package/package.json +1 -1
- package/spec/README.md +2 -0
- 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/flow-evidence.schema.json +6 -2
- package/spec/remediation.json +54 -54
- package/spec/schemas/advice.schema.json +92 -0
- 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,12 +1,15 @@
|
|
|
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. */
|
|
@@ -15,6 +18,13 @@ export const DOCS_BASE = 'https://github.com/PerryLink/dsh-plugin-guide/blob/v0.
|
|
|
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',
|
|
@@ -31,6 +32,74 @@ function adjustEntryCheck(item, entryClaim) {
|
|
|
31
32
|
return { ...item, result: 'WARN', reason: 'C-2.1 version declaration missing/invalid; entry treats it as WARN (C-2.2/C-2.3 remain blocking)' }
|
|
32
33
|
}
|
|
33
34
|
|
|
35
|
+
/** Declaration-class checks: a WARN here is hygiene, not a loadability defect. */
|
|
36
|
+
export const DECLARATION_CHECKS = new Set(['DSC-11', 'DSC-12', 'DSC-13'])
|
|
37
|
+
const DETAIL_RANK = { FAIL: 5, ERROR: 4, WARN: 3, PASS: 2, NA: 1 }
|
|
38
|
+
|
|
39
|
+
function bucketRow(item, extra = {}) {
|
|
40
|
+
return {
|
|
41
|
+
id: item.id,
|
|
42
|
+
clause: item.clause ?? null,
|
|
43
|
+
result: item.result,
|
|
44
|
+
coverage: item.coverage ?? null,
|
|
45
|
+
reason: item.reason ?? '',
|
|
46
|
+
...extra,
|
|
47
|
+
}
|
|
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
|
+
|
|
78
|
+
/**
|
|
79
|
+
* `entryBlocking` is only sound when every prerequisite was actually evaluated.
|
|
80
|
+
* C-1.x always produce details; C-3.1/3.2/3.5 only produce them when an install
|
|
81
|
+
* spec or market index was supplied. Missing details must therefore make
|
|
82
|
+
* `pass` false instead of silently claiming the gate held.
|
|
83
|
+
*/
|
|
84
|
+
export function summarizeBlocking(checks, blocking) {
|
|
85
|
+
const seen = new Map()
|
|
86
|
+
for (const item of checks) {
|
|
87
|
+
for (const detail of item.details ?? []) {
|
|
88
|
+
if (!blocking.includes(detail.item)) continue
|
|
89
|
+
const rank = DETAIL_RANK[detail.result] ?? 0
|
|
90
|
+
if (!seen.has(detail.item) || rank > (DETAIL_RANK[seen.get(detail.item)] ?? 0)) seen.set(detail.item, detail.result)
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
const checked = blocking.filter(id => seen.has(id))
|
|
94
|
+
// C-2.3 (workspace: range) has no detail when C-2.2 was evaluated against a
|
|
95
|
+
// normal range; evaluating C-2.2 rules the workspace form out, so count it.
|
|
96
|
+
if (seen.has('C-2.2') && blocking.includes('C-2.3') && !checked.includes('C-2.3')) checked.push('C-2.3')
|
|
97
|
+
checked.sort((a, b) => blocking.indexOf(a) - blocking.indexOf(b))
|
|
98
|
+
const failed = blocking.filter(id => seen.get(id) === 'FAIL')
|
|
99
|
+
const notChecked = blocking.filter(id => !checked.includes(id))
|
|
100
|
+
return { required: blocking.length, checked, notChecked, failed, pass: failed.length === 0 && notChecked.length === 0 }
|
|
101
|
+
}
|
|
102
|
+
|
|
34
103
|
function allowedNa(check, scope, profile, kind) {
|
|
35
104
|
if (check.result !== 'NA') return false
|
|
36
105
|
if (check.id === 'DSC-03' && scope !== 'mvp' && profile === 'package') return true
|
|
@@ -39,7 +108,7 @@ function allowedNa(check, scope, profile, kind) {
|
|
|
39
108
|
return false
|
|
40
109
|
}
|
|
41
110
|
|
|
42
|
-
export function buildReport({ subject, anchor, checks, g4 = null, scope, profile, kind, staticOnly = false, entryRuntime = 'require', entryClaim = 'runtime', advice = true }) {
|
|
111
|
+
export function buildReport({ subject, anchor, checks, g4 = null, scope, profile, kind, staticOnly = false, entryRuntime = 'require', entryClaim = 'runtime', advice = true, declarations = null }) {
|
|
43
112
|
const mandatory = mandatoryFor(scope, profile, staticOnly, entryClaim)
|
|
44
113
|
const adjustedChecks = scope === 'entry' ? checks.map(item => adjustEntryCheck(item, entryClaim)) : checks
|
|
45
114
|
const runtimeIds = new Set(['DSC-04', 'DSC-05', 'DSC-06', 'DSC-07', 'DSC-08', 'DSC-09'])
|
|
@@ -59,6 +128,17 @@ export function buildReport({ subject, anchor, checks, g4 = null, scope, profile
|
|
|
59
128
|
const warnings = selected.filter(item => item.result === 'WARN')
|
|
60
129
|
const pass = selected.filter(item => item.result === 'PASS')
|
|
61
130
|
|
|
131
|
+
// Coverage counts every check the report carries (14 in a full run), not just
|
|
132
|
+
// the mandatory subset. A report that only counts its mandatory five hides
|
|
133
|
+
// the runtime checks it never ran; that is the bug this block fixes.
|
|
134
|
+
const evaluatedAll = effectiveChecks.filter(item => ['PASS', 'WARN', 'FAIL'].includes(item.result))
|
|
135
|
+
const naAll = effectiveChecks.filter(item => item.result === 'NA')
|
|
136
|
+
const errorAll = effectiveChecks.filter(item => item.result === 'ERROR')
|
|
137
|
+
const notRunAll = effectiveChecks.filter(item => item.result !== 'NA' && (item.coverage === 'not-run' || item.coverage === 'partial'))
|
|
138
|
+
const notRunAllIds = [...new Set([...notRunAll.map(item => item.id), ...errorAll.map(item => item.id), ...missing])]
|
|
139
|
+
const entryBlockingIds = scope === 'entry' ? (entryClaim === 'static' ? ENTRY_BLOCKING : ENTRY_RUNTIME_BLOCKING) : null
|
|
140
|
+
const entryBlocking = entryBlockingIds ? summarizeBlocking(effectiveChecks, entryBlockingIds) : null
|
|
141
|
+
|
|
62
142
|
let status
|
|
63
143
|
let exitCode
|
|
64
144
|
if (failed.length > 0) { status = 'fail'; exitCode = 1 }
|
|
@@ -81,12 +161,58 @@ export function buildReport({ subject, anchor, checks, g4 = null, scope, profile
|
|
|
81
161
|
level = staticOnly ? `DS-C static${status === 'warn' ? ' (warnings)' : ''}` : profile === 'listing' || profile === 'both' ? `DS-C (listing${suffix})` : `DS-C (package${suffix})`
|
|
82
162
|
}
|
|
83
163
|
|
|
164
|
+
const partialIds = selected.filter(item => item.coverage === 'partial').map(item => item.id)
|
|
165
|
+
const fullyNotRun = notRunIds.filter(id => !partialIds.includes(id))
|
|
84
166
|
const reasonParts = []
|
|
85
167
|
if (failed.length > 0) reasonParts.push(`FAIL: ${failed.map(item => item.id).join(', ')}`)
|
|
86
|
-
if (
|
|
168
|
+
if (fullyNotRun.length > 0) reasonParts.push(`not run: ${fullyNotRun.join(', ')}`)
|
|
169
|
+
if (partialIds.length > 0) reasonParts.push(`partial: ${partialIds.join(', ')}`)
|
|
87
170
|
if (warnings.length > 0) reasonParts.push(`WARN: ${warnings.map(item => item.id).join(', ')}`)
|
|
88
171
|
if (advisoryWarnings.length > 0) reasonParts.push(`advisory: ${advisoryWarnings.map(item => item.id).join(', ')}`)
|
|
89
172
|
|
|
173
|
+
// Findings by reader-relevant bucket. A check appears in exactly one bucket:
|
|
174
|
+
// FAIL -> blocking, WARN -> hygiene, NA/ERROR/not-run -> notChecked.
|
|
175
|
+
const blockingBucket = effectiveChecks.filter(item => item.result === 'FAIL').map(item => bucketRow(item))
|
|
176
|
+
const hygieneBucket = effectiveChecks.filter(item => item.result === 'WARN').map(item => bucketRow(item, {
|
|
177
|
+
declaration: DECLARATION_CHECKS.has(item.id),
|
|
178
|
+
advisory: item.advisory === true,
|
|
179
|
+
}))
|
|
180
|
+
const notCheckedBucket = effectiveChecks
|
|
181
|
+
.filter(item => item.result === 'NA' || item.result === 'ERROR')
|
|
182
|
+
.map(item => bucketRow(item, { howToEnable: howToEnableFor(item) }))
|
|
183
|
+
for (const id of missing) {
|
|
184
|
+
notCheckedBucket.push({ id, clause: null, result: 'NOT-RUN', coverage: 'not-run', reason: 'mandatory check missing', howToEnable: howToEnableFor({ id, reason: 'mandatory check missing' }) })
|
|
185
|
+
}
|
|
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)
|
|
205
|
+
const declaredFields = Array.isArray(declarations?.present) ? declarations.present.length : 0
|
|
206
|
+
const canMarkSuspect = anchor?.suspect === true
|
|
207
|
+
|
|
208
|
+
const metrics = {
|
|
209
|
+
checked: evaluatedAll.length,
|
|
210
|
+
total: effectiveChecks.length,
|
|
211
|
+
declaredFields,
|
|
212
|
+
}
|
|
213
|
+
const prereq = entryBlocking ? ` (prereq ${entryBlocking.checked.length}/${entryBlocking.required})` : ''
|
|
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}`
|
|
215
|
+
|
|
90
216
|
const report = {
|
|
91
217
|
schema: 'dsh-conformance/1',
|
|
92
218
|
standard: { id: 'DS-C', version: '1.0.0' },
|
|
@@ -95,21 +221,24 @@ export function buildReport({ subject, anchor, checks, g4 = null, scope, profile
|
|
|
95
221
|
g4,
|
|
96
222
|
checks: effectiveChecks,
|
|
97
223
|
coverage: {
|
|
224
|
+
total: effectiveChecks.length,
|
|
98
225
|
mandatory: mandatory.length,
|
|
99
|
-
|
|
100
|
-
|
|
226
|
+
checked: evaluatedAll.length,
|
|
227
|
+
pass: evaluatedAll.filter(item => item.result === 'PASS').length,
|
|
228
|
+
warn: evaluatedAll.filter(item => item.result === 'WARN').length,
|
|
101
229
|
advisory: advisoryWarnings.length,
|
|
102
|
-
fail:
|
|
103
|
-
na:
|
|
104
|
-
notRun:
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
const failedItems = effectiveChecks.flatMap(item => (item.details ?? []).filter(detail => detail.result === 'FAIL' && blocking.includes(detail.item)).map(detail => detail.item))
|
|
109
|
-
return { required: blocking.length, failed: failedItems, pass: failedItems.length === 0 }
|
|
110
|
-
})() : null,
|
|
230
|
+
fail: evaluatedAll.filter(item => item.result === 'FAIL').length,
|
|
231
|
+
na: naAll.length,
|
|
232
|
+
notRun: notRunAllIds.length,
|
|
233
|
+
partial: partialCount,
|
|
234
|
+
error: errorAll.length,
|
|
235
|
+
entryBlocking,
|
|
111
236
|
},
|
|
237
|
+
buckets,
|
|
238
|
+
notChecked,
|
|
239
|
+
metrics,
|
|
112
240
|
conclusion: {
|
|
241
|
+
headline,
|
|
113
242
|
level,
|
|
114
243
|
status,
|
|
115
244
|
exitCode,
|
|
@@ -125,32 +254,80 @@ export function buildReport({ subject, anchor, checks, g4 = null, scope, profile
|
|
|
125
254
|
// Additive only: `advice` is a sibling of `checks`, never part of a check,
|
|
126
255
|
// and the verdict is computed above without reading it.
|
|
127
256
|
if (advice !== false) {
|
|
128
|
-
report.advice = joinAdvice(report, remediationSpec, DOCS_BASE)
|
|
257
|
+
report.advice = retargetCompatibilityAdvice(joinAdvice(report, remediationSpec, DOCS_BASE), anchor)
|
|
129
258
|
}
|
|
130
259
|
return report
|
|
131
260
|
}
|
|
132
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
|
+
|
|
133
283
|
const ICON = { PASS: 'PASS', WARN: 'WARN', FAIL: 'FAIL', NA: 'NA ', ERROR: 'ERR ' }
|
|
134
284
|
|
|
285
|
+
function renderBucket(lines, title, rows, byId) {
|
|
286
|
+
lines.push(`${title} (${rows.length}):`)
|
|
287
|
+
if (rows.length === 0) {
|
|
288
|
+
lines.push(' none')
|
|
289
|
+
return
|
|
290
|
+
}
|
|
291
|
+
for (const row of rows) {
|
|
292
|
+
const tags = [row.declaration ? 'declaration' : null, row.advisory ? 'advisory' : null].filter(Boolean)
|
|
293
|
+
const reason = String(row.reason ?? '').replace(/\s+/g, ' ').slice(0, 150)
|
|
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)}`)
|
|
296
|
+
const item = byId.get(row.id)
|
|
297
|
+
if (!item || (row.result !== 'FAIL' && row.result !== 'WARN')) continue
|
|
298
|
+
for (const detail of item.details ?? []) {
|
|
299
|
+
if (detail.result === 'PASS') continue
|
|
300
|
+
lines.push(` - ${detail.item}: ${detail.result} ${String(detail.reason ?? '').replace(/\s+/g, ' ').slice(0, 140)}`)
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
135
305
|
export function formatText(report) {
|
|
136
306
|
const lines = []
|
|
137
|
-
const { subject, anchor, conclusion, coverage } = report
|
|
307
|
+
const { subject, anchor = {}, conclusion, coverage } = report
|
|
308
|
+
const byId = new Map((report.checks ?? []).map(item => [item.id, item]))
|
|
138
309
|
lines.push(`DS-C check — ${subject.name ?? '<unknown>'}@${subject.version ?? '?'} (${subject.kind}${subject.kindInferred ? ', inferred' : ''})`)
|
|
139
|
-
lines.push(`anchor: repo=${anchor.repo} commit=${anchor.commit.slice(0, 12)} dsh=${anchor.dsh} cordis=${anchor.cordis}`)
|
|
310
|
+
lines.push(`anchor: repo=${anchor.repo ?? '?'} commit=${String(anchor.commit ?? '').slice(0, 12)} dsh=${anchor.dsh ?? '?'} cordis=${anchor.cordis ?? '?'}${anchor.runtimeSource ? ` source=${anchor.runtimeSource}` : ''}${anchor.suspect ? ' suspect=true' : ''}`)
|
|
140
311
|
lines.push(`scope=${conclusion.scope} profile=${conclusion.profile} status=${conclusion.status} exit=${conclusion.exitCode}${conclusion.subset ? ` subset=${conclusion.subset}` : ''}${conclusion.staticOnly ? ' static-only' : ''}`)
|
|
141
312
|
if (conclusion.level) lines.push(`level: ${conclusion.level}`)
|
|
313
|
+
if (conclusion.headline) lines.push(`headline: ${conclusion.headline}`)
|
|
142
314
|
lines.push('')
|
|
143
|
-
lines
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
if (detail.result === 'PASS') continue
|
|
149
|
-
lines.push(` - ${detail.item}: ${detail.result} ${String(detail.reason).slice(0, 140)}`)
|
|
150
|
-
}
|
|
151
|
-
}
|
|
315
|
+
renderBucket(lines, 'Blocking (FAIL)', report.buckets?.blocking ?? [], byId)
|
|
316
|
+
lines.push('')
|
|
317
|
+
renderBucket(lines, 'Hygiene (WARN)', report.buckets?.hygiene ?? [], byId)
|
|
318
|
+
lines.push('')
|
|
319
|
+
renderBucket(lines, 'Partly checked (static subset)', report.buckets?.partial ?? [], byId)
|
|
152
320
|
lines.push('')
|
|
153
|
-
lines
|
|
321
|
+
renderBucket(lines, 'Not checked (NA / not-run / ERROR)', report.buckets?.notChecked ?? [], byId)
|
|
322
|
+
lines.push('')
|
|
323
|
+
const passed = (report.checks ?? []).filter(item => item.result === 'PASS')
|
|
324
|
+
lines.push(`passed (${passed.length}): ${passed.map(item => item.id).join(', ') || 'none'}`)
|
|
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}`)
|
|
326
|
+
if (coverage.entryBlocking) {
|
|
327
|
+
const blocking = coverage.entryBlocking
|
|
328
|
+
lines.push(`entryBlocking: checked=${blocking.checked?.length ?? '?'}/${blocking.required} failed=[${(blocking.failed ?? []).join(', ')}] notChecked=[${(blocking.notChecked ?? []).join(', ')}] pass=${blocking.pass}`)
|
|
329
|
+
}
|
|
330
|
+
lines.push(`metrics: checked=${report.metrics?.checked ?? '?'}/${report.metrics?.total ?? '?'} declaredFields=${report.metrics?.declaredFields ?? 0}/5`)
|
|
154
331
|
if (conclusion.reason) lines.push(`reason: ${conclusion.reason}`)
|
|
155
332
|
if (conclusion.status === 'incomplete') lines.push('NOT A DS-C PASS: at least one mandatory check did not run (see notRun).')
|
|
156
333
|
if (Array.isArray(report.advice) && report.advice.length > 0) {
|