dsh-standards 1.2.1 → 1.3.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 (36) hide show
  1. package/40-checker/README.md +25 -3
  2. package/40-checker/bin/dsh-levels.mjs +7 -1
  3. package/40-checker/scripts/build-offline-kit.mjs +284 -0
  4. package/40-checker/scripts/build-sidecar.mjs +484 -0
  5. package/40-checker/scripts/flow-collect.mjs +147 -22
  6. package/40-checker/scripts/level-audit.mjs +57 -9
  7. package/40-checker/scripts/run-tests.mjs +17 -0
  8. package/40-checker/src/author-cli.mjs +139 -0
  9. package/40-checker/src/author-report.mjs +260 -0
  10. package/40-checker/src/cli.mjs +73 -8
  11. package/40-checker/src/flow-evidence.mjs +189 -3
  12. package/40-checker/src/index.mjs +3 -3
  13. package/40-checker/src/report.mjs +125 -24
  14. package/40-checker/src/sandbox-run.mjs +403 -0
  15. package/CHANGELOG.md +26 -0
  16. package/README.en.md +352 -0
  17. package/README.es.md +352 -0
  18. package/README.hi.md +352 -0
  19. package/README.md +300 -82
  20. package/README.pt.md +352 -0
  21. package/bin/dsh-standards.mjs +13 -2
  22. package/docs/assets/dsh-standards-flow-dark.svg +38 -0
  23. package/docs/assets/dsh-standards-flow-light.svg +38 -0
  24. package/docs/assets/dsh-standards-flow-v3-dark.svg +87 -0
  25. package/docs/assets/dsh-standards-flow-v3-light.svg +87 -0
  26. package/docs/assets/dsh-standards-flow-v4-dark.svg +79 -0
  27. package/docs/assets/dsh-standards-flow-v4-light.svg +79 -0
  28. package/docs/assets/dsh-standards-hero-en-dark.webp +0 -0
  29. package/docs/assets/dsh-standards-hero-en-light.webp +0 -0
  30. package/docs/assets/dsh-standards-hero-zh-dark.webp +0 -0
  31. package/docs/assets/dsh-standards-hero-zh-light.webp +0 -0
  32. package/package.json +2 -2
  33. package/spec/README.md +2 -0
  34. package/spec/flow/README.md +19 -6
  35. package/spec/flow-evidence.schema.json +76 -6
  36. package/spec/schemas/advice.schema.json +92 -0
