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.
@@ -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,20 +1,30 @@
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. */
13
- export const DOCS_BASE = 'https://github.com/PerryLink/dsh-plugin-guide/blob/v0.4.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 (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(', ')}`)
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
- const buckets = { blocking: blockingBucket, hygiene: hygieneBucket, notChecked: notCheckedBucket }
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}`)