dsh-standards 1.0.3 → 1.2.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 (99) hide show
  1. package/40-checker/README.md +15 -0
  2. package/40-checker/bin/dsh-flow-collect.mjs +3 -0
  3. package/40-checker/bin/dsh-levels.mjs +13 -2
  4. package/40-checker/fixtures/bundle-patch-only/cordis.patch.yml +3 -0
  5. package/40-checker/fixtures/bundle-patch-only/package.json +12 -0
  6. package/40-checker/fixtures/c12-command-arg/index.mjs +7 -0
  7. package/40-checker/fixtures/c12-command-arg/package.json +10 -0
  8. package/40-checker/fixtures/c12-comment/index.mjs +3 -0
  9. package/40-checker/fixtures/c12-comment/package.json +10 -0
  10. package/40-checker/fixtures/c12-compare/index.mjs +3 -0
  11. package/40-checker/fixtures/c12-compare/package.json +10 -0
  12. package/40-checker/fixtures/c12-doc-string/index.mjs +13 -0
  13. package/40-checker/fixtures/c12-doc-string/package.json +10 -0
  14. package/40-checker/fixtures/c12-grant/index.mjs +3 -0
  15. package/40-checker/fixtures/c12-grant/package.json +10 -0
  16. package/40-checker/fixtures/c12-string/index.mjs +3 -0
  17. package/40-checker/fixtures/c12-string/package.json +10 -0
  18. package/40-checker/fixtures/declaration-file/index.d.ts +1 -0
  19. package/40-checker/fixtures/declaration-file/index.mjs +9 -0
  20. package/40-checker/fixtures/declaration-file/package.json +10 -0
  21. package/40-checker/fixtures/import-local-side-effect/index.mjs +11 -0
  22. package/40-checker/fixtures/import-local-side-effect/package.json +21 -0
  23. package/40-checker/fixtures/import-local-side-effect/side-effectful.mjs +5 -0
  24. package/40-checker/fixtures/import-open-readonly/index.mjs +14 -0
  25. package/40-checker/fixtures/import-open-readonly/package.json +19 -0
  26. package/40-checker/fixtures/import-package-only/index.mjs +11 -0
  27. package/40-checker/fixtures/import-package-only/package.json +21 -0
  28. package/40-checker/fixtures/import-parse-error/index.mjs +5 -0
  29. package/40-checker/fixtures/import-parse-error/package.json +10 -0
  30. package/40-checker/fixtures/import-side-effect/index.mjs +14 -0
  31. package/40-checker/fixtures/import-side-effect/package.json +19 -0
  32. package/40-checker/fixtures/import-side-effect-nonentry/index.mjs +9 -0
  33. package/40-checker/fixtures/import-side-effect-nonentry/package.json +19 -0
  34. package/40-checker/fixtures/import-side-effect-nonentry/selfcheck.mjs +8 -0
  35. package/40-checker/fixtures/import-void-main/index.mjs +16 -0
  36. package/40-checker/fixtures/import-void-main/package.json +19 -0
  37. package/40-checker/fixtures/no-source/package.json +19 -0
  38. package/40-checker/fixtures/parse-error-nonentry/index.mjs +2 -0
  39. package/40-checker/fixtures/parse-error-nonentry/package.json +10 -0
  40. package/40-checker/fixtures/parse-error-nonentry/references/broken.js +1 -0
  41. package/40-checker/scripts/bundle-smoke.mjs +3 -0
  42. package/40-checker/scripts/entry18-ci.mjs +4 -3
  43. package/40-checker/scripts/entry18-web.mjs +4 -1
  44. package/40-checker/scripts/flow-collect.mjs +494 -0
  45. package/40-checker/scripts/flow-sample-ci.mjs +4 -4
  46. package/40-checker/scripts/host-farm.mjs +2 -1
  47. package/40-checker/scripts/journey-config-observe.mjs +5 -3
  48. package/40-checker/scripts/journey-runtime.mjs +208 -83
  49. package/40-checker/scripts/level-audit.mjs +91 -19
  50. package/40-checker/scripts/m2-runner.mjs +4 -3
  51. package/40-checker/scripts/runtime-child.mjs +4 -0
  52. package/40-checker/src/cli.mjs +11 -3
  53. package/40-checker/src/declarations.mjs +514 -19
  54. package/40-checker/src/flow-evidence.mjs +431 -0
  55. package/40-checker/src/index.mjs +3 -0
  56. package/40-checker/src/manifest.mjs +5 -0
  57. package/40-checker/src/remediation.mjs +132 -0
  58. package/40-checker/src/report.mjs +19 -2
  59. package/40-checker/src/sandbox-env.mjs +78 -0
  60. package/40-checker/test/checks.test.mjs +243 -1
  61. package/40-checker/test/flow-evidence.test.mjs +295 -0
  62. package/40-checker/test/remediation.test.mjs +300 -0
  63. package/40-checker/vendor/babel/README.md +10 -0
  64. package/40-checker/vendor/babel/index.cjs +3 -0
  65. package/40-checker/vendor/babel/node_modules/@babel/helper-string-parser/LICENSE +22 -0
  66. package/40-checker/vendor/babel/node_modules/@babel/helper-string-parser/README.md +19 -0
  67. package/40-checker/vendor/babel/node_modules/@babel/helper-string-parser/lib/index.d.ts +41 -0
  68. package/40-checker/vendor/babel/node_modules/@babel/helper-string-parser/lib/index.js +286 -0
  69. package/40-checker/vendor/babel/node_modules/@babel/helper-string-parser/package.json +31 -0
  70. package/40-checker/vendor/babel/node_modules/@babel/helper-validator-identifier/LICENSE +22 -0
  71. package/40-checker/vendor/babel/node_modules/@babel/helper-validator-identifier/README.md +19 -0
  72. package/40-checker/vendor/babel/node_modules/@babel/helper-validator-identifier/lib/identifier-browser.js +62 -0
  73. package/40-checker/vendor/babel/node_modules/@babel/helper-validator-identifier/lib/identifier.js +60 -0
  74. package/40-checker/vendor/babel/node_modules/@babel/helper-validator-identifier/lib/index.d.ts +28 -0
  75. package/40-checker/vendor/babel/node_modules/@babel/helper-validator-identifier/lib/index.js +27 -0
  76. package/40-checker/vendor/babel/node_modules/@babel/helper-validator-identifier/package.json +45 -0
  77. package/40-checker/vendor/babel/node_modules/@babel/parser/CHANGELOG.md +1073 -0
  78. package/40-checker/vendor/babel/node_modules/@babel/parser/LICENSE +19 -0
  79. package/40-checker/vendor/babel/node_modules/@babel/parser/README.md +19 -0
  80. package/40-checker/vendor/babel/node_modules/@babel/parser/bin/babel-parser.js +14 -0
  81. package/40-checker/vendor/babel/node_modules/@babel/parser/lib/index.d.ts +1047 -0
  82. package/40-checker/vendor/babel/node_modules/@babel/parser/lib/index.js +14612 -0
  83. package/40-checker/vendor/babel/node_modules/@babel/parser/package.json +57 -0
  84. package/40-checker/vendor/babel/node_modules/@babel/parser/typings/babel-parser.d.ts +636 -0
  85. package/40-checker/vendor/babel/node_modules/@babel/types/LICENSE +22 -0
  86. package/40-checker/vendor/babel/node_modules/@babel/types/README.md +19 -0
  87. package/40-checker/vendor/babel/node_modules/@babel/types/lib/index-legacy.d.ts +2765 -0
  88. package/40-checker/vendor/babel/node_modules/@babel/types/lib/index.d.ts +3668 -0
  89. package/40-checker/vendor/babel/node_modules/@babel/types/lib/index.js +10758 -0
  90. package/40-checker/vendor/babel/node_modules/@babel/types/lib/index.js.flow +2618 -0
  91. package/40-checker/vendor/babel/node_modules/@babel/types/package.json +38 -0
  92. package/CHANGELOG.md +66 -0
  93. package/bin/dsh-flow-collect.mjs +4 -0
  94. package/package.json +2 -1
  95. package/spec/README.md +2 -0
  96. package/spec/flow/README.md +23 -2
  97. package/spec/flow-evidence.schema.json +217 -0
  98. package/spec/levels.json +2 -0
  99. package/spec/remediation.json +478 -0
@@ -0,0 +1,431 @@
1
+ /**
2
+ * FLOW evidence chain: `dsh-flow-evidence/2` construction and validation.
3
+ *
4
+ * One module owns the shape so the collector and the audit cannot drift:
5
+ * - `treeSha256` binds the package directory (read-only copy stays identical);
6
+ * - `finalizeEvidence`/`evidenceHash` bind the whole document (chain);
7
+ * - `validateFlowEvidence` is the structural gate `level-audit` uses. v1
8
+ * (`dsh-flow-journey/1`, `dsh-flow-runtime/1`) and the bare
9
+ * `{ "J1": { "status": "pass" } }` shape are recognised only so the audit
10
+ * can label them `deprecated`; they never count as a pass.
11
+ *
12
+ * Pass rule (per journey, enforced from `checks[]`, never from the declared
13
+ * status alone): at least one check, every check has exitCode 0, at least one
14
+ * observed (non-static) check, and every observed check's assertions hold.
15
+ * A journey declared `warn`/`blocked`/`na`/`static`/`not-run` never counts.
16
+ */
17
+ import crypto from 'node:crypto'
18
+ import fs from 'node:fs'
19
+ import path from 'node:path'
20
+
21
+ export const FLOW_IDS = ['J1', 'J2', 'J3', 'J4', 'J5', 'J6', 'J7', 'J8']
22
+ export const FLOW_EVIDENCE_SCHEMA = 'dsh-flow-evidence/2'
23
+ export const FLOW_V1_SCHEMAS = new Set(['dsh-flow-journey/1', 'dsh-flow-runtime/1'])
24
+ export const JOURNEY_STATUSES = new Set(['pass', 'fail', 'warn', 'blocked', 'static', 'na', 'not-run'])
25
+ export const CHECK_KINDS = new Set(['observed', 'static', 'manual'])
26
+
27
+ const HASH_RE = /^sha256:[0-9a-f]{64}$/
28
+ const ISO_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/
29
+ const COMMIT_RE = /^[0-9a-f]{40}$/
30
+ const INTEGRITY_RE = /^(?:sha512|sha256)-[A-Za-z0-9+/=]+$/
31
+
32
+ /** Journey -> normative anchor in 32-DSH-FLOW顺畅规范.md (R1..R8). */
33
+ export const FLOW_CLAUSE_ANCHORS = {
34
+ J1: '32#R1 安装/移除',
35
+ J2: '32#R2 启用/停用',
36
+ J3: '32#R3 配置',
37
+ J4: '32#R4 首次成功',
38
+ J5: '32#R5 权限',
39
+ J6: '32#R6 升级/回滚/卸载',
40
+ J7: '32#R7 可观测',
41
+ J8: '32#R8 client/server',
42
+ }
43
+
44
+ export function clauseAnchor(checkId) {
45
+ const id = String(checkId ?? '')
46
+ return FLOW_CLAUSE_ANCHORS[id.slice(0, 2)] ?? null
47
+ }
48
+
49
+ function isPlainObject(value) {
50
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
51
+ }
52
+
53
+ export function sha256Hex(data) {
54
+ return crypto.createHash('sha256').update(data).digest('hex')
55
+ }
56
+
57
+ export function sha256Value(data) {
58
+ return `sha256:${sha256Hex(data)}`
59
+ }
60
+
61
+ export function sha256File(file) {
62
+ return sha256Value(fs.readFileSync(file))
63
+ }
64
+
65
+ /** JSON with recursively sorted object keys; array order is preserved. */
66
+ export function canonicalJson(value) {
67
+ const sort = (node) => {
68
+ if (Array.isArray(node)) return node.map(sort)
69
+ if (isPlainObject(node)) {
70
+ const out = {}
71
+ for (const key of Object.keys(node).sort()) out[key] = sort(node[key])
72
+ return out
73
+ }
74
+ return node
75
+ }
76
+ return JSON.stringify(sort(value))
77
+ }
78
+
79
+ /** Chain hash: every field except `chain` and `signature`. */
80
+ export function evidenceHash(document) {
81
+ const copy = { ...document }
82
+ delete copy.chain
83
+ delete copy.signature
84
+ return sha256Value(canonicalJson(copy))
85
+ }
86
+
87
+ export function finalizeEvidence(document) {
88
+ const output = { ...document }
89
+ output.chain = { evidenceHash: evidenceHash(output) }
90
+ return output
91
+ }
92
+
93
+ export function evidenceHashMatches(document) {
94
+ const declared = document?.chain?.evidenceHash
95
+ if (typeof declared !== 'string') return false
96
+ return declared === evidenceHash(document)
97
+ }
98
+
99
+ const DEFAULT_TREE_EXCLUDE = ['.git', 'node_modules']
100
+ const TREE_EXCLUDE_ALWAYS = new Set(['.git', 'node_modules'])
101
+
102
+ /**
103
+ * Deterministic directory hash: relative path + byte length + file hash for
104
+ * every regular file, sorted, with `.git`/`node_modules` and `exclude` skipped.
105
+ * Symlinks are hashed by target string so re-linking cannot pass unnoticed.
106
+ * Returns `{ hash, files, bytes })` where `hash` is `sha256:<64 hex>`.
107
+ */
108
+ export function treeSha256(dir, { exclude = [] } = {}) {
109
+ const root = path.resolve(dir)
110
+ const skip = new Set([...DEFAULT_TREE_EXCLUDE, ...exclude.map(item => String(item).split(path.sep).join('/'))])
111
+ const rows = []
112
+ let bytes = 0
113
+ const walk = (current, relative) => {
114
+ const entries = fs.readdirSync(current, { withFileTypes: true }).sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))
115
+ for (const entry of entries) {
116
+ const rel = relative ? `${relative}/${entry.name}` : entry.name
117
+ const segments = rel.split('/')
118
+ if (segments.some(segment => TREE_EXCLUDE_ALWAYS.has(segment))) continue
119
+ if ([...skip].some(pattern => rel === pattern || rel.startsWith(`${pattern}/`))) continue
120
+ const absolute = path.join(current, entry.name)
121
+ if (entry.isDirectory()) {
122
+ walk(absolute, rel)
123
+ } else if (entry.isSymbolicLink()) {
124
+ const target = fs.readlinkSync(absolute)
125
+ rows.push(`${rel}\0link\0${sha256Hex(target)}`)
126
+ } else if (entry.isFile()) {
127
+ const size = fs.statSync(absolute).size
128
+ bytes += size
129
+ rows.push(`${rel}\0${size}\0${sha256Hex(fs.readFileSync(absolute))}`)
130
+ }
131
+ }
132
+ }
133
+ walk(root, '')
134
+ rows.sort()
135
+ return { hash: sha256Value(rows.join('\n')), files: rows.length, bytes }
136
+ }
137
+
138
+ export function isIsoTimestamp(value) {
139
+ return typeof value === 'string' && ISO_RE.test(value) && Number.isFinite(Date.parse(value))
140
+ }
141
+
142
+ /**
143
+ * Aggregate one journey from its checks.
144
+ * `pass` requires observed evidence; `static` means preflight only; `warn`
145
+ * means the commands succeeded but an assertion contradicts the claim.
146
+ */
147
+ export function journeyAggregate(checks) {
148
+ if (!Array.isArray(checks) || checks.length === 0) return 'not-run'
149
+ if (checks.some(check => check?.exitCode !== 0)) return 'fail'
150
+ const observed = checks.filter(check => (check?.kind ?? 'observed') !== 'static')
151
+ if (observed.length === 0) return 'static'
152
+ const held = observed.every(check => Array.isArray(check.assertions)
153
+ && check.assertions.length > 0
154
+ && check.assertions.every(assertion => assertion?.observed === true))
155
+ return held ? 'pass' : 'warn'
156
+ }
157
+
158
+ /** True only when a declared journey status agrees with its checks. */
159
+ export function journeyCountsAsPass(journey) {
160
+ const derived = journeyAggregate(journey?.checks)
161
+ const declared = journey?.status ?? null
162
+ if (derived !== 'pass') return false
163
+ return declared === null || declared === 'pass'
164
+ }
165
+
166
+ function emptyVerdict(kind, schema, errors) {
167
+ return {
168
+ kind,
169
+ schema: schema ?? null,
170
+ valid: false,
171
+ errors,
172
+ journeys: {},
173
+ computedHash: null,
174
+ declaredHash: null,
175
+ subject: null,
176
+ host: null,
177
+ generatedAt: null,
178
+ conclusion: null,
179
+ }
180
+ }
181
+
182
+ /**
183
+ * Classify + structurally validate a flow evidence document.
184
+ * `kind`: `v2` | `v1-deprecated` | `legacy` | `invalid`.
185
+ */
186
+ export function validateFlowEvidence(document) {
187
+ if (!isPlainObject(document)) return emptyVerdict('invalid', null, ['root must be a JSON object'])
188
+ const schema = typeof document.schema === 'string' ? document.schema : null
189
+ if (schema === FLOW_EVIDENCE_SCHEMA) return validateV2(document)
190
+ const legacyRows = FLOW_IDS.some(id => isPlainObject(document[id]))
191
+ if (schema && (FLOW_V1_SCHEMAS.has(schema) || !legacyRows)) {
192
+ return emptyVerdict('v1-deprecated', schema, [
193
+ `schema ${schema} is deprecated; only ${FLOW_EVIDENCE_SCHEMA} is accepted`,
194
+ ])
195
+ }
196
+ if (legacyRows || isPlainObject(document.journeys)) {
197
+ return emptyVerdict('legacy', schema, [
198
+ 'bare {Jx:{status}} rows are deprecated and never count as a pass',
199
+ ])
200
+ }
201
+ return emptyVerdict('invalid', schema, [`unrecognised flow evidence document; expected schema ${FLOW_EVIDENCE_SCHEMA}`])
202
+ }
203
+
204
+ function validateV2(document) {
205
+ const errors = []
206
+ const requireObject = (value, label) => {
207
+ if (!isPlainObject(value)) {
208
+ errors.push(`${label} must be an object`)
209
+ return false
210
+ }
211
+ return true
212
+ }
213
+ const requireString = (value, label, { allowNull = false, minLength = 1, pattern = null } = {}) => {
214
+ if (value === null && allowNull) return
215
+ if (typeof value !== 'string' || value.length < minLength) {
216
+ errors.push(`${label} must be a non-empty string${allowNull ? ' or null' : ''}`)
217
+ return
218
+ }
219
+ if (pattern && !pattern.test(value)) errors.push(`${label} has an invalid format`)
220
+ }
221
+ const requireIso = (value, label) => {
222
+ if (!isIsoTimestamp(value)) errors.push(`${label} must be an ISO-8601 timestamp`)
223
+ }
224
+ const requireHash = (value, label, { allowNull = false } = {}) => {
225
+ if (value === null && allowNull) return
226
+ if (typeof value !== 'string' || !HASH_RE.test(value)) errors.push(`${label} must be sha256:<64 lowercase hex>${allowNull ? ' or null' : ''}`)
227
+ }
228
+ const requireLog = (value, label) => {
229
+ if (!requireObject(value, label)) return
230
+ requireString(value.path, `${label}.path`)
231
+ requireHash(value.sha256, `${label}.sha256`)
232
+ for (const key of Object.keys(value)) if (!['path', 'sha256'].includes(key)) errors.push(`${label}.${key} is not allowed`)
233
+ }
234
+
235
+ for (const key of ['schema', 'generatedAt', 'producer', 'subject', 'host', 'journeys', 'claims', 'chain']) {
236
+ if (!(key in document)) errors.push(`${key} is required`)
237
+ }
238
+ if (!errors.includes('schema is required')) {
239
+ if (document.schema !== FLOW_EVIDENCE_SCHEMA) errors.push(`schema must be ${FLOW_EVIDENCE_SCHEMA}`)
240
+ }
241
+ if ('generatedAt' in document) requireIso(document.generatedAt, 'generatedAt')
242
+ if ('evidenceId' in document) requireString(document.evidenceId, 'evidenceId')
243
+
244
+ if (requireObject(document.producer, 'producer')) {
245
+ requireIso(document.producer.at ?? null, 'producer.at')
246
+ const command = document.producer.command
247
+ if (!Array.isArray(command) || command.length === 0 || command.some(item => typeof item !== 'string')) {
248
+ errors.push('producer.command must be a non-empty array of strings')
249
+ }
250
+ if ('kind' in document.producer && document.producer.kind !== null && !['local', 'ci', 'sandbox'].includes(document.producer.kind)) {
251
+ errors.push('producer.kind must be local, ci, or sandbox')
252
+ }
253
+ for (const key of ['runner', 'actor', 'runUrl']) {
254
+ if (key in document.producer && document.producer[key] !== null) requireString(document.producer[key], `producer.${key}`)
255
+ }
256
+ }
257
+
258
+ if (requireObject(document.subject, 'subject')) {
259
+ const subject = document.subject
260
+ requireString(subject.name, 'subject.name')
261
+ requireString(subject.version, 'subject.version')
262
+ requireFieldOrNull(errors, subject, 'integrity', value => requireString(value, 'subject.integrity', { allowNull: true, pattern: INTEGRITY_RE }))
263
+ requireField(errors, subject, 'tarballSha256', () => requireHash(subject.tarballSha256, 'subject.tarballSha256'))
264
+ requireFieldOrNull(errors, subject, 'commit', value => requireString(value, 'subject.commit', { allowNull: true, pattern: COMMIT_RE }))
265
+ requireField(errors, subject, 'treeSha256', () => requireHash(subject.treeSha256, 'subject.treeSha256'))
266
+ if ('treeSha256After' in subject) requireHash(subject.treeSha256After, 'subject.treeSha256After', { allowNull: true })
267
+ if ('treeStable' in subject && typeof subject.treeStable !== 'boolean') errors.push('subject.treeStable must be a boolean')
268
+ if ('treeExclude' in subject) {
269
+ if (!Array.isArray(subject.treeExclude) || subject.treeExclude.some(item => typeof item !== 'string')) {
270
+ errors.push('subject.treeExclude must be an array of strings')
271
+ }
272
+ }
273
+ if ('source' in subject) requireString(subject.source, 'subject.source')
274
+ }
275
+
276
+ if (requireObject(document.host, 'host')) {
277
+ requireString(document.host.dsh, 'host.dsh', { allowNull: true })
278
+ requireString(document.host.node, 'host.node')
279
+ requireString(document.host.platform, 'host.platform')
280
+ for (const key of ['dshCommit', 'cordis']) {
281
+ if (key in document.host && document.host[key] !== null) requireString(document.host[key], `host.${key}`)
282
+ }
283
+ }
284
+
285
+ if (requireObject(document.journeys, 'journeys')) {
286
+ for (const key of Object.keys(document.journeys)) {
287
+ if (!FLOW_IDS.includes(key)) errors.push(`journeys.${key} is not a known journey (J1-J8)`)
288
+ }
289
+ for (const id of FLOW_IDS) {
290
+ const journey = document.journeys[id]
291
+ if (!isPlainObject(journey)) {
292
+ errors.push(`journeys.${id} must be an object`)
293
+ continue
294
+ }
295
+ for (const key of Object.keys(journey)) {
296
+ if (!['status', 'reason', 'kind', 'checks', 'limitations'].includes(key)) errors.push(`journeys.${id}.${key} is not allowed`)
297
+ }
298
+ if ('status' in journey && journey.status !== null && !JOURNEY_STATUSES.has(journey.status)) {
299
+ errors.push(`journeys.${id}.status must be one of ${[...JOURNEY_STATUSES].join(', ')}`)
300
+ }
301
+ if ('reason' in journey && journey.reason !== null) requireString(journey.reason, `journeys.${id}.reason`)
302
+ if ('limitations' in journey) {
303
+ if (!Array.isArray(journey.limitations) || journey.limitations.some(item => typeof item !== 'string')) {
304
+ errors.push(`journeys.${id}.limitations must be an array of strings`)
305
+ }
306
+ }
307
+ if (!Array.isArray(journey.checks)) {
308
+ errors.push(`journeys.${id}.checks must be an array`)
309
+ continue
310
+ }
311
+ journey.checks.forEach((check, index) => validateCheck(check, `journeys.${id}.checks[${index}]`))
312
+ }
313
+ }
314
+
315
+ if (requireObject(document.claims, 'claims')) {
316
+ const claims = document.claims
317
+ if (!['self-attested', 'ci-attested', 'sandbox-attested'].includes(claims.claimLevel)) {
318
+ errors.push('claims.claimLevel must be self-attested, ci-attested, or sandbox-attested')
319
+ }
320
+ if ('notClaimed' in claims && (!Array.isArray(claims.notClaimed) || claims.notClaimed.some(item => typeof item !== 'string'))) {
321
+ errors.push('claims.notClaimed must be an array of strings')
322
+ }
323
+ if ('method' in claims) requireString(claims.method, 'claims.method')
324
+ }
325
+
326
+ if (requireObject(document.chain, 'chain')) {
327
+ requireHash(document.chain.evidenceHash, 'chain.evidenceHash')
328
+ for (const key of Object.keys(document.chain)) if (key !== 'evidenceHash') errors.push(`chain.${key} is not allowed`)
329
+ }
330
+ if ('signature' in document && document.signature !== null && !isPlainObject(document.signature)) {
331
+ errors.push('signature must be null or an object')
332
+ }
333
+ if ('conclusion' in document && document.conclusion !== null && !['flow-pass', 'flow-warn', 'flow-blocked', 'flow-fail'].includes(document.conclusion)) {
334
+ errors.push('conclusion must be flow-pass, flow-warn, flow-blocked, or flow-fail')
335
+ }
336
+
337
+ // Aggregation consistency: declared pass/fail must agree with the checks.
338
+ const journeys = {}
339
+ for (const id of FLOW_IDS) {
340
+ const journey = isPlainObject(document.journeys?.[id]) ? document.journeys[id] : null
341
+ const checks = Array.isArray(journey?.checks) ? journey.checks : []
342
+ const derived = journeyAggregate(checks)
343
+ const declared = journey?.status ?? null
344
+ const passes = journeyCountsAsPass(journey)
345
+ journeys[id] = { declared, derived, checks: checks.length, passes }
346
+ if (declared === 'pass' && derived !== 'pass') errors.push(`journeys.${id} declares pass but its checks aggregate to ${derived}`)
347
+ if (declared === 'fail' && derived === 'pass') errors.push(`journeys.${id} declares fail but its checks aggregate to pass`)
348
+ }
349
+
350
+ // Chain integrity: recompute over every field except chain/signature.
351
+ const computedHash = evidenceHash(document)
352
+ const declaredHash = typeof document.chain?.evidenceHash === 'string' ? document.chain.evidenceHash : null
353
+ if (declaredHash !== computedHash) {
354
+ errors.push(`chain.evidenceHash does not match the document (declared ${declaredHash ?? 'missing'}, computed ${computedHash})`)
355
+ }
356
+
357
+ return {
358
+ kind: 'v2',
359
+ schema: FLOW_EVIDENCE_SCHEMA,
360
+ valid: errors.length === 0,
361
+ errors,
362
+ journeys,
363
+ computedHash,
364
+ declaredHash,
365
+ subject: isPlainObject(document.subject) ? document.subject : null,
366
+ host: isPlainObject(document.host) ? document.host : null,
367
+ generatedAt: document.generatedAt ?? null,
368
+ conclusion: document.conclusion ?? null,
369
+ }
370
+
371
+ function validateCheck(check, label) {
372
+ if (!isPlainObject(check)) {
373
+ errors.push(`${label} must be an object`)
374
+ return
375
+ }
376
+ for (const key of Object.keys(check)) {
377
+ if (!['id', 'kind', 'command', 'exitCode', 'startedAt', 'endedAt', 'stdout', 'stderr', 'assertions', 'durationMs'].includes(key)) {
378
+ errors.push(`${label}.${key} is not allowed`)
379
+ }
380
+ }
381
+ requireString(check.id, `${label}.id`, { pattern: /^J[1-8](?:\.[A-Za-z0-9._-]+)?$/ })
382
+ if ('kind' in check && !CHECK_KINDS.has(check.kind)) errors.push(`${label}.kind must be observed, static, or manual`)
383
+ if (!Array.isArray(check.command) || check.command.length === 0 || check.command.some(item => typeof item !== 'string')) {
384
+ errors.push(`${label}.command must be a non-empty array of strings`)
385
+ }
386
+ if (!Number.isInteger(check.exitCode) || check.exitCode < 0 || check.exitCode > 255) {
387
+ errors.push(`${label}.exitCode must be an integer in 0..255`)
388
+ }
389
+ requireIso(check.startedAt, `${label}.startedAt`)
390
+ requireIso(check.endedAt, `${label}.endedAt`)
391
+ if (isIsoTimestamp(check.startedAt) && isIsoTimestamp(check.endedAt) && Date.parse(check.endedAt) < Date.parse(check.startedAt)) {
392
+ errors.push(`${label}.endedAt is before startedAt`)
393
+ }
394
+ requireField(errors, check, 'stdout', () => requireLog(check.stdout, `${label}.stdout`))
395
+ if ('stderr' in check) requireLog(check.stderr, `${label}.stderr`)
396
+ if (!Array.isArray(check.assertions) || check.assertions.length === 0) {
397
+ errors.push(`${label}.assertions must be a non-empty array`)
398
+ } else {
399
+ check.assertions.forEach((assertion, index) => {
400
+ const alabel = `${label}.assertions[${index}]`
401
+ if (!isPlainObject(assertion)) {
402
+ errors.push(`${alabel} must be an object`)
403
+ return
404
+ }
405
+ for (const key of Object.keys(assertion)) if (!['claim', 'observed', 'detail'].includes(key)) errors.push(`${alabel}.${key} is not allowed`)
406
+ requireString(assertion.claim, `${alabel}.claim`)
407
+ if (typeof assertion.observed !== 'boolean') errors.push(`${alabel}.observed must be a boolean`)
408
+ if ('detail' in assertion && assertion.detail !== null) requireString(assertion.detail, `${alabel}.detail`)
409
+ })
410
+ }
411
+ if ('durationMs' in check && (typeof check.durationMs !== 'number' || check.durationMs < 0)) {
412
+ errors.push(`${label}.durationMs must be a non-negative number`)
413
+ }
414
+ }
415
+ }
416
+
417
+ function requireField(errors, object, key, validate) {
418
+ if (!(key in object)) {
419
+ errors.push(`${key} is required`)
420
+ return
421
+ }
422
+ validate()
423
+ }
424
+
425
+ function requireFieldOrNull(errors, object, key, validate) {
426
+ if (!(key in object)) {
427
+ errors.push(`${key} is required`)
428
+ return
429
+ }
430
+ validate(object[key])
431
+ }
@@ -4,5 +4,8 @@ export { runManifestChecks, checkMarketEntry, DEFAULT_RUNTIME_VERSION, inferKind
4
4
  export { checkCapabilities, checkCredentials, checkLabels, checkImportTimeAdvisory, CAPABILITIES, LABELS } from './declarations.mjs'
5
5
  export { runRuntimeChecks } from './runtime.mjs'
6
6
  export { buildReport, formatText, formatSarif, writeReport } from './report.mjs'
7
+ export { joinAdvice, DOCS_BASE } from './remediation.mjs'
7
8
  export { satisfies, validRange, parseVersion, compareVersions } from './semver.mjs'
8
9
  export { parseInstallSpec } from './install-spec.mjs'
10
+ export { FLOW_IDS, FLOW_EVIDENCE_SCHEMA, evidenceHash, finalizeEvidence, journeyAggregate, treeSha256, validateFlowEvidence } from './flow-evidence.mjs'
11
+ export { sanitizedEnv, scrubProcessEnv, isSecretEnvName, isAllowedEnvName, envPolicy } from './sandbox-env.mjs'
@@ -204,6 +204,11 @@ export function runManifestChecks(dir, options = {}) {
204
204
  manifestDetails.push(detail('C-1.2', 'PASS', `entry resolves via ${entry.source}: ${entry.relative}`, [entry.relative]))
205
205
  } else if (entry.buildHint) {
206
206
  manifestDetails.push(detail('C-1.2', 'WARN', `entry not present in checkout; source/build hint exists (${entry.sourceEntry ?? 'build script'})`, ['package.json#scripts']))
207
+ } else if (kind === 'bundle' && patch.present) {
208
+ // 2026-11-04: a bundle that ships only a cordis.patch.yml has no runtime entry
209
+ // by design (dsh.bundle.patch is the carrier). Report NA, not WARN, so the
210
+ // mandatory coverage is not charged for something the package never claimed.
211
+ manifestDetails.push(detail('C-1.2', 'NA', 'patch-only bundle; no runtime entry by design', patch.files.map(f => f.relative)))
207
212
  } else if (kind === 'bundle' || kind === 'client') {
208
213
  manifestDetails.push(detail('C-1.2', 'WARN', `no runtime entry; ${kind} packages may be patch/client only`, ['package.json#exports']))
209
214
  } else if (Array.isArray(manifest.workspaces) || (manifest.workspaces && typeof manifest.workspaces === 'object')) {
@@ -0,0 +1,132 @@
1
+ /**
2
+ * Join a dsh-conformance/1 report with the remediation rule table.
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.
8
+ */
9
+
10
+ import packageJson from '../../package.json' with { type: 'json' }
11
+
12
+ /** 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/'
14
+
15
+ const LIVE = new Set(['FAIL', 'WARN'])
16
+ const SEVERITY_RANK = { FAIL: 2, WARN: 1 }
17
+
18
+ function tableOf(rules) {
19
+ if (!rules || typeof rules !== 'object') return {}
20
+ // Accept either the whole spec/1 object or a bare key→rule map.
21
+ if (rules.rules && typeof rules.rules === 'object') return rules.rules
22
+ if (rules.items && typeof rules.items === 'object') return rules.items
23
+ return rules
24
+ }
25
+
26
+ function versionOf(rules) {
27
+ if (rules && typeof rules.version === 'string') return rules.version
28
+ return typeof packageJson.version === 'string' ? packageJson.version : null
29
+ }
30
+
31
+ function docsUrlOf(rule, docsBase) {
32
+ if (!rule || typeof rule.docs !== 'string' || rule.docs.trim() === '') return null
33
+ const base = String(docsBase ?? DOCS_BASE)
34
+ const prefix = base.endsWith('/') ? base : `${base}/`
35
+ return `${prefix}${rule.docs.replace(/^\/+/, '')}`
36
+ }
37
+
38
+ function uniqueEvidence(...lists) {
39
+ const seen = new Set()
40
+ const out = []
41
+ for (const list of lists) {
42
+ for (const value of Array.isArray(list) ? list : []) {
43
+ if (typeof value !== 'string' || value === '' || seen.has(value)) continue
44
+ seen.add(value)
45
+ out.push(value)
46
+ }
47
+ }
48
+ return out.slice(0, 10)
49
+ }
50
+
51
+ /**
52
+ * Build additive advice from a report.
53
+ *
54
+ * One entry is produced per (check.id, detail.item) pair with FAIL/WARN — or,
55
+ * for a FAIL/WARN check with no live details, one entry keyed by check.clause.
56
+ * Duplicate details are merged: severity takes the worst value, evidence is
57
+ * unioned, and the specific reasons are joined for diagnostics.
58
+ *
59
+ * @param {object} report dsh-conformance/1 report
60
+ * @param {object} rules flat key→rule map (also accepts a { rules } or { items } wrapper)
61
+ * @param {string} [docsBase] absolute release-pinned guide base
62
+ * @returns {object[]} advice entries
63
+ */
64
+ export function joinAdvice(report, rules, docsBase = DOCS_BASE) {
65
+ const table = tableOf(rules)
66
+ const version = versionOf(rules)
67
+ const merged = new Map()
68
+
69
+ const add = (check, key, severity, reason, evidence) => {
70
+ if (typeof key !== 'string' || key === '' || !LIVE.has(severity)) return false
71
+ const id = `${check.id}|${key}`
72
+ const current = merged.get(id)
73
+ if (!current) {
74
+ merged.set(id, {
75
+ check,
76
+ key,
77
+ severity,
78
+ reasons: typeof reason === 'string' && reason !== '' ? [reason] : [],
79
+ evidence: uniqueEvidence(evidence),
80
+ occurrences: 1,
81
+ })
82
+ return true
83
+ }
84
+ current.occurrences += 1
85
+ if (typeof reason === 'string' && reason !== '' && !current.reasons.includes(reason)) current.reasons.push(reason)
86
+ if (SEVERITY_RANK[severity] > SEVERITY_RANK[current.severity]) current.severity = severity
87
+ current.evidence = uniqueEvidence(current.evidence, evidence)
88
+ return true
89
+ }
90
+
91
+ for (const check of Array.isArray(report?.checks) ? report.checks : []) {
92
+ const liveDetails = (Array.isArray(check.details) ? check.details : [])
93
+ .filter(detail => detail && LIVE.has(detail.result))
94
+ let added = 0
95
+ for (const detail of liveDetails) if (add(check, detail.item, detail.result, detail.reason, detail.evidence)) added += 1
96
+ // A runtime FAIL (DSC-04 / DSC-14) has no details; its clause is the only
97
+ // joinable key. Only use the fallback when no joinable detail was produced,
98
+ // so a specific detail always wins over the check-level summary.
99
+ if (added === 0 && LIVE.has(check.result)) {
100
+ add(check, check.clause, check.result, check.reason, check.evidence)
101
+ }
102
+ }
103
+
104
+ const advice = []
105
+ for (const entry of merged.values()) {
106
+ const rule = table[entry.key] ?? null
107
+ const item = {
108
+ key: entry.key,
109
+ severity: entry.severity,
110
+ title: rule?.title ?? entry.key,
111
+ why: rule?.why ?? null,
112
+ fix: rule?.fix ?? null,
113
+ docsUrl: docsUrlOf(rule, docsBase),
114
+ source: entry.check.id,
115
+ version,
116
+ evidence: entry.evidence,
117
+ reason: entry.reasons.slice(0, 3).join(' | '),
118
+ occurrences: entry.occurrences,
119
+ advisory: entry.check.advisory === true,
120
+ }
121
+ if (!rule) item.ruleMissing = true
122
+ advice.push(item)
123
+ }
124
+
125
+ // FAIL before WARN, then key/source for deterministic output.
126
+ advice.sort((a, b) => (
127
+ (SEVERITY_RANK[b.severity] ?? 0) - (SEVERITY_RANK[a.severity] ?? 0)
128
+ || a.key.localeCompare(b.key)
129
+ || a.source.localeCompare(b.source)
130
+ ))
131
+ return advice
132
+ }
@@ -1,4 +1,6 @@
1
1
  import path from 'node:path'
2
+ import remediationSpec from '../../spec/remediation.json' with { type: 'json' }
3
+ import { DOCS_BASE, joinAdvice } from './remediation.mjs'
2
4
 
3
5
  export const PACKAGE_MANDATORY = [
4
6
  'DSC-01', 'DSC-02', 'DSC-04', 'DSC-05', 'DSC-06', 'DSC-07', 'DSC-08', 'DSC-09', 'DSC-11', 'DSC-12', 'DSC-13',
@@ -37,7 +39,7 @@ function allowedNa(check, scope, profile, kind) {
37
39
  return false
38
40
  }
39
41
 
40
- export function buildReport({ subject, anchor, checks, g4 = null, scope, profile, kind, staticOnly = false, entryRuntime = 'require', entryClaim = 'runtime' }) {
42
+ export function buildReport({ subject, anchor, checks, g4 = null, scope, profile, kind, staticOnly = false, entryRuntime = 'require', entryClaim = 'runtime', advice = true }) {
41
43
  const mandatory = mandatoryFor(scope, profile, staticOnly, entryClaim)
42
44
  const adjustedChecks = scope === 'entry' ? checks.map(item => adjustEntryCheck(item, entryClaim)) : checks
43
45
  const runtimeIds = new Set(['DSC-04', 'DSC-05', 'DSC-06', 'DSC-07', 'DSC-08', 'DSC-09'])
@@ -85,7 +87,7 @@ export function buildReport({ subject, anchor, checks, g4 = null, scope, profile
85
87
  if (warnings.length > 0) reasonParts.push(`WARN: ${warnings.map(item => item.id).join(', ')}`)
86
88
  if (advisoryWarnings.length > 0) reasonParts.push(`advisory: ${advisoryWarnings.map(item => item.id).join(', ')}`)
87
89
 
88
- return {
90
+ const report = {
89
91
  schema: 'dsh-conformance/1',
90
92
  standard: { id: 'DS-C', version: '1.0.0' },
91
93
  subject,
@@ -120,6 +122,12 @@ export function buildReport({ subject, anchor, checks, g4 = null, scope, profile
120
122
  reason: reasonParts.join('; '),
121
123
  },
122
124
  }
125
+ // Additive only: `advice` is a sibling of `checks`, never part of a check,
126
+ // and the verdict is computed above without reading it.
127
+ if (advice !== false) {
128
+ report.advice = joinAdvice(report, remediationSpec, DOCS_BASE)
129
+ }
130
+ return report
123
131
  }
124
132
 
125
133
  const ICON = { PASS: 'PASS', WARN: 'WARN', FAIL: 'FAIL', NA: 'NA ', ERROR: 'ERR ' }
@@ -145,6 +153,15 @@ export function formatText(report) {
145
153
  lines.push(`coverage: mandatory=${coverage.mandatory} pass=${coverage.pass} warn=${coverage.warn} fail=${coverage.fail} na=${coverage.na} notRun=${coverage.notRun}`)
146
154
  if (conclusion.reason) lines.push(`reason: ${conclusion.reason}`)
147
155
  if (conclusion.status === 'incomplete') lines.push('NOT A DS-C PASS: at least one mandatory check did not run (see notRun).')
156
+ if (Array.isArray(report.advice) && report.advice.length > 0) {
157
+ lines.push('')
158
+ lines.push('advice (non-blocking):')
159
+ for (const item of report.advice) {
160
+ const missing = item.ruleMissing ? ' [ruleMissing]' : ''
161
+ lines.push(` ${String(item.severity).padEnd(4)} ${String(item.key).padEnd(7)} ${item.source}${missing}: ${String(item.fix ?? item.title ?? '').replace(/\s+/g, ' ').slice(0, 160)}`)
162
+ if (item.docsUrl) lines.push(` ${item.docsUrl}`)
163
+ }
164
+ }
148
165
  lines.push('')
149
166
  return `${lines.join('\n')}\n`
150
167
  }