@@ -0,0 +1,484 @@
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
+ * Deliberately absent: advice (author view), issuer / recordHash / verifyUrl
14
+ * (we are not an issuer). `--today` pins the clock so CI and tests are
15
+ * reproducible.
16
+ *
17
+ * Usage:
18
+ * node 40-checker/scripts/build-sidecar.mjs --levels audit.json \
19
+ * --name dsh-security-pack --version 3.0.1 --tarball dsh-security-pack-3.0.1.tgz \
20
+ * --out publish/sidecar.json --today 2026-11-06
21
+ *
22
+ * node 40-checker/scripts/build-sidecar.mjs --prev publish/sidecar.json \
23
+ * --withdrawals publish/withdrawals.json --out publish/sidecar.json \
24
+ * --today 2026-11-06
25
+ */
26
+ import fs from 'node:fs'
27
+ import path from 'node:path'
28
+ import crypto from 'node:crypto'
29
+ import { execFileSync } from 'node:child_process'
30
+ import { fileURLToPath } from 'node:url'
31
+
32
+ const HERE = path.dirname(fileURLToPath(import.meta.url))
33
+ const ROOT = path.resolve(HERE, '..', '..')
34
+ const SCHEMA = 'dsh-market-sidecar/1'
35
+ const DATE = /^\d{4}-\d{2}-\d{2}$/
36
+ const HEX40 = /^[0-9a-f]{40}$/
37
+ const HEX64 = /^[0-9a-f]{64}$/
38
+ const LIFECYCLE = new Set(['active', 'stale', 'under-review', 'withdrawn'])
39
+ const FORBIDDEN = new Set(['advice', 'issuer', 'recordHash', 'verifyUrl'])
40
+ const NOT_CLAIMED = ['official', 'certification', 'security-audit', 'endorsement', 'ordering', 'comparison', 'warranty']
41
+
42
+ function usage() {
43
+ console.log(`build-sidecar — publish/sidecar.json (${SCHEMA})
44
+
45
+ Usage:
46
+ node 40-checker/scripts/build-sidecar.mjs --levels <audit.json> [options]
47
+ node 40-checker/scripts/build-sidecar.mjs --prev <sidecar.json> [options]
48
+
49
+ Options:
50
+ --levels <file> one dsh-level-audit/1 JSON (single package)
51
+ --prev <file> existing sidecar; entries survive a lifecycle refresh
52
+ --withdrawals <file> append-only withdrawal list (default: alongside --prev)
53
+ --out <file> output path, default publish/sidecar.json
54
+ --today <YYYY-MM-DD> fixed clock for generatedAt/checkedAt/lifecycle
55
+ --ttl-days <n> validity policy, default 90 (writes "90d")
56
+ --name <name> subject name; default package.json / audit.subject
57
+ --version <version> subject version; default package.json / audit.subject
58
+ --package <dir> package.json directory for name/version
59
+ --tarball <file> package tarball; sha256 is recorded in subject
60
+ --tarball-sha256 <hex> explicit tarball sha256 (checked against --tarball)
61
+ --evidence-url <url> optional https evidence URL
62
+ --evidence-file <file> hash this file into evidence.sha256
63
+ --evidence-sha256 <hex> explicit evidence sha256
64
+ --checker-version <v> default dsh-standards package.json version
65
+ --checker-sha <40hex> default git HEAD when available
66
+ --index-url <url> default https://awesome-dsh-plugin.com/index.json
67
+ --organizer sets selfAssessed=false (default true)
68
+ --help show this help
69
+
70
+ Exit codes: 0 written/unchanged; 3 invalid input.`)
71
+ }
72
+
73
+ function readJson(file, label) {
74
+ try {
75
+ return JSON.parse(fs.readFileSync(file, 'utf8'))
76
+ } catch (error) {
77
+ throw new Error(`${label} is not readable JSON (${file}): ${error.message}`)
78
+ }
79
+ }
80
+
81
+ function sha256File(file) {
82
+ return crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex')
83
+ }
84
+
85
+ function normalizeDate(value, label) {
86
+ const match = String(value ?? '').match(/^(\d{4}-\d{2}-\d{2})(?:[T ].*)?$/)
87
+ if (!match) throw new Error(`${label} must be YYYY-MM-DD, got ${JSON.stringify(value)}`)
88
+ const day = match[1]
89
+ const parsed = new Date(`${day}T00:00:00.000Z`)
90
+ if (Number.isNaN(parsed.getTime()) || parsed.toISOString().slice(0, 10) !== day) {
91
+ throw new Error(`${label} is not a real date: ${day}`)
92
+ }
93
+ return day
94
+ }
95
+
96
+ function addDays(day, count) {
97
+ const date = new Date(`${day}T00:00:00.000Z`)
98
+ date.setUTCDate(date.getUTCDate() + count)
99
+ return date.toISOString().slice(0, 10)
100
+ }
101
+
102
+ function todayUtc() {
103
+ return new Date().toISOString().slice(0, 10)
104
+ }
105
+
106
+ function parseArgs(argv) {
107
+ const options = {
108
+ levels: null, prev: null, withdrawals: null,
109
+ out: 'publish/sidecar.json',
110
+ today: null, todayProvided: false, ttlDays: 90,
111
+ name: null, version: null, packageDir: null,
112
+ tarball: null, tarballSha256: null,
113
+ evidenceUrl: null, evidenceFile: null, evidenceSha256: null,
114
+ checkerVersion: null, checkerSha: null,
115
+ indexUrl: 'https://awesome-dsh-plugin.com/index.json',
116
+ selfAssessed: true,
117
+ }
118
+ for (let i = 0; i < argv.length; i += 1) {
119
+ const arg = argv[i]
120
+ const next = () => {
121
+ const value = argv[i + 1]
122
+ if (value === undefined) throw new Error(`${arg} needs a value`)
123
+ i += 1
124
+ return value
125
+ }
126
+ if (arg === '--levels') options.levels = next()
127
+ else if (arg === '--prev') options.prev = next()
128
+ else if (arg === '--withdrawals') options.withdrawals = next()
129
+ else if (arg === '--out') options.out = next()
130
+ else if (arg === '--today') { options.today = normalizeDate(next(), '--today'); options.todayProvided = true }
131
+ else if (arg === '--ttl-days') options.ttlDays = Number(next())
132
+ else if (arg === '--name') options.name = next()
133
+ else if (arg === '--version') options.version = next()
134
+ else if (arg === '--package') options.packageDir = next()
135
+ else if (arg === '--tarball') options.tarball = next()
136
+ else if (arg === '--tarball-sha256') options.tarballSha256 = next().toLowerCase()
137
+ else if (arg === '--evidence-url') options.evidenceUrl = next()
138
+ else if (arg === '--evidence-file') options.evidenceFile = next()
139
+ else if (arg === '--evidence-sha256') options.evidenceSha256 = next().toLowerCase()
140
+ else if (arg === '--checker-version') options.checkerVersion = next()
141
+ else if (arg === '--checker-sha') options.checkerSha = next().toLowerCase()
142
+ else if (arg === '--index-url') options.indexUrl = next()
143
+ else if (arg === '--organizer') options.selfAssessed = false
144
+ else if (arg === '--help' || arg === '-h') { usage(); process.exit(0) }
145
+ else throw new Error(`unknown option: ${arg}`)
146
+ }
147
+ if (!options.levels && !options.prev) throw new Error('one of --levels or --prev is required')
148
+ if (!Number.isInteger(options.ttlDays) || options.ttlDays < 1 || options.ttlDays > 3650) {
149
+ throw new Error('--ttl-days must be an integer between 1 and 3650')
150
+ }
151
+ return options
152
+ }
153
+
154
+ function readPackageMeta(directory) {
155
+ if (!directory) return {}
156
+ const file = path.join(directory, 'package.json')
157
+ if (!fs.existsSync(file)) return {}
158
+ try {
159
+ return readJson(file, 'package.json')
160
+ } catch {
161
+ return {}
162
+ }
163
+ }
164
+
165
+ function pickLevel(row, keys, fallback) {
166
+ const level = {}
167
+ if (row && typeof row === 'object') {
168
+ for (const key of keys) {
169
+ if (typeof row[key] === 'string' && row[key] !== '') level[key] = row[key]
170
+ }
171
+ }
172
+ if (!level.state) level.state = fallback
173
+ return level
174
+ }
175
+
176
+ function gitHead() {
177
+ try {
178
+ const sha = execFileSync('git', ['rev-parse', 'HEAD'], {
179
+ cwd: ROOT,
180
+ encoding: 'utf8',
181
+ stdio: ['ignore', 'pipe', 'ignore'],
182
+ }).trim()
183
+ return HEX40.test(sha) ? sha : null
184
+ } catch {
185
+ return null
186
+ }
187
+ }
188
+
189
+ function loadPrev(file) {
190
+ const prev = readJson(file, 'previous sidecar')
191
+ if (prev.schema !== SCHEMA) throw new Error(`previous sidecar schema is ${JSON.stringify(prev.schema)}, expected ${SCHEMA}`)
192
+ if (!prev.entries || typeof prev.entries !== 'object' || Array.isArray(prev.entries)) {
193
+ throw new Error('previous sidecar has no entries object')
194
+ }
195
+ if (typeof prev.generatedAt !== 'string') throw new Error('previous sidecar has no generatedAt')
196
+ return prev
197
+ }
198
+
199
+ function parseWithdrawals(file) {
200
+ const result = { exact: new Map(), names: new Map(), records: 0 }
201
+ if (!file) return result
202
+ const raw = readJson(file, 'withdrawals')
203
+ let rows
204
+ if (Array.isArray(raw)) rows = raw
205
+ else if (Array.isArray(raw.withdrawals)) rows = raw.withdrawals
206
+ else if (raw.withdrawals && typeof raw.withdrawals === 'object') {
207
+ rows = Object.entries(raw.withdrawals).map(([key, value]) => ({ key, ...(typeof value === 'string' ? { reason: value } : value) }))
208
+ } else if (raw.entries && typeof raw.entries === 'object') {
209
+ rows = Object.entries(raw.entries).map(([key, value]) => ({ key, ...(typeof value === 'string' ? { reason: value } : value) }))
210
+ } else {
211
+ throw new Error('withdrawals must be an array or {"withdrawals":[...]} / {"entries":{...}}')
212
+ }
213
+ for (const row of rows) {
214
+ if (!row || typeof row !== 'object') throw new Error('every withdrawal record must be an object')
215
+ let name = row.name
216
+ let version = row.version
217
+ if (row.key) {
218
+ const at = String(row.key).lastIndexOf('@')
219
+ if (at <= 0) throw new Error(`withdrawal key must look like name@version: ${row.key}`)
220
+ name = String(row.key).slice(0, at)
221
+ version = String(row.key).slice(at + 1)
222
+ }
223
+ if (typeof name !== 'string' || name === '') throw new Error('withdrawal record needs a name')
224
+ const record = {}
225
+ const since = row.date ?? row.withdrawnAt ?? row.since
226
+ if (since !== undefined) record.since = normalizeDate(since, 'withdrawal date')
227
+ if (row.reason !== undefined) record.reason = String(row.reason)
228
+ result.records += 1
229
+ if (version !== undefined && version !== null && version !== '') {
230
+ result.exact.set(`${name}@${version}`, record)
231
+ } else {
232
+ result.names.set(name, record)
233
+ }
234
+ }
235
+ return result
236
+ }
237
+
238
+ function entryFromAudit(audit, options, levelFile) {
239
+ const targetDir = audit.target?.dir
240
+ ? path.resolve(path.dirname(levelFile), audit.target.dir)
241
+ : options.packageDir
242
+ const meta = readPackageMeta(options.packageDir ?? targetDir)
243
+ const name = options.name ?? audit.subject?.name ?? meta.name
244
+ const version = options.version ?? audit.subject?.version ?? meta.version
245
+ if (typeof name !== 'string' || name === '') throw new Error('cannot determine subject.name; pass --name or --package')
246
+ if (typeof version !== 'string' || version === '') throw new Error('cannot determine subject.version; pass --version or --package')
247
+
248
+ const auditLevels = audit.levels && typeof audit.levels === 'object' ? audit.levels : {}
249
+ const levels = {
250
+ entry: pickLevel(auditLevels.entry, ['state', 'claim'], 'not-run'),
251
+ flow: pickLevel(auditLevels.flow, ['state'], 'not-run'),
252
+ boost: pickLevel(auditLevels.boost, ['state'], 'not-measured'),
253
+ }
254
+
255
+ let checkedAt
256
+ try {
257
+ checkedAt = normalizeDate(audit.generatedAt, 'audit.generatedAt')
258
+ } catch {
259
+ checkedAt = options.today ?? todayUtc()
260
+ }
261
+ const expiresAt = addDays(checkedAt, options.ttlDays)
262
+
263
+ let tarballSha256 = options.tarballSha256 ?? audit.subject?.tarballSha256 ?? null
264
+ if (tarballSha256 !== null) tarballSha256 = String(tarballSha256).toLowerCase()
265
+ if (options.tarball) {
266
+ const computed = sha256File(options.tarball)
267
+ if (tarballSha256 !== null && tarballSha256 !== computed) {
268
+ throw new Error(`--tarball-sha256 does not match ${options.tarball}`)
269
+ }
270
+ tarballSha256 = computed
271
+ }
272
+ if (tarballSha256 !== null && !HEX64.test(tarballSha256)) throw new Error('tarball sha256 must be 64 hex chars')
273
+
274
+ let evidenceSha256 = options.evidenceSha256 ?? null
275
+ if (options.evidenceFile) {
276
+ const computed = sha256File(options.evidenceFile)
277
+ if (evidenceSha256 !== null && evidenceSha256 !== computed) {
278
+ throw new Error('--evidence-sha256 does not match --evidence-file')
279
+ }
280
+ evidenceSha256 = computed
281
+ }
282
+ if (evidenceSha256 !== null && !HEX64.test(evidenceSha256)) throw new Error('evidence sha256 must be 64 hex chars')
283
+
284
+ const evidence = {}
285
+ if (options.evidenceUrl) {
286
+ let parsed
287
+ try { parsed = new URL(options.evidenceUrl) } catch { throw new Error(`--evidence-url is not a URL: ${options.evidenceUrl}`) }
288
+ if (parsed.protocol !== 'https:') throw new Error('--evidence-url must be https')
289
+ evidence.url = options.evidenceUrl
290
+ }
291
+ if (evidenceSha256 !== null) evidence.sha256 = evidenceSha256
292
+
293
+ return {
294
+ key: `${name}@${version}`,
295
+ entry: {
296
+ subject: { name, version, tarballSha256 },
297
+ levels,
298
+ evidence,
299
+ validity: { checkedAt, expiresAt, policy: `${options.ttlDays}d` },
300
+ lifecycle: 'active',
301
+ selfAssessed: options.selfAssessed,
302
+ },
303
+ }
304
+ }
305
+
306
+ function withdrawalFor(key, entry, withdrawals, previous) {
307
+ if (withdrawals.exact.has(key)) return withdrawals.exact.get(key)
308
+ const byName = withdrawals.names.get(entry.subject.name)
309
+ if (byName) return byName
310
+ if (previous?.lifecycle === 'withdrawn') {
311
+ return previous.lifecycleDetail ?? {}
312
+ }
313
+ return null
314
+ }
315
+
316
+ function applyLifecycle(entries, previousEntries, options, withdrawals) {
317
+ const counts = { active: 0, stale: 0, 'under-review': 0, withdrawn: 0 }
318
+ for (const [key, entry] of Object.entries(entries)) {
319
+ const previous = previousEntries?.[key]
320
+ const withdrawn = withdrawalFor(key, entry, withdrawals, previous)
321
+ if (withdrawn) {
322
+ entry.lifecycle = 'withdrawn'
323
+ if (Object.keys(withdrawn).length > 0) entry.lifecycleDetail = withdrawn
324
+ else if (previous?.lifecycleDetail) entry.lifecycleDetail = previous.lifecycleDetail
325
+ else delete entry.lifecycleDetail
326
+ } else if (previous?.lifecycle === 'under-review') {
327
+ entry.lifecycle = 'under-review'
328
+ if (previous.lifecycleDetail) entry.lifecycleDetail = previous.lifecycleDetail
329
+ } else if (entry.validity?.expiresAt && options.today > entry.validity.expiresAt) {
330
+ entry.lifecycle = 'stale'
331
+ } else {
332
+ entry.lifecycle = 'active'
333
+ }
334
+ if (entry.lifecycle === 'active') delete entry.lifecycleDetail
335
+ else if (entry.lifecycle === 'stale') delete entry.lifecycleDetail
336
+ counts[entry.lifecycle] = (counts[entry.lifecycle] ?? 0) + 1
337
+ }
338
+ return counts
339
+ }
340
+
341
+ function walkKeys(value, trail, callback) {
342
+ if (!value || typeof value !== 'object') return
343
+ if (Array.isArray(value)) {
344
+ value.forEach((item, index) => walkKeys(item, `${trail}[${index}]`, callback))
345
+ return
346
+ }
347
+ for (const [key, child] of Object.entries(value)) {
348
+ callback(key, `${trail}.${key}`)
349
+ walkKeys(child, `${trail}.${key}`, callback)
350
+ }
351
+ }
352
+
353
+ function validateSidecar(sidecar) {
354
+ if (sidecar.schema !== SCHEMA) throw new Error(`sidecar schema must be ${SCHEMA}`)
355
+ if (typeof sidecar.generatedAt !== 'string' || Number.isNaN(Date.parse(sidecar.generatedAt))) {
356
+ throw new Error('sidecar.generatedAt is not an ISO timestamp')
357
+ }
358
+ if (!sidecar.index || sidecar.index.format !== 1 || typeof sidecar.index.url !== 'string') {
359
+ throw new Error('sidecar.index must be {url, format:1}')
360
+ }
361
+ if (!sidecar.checker || typeof sidecar.checker.name !== 'string' || typeof sidecar.checker.version !== 'string') {
362
+ throw new Error('sidecar.checker must name a checker version')
363
+ }
364
+ if (sidecar.checker.sha !== null && !HEX40.test(String(sidecar.checker.sha))) {
365
+ throw new Error('sidecar.checker.sha must be null or 40 hex chars')
366
+ }
367
+ if (!Array.isArray(sidecar.notClaimed) || sidecar.notClaimed.length !== NOT_CLAIMED.length) {
368
+ throw new Error('sidecar.notClaimed must list the fixed not-claimed set')
369
+ }
370
+ if (!sidecar.entries || typeof sidecar.entries !== 'object' || Array.isArray(sidecar.entries)) {
371
+ throw new Error('sidecar.entries must be an object')
372
+ }
373
+ for (const [key, entry] of Object.entries(sidecar.entries)) {
374
+ if (!entry?.subject || typeof entry.subject.name !== 'string' || typeof entry.subject.version !== 'string') {
375
+ throw new Error(`${key}: subject.name/subject.version are required`)
376
+ }
377
+ if (key !== `${entry.subject.name}@${entry.subject.version}`) throw new Error(`${key}: key does not match subject`)
378
+ if (entry.subject.tarballSha256 !== null && !HEX64.test(String(entry.subject.tarballSha256))) {
379
+ throw new Error(`${key}: subject.tarballSha256 must be null or 64 hex chars`)
380
+ }
381
+ if (!entry.levels || typeof entry.levels !== 'object') throw new Error(`${key}: levels are required`)
382
+ for (const id of ['entry', 'flow', 'boost']) {
383
+ if (!entry.levels[id] || typeof entry.levels[id].state !== 'string' || entry.levels[id].state === '') {
384
+ throw new Error(`${key}: levels.${id}.state is required`)
385
+ }
386
+ }
387
+ if (!entry.validity || !DATE.test(String(entry.validity.checkedAt))) throw new Error(`${key}: validity.checkedAt is required`)
388
+ if (!DATE.test(String(entry.validity.expiresAt))) throw new Error(`${key}: validity.expiresAt is required`)
389
+ if (typeof entry.validity.policy !== 'string') throw new Error(`${key}: validity.policy is required`)
390
+ if (!LIFECYCLE.has(entry.lifecycle)) throw new Error(`${key}: lifecycle must be one of ${[...LIFECYCLE].join(', ')}`)
391
+ if (typeof entry.selfAssessed !== 'boolean') throw new Error(`${key}: selfAssessed must be boolean`)
392
+ if (!entry.evidence || typeof entry.evidence !== 'object' || Array.isArray(entry.evidence)) {
393
+ throw new Error(`${key}: evidence must be an object`)
394
+ }
395
+ if (entry.evidence.url !== undefined) {
396
+ let url
397
+ try { url = new URL(entry.evidence.url) } catch { throw new Error(`${key}: evidence.url is not a URL`) }
398
+ if (url.protocol !== 'https:') throw new Error(`${key}: evidence.url must be https`)
399
+ }
400
+ if (entry.evidence.sha256 !== undefined && !HEX64.test(String(entry.evidence.sha256))) {
401
+ throw new Error(`${key}: evidence.sha256 must be 64 hex chars`)
402
+ }
403
+ }
404
+ walkKeys(sidecar, 'sidecar', (key, where) => {
405
+ if (FORBIDDEN.has(key)) throw new Error(`${where} is forbidden in a sidecar (${[...FORBIDDEN].join(', ')})`)
406
+ })
407
+ }
408
+
409
+ function main() {
410
+ const options = parseArgs(process.argv.slice(2))
411
+ const today = options.today ?? todayUtc()
412
+ const outFile = path.resolve(options.out)
413
+ const prev = options.prev ? loadPrev(path.resolve(options.prev)) : null
414
+ const withdrawalsFile = options.withdrawals
415
+ ? path.resolve(options.withdrawals)
416
+ : prev && options.prev
417
+ ? path.join(path.dirname(path.resolve(options.prev)), 'withdrawals.json')
418
+ : null
419
+ let withdrawals = { exact: new Map(), names: new Map(), records: 0 }
420
+ if (withdrawalsFile && fs.existsSync(withdrawalsFile)) withdrawals = parseWithdrawals(withdrawalsFile)
421
+ else if (options.withdrawals) throw new Error(`withdrawals file not found: ${withdrawalsFile}`)
422
+
423
+ const previousEntries = prev ? prev.entries : null
424
+ let entries = previousEntries ? JSON.parse(JSON.stringify(previousEntries)) : {}
425
+ let generatedAt = prev?.generatedAt ?? null
426
+ if (options.levels) {
427
+ const levelFile = path.resolve(options.levels)
428
+ const audit = readJson(levelFile, 'level audit')
429
+ if (!audit.levels || typeof audit.levels !== 'object') throw new Error('--levels must be a dsh-level-audit/1 JSON')
430
+ const built = entryFromAudit(audit, options, levelFile)
431
+ entries[built.key] = built.entry
432
+ generatedAt = options.todayProvided ? `${today}T00:00:00.000Z` : new Date().toISOString()
433
+ }
434
+ if (!generatedAt) throw new Error('no --prev and no --levels generatedAt')
435
+
436
+ const checkerPkg = readPackageMeta(ROOT)
437
+ const checkerVersion = options.checkerVersion ?? checkerPkg.version
438
+ if (typeof checkerVersion !== 'string' || checkerVersion === '') throw new Error('cannot determine checker version')
439
+ const checkerSha = options.checkerSha ?? gitHead()
440
+ if (checkerSha !== null && !HEX40.test(checkerSha)) throw new Error('--checker-sha must be 40 hex chars')
441
+ try {
442
+ const parsed = new URL(options.indexUrl)
443
+ if (parsed.protocol !== 'https:') throw new Error()
444
+ } catch {
445
+ throw new Error('--index-url must be https')
446
+ }
447
+
448
+ const counts = applyLifecycle(entries, previousEntries, { today }, withdrawals)
449
+ const sortedEntries = Object.fromEntries(Object.keys(entries).sort().map(key => [key, entries[key]]))
450
+ const sidecar = {
451
+ schema: SCHEMA,
452
+ generatedAt,
453
+ index: { url: options.indexUrl, format: 1 },
454
+ checker: { name: 'dsh-standards', version: checkerVersion, sha: checkerSha },
455
+ notClaimed: [...NOT_CLAIMED],
456
+ entries: sortedEntries,
457
+ }
458
+ validateSidecar(sidecar)
459
+
460
+ const text = `${JSON.stringify(sidecar, null, 2)}\n`
461
+ let written = false
462
+ if (!fs.existsSync(outFile) || fs.readFileSync(outFile, 'utf8') !== text) {
463
+ fs.mkdirSync(path.dirname(outFile), { recursive: true })
464
+ fs.writeFileSync(outFile, text)
465
+ written = true
466
+ }
467
+ console.log(JSON.stringify({
468
+ out: outFile,
469
+ schema: SCHEMA,
470
+ generatedAt,
471
+ entries: Object.keys(sortedEntries).length,
472
+ lifecycle: counts,
473
+ withdrawals: { records: withdrawals.records, file: withdrawalsFile && fs.existsSync(withdrawalsFile) ? withdrawalsFile : null },
474
+ written,
475
+ unchanged: !written,
476
+ }, null, 2))
477
+ }
478
+
479
+ try {
480
+ main()
481
+ } catch (error) {
482
+ console.error(`build-sidecar: ${error.message}`)
483
+ process.exitCode = 3
484
+ }