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
@@ -0,0 +1,714 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * build-sidecar — generate or refresh `publish/sidecar.json`
4
+ * (`dsh-market-sidecar/1`), the index cache the market can read without
5
+ * executing a plugin and without treating us as an issuer.
6
+ *
7
+ * Two modes:
8
+ * fresh read one `dsh-level-audit/1` JSON (`dsh-levels <dir> --format
9
+ * json`) plus a package tarball, emit one entry keyed name@version.
10
+ * lifecycle read an existing sidecar (`--prev`) and only recompute
11
+ * `lifecycle` from `--today` and `publish/withdrawals.json`.
12
+ *
13
+ * Entry fields are a superset of the original `dsh-market-sidecar/1` shape
14
+ * (subject/levels/evidence/validity/lifecycle/selfAssessed are unchanged):
15
+ * title/repo/license from the published package.json, null when absent
16
+ * scope {dsh, cordis, platform} from dsh.engines / npm engines
17
+ * / peerDependencies, falling back to the audit anchor
18
+ * checks checker results as {id,result,coverage} only; detailed
19
+ * reasons stay in the report, never in the sidecar
20
+ * evidence url/sha256 kept for compatibility; reportUrl/
21
+ * reportSha256 are the same values under the new names
22
+ * checkedBy {checker, command} that produced the audit
23
+ * notChecked short honest list of what the run did not exercise
24
+ *
25
+ * Deliberately absent: advice (author view), issuer / recordHash / verifyUrl
26
+ * (we are not an issuer), score / rank (we do not grade). `--today` pins the
27
+ * clock so CI and tests are reproducible.
28
+ *
29
+ * Usage:
30
+ * node 40-checker/scripts/build-sidecar.mjs --levels audit.json \
31
+ * --report report.json --tarball dsh-security-pack-3.0.1.tgz \
32
+ * --out publish/sidecar.json --today 2026-11-06
33
+ *
34
+ * node 40-checker/scripts/build-sidecar.mjs --prev publish/sidecar.json \
35
+ * --withdrawals publish/withdrawals.json --out publish/sidecar.json \
36
+ * --today 2026-11-06
37
+ */
38
+ import fs from 'node:fs'
39
+ import path from 'node:path'
40
+ import crypto from 'node:crypto'
41
+ import { execFileSync } from 'node:child_process'
42
+ import { fileURLToPath } from 'node:url'
43
+
44
+ const HERE = path.dirname(fileURLToPath(import.meta.url))
45
+ const ROOT = path.resolve(HERE, '..', '..')
46
+ const SCHEMA = 'dsh-market-sidecar/1'
47
+ const DATE = /^\d{4}-\d{2}-\d{2}$/
48
+ const HEX40 = /^[0-9a-f]{40}$/
49
+ const HEX64 = /^[0-9a-f]{64}$/
50
+ const LIFECYCLE = new Set(['active', 'stale', 'under-review', 'withdrawn'])
51
+ const FORBIDDEN = new Set(['advice', 'issuer', 'recordHash', 'verifyUrl', 'score', 'rank'])
52
+ const NOT_CLAIMED = ['official', 'certification', 'security-audit', 'endorsement', 'ordering', 'comparison', 'warranty']
53
+ const CHECK_KEYS = new Set(['id', 'result', 'coverage'])
54
+ const SCOPE_KEYS = new Set(['dsh', 'cordis', 'platform'])
55
+ const CHECKED_BY_KEYS = new Set(['checker', 'command'])
56
+ const REPO_GIT_SHORTHAND = /^github:([^/]+\/[^/]+?)(?:\.git)?$/
57
+
58
+ function usage() {
59
+ console.log(`build-sidecar — publish/sidecar.json (${SCHEMA})
60
+
61
+ Usage:
62
+ node 40-checker/scripts/build-sidecar.mjs --levels <audit.json> [options]
63
+ node 40-checker/scripts/build-sidecar.mjs --prev <sidecar.json> [options]
64
+
65
+ Options:
66
+ --levels <file> one dsh-level-audit/1 JSON (single package)
67
+ --prev <file> existing sidecar; entries survive a lifecycle refresh
68
+ --withdrawals <file> append-only withdrawal list (default: alongside --prev)
69
+ --out <file> output path, default publish/sidecar.json
70
+ --today <YYYY-MM-DD> fixed clock for generatedAt/checkedAt/lifecycle
71
+ --ttl-days <n> validity policy, default 90 (writes "90d")
72
+ --name <name> subject name; default package.json / audit.subject
73
+ --version <version> subject version; default package.json / audit.subject
74
+ --package <dir> package.json directory for name/version
75
+ --tarball <file> package tarball; sha256 is recorded in subject
76
+ --tarball-sha256 <hex> explicit tarball sha256 (checked against --tarball)
77
+ --report <file> dsh-conformance/1 checker report; its checks are
78
+ reduced to {id,result,coverage} (no details)
79
+ --checked-command <s> command recorded in checkedBy.command
80
+ --evidence-url <url> optional https evidence URL (url + reportUrl)
81
+ --evidence-file <file> hash this file into evidence.sha256/reportSha256
82
+ --evidence-sha256 <hex> explicit evidence sha256 (sha256 + reportSha256)
83
+ --checker-version <v> default dsh-standards package.json version
84
+ --checker-sha <40hex> default git HEAD when available
85
+ --index-url <url> default https://awesome-dsh-plugin.com/index.json
86
+ --organizer sets selfAssessed=false (default true)
87
+ --help show this help
88
+
89
+ Exit codes: 0 written/unchanged; 3 invalid input.`)
90
+ }
91
+
92
+ function readJson(file, label) {
93
+ try {
94
+ return JSON.parse(fs.readFileSync(file, 'utf8'))
95
+ } catch (error) {
96
+ throw new Error(`${label} is not readable JSON (${file}): ${error.message}`)
97
+ }
98
+ }
99
+
100
+ function sha256File(file) {
101
+ return crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex')
102
+ }
103
+
104
+ function normalizeDate(value, label) {
105
+ const match = String(value ?? '').match(/^(\d{4}-\d{2}-\d{2})(?:[T ].*)?$/)
106
+ if (!match) throw new Error(`${label} must be YYYY-MM-DD, got ${JSON.stringify(value)}`)
107
+ const day = match[1]
108
+ const parsed = new Date(`${day}T00:00:00.000Z`)
109
+ if (Number.isNaN(parsed.getTime()) || parsed.toISOString().slice(0, 10) !== day) {
110
+ throw new Error(`${label} is not a real date: ${day}`)
111
+ }
112
+ return day
113
+ }
114
+
115
+ function addDays(day, count) {
116
+ const date = new Date(`${day}T00:00:00.000Z`)
117
+ date.setUTCDate(date.getUTCDate() + count)
118
+ return date.toISOString().slice(0, 10)
119
+ }
120
+
121
+ function todayUtc() {
122
+ return new Date().toISOString().slice(0, 10)
123
+ }
124
+
125
+ function parseArgs(argv) {
126
+ const options = {
127
+ levels: null, prev: null, withdrawals: null,
128
+ out: 'publish/sidecar.json',
129
+ today: null, todayProvided: false, ttlDays: 90,
130
+ name: null, version: null, packageDir: null,
131
+ tarball: null, tarballSha256: null,
132
+ report: null, checkedCommand: null,
133
+ evidenceUrl: null, evidenceFile: null, evidenceSha256: null,
134
+ checkerVersion: null, checkerSha: null,
135
+ indexUrl: 'https://awesome-dsh-plugin.com/index.json',
136
+ selfAssessed: true,
137
+ }
138
+ for (let i = 0; i < argv.length; i += 1) {
139
+ const arg = argv[i]
140
+ const next = () => {
141
+ const value = argv[i + 1]
142
+ if (value === undefined) throw new Error(`${arg} needs a value`)
143
+ i += 1
144
+ return value
145
+ }
146
+ if (arg === '--levels') options.levels = next()
147
+ else if (arg === '--prev') options.prev = next()
148
+ else if (arg === '--withdrawals') options.withdrawals = next()
149
+ else if (arg === '--out') options.out = next()
150
+ else if (arg === '--today') { options.today = normalizeDate(next(), '--today'); options.todayProvided = true }
151
+ else if (arg === '--ttl-days') options.ttlDays = Number(next())
152
+ else if (arg === '--name') options.name = next()
153
+ else if (arg === '--version') options.version = next()
154
+ else if (arg === '--package') options.packageDir = next()
155
+ else if (arg === '--tarball') options.tarball = next()
156
+ else if (arg === '--tarball-sha256') options.tarballSha256 = next().toLowerCase()
157
+ else if (arg === '--report') options.report = next()
158
+ else if (arg === '--checked-command') options.checkedCommand = next()
159
+ else if (arg === '--evidence-url') options.evidenceUrl = next()
160
+ else if (arg === '--evidence-file') options.evidenceFile = next()
161
+ else if (arg === '--evidence-sha256') options.evidenceSha256 = next().toLowerCase()
162
+ else if (arg === '--checker-version') options.checkerVersion = next()
163
+ else if (arg === '--checker-sha') options.checkerSha = next().toLowerCase()
164
+ else if (arg === '--index-url') options.indexUrl = next()
165
+ else if (arg === '--organizer') options.selfAssessed = false
166
+ else if (arg === '--help' || arg === '-h') { usage(); process.exit(0) }
167
+ else throw new Error(`unknown option: ${arg}`)
168
+ }
169
+ if (!options.levels && !options.prev) throw new Error('one of --levels or --prev is required')
170
+ if (!Number.isInteger(options.ttlDays) || options.ttlDays < 1 || options.ttlDays > 3650) {
171
+ throw new Error('--ttl-days must be an integer between 1 and 3650')
172
+ }
173
+ return options
174
+ }
175
+
176
+ function readPackageMeta(directory) {
177
+ if (!directory) return {}
178
+ const file = path.join(directory, 'package.json')
179
+ if (!fs.existsSync(file)) return {}
180
+ try {
181
+ return readJson(file, 'package.json')
182
+ } catch {
183
+ return {}
184
+ }
185
+ }
186
+
187
+ function firstLine(value) {
188
+ if (typeof value !== 'string') return null
189
+ const line = value.split(/\r?\n/)[0].trim()
190
+ return line === '' ? null : line
191
+ }
192
+
193
+ function httpsUrl(value) {
194
+ if (typeof value !== 'string' || value.trim() === '') return null
195
+ let url = value.trim()
196
+ const shorthand = url.match(REPO_GIT_SHORTHAND)
197
+ if (shorthand) url = `https://github.com/${shorthand[1]}`
198
+ if (url.startsWith('git+')) url = url.slice(4)
199
+ if (url.startsWith('ssh://git@')) url = `https://${url.slice('ssh://git@'.length)}`
200
+ if (url.startsWith('git@')) {
201
+ const match = url.match(/^git@([^:]+):(.+)$/)
202
+ if (match) url = `https://${match[1]}/${match[2]}`
203
+ }
204
+ if (url.startsWith('git://')) url = `https://${url.slice('git://'.length)}`
205
+ if (url.startsWith('http://')) url = `https://${url.slice('http://'.length)}`
206
+ if (!url.startsWith('https://')) return null
207
+ url = url.replace(/\.git$/i, '').replace(/\/+$/, '')
208
+ try {
209
+ const parsed = new URL(url)
210
+ if (parsed.protocol !== 'https:') return null
211
+ } catch {
212
+ return null
213
+ }
214
+ return url
215
+ }
216
+
217
+ function normalizeRepo(repository) {
218
+ if (typeof repository === 'string') return httpsUrl(repository)
219
+ if (!repository || typeof repository !== 'object') return null
220
+ return httpsUrl(repository.url)
221
+ }
222
+
223
+ function normalizeLicense(license) {
224
+ let value = license
225
+ if (value && typeof value === 'object') value = value.type
226
+ if (typeof value !== 'string') return null
227
+ const text = value.trim()
228
+ if (text === '' || /^SEE LICENSE/i.test(text) || text === 'UNLICENSED') return null
229
+ // Accept plain SPDX ids and simple AND/OR/WITH expressions; anything else is
230
+ // left null rather than guessed.
231
+ if (!/^[A-Za-z0-9.+-]+(?: \([^()]+\))?(?: (?:AND|OR|WITH) [A-Za-z0-9.+-]+(?: \([^()]+\))?)*$/.test(text)) return null
232
+ return text
233
+ }
234
+
235
+ function firstString(...values) {
236
+ for (const value of values) {
237
+ if (typeof value === 'string' && value.trim() !== '') return value.trim()
238
+ }
239
+ return null
240
+ }
241
+
242
+ function platformFromPackage(meta) {
243
+ const declared = firstString(meta?.dsh?.client?.platform, meta?.dsh?.platform, meta?.engines?.platform)
244
+ if (declared) return declared
245
+ const os = Array.isArray(meta?.os) ? meta.os.filter(item => typeof item === 'string' && item !== '') : []
246
+ const cpu = Array.isArray(meta?.cpu) ? meta.cpu.filter(item => typeof item === 'string' && item !== '') : []
247
+ if (os.length === 0 && cpu.length === 0) return null
248
+ return [os.join('/'), cpu.join('/')].filter(Boolean).join('-')
249
+ }
250
+
251
+ function inferScope(meta, audit) {
252
+ const anchor = audit?.checker?.anchor && typeof audit.checker.anchor === 'object' ? audit.checker.anchor : {}
253
+ const dshEngines = meta?.dsh?.engines && typeof meta.dsh.engines === 'object' ? meta.dsh.engines : {}
254
+ const engines = meta?.engines && typeof meta.engines === 'object' ? meta.engines : {}
255
+ const peer = meta?.peerDependencies && typeof meta.peerDependencies === 'object' ? meta.peerDependencies : {}
256
+ const deps = meta?.dependencies && typeof meta.dependencies === 'object' ? meta.dependencies : {}
257
+ return {
258
+ dsh: firstString(dshEngines.dsh, engines.dsh, anchor.dsh),
259
+ cordis: firstString(dshEngines.cordis, engines.cordis, peer['@deepseek-ai/cordis'], deps['@deepseek-ai/cordis'], anchor.cordis),
260
+ platform: platformFromPackage(meta),
261
+ }
262
+ }
263
+
264
+ function extractChecks(report, name, version) {
265
+ if (!report || !Array.isArray(report.checks)) return []
266
+ if (report.subject && typeof report.subject === 'object') {
267
+ const subjectName = firstString(report.subject.name)
268
+ const subjectVersion = firstString(report.subject.version)
269
+ if (subjectName !== null && name !== null && subjectName !== name) {
270
+ throw new Error(`--report subject.name ${subjectName} does not match ${name}`)
271
+ }
272
+ if (subjectVersion !== null && version !== null && subjectVersion !== version) {
273
+ throw new Error(`--report subject.version ${subjectVersion} does not match ${version}`)
274
+ }
275
+ }
276
+ return report.checks.map(check => {
277
+ if (!check || typeof check !== 'object') throw new Error('--report checks must be objects')
278
+ const id = firstString(check.id)
279
+ const result = firstString(check.result)
280
+ const coverage = firstString(check.coverage)
281
+ if (id === null || result === null || coverage === null) {
282
+ throw new Error('--report checks need id/result/coverage')
283
+ }
284
+ return { id, result, coverage }
285
+ })
286
+ }
287
+
288
+ function summarizeNotChecked(audit, checks, hasReport) {
289
+ const items = []
290
+ const entry = audit?.levels?.entry && typeof audit.levels.entry === 'object' ? audit.levels.entry : {}
291
+ if (entry.claim !== 'runtime') items.push(`ENTRY:runtime (claim=${firstString(entry.claim) ?? 'unknown'}; not exercised)`)
292
+ if (!hasReport) items.push('ENTRY:checks (no --report; checker results unavailable)')
293
+ const notRun = checks.filter(check => check.coverage === 'not-run').map(check => check.id)
294
+ if (notRun.length > 0) items.push(`ENTRY:${notRun.join(', ')} (coverage=not-run)`)
295
+ const flow = audit?.levels?.flow && typeof audit.levels.flow === 'object' ? audit.levels.flow : {}
296
+ if (flow.state !== 'runtime') {
297
+ items.push(`FLOW:J1-J8 (${firstString(flow.state) ?? 'not-run'}; ${Number.isInteger(flow.passed) ? flow.passed : 0}/8 journeys pass)`)
298
+ }
299
+ const boost = audit?.levels?.boost && typeof audit.levels.boost === 'object' ? audit.levels.boost : {}
300
+ if (boost.state !== 'measured') items.push(`BOOST:LEV-3..LEV-4 (${firstString(boost.state) ?? 'not-measured'})`)
301
+ return items
302
+ }
303
+
304
+ function pickLevel(row, keys, fallback) {
305
+ const level = {}
306
+ if (row && typeof row === 'object') {
307
+ for (const key of keys) {
308
+ if (typeof row[key] === 'string' && row[key] !== '') level[key] = row[key]
309
+ }
310
+ }
311
+ if (!level.state) level.state = fallback
312
+ return level
313
+ }
314
+
315
+ function gitHead() {
316
+ try {
317
+ const sha = execFileSync('git', ['rev-parse', 'HEAD'], {
318
+ cwd: ROOT,
319
+ encoding: 'utf8',
320
+ stdio: ['ignore', 'pipe', 'ignore'],
321
+ }).trim()
322
+ return HEX40.test(sha) ? sha : null
323
+ } catch {
324
+ return null
325
+ }
326
+ }
327
+
328
+ function loadPrev(file) {
329
+ const prev = readJson(file, 'previous sidecar')
330
+ if (prev.schema !== SCHEMA) throw new Error(`previous sidecar schema is ${JSON.stringify(prev.schema)}, expected ${SCHEMA}`)
331
+ if (!prev.entries || typeof prev.entries !== 'object' || Array.isArray(prev.entries)) {
332
+ throw new Error('previous sidecar has no entries object')
333
+ }
334
+ if (typeof prev.generatedAt !== 'string') throw new Error('previous sidecar has no generatedAt')
335
+ return prev
336
+ }
337
+
338
+ function parseWithdrawals(file) {
339
+ const result = { exact: new Map(), names: new Map(), records: 0 }
340
+ if (!file) return result
341
+ const raw = readJson(file, 'withdrawals')
342
+ let rows
343
+ if (Array.isArray(raw)) rows = raw
344
+ else if (Array.isArray(raw.withdrawals)) rows = raw.withdrawals
345
+ else if (raw.withdrawals && typeof raw.withdrawals === 'object') {
346
+ rows = Object.entries(raw.withdrawals).map(([key, value]) => ({ key, ...(typeof value === 'string' ? { reason: value } : value) }))
347
+ } else if (raw.entries && typeof raw.entries === 'object') {
348
+ rows = Object.entries(raw.entries).map(([key, value]) => ({ key, ...(typeof value === 'string' ? { reason: value } : value) }))
349
+ } else {
350
+ throw new Error('withdrawals must be an array or {"withdrawals":[...]} / {"entries":{...}}')
351
+ }
352
+ for (const row of rows) {
353
+ if (!row || typeof row !== 'object') throw new Error('every withdrawal record must be an object')
354
+ let name = row.name
355
+ let version = row.version
356
+ if (row.key) {
357
+ const at = String(row.key).lastIndexOf('@')
358
+ if (at <= 0) throw new Error(`withdrawal key must look like name@version: ${row.key}`)
359
+ name = String(row.key).slice(0, at)
360
+ version = String(row.key).slice(at + 1)
361
+ }
362
+ if (typeof name !== 'string' || name === '') throw new Error('withdrawal record needs a name')
363
+ const record = {}
364
+ const since = row.date ?? row.withdrawnAt ?? row.since
365
+ if (since !== undefined) record.since = normalizeDate(since, 'withdrawal date')
366
+ if (row.reason !== undefined) record.reason = String(row.reason)
367
+ result.records += 1
368
+ if (version !== undefined && version !== null && version !== '') {
369
+ result.exact.set(`${name}@${version}`, record)
370
+ } else {
371
+ result.names.set(name, record)
372
+ }
373
+ }
374
+ return result
375
+ }
376
+
377
+ function entryFromAudit(audit, options, levelFile) {
378
+ const targetDir = audit.target?.dir
379
+ ? path.resolve(path.dirname(levelFile), audit.target.dir)
380
+ : options.packageDir
381
+ const meta = readPackageMeta(options.packageDir ?? targetDir)
382
+ const name = options.name ?? audit.subject?.name ?? meta.name
383
+ const version = options.version ?? audit.subject?.version ?? meta.version
384
+ if (typeof name !== 'string' || name === '') throw new Error('cannot determine subject.name; pass --name or --package')
385
+ if (typeof version !== 'string' || version === '') throw new Error('cannot determine subject.version; pass --version or --package')
386
+
387
+ const report = options.report ? readJson(path.resolve(options.report), '--report') : null
388
+ const checks = extractChecks(report, name, version)
389
+
390
+ const auditLevels = audit.levels && typeof audit.levels === 'object' ? audit.levels : {}
391
+ const levels = {
392
+ entry: pickLevel(auditLevels.entry, ['state', 'claim'], 'not-run'),
393
+ flow: pickLevel(auditLevels.flow, ['state'], 'not-run'),
394
+ boost: pickLevel(auditLevels.boost, ['state'], 'not-measured'),
395
+ }
396
+
397
+ let checkedAt
398
+ try {
399
+ checkedAt = normalizeDate(audit.generatedAt, 'audit.generatedAt')
400
+ } catch {
401
+ checkedAt = options.today ?? todayUtc()
402
+ }
403
+ const expiresAt = addDays(checkedAt, options.ttlDays)
404
+
405
+ let tarballSha256 = options.tarballSha256 ?? audit.subject?.tarballSha256 ?? null
406
+ if (tarballSha256 !== null) tarballSha256 = String(tarballSha256).toLowerCase()
407
+ if (options.tarball) {
408
+ const computed = sha256File(options.tarball)
409
+ if (tarballSha256 !== null && tarballSha256 !== computed) {
410
+ throw new Error(`--tarball-sha256 does not match ${options.tarball}`)
411
+ }
412
+ tarballSha256 = computed
413
+ }
414
+ if (tarballSha256 !== null && !HEX64.test(tarballSha256)) throw new Error('tarball sha256 must be 64 hex chars')
415
+
416
+ let evidenceSha256 = options.evidenceSha256 ?? null
417
+ if (options.evidenceFile) {
418
+ const computed = sha256File(options.evidenceFile)
419
+ if (evidenceSha256 !== null && evidenceSha256 !== computed) {
420
+ throw new Error('--evidence-sha256 does not match --evidence-file')
421
+ }
422
+ evidenceSha256 = computed
423
+ }
424
+ if (evidenceSha256 !== null && !HEX64.test(evidenceSha256)) throw new Error('evidence sha256 must be 64 hex chars')
425
+
426
+ const evidence = {}
427
+ if (options.evidenceUrl) {
428
+ let parsed
429
+ try { parsed = new URL(options.evidenceUrl) } catch { throw new Error(`--evidence-url is not a URL: ${options.evidenceUrl}`) }
430
+ if (parsed.protocol !== 'https:') throw new Error('--evidence-url must be https')
431
+ // url/sha256 are the original names; reportUrl/reportSha256 are the same
432
+ // values under the newer, explicit names. Both stay so old readers work.
433
+ evidence.url = options.evidenceUrl
434
+ evidence.reportUrl = options.evidenceUrl
435
+ }
436
+ if (evidenceSha256 !== null) {
437
+ evidence.sha256 = evidenceSha256
438
+ evidence.reportSha256 = evidenceSha256
439
+ }
440
+
441
+ const scanner = firstString(audit.checker?.anchor?.scanner)
442
+ const checkerVersion = firstString(options.checkerVersion, readPackageMeta(ROOT).version) ?? 'unknown'
443
+ const checker = scanner === null
444
+ ? `dsh-standards@${checkerVersion}`
445
+ : `dsh-standards@${checkerVersion} (${scanner})`
446
+ const command = firstString(options.checkedCommand) ?? (levels.entry.claim === 'runtime'
447
+ ? 'dsh-levels <package-dir> --format json'
448
+ : 'dsh-levels <package-dir> --no-runtime --format json')
449
+
450
+ return {
451
+ key: `${name}@${version}`,
452
+ entry: {
453
+ subject: { name, version, tarballSha256 },
454
+ title: firstLine(meta.description),
455
+ repo: normalizeRepo(meta.repository),
456
+ license: normalizeLicense(meta.license),
457
+ scope: inferScope(meta, audit),
458
+ levels,
459
+ checks,
460
+ evidence,
461
+ checkedBy: { checker, command },
462
+ notChecked: summarizeNotChecked(audit, checks, report !== null),
463
+ validity: { checkedAt, expiresAt, policy: `${options.ttlDays}d` },
464
+ lifecycle: 'active',
465
+ selfAssessed: options.selfAssessed,
466
+ },
467
+ }
468
+ }
469
+
470
+ function withdrawalFor(key, entry, withdrawals, previous) {
471
+ if (withdrawals.exact.has(key)) return withdrawals.exact.get(key)
472
+ const byName = withdrawals.names.get(entry.subject.name)
473
+ if (byName) return byName
474
+ if (previous?.lifecycle === 'withdrawn') {
475
+ return previous.lifecycleDetail ?? {}
476
+ }
477
+ return null
478
+ }
479
+
480
+ function applyLifecycle(entries, previousEntries, options, withdrawals) {
481
+ const counts = { active: 0, stale: 0, 'under-review': 0, withdrawn: 0 }
482
+ for (const [key, entry] of Object.entries(entries)) {
483
+ const previous = previousEntries?.[key]
484
+ const withdrawn = withdrawalFor(key, entry, withdrawals, previous)
485
+ if (withdrawn) {
486
+ entry.lifecycle = 'withdrawn'
487
+ if (Object.keys(withdrawn).length > 0) entry.lifecycleDetail = withdrawn
488
+ else if (previous?.lifecycleDetail) entry.lifecycleDetail = previous.lifecycleDetail
489
+ else delete entry.lifecycleDetail
490
+ } else if (previous?.lifecycle === 'under-review') {
491
+ entry.lifecycle = 'under-review'
492
+ if (previous.lifecycleDetail) entry.lifecycleDetail = previous.lifecycleDetail
493
+ } else if (entry.validity?.expiresAt && options.today > entry.validity.expiresAt) {
494
+ entry.lifecycle = 'stale'
495
+ } else {
496
+ entry.lifecycle = 'active'
497
+ }
498
+ if (entry.lifecycle === 'active') delete entry.lifecycleDetail
499
+ else if (entry.lifecycle === 'stale') delete entry.lifecycleDetail
500
+ counts[entry.lifecycle] = (counts[entry.lifecycle] ?? 0) + 1
501
+ }
502
+ return counts
503
+ }
504
+
505
+ function walkKeys(value, trail, callback) {
506
+ if (!value || typeof value !== 'object') return
507
+ if (Array.isArray(value)) {
508
+ value.forEach((item, index) => walkKeys(item, `${trail}[${index}]`, callback))
509
+ return
510
+ }
511
+ for (const [key, child] of Object.entries(value)) {
512
+ callback(key, `${trail}.${key}`)
513
+ walkKeys(child, `${trail}.${key}`, callback)
514
+ }
515
+ }
516
+
517
+ function validateSidecar(sidecar) {
518
+ if (sidecar.schema !== SCHEMA) throw new Error(`sidecar schema must be ${SCHEMA}`)
519
+ if (typeof sidecar.generatedAt !== 'string' || Number.isNaN(Date.parse(sidecar.generatedAt))) {
520
+ throw new Error('sidecar.generatedAt is not an ISO timestamp')
521
+ }
522
+ if (!sidecar.index || sidecar.index.format !== 1 || typeof sidecar.index.url !== 'string') {
523
+ throw new Error('sidecar.index must be {url, format:1}')
524
+ }
525
+ if (!sidecar.checker || typeof sidecar.checker.name !== 'string' || typeof sidecar.checker.version !== 'string') {
526
+ throw new Error('sidecar.checker must name a checker version')
527
+ }
528
+ if (sidecar.checker.sha !== null && !HEX40.test(String(sidecar.checker.sha))) {
529
+ throw new Error('sidecar.checker.sha must be null or 40 hex chars')
530
+ }
531
+ if (!Array.isArray(sidecar.notClaimed) || sidecar.notClaimed.length !== NOT_CLAIMED.length) {
532
+ throw new Error('sidecar.notClaimed must list the fixed not-claimed set')
533
+ }
534
+ if (!sidecar.entries || typeof sidecar.entries !== 'object' || Array.isArray(sidecar.entries)) {
535
+ throw new Error('sidecar.entries must be an object')
536
+ }
537
+ for (const [key, entry] of Object.entries(sidecar.entries)) {
538
+ if (!entry?.subject || typeof entry.subject.name !== 'string' || typeof entry.subject.version !== 'string') {
539
+ throw new Error(`${key}: subject.name/subject.version are required`)
540
+ }
541
+ if (key !== `${entry.subject.name}@${entry.subject.version}`) throw new Error(`${key}: key does not match subject`)
542
+ if (entry.subject.tarballSha256 !== null && !HEX64.test(String(entry.subject.tarballSha256))) {
543
+ throw new Error(`${key}: subject.tarballSha256 must be null or 64 hex chars`)
544
+ }
545
+ if (!entry.levels || typeof entry.levels !== 'object') throw new Error(`${key}: levels are required`)
546
+ for (const id of ['entry', 'flow', 'boost']) {
547
+ if (!entry.levels[id] || typeof entry.levels[id].state !== 'string' || entry.levels[id].state === '') {
548
+ throw new Error(`${key}: levels.${id}.state is required`)
549
+ }
550
+ }
551
+ if (!entry.validity || !DATE.test(String(entry.validity.checkedAt))) throw new Error(`${key}: validity.checkedAt is required`)
552
+ if (!DATE.test(String(entry.validity.expiresAt))) throw new Error(`${key}: validity.expiresAt is required`)
553
+ if (typeof entry.validity.policy !== 'string') throw new Error(`${key}: validity.policy is required`)
554
+ if (!LIFECYCLE.has(entry.lifecycle)) throw new Error(`${key}: lifecycle must be one of ${[...LIFECYCLE].join(', ')}`)
555
+ if (typeof entry.selfAssessed !== 'boolean') throw new Error(`${key}: selfAssessed must be boolean`)
556
+ if (!entry.evidence || typeof entry.evidence !== 'object' || Array.isArray(entry.evidence)) {
557
+ throw new Error(`${key}: evidence must be an object`)
558
+ }
559
+ if (entry.evidence.url !== undefined) {
560
+ let url
561
+ try { url = new URL(entry.evidence.url) } catch { throw new Error(`${key}: evidence.url is not a URL`) }
562
+ if (url.protocol !== 'https:') throw new Error(`${key}: evidence.url must be https`)
563
+ }
564
+ if (entry.evidence.sha256 !== undefined && !HEX64.test(String(entry.evidence.sha256))) {
565
+ throw new Error(`${key}: evidence.sha256 must be 64 hex chars`)
566
+ }
567
+ if (entry.evidence.reportUrl !== undefined) {
568
+ let url
569
+ try { url = new URL(entry.evidence.reportUrl) } catch { throw new Error(`${key}: evidence.reportUrl is not a URL`) }
570
+ if (url.protocol !== 'https:') throw new Error(`${key}: evidence.reportUrl must be https`)
571
+ }
572
+ if (entry.evidence.reportSha256 !== undefined && !HEX64.test(String(entry.evidence.reportSha256))) {
573
+ throw new Error(`${key}: evidence.reportSha256 must be 64 hex chars`)
574
+ }
575
+ if (entry.title !== undefined && entry.title !== null && (typeof entry.title !== 'string' || entry.title === '')) {
576
+ throw new Error(`${key}: title must be a non-empty string or null`)
577
+ }
578
+ if (entry.repo !== undefined && entry.repo !== null) {
579
+ let url
580
+ try { url = new URL(entry.repo) } catch { throw new Error(`${key}: repo is not a URL`) }
581
+ if (url.protocol !== 'https:') throw new Error(`${key}: repo must be https or null`)
582
+ }
583
+ if (entry.license !== undefined && entry.license !== null && (typeof entry.license !== 'string' || entry.license === '')) {
584
+ throw new Error(`${key}: license must be a non-empty string or null`)
585
+ }
586
+ if (entry.scope !== undefined) {
587
+ if (!entry.scope || typeof entry.scope !== 'object' || Array.isArray(entry.scope)) throw new Error(`${key}: scope must be an object`)
588
+ for (const field of Object.keys(entry.scope)) {
589
+ if (!SCOPE_KEYS.has(field)) throw new Error(`${key}: scope.${field} is not part of the sidecar contract`)
590
+ }
591
+ for (const field of ['dsh', 'cordis', 'platform']) {
592
+ const value = entry.scope[field]
593
+ if (field === 'platform' && value === undefined) continue
594
+ if (value !== null && (typeof value !== 'string' || value === '')) {
595
+ throw new Error(`${key}: scope.${field} must be a non-empty string or null`)
596
+ }
597
+ }
598
+ }
599
+ if (entry.checks !== undefined) {
600
+ if (!Array.isArray(entry.checks)) throw new Error(`${key}: checks must be an array`)
601
+ const seen = new Set()
602
+ for (const check of entry.checks) {
603
+ if (!check || typeof check !== 'object' || Array.isArray(check)) throw new Error(`${key}: every check must be an object`)
604
+ for (const field of Object.keys(check)) {
605
+ if (!CHECK_KEYS.has(field)) throw new Error(`${key}: checks must not carry ${field}; only id/result/coverage`)
606
+ }
607
+ for (const field of ['id', 'result', 'coverage']) {
608
+ if (typeof check[field] !== 'string' || check[field] === '') throw new Error(`${key}: checks[].${field} is required`)
609
+ }
610
+ if (seen.has(check.id)) throw new Error(`${key}: duplicate check id ${check.id}`)
611
+ seen.add(check.id)
612
+ }
613
+ }
614
+ if (entry.checkedBy !== undefined) {
615
+ if (!entry.checkedBy || typeof entry.checkedBy !== 'object' || Array.isArray(entry.checkedBy)) {
616
+ throw new Error(`${key}: checkedBy must be an object`)
617
+ }
618
+ for (const field of Object.keys(entry.checkedBy)) {
619
+ if (!CHECKED_BY_KEYS.has(field)) throw new Error(`${key}: checkedBy.${field} is not part of the sidecar contract`)
620
+ }
621
+ for (const field of ['checker', 'command']) {
622
+ if (typeof entry.checkedBy[field] !== 'string' || entry.checkedBy[field] === '') {
623
+ throw new Error(`${key}: checkedBy.${field} is required`)
624
+ }
625
+ }
626
+ }
627
+ if (entry.notChecked !== undefined) {
628
+ if (!Array.isArray(entry.notChecked)) throw new Error(`${key}: notChecked must be an array`)
629
+ for (const item of entry.notChecked) {
630
+ if (typeof item !== 'string' || item === '') throw new Error(`${key}: notChecked entries must be non-empty strings`)
631
+ }
632
+ }
633
+ }
634
+ walkKeys(sidecar, 'sidecar', (key, where) => {
635
+ if (FORBIDDEN.has(key)) throw new Error(`${where} is forbidden in a sidecar (${[...FORBIDDEN].join(', ')})`)
636
+ })
637
+ }
638
+
639
+ function main() {
640
+ const options = parseArgs(process.argv.slice(2))
641
+ const today = options.today ?? todayUtc()
642
+ const outFile = path.resolve(options.out)
643
+ const prev = options.prev ? loadPrev(path.resolve(options.prev)) : null
644
+ const withdrawalsFile = options.withdrawals
645
+ ? path.resolve(options.withdrawals)
646
+ : prev && options.prev
647
+ ? path.join(path.dirname(path.resolve(options.prev)), 'withdrawals.json')
648
+ : null
649
+ let withdrawals = { exact: new Map(), names: new Map(), records: 0 }
650
+ if (withdrawalsFile && fs.existsSync(withdrawalsFile)) withdrawals = parseWithdrawals(withdrawalsFile)
651
+ else if (options.withdrawals) throw new Error(`withdrawals file not found: ${withdrawalsFile}`)
652
+
653
+ const previousEntries = prev ? prev.entries : null
654
+ let entries = previousEntries ? JSON.parse(JSON.stringify(previousEntries)) : {}
655
+ let generatedAt = prev?.generatedAt ?? null
656
+ if (options.levels) {
657
+ const levelFile = path.resolve(options.levels)
658
+ const audit = readJson(levelFile, 'level audit')
659
+ if (!audit.levels || typeof audit.levels !== 'object') throw new Error('--levels must be a dsh-level-audit/1 JSON')
660
+ const built = entryFromAudit(audit, options, levelFile)
661
+ entries[built.key] = built.entry
662
+ generatedAt = options.todayProvided ? `${today}T00:00:00.000Z` : new Date().toISOString()
663
+ }
664
+ if (!generatedAt) throw new Error('no --prev and no --levels generatedAt')
665
+
666
+ const checkerPkg = readPackageMeta(ROOT)
667
+ const checkerVersion = options.checkerVersion ?? checkerPkg.version
668
+ if (typeof checkerVersion !== 'string' || checkerVersion === '') throw new Error('cannot determine checker version')
669
+ const checkerSha = options.checkerSha ?? gitHead()
670
+ if (checkerSha !== null && !HEX40.test(checkerSha)) throw new Error('--checker-sha must be 40 hex chars')
671
+ try {
672
+ const parsed = new URL(options.indexUrl)
673
+ if (parsed.protocol !== 'https:') throw new Error()
674
+ } catch {
675
+ throw new Error('--index-url must be https')
676
+ }
677
+
678
+ const counts = applyLifecycle(entries, previousEntries, { today }, withdrawals)
679
+ const sortedEntries = Object.fromEntries(Object.keys(entries).sort().map(key => [key, entries[key]]))
680
+ const sidecar = {
681
+ schema: SCHEMA,
682
+ generatedAt,
683
+ index: { url: options.indexUrl, format: 1 },
684
+ checker: { name: 'dsh-standards', version: checkerVersion, sha: checkerSha },
685
+ notClaimed: [...NOT_CLAIMED],
686
+ entries: sortedEntries,
687
+ }
688
+ validateSidecar(sidecar)
689
+
690
+ const text = `${JSON.stringify(sidecar, null, 2)}\n`
691
+ let written = false
692
+ if (!fs.existsSync(outFile) || fs.readFileSync(outFile, 'utf8') !== text) {
693
+ fs.mkdirSync(path.dirname(outFile), { recursive: true })
694
+ fs.writeFileSync(outFile, text)
695
+ written = true
696
+ }
697
+ console.log(JSON.stringify({
698
+ out: outFile,
699
+ schema: SCHEMA,
700
+ generatedAt,
701
+ entries: Object.keys(sortedEntries).length,
702
+ lifecycle: counts,
703
+ withdrawals: { records: withdrawals.records, file: withdrawalsFile && fs.existsSync(withdrawalsFile) ? withdrawalsFile : null },
704
+ written,
705
+ unchanged: !written,
706
+ }, null, 2))
707
+ }
708
+
709
+ try {
710
+ main()
711
+ } catch (error) {
712
+ console.error(`build-sidecar: ${error.message}`)
713
+ process.exitCode = 3
714
+ }