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.
Files changed (46) hide show
  1. package/40-checker/README.md +18 -1
  2. package/40-checker/scripts/build-offline-kit.mjs +284 -0
  3. package/40-checker/scripts/build-sidecar.mjs +714 -0
  4. package/40-checker/scripts/flow-collect.mjs +7 -1
  5. package/40-checker/scripts/level-audit.mjs +45 -7
  6. package/40-checker/scripts/run-tests.mjs +3 -7
  7. package/40-checker/src/author-cli.mjs +406 -0
  8. package/40-checker/src/author-report.mjs +271 -0
  9. package/40-checker/src/bundle-static.mjs +181 -0
  10. package/40-checker/src/cli.mjs +100 -11
  11. package/40-checker/src/client-shape.mjs +33 -3
  12. package/40-checker/src/declarations.mjs +53 -8
  13. package/40-checker/src/flow-evidence.mjs +106 -1
  14. package/40-checker/src/index.mjs +6 -4
  15. package/40-checker/src/manifest.mjs +87 -14
  16. package/40-checker/src/remediation.mjs +108 -7
  17. package/40-checker/src/report.mjs +203 -26
  18. package/40-checker/src/semver.mjs +112 -65
  19. package/40-checker/src/util.mjs +51 -8
  20. package/CHANGELOG.md +44 -0
  21. package/README.en.md +7 -2
  22. package/README.es.md +7 -2
  23. package/README.hi.md +7 -2
  24. package/README.md +35 -18
  25. package/README.pt.md +7 -2
  26. package/bin/dsh-standards.mjs +13 -2
  27. package/docs/assets/dsh-standards-flow-v3-dark.svg +87 -0
  28. package/docs/assets/dsh-standards-flow-v3-light.svg +87 -0
  29. package/docs/assets/dsh-standards-flow-v4-dark.svg +79 -0
  30. package/docs/assets/dsh-standards-flow-v4-light.svg +79 -0
  31. package/docs/assets/dsh-standards-hero-en-dark.webp +0 -0
  32. package/docs/assets/dsh-standards-hero-en-light.webp +0 -0
  33. package/docs/assets/dsh-standards-hero-zh-dark.webp +0 -0
  34. package/docs/assets/dsh-standards-hero-zh-light.webp +0 -0
  35. package/docs/coverage.md +82 -0
  36. package/package.json +1 -1
  37. package/spec/README.md +2 -0
  38. package/spec/coverage-contract.md +100 -0
  39. package/spec/dsc-clauses.json +766 -0
  40. package/spec/examples/boost-card.example.json +15 -0
  41. package/spec/examples/flow-evidence.example.json +113 -0
  42. package/spec/flow-evidence.schema.json +6 -2
  43. package/spec/remediation.json +54 -54
  44. package/spec/schemas/advice.schema.json +92 -0
  45. package/spec/schemas/boost-card.schema.json +107 -0
  46. 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
- manifestDetails.push(detail('C-1.2', 'FAIL', `exports["."] points to a missing file: ${entry.target}`, ['package.json#exports']))
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 failed = false
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
- failed = true
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
- failed = true
344
+ invalid = true
281
345
  } else if (!satisfies(runtimeVersion, effective)) {
282
- compatDetails.push(detail('C-2.2', 'FAIL', `peer ${peer} ${rangeValue} does not satisfy runtime ${runtimeVersion}`, [`package.json#peerDependencies.${peer}`]))
283
- failed = true
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
- compatDetails.push(detail('C-2.1', failed ? 'FAIL' : 'WARN', failed ? 'one or more dsh peers are unsatisfied' : 'no engines.dsh; dsh peer range is the weaker alternate declaration'))
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 deliberately pure: it never reads files, never writes, and
5
- * never changes the report it receives. `buildReport()` calls it at the end so
6
- * every report carries additive `advice[]` entries; the checker's verdict is
7
- * untouched.
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 (notRunIds.length > 0) reasonParts.push(`not run: ${notRunIds.join(', ')}`)
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
- pass: pass.length,
100
- warn: warnings.length,
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: failed.length,
103
- na: selected.filter(item => item.result === 'NA').length,
104
- notRun: notRunIds.length,
105
- error: errored.length,
106
- entryBlocking: scope === 'entry' ? (() => {
107
- const blocking = entryClaim === 'static' ? ENTRY_BLOCKING : ENTRY_RUNTIME_BLOCKING
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.push('id result clause layer reason')
144
- for (const item of report.checks) {
145
- const reason = (item.reason || '').replace(/\s+/g, ' ').slice(0, 120)
146
- lines.push(`${item.id.padEnd(7)} ${ICON[item.result] ?? item.result} ${item.clause.padEnd(6)} ${item.layer.padEnd(6)} ${reason}`)
147
- for (const detail of item.details ?? []) {
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.push(`coverage: mandatory=${coverage.mandatory} pass=${coverage.pass} warn=${coverage.warn} fail=${coverage.fail} na=${coverage.na} notRun=${coverage.notRun}`)
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) {