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
@@ -4,33 +4,45 @@ import { fileURLToPath } from 'node:url'
4
4
  import { runManifestChecks, DEFAULT_RUNTIME_VERSION } from './manifest.mjs'
5
5
  import { checkCapabilities, checkCredentials, checkImportTimeAdvisory, checkLabels } from './declarations.mjs'
6
6
  import { runRuntimeChecks } from './runtime.mjs'
7
+ import { runStaticBundleChecks } from './bundle-static.mjs'
7
8
  import { spawn, spawnSync } from 'node:child_process'
8
9
  import { sanitizedEnv } from './sandbox-env.mjs'
9
- import { readSources } from './util.mjs'
10
+ import { readSourcesWithStats } from './util.mjs'
10
11
  import { buildReport, writeReport } from './report.mjs'
11
12
 
12
13
  const VERSION = '0.1.0'
13
14
  const DEFAULT_COMMIT = 'ce7073d51254c26cd63b22a60835b8f948deb447'
14
15
  const DEFAULT_CORDIS = '4.0.6-alpha.1'
16
+ const HOST_RUNTIME_TIMEOUT_MS = 5000
17
+
18
+ function binName() {
19
+ const brand = process.env.DSH_CHECKER_BRAND
20
+ if (typeof brand === 'string' && brand.trim() !== '') return brand.trim().split(/\s+/)[0]
21
+ return 'dsh-plugin-verify'
22
+ }
15
23
 
16
24
  function usage() {
25
+ const bin = binName()
17
26
  return `${process.env.DSH_CHECKER_BRAND || 'dsh-plugin-verify ' + VERSION} — DS-C conformance checker (MVP)
18
27
 
19
28
  Usage:
20
- dsh-plugin-verify <package-dir> [options]
29
+ ${bin} check [dir] [--json] [--html <file>] author-facing report (Markdown by default)
30
+ ${bin} <package-dir> [options] raw conformance report
21
31
 
22
32
  Options:
23
33
  --format text|json|sarif output format (default: text)
24
34
  --scope mvp|ds-c|entry mvp = 8 MVP ids; ds-c = full claim (default); entry = compatibility gate
25
35
  --profile package|listing|both evaluation profile (default: package; listing if --market-index is set)
26
36
  --runtime <path> path to a built @deepseek-ai/cordis lib/index.js; enables L1-L3
27
- --runtime-version <v> dsh runtime version used for C-2 (default: ${DEFAULT_RUNTIME_VERSION})
37
+ --runtime-version <v> dsh runtime version used for C-2 (default: host via \`dsh --version\`, else ${DEFAULT_RUNTIME_VERSION})
28
38
  --cordis-version <v> cordis version recorded in the anchor (default: ${DEFAULT_CORDIS})
29
39
  --commit <sha> harness commit recorded in the anchor (default: ${DEFAULT_COMMIT.slice(0, 12)})
30
40
  --install-spec <spec> recommended install spec for C-3 (listing/install check)
31
41
  --market-index <file> format-1 market index JSON for C-3 listing mode
32
42
  --plugin-name <name> plugin name inside the market index
33
43
  --timeout <ms> L1-L3 timeout (default: 10000)
44
+ --max-files <n> source scan file budget (default: 400)
45
+ --max-bytes <n> source scan byte budget (default: 2000000)
34
46
  --no-exec skip L1-L3 entirely (reported as not-run/NA, never PASS)
35
47
  --static-only M1 mode: listing profile, only C-1/C-2/C-3/C-11/C-12/C-13; C-4..C-9 are out of scope
36
48
  --treat-as-plugin run L1-L3 on a bundle's server entry (lib/index.js, index.js, lib/public-entry.js, src/index.ts)
@@ -61,13 +73,15 @@ function parseArgs(argv) {
61
73
  scope: 'ds-c',
62
74
  profile: null,
63
75
  runtime: null,
64
- runtimeVersion: DEFAULT_RUNTIME_VERSION,
76
+ runtimeVersion: null,
65
77
  cordisVersion: DEFAULT_CORDIS,
66
78
  commit: DEFAULT_COMMIT,
67
79
  installSpec: null,
68
80
  marketIndex: null,
69
81
  pluginName: null,
70
82
  timeoutMs: 10_000,
83
+ maxFiles: 400,
84
+ maxBytes: 2_000_000,
71
85
  noExec: false,
72
86
  staticOnly: false,
73
87
  treatAsPlugin: false,
@@ -111,6 +125,8 @@ function parseArgs(argv) {
111
125
  case '--market-index': options.marketIndex = value(); break
112
126
  case '--plugin-name': options.pluginName = value(); break
113
127
  case '--timeout': options.timeoutMs = Number(value()); break
128
+ case '--max-files': options.maxFiles = Number(value()); break
129
+ case '--max-bytes': options.maxBytes = Number(value()); break
114
130
  case '--out': options.out = value(); break
115
131
  case '--no-exec': options.noExec = true; break
116
132
  case '--static-only': options.staticOnly = true; break
@@ -135,6 +151,8 @@ function parseArgs(argv) {
135
151
  if (!['package', 'listing', 'both'].includes(options.profile)) throw new Error(`unsupported --profile: ${options.profile}`)
136
152
  if (options.staticOnly && options.treatAsPlugin) throw new Error('--static-only and --treat-as-plugin are mutually exclusive')
137
153
  if (!Number.isFinite(options.timeoutMs) || options.timeoutMs < 100 || options.timeoutMs > 120_000) throw new Error('--timeout must be between 100 and 120000 ms')
154
+ if (!Number.isInteger(options.maxFiles) || options.maxFiles < 1 || options.maxFiles > 200_000) throw new Error('--max-files must be an integer between 1 and 200000')
155
+ if (!Number.isInteger(options.maxBytes) || options.maxBytes < 1000 || options.maxBytes > 2_000_000_000) throw new Error('--max-bytes must be an integer between 1000 and 2000000000')
138
156
  if (options.target === null) options.target = '.'
139
157
  return options
140
158
  }
@@ -223,14 +241,66 @@ async function entryRunCheck(kind, runtimeChecks, options, dir) {
223
241
  return { ...base, result: 'ERROR', coverage: 'not-run', reason: c4?.reason || 'no runtime evidence' }
224
242
  }
225
243
 
226
- function anchorOf(options, runtime) {
244
+ /**
245
+ * Read the runtime version the host actually runs. The anchor used to be a
246
+ * hard-coded `0.2.2-alpha.2`, which manufactured dozens of C-2.2 failures on a
247
+ * host that had already moved on. Detection is best-effort: when `dsh` is not
248
+ * usable the anchor falls back to the checked-in constant and says so.
249
+ */
250
+ export function detectHostRuntime({ bin = process.env.DSH_BIN || (process.platform === 'win32' ? 'dsh.cmd' : 'dsh'), timeoutMs = HOST_RUNTIME_TIMEOUT_MS } = {}) {
251
+ const run = spawnSync(bin, ['--version'], { encoding: 'utf8', timeout: timeoutMs })
252
+ if (run.error || run.status !== 0) return { version: null, bin, error: run.error?.message ?? `exit ${run.status}` }
253
+ const text = `${run.stdout ?? ''}\n${run.stderr ?? ''}`
254
+ const match = /(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?)/.exec(text)
255
+ if (!match) return { version: null, bin, error: 'no semver in `dsh --version` output' }
256
+ return { version: match[1], bin, error: null }
257
+ }
258
+
259
+ /**
260
+ * Resolve what `anchor.dsh` means:
261
+ * cli the caller passed --runtime-version; suspect when it differs from the host
262
+ * host no flag: the host version is the anchor, so it cannot be suspect
263
+ * fallback dsh unavailable: keep the constant, flagged as not host-derived
264
+ */
265
+ export function resolveRuntimeAnchor(requestedVersion = null, hostVersion = null) {
266
+ if (typeof requestedVersion === 'string' && requestedVersion.trim() !== '') {
267
+ const requested = requestedVersion.trim()
268
+ return {
269
+ dsh: requested,
270
+ runtimeSource: 'cli',
271
+ hostVersion: hostVersion ?? null,
272
+ suspect: typeof hostVersion === 'string' && hostVersion !== '' && hostVersion !== requested,
273
+ }
274
+ }
275
+ if (typeof hostVersion === 'string' && hostVersion !== '') {
276
+ return { dsh: hostVersion, runtimeSource: 'host', hostVersion, suspect: false }
277
+ }
278
+ return { dsh: DEFAULT_RUNTIME_VERSION, runtimeSource: 'fallback', hostVersion: null, suspect: false }
279
+ }
280
+
281
+ /** The five optional declaration surfaces, counted but never scored. */
282
+ export function declarationsOf(manifest) {
283
+ const dsh = manifest?.dsh ?? {}
284
+ const present = []
285
+ if (Array.isArray(dsh.capabilities) && dsh.capabilities.length > 0) present.push('capabilities')
286
+ if (Array.isArray(dsh.labels) && dsh.labels.length > 0) present.push('labels')
287
+ if (dsh.commitments && typeof dsh.commitments === 'object' && !Array.isArray(dsh.commitments)) present.push('commitments')
288
+ if (dsh.disclosure && typeof dsh.disclosure === 'object' && !Array.isArray(dsh.disclosure)) present.push('disclosure')
289
+ if (typeof manifest?.engines?.dsh === 'string' && manifest.engines.dsh.trim() !== '') present.push('engines.dsh')
290
+ return { total: 5, present }
291
+ }
292
+
293
+ function anchorOf(options, runtimeInfo, runtimePath) {
227
294
  return {
228
295
  repo: 'deepseek-ai/deepseek-harness',
229
296
  commit: options.commit,
230
297
  scanner: `dsh-plugin-verify@${VERSION}`,
231
- dsh: options.runtimeVersion,
298
+ dsh: runtimeInfo.dsh,
232
299
  cordis: options.cordisVersion,
233
- runtime: runtime ? path.resolve(runtime) : null,
300
+ runtime: runtimePath ? path.resolve(runtimePath) : null,
301
+ runtimeSource: runtimeInfo.runtimeSource,
302
+ hostVersion: runtimeInfo.hostVersion,
303
+ suspect: runtimeInfo.suspect,
234
304
  }
235
305
  }
236
306
 
@@ -261,15 +331,23 @@ export async function main(argv = process.argv.slice(2)) {
261
331
  return 3
262
332
  }
263
333
 
334
+ const host = detectHostRuntime()
335
+ const runtimeInfo = resolveRuntimeAnchor(options.runtimeVersion, host.version)
336
+
264
337
  const { manifest, kind, kindInferred, entry, patch, g4, checks: manifestChecks } = runManifestChecks(dir, {
265
- runtimeVersion: options.runtimeVersion,
338
+ runtimeVersion: runtimeInfo.dsh,
266
339
  installSpec: options.installSpec,
267
340
  marketIndex: options.marketIndex,
268
341
  pluginName: options.pluginName,
269
342
  })
270
343
 
271
344
  let sources = []
272
- try { sources = readSources(dir) } catch { sources = [] }
345
+ let scan = null
346
+ try {
347
+ const read = readSourcesWithStats(dir, { maxFiles: options.maxFiles, maxBytes: options.maxBytes })
348
+ sources = read.sources
349
+ scan = read.scan
350
+ } catch { sources = [] }
273
351
  const declarationChecks = manifest ? [
274
352
  checkCapabilities(manifest, sources, patch),
275
353
  checkCredentials(manifest, sources),
@@ -280,7 +358,7 @@ export async function main(argv = process.argv.slice(2)) {
280
358
  { id: 'DSC-13', clause: 'C-13', layer: 'L0', result: 'ERROR', coverage: 'not-run', reason: 'manifest unavailable', details: [], evidence: [], method: 'M', advisory: false },
281
359
  ]
282
360
  const declarationAdjusted = declarationChecks.map(item => (item.result === 'WARN' && ['DSC-11', 'DSC-13'].includes(item.id) ? { ...item, advisory: true } : item))
283
- const advisoryChecks = [checkImportTimeAdvisory(sources, entry?.relative)]
361
+ const advisoryChecks = [checkImportTimeAdvisory(sources, entry?.relative, scan)]
284
362
 
285
363
  if (options.scope === 'entry' && options.entryRuntime === 'server-entry' && kind !== 'plugin') options.treatAsPlugin = true
286
364
  let runtimeKind = kind
@@ -311,6 +389,16 @@ export async function main(argv = process.argv.slice(2)) {
311
389
  })
312
390
  }
313
391
 
392
+ // A bundle/client has no plugin entry for L1-L3. Instead of leaving all six
393
+ // runtime checks NA, cover their statically decidable part explicitly and
394
+ // mark the coverage partial (the report says what still needs --host/--runtime).
395
+ const runtimeUsable = runtimeChecks.some(item => ['PASS', 'WARN', 'FAIL'].includes(item.result))
396
+ if (manifest && ['bundle', 'client'].includes(kind) && !runtimeUsable) {
397
+ const staticChecks = runStaticBundleChecks({ manifest, kind, entry, patch, sources, g4, scan, dir })
398
+ const staticById = new Map(staticChecks.map(item => [item.id, item]))
399
+ runtimeChecks = runtimeChecks.map(item => staticById.get(item.id) ?? item)
400
+ }
401
+
314
402
  const runCheck = await entryRunCheck(kind, runtimeChecks, options, dir)
315
403
  const checks = [
316
404
  manifestChecks[0], manifestChecks[1], manifestChecks[2],
@@ -341,7 +429,7 @@ export async function main(argv = process.argv.slice(2)) {
341
429
  }
342
430
  const report = buildReport({
343
431
  subject,
344
- anchor: anchorOf(options, options.runtime),
432
+ anchor: anchorOf(options, runtimeInfo, options.runtime),
345
433
  checks,
346
434
  g4,
347
435
  scope: options.scope,
@@ -351,6 +439,7 @@ export async function main(argv = process.argv.slice(2)) {
351
439
  entryRuntime: options.entryRuntime,
352
440
  entryClaim: options.entryClaim,
353
441
  advice: options.advice,
442
+ declarations: declarationsOf(manifest),
354
443
  })
355
444
  const output = writeReport(report, options.format)
356
445
  if (options.out) {
@@ -1,11 +1,28 @@
1
1
  /**
2
2
  * Static G4 check: when `dsh.client` exists, the package must expose
3
- * `exports["./client"]` and declare the client at the package root.
3
+ * `exports["./client"]` and that target must exist in the package.
4
4
  *
5
5
  * This is a static shape check. It does not prove the client module loads or
6
6
  * renders; that is a runtime/UI probe and is reported separately.
7
7
  */
8
- export function checkClientShape(manifest) {
8
+ import path from 'node:path'
9
+ import { isFile } from './util.mjs'
10
+
11
+ function clientTarget(exported) {
12
+ if (typeof exported === 'string') return exported
13
+ if (exported && typeof exported === 'object' && !Array.isArray(exported)) {
14
+ for (const key of ['default', 'import', 'require', 'node', 'browser']) {
15
+ if (typeof exported[key] === 'string') return exported[key]
16
+ }
17
+ for (const value of Object.values(exported)) {
18
+ const nested = clientTarget(value)
19
+ if (nested) return nested
20
+ }
21
+ }
22
+ return null
23
+ }
24
+
25
+ export function checkClientShape(manifest, dir = null) {
9
26
  const hasClient = manifest?.dsh?.client !== undefined
10
27
  if (!hasClient) {
11
28
  return {
@@ -28,10 +45,23 @@ export function checkClientShape(manifest) {
28
45
  reason: 'dsh.client exists but exports["./client"] is missing; a package-root row cannot resolve it',
29
46
  }
30
47
  }
48
+ const target = clientTarget(exported)
49
+ if (target && typeof dir === 'string' && dir !== '' && !target.includes('*')) {
50
+ const absolute = path.resolve(dir, target)
51
+ const relative = path.relative(dir, absolute)
52
+ if (relative.startsWith('..') || path.isAbsolute(relative) || !isFile(absolute)) {
53
+ return {
54
+ hasClient: true,
55
+ ok: false,
56
+ result: 'FAIL',
57
+ reason: `exports["./client"] points to a missing file: ${target}`,
58
+ }
59
+ }
60
+ }
31
61
  return {
32
62
  hasClient: true,
33
63
  ok: true,
34
64
  result: 'PASS',
35
- reason: 'dsh.client + exports["./client"] present (static only)',
65
+ reason: 'dsh.client + exports["./client"] present and resolvable (static only)',
36
66
  }
37
67
  }
@@ -424,13 +424,28 @@ function collectSideEffectHits(ast, file, hits) {
424
424
  })
425
425
  }
426
426
 
427
- function analyseImportClosure(sources, entryRelative) {
427
+ function analyseImportClosure(sources, entryRelative, scan = null) {
428
428
  const byRelative = new Map()
429
429
  for (const source of sources) byRelative.set(normalizeRelative(source.relative), source)
430
430
  const queue = []
431
431
  if (entryRelative) {
432
432
  const entry = normalizeRelative(entryRelative)
433
- if (!byRelative.has(entry)) return { error: `entry module ${entryRelative} was not found among scanned sources` }
433
+ if (!byRelative.has(entry)) {
434
+ // A bounded scan can stop before the entry module. That is missing
435
+ // coverage, not proof that the package has no entry.
436
+ if (scan?.truncated) {
437
+ return {
438
+ entryOutsideScan: true,
439
+ truncated: scan,
440
+ visited: new Set(),
441
+ hits: [],
442
+ parseErrors: [],
443
+ coverage: [],
444
+ localEdges: [],
445
+ }
446
+ }
447
+ return { error: `entry module ${entryRelative} was not found among scanned sources` }
448
+ }
434
449
  queue.push(entry)
435
450
  } else {
436
451
  // Programmatic callers without an entry: keep the legacy "all sources" shape.
@@ -483,23 +498,30 @@ function hitEvidence(hits, limit = 3) {
483
498
  return hits.slice(0, limit).map(hit => `${hit.file}:${hit.line}`)
484
499
  }
485
500
 
501
+ /** The exact bounded-scan statement the report must carry when it truncates. */
502
+ export function scanTruncationReason(scan) {
503
+ if (!scan?.truncated) return ''
504
+ return `truncated at ${scan.maxFiles} files / ${scan.maxBytes} bytes; entry may be beyond the scan (scanned ${scan.files} files / ${scan.bytes} bytes)`
505
+ }
506
+
486
507
  /** DSC-10: advisory only; never FAIL. */
487
- export function checkImportTimeAdvisory(sources, entryRelative = null) {
508
+ export function checkImportTimeAdvisory(sources, entryRelative = null, scan = null) {
488
509
  if (sources.length === 0) {
510
+ const truncation = scanTruncationReason(scan)
489
511
  return check({
490
512
  id: 'DSC-10',
491
513
  clause: 'C-10',
492
514
  layer: 'L0',
493
515
  result: 'NA',
494
516
  advisory: true,
495
- coverage: 'not-run',
496
- reason: 'no source files scanned; C-10 not-run',
497
- details: [],
517
+ coverage: truncation ? 'partial' : 'not-run',
518
+ reason: truncation ? `partial: ${truncation}; C-10 not-run` : 'no source files scanned; C-10 not-run',
519
+ details: truncation ? [detail('C-10.3', 'NA', `not-run: ${truncation}`, [])] : [],
498
520
  evidence: [],
499
521
  })
500
522
  }
501
523
 
502
- const analysis = analyseImportClosure(sources, entryRelative)
524
+ const analysis = analyseImportClosure(sources, entryRelative, scan)
503
525
  if (analysis.error) {
504
526
  const evidence = normalizeRelative(entryRelative)
505
527
  return check({
@@ -515,12 +537,32 @@ export function checkImportTimeAdvisory(sources, entryRelative = null) {
515
537
  })
516
538
  }
517
539
 
540
+ if (analysis.entryOutsideScan) {
541
+ const truncation = scanTruncationReason(analysis.truncated)
542
+ const entry = normalizeRelative(entryRelative)
543
+ return check({
544
+ id: 'DSC-10',
545
+ clause: 'C-10',
546
+ layer: 'L0',
547
+ result: 'NA',
548
+ advisory: true,
549
+ coverage: 'partial',
550
+ reason: `partial: ${truncation}; C-10 not-run`,
551
+ details: [detail('C-10.3', 'NA', `not-run: ${truncation}; entry module ${entryRelative} is outside the scanned set`, [entry])],
552
+ evidence: [entry],
553
+ })
554
+ }
555
+
556
+ const truncation = scanTruncationReason(scan)
518
557
  const { visited, hits, parseErrors, coverage, localEdges } = analysis
519
558
  const details = hits.slice(0, 20).map(hit => detail(hit.clause, 'WARN', `${hit.pattern} at ${hit.file}:${hit.line}`, [`${hit.file}:${hit.line}`]))
520
559
  details.push(...coverage.slice(0, 10))
521
560
  if (localEdges.length > 0) {
522
561
  details.push(detail('C-10.3', 'PASS', `local relative import closure analysed (${visited.size} module(s))`, [...visited].slice(0, 5)))
523
562
  }
563
+ if (truncation) {
564
+ details.push(detail('C-10.3', 'NA', `not-run: ${truncation}; the local import closure is incomplete`, []))
565
+ }
524
566
  for (const error of parseErrors) {
525
567
  details.push(detail('C-10', 'ERROR', `not-run: AST parse failed in ${error.file}:${error.line} (${error.message})`, [`${error.file}:${error.line}`]))
526
568
  }
@@ -538,6 +580,9 @@ export function checkImportTimeAdvisory(sources, entryRelative = null) {
538
580
  result = 'PASS'
539
581
  reason = ''
540
582
  }
583
+ if (truncation) {
584
+ reason = reason ? `${reason}; partial: ${truncation}` : `partial: ${truncation}`
585
+ }
541
586
 
542
587
  const evidence = hits.length > 0
543
588
  ? hitEvidence(hits)
@@ -551,7 +596,7 @@ export function checkImportTimeAdvisory(sources, entryRelative = null) {
551
596
  layer: 'L0',
552
597
  result,
553
598
  advisory: true,
554
- coverage: result === 'ERROR' ? 'not-run' : 'full',
599
+ coverage: result === 'ERROR' ? 'not-run' : truncation ? 'partial' : 'full',
555
600
  reason,
556
601
  details,
557
602
  evidence,
@@ -25,7 +25,9 @@
25
25
  */
26
26
  import crypto from 'node:crypto'
27
27
  import fs from 'node:fs'
28
+ import os from 'node:os'
28
29
  import path from 'node:path'
30
+ import { spawnSync } from 'node:child_process'
29
31
 
30
32
  export const FLOW_IDS = ['J1', 'J2', 'J3', 'J4', 'J5', 'J6', 'J7', 'J8']
31
33
  export const FLOW_EVIDENCE_SCHEMA = 'dsh-flow-evidence/2'
@@ -144,6 +146,103 @@ export function treeSha256(dir, { exclude = [] } = {}) {
144
146
  return { hash: sha256Value(rows.join('\n')), files: rows.length, bytes }
145
147
  }
146
148
 
149
+ const NPM = process.platform === 'win32' ? 'npm.cmd' : 'npm'
150
+
151
+ function subjectFields(subject) {
152
+ if (!isPlainObject(subject)) return null
153
+ return {
154
+ name: subject.name ?? null,
155
+ version: subject.version ?? null,
156
+ integrity: subject.integrity ?? null,
157
+ tarballSha256: subject.tarballSha256 ?? null,
158
+ treeSha256: subject.treeSha256 ?? null,
159
+ commit: subject.commit ?? null,
160
+ }
161
+ }
162
+
163
+ /**
164
+ * Recompute the subject binding from a package directory: package.json
165
+ * name/version, the deterministic tree hash, and the npm-pack tarball hash +
166
+ * integrity. npm packs the same tree byte-identically, so this is the same
167
+ * binding `dsh-flow-collect` recorded.
168
+ */
169
+ export function recomputeSubject(dir, { exclude = [], timeoutMs = 120_000 } = {}) {
170
+ const root = path.resolve(dir)
171
+ const manifest = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8'))
172
+ const tree = treeSha256(root, { exclude })
173
+ const destination = fs.mkdtempSync(path.join(os.tmpdir(), 'dsh-subject-'))
174
+ try {
175
+ const run = spawnSync(NPM, ['pack', '--ignore-scripts', '--json', '--pack-destination', destination], {
176
+ cwd: root, encoding: 'utf8', timeout: timeoutMs, maxBuffer: 16 * 1024 * 1024,
177
+ })
178
+ if (run.error) throw new Error(`npm pack failed to start: ${run.error.message}`)
179
+ if (run.status !== 0) throw new Error(`npm pack failed (exit ${run.status}): ${String(run.stderr ?? '').replace(/\s+/g, ' ').slice(0, 300)}`)
180
+ let parsed
181
+ try { parsed = JSON.parse(run.stdout) } catch { throw new Error('npm pack did not print JSON') }
182
+ const info = Array.isArray(parsed) ? parsed[0] : parsed
183
+ if (!info?.filename) throw new Error('npm pack did not report a tarball filename')
184
+ return {
185
+ name: typeof manifest.name === 'string' ? manifest.name : null,
186
+ version: typeof manifest.version === 'string' ? manifest.version : null,
187
+ treeSha256: tree.hash,
188
+ tarballSha256: sha256File(path.join(destination, info.filename)),
189
+ integrity: typeof info.integrity === 'string' ? info.integrity : null,
190
+ }
191
+ } finally {
192
+ fs.rmSync(destination, { recursive: true, force: true })
193
+ }
194
+ }
195
+
196
+ /**
197
+ * Bind a flow-evidence document to a package directory. Any mismatch in
198
+ * name/version/treeSha256/tarballSha256 (or a declared integrity) is a hard
199
+ * failure: the audit must not accept evidence for a different subject.
200
+ */
201
+ export function bindEvidenceSubject(document, dir, options = {}) {
202
+ const declared = isPlainObject(document?.subject) ? document.subject : null
203
+ const base = { dir: path.resolve(dir), declared: subjectFields(declared), recomputed: null, mismatches: [], errors: [] }
204
+ if (!declared) return { ...base, ok: false, errors: ['subject is missing'] }
205
+ let recomputed
206
+ try {
207
+ recomputed = recomputeSubject(dir, {
208
+ exclude: Array.isArray(declared.treeExclude) ? declared.treeExclude : [],
209
+ timeoutMs: options.timeoutMs,
210
+ })
211
+ } catch (error) {
212
+ return { ...base, ok: false, errors: [error.message] }
213
+ }
214
+ const mismatches = []
215
+ for (const key of ['name', 'version', 'treeSha256', 'tarballSha256']) {
216
+ if (declared[key] !== recomputed[key]) {
217
+ mismatches.push(`${key}: declared ${declared[key] ?? 'null'} != recomputed ${recomputed[key] ?? 'null'}`)
218
+ }
219
+ }
220
+ if (typeof declared.integrity === 'string' && recomputed.integrity !== null && declared.integrity !== recomputed.integrity) {
221
+ mismatches.push('integrity: declared value != recomputed value')
222
+ }
223
+ return { ...base, ok: mismatches.length === 0, recomputed, mismatches }
224
+ }
225
+
226
+ /**
227
+ * Plain CI attribution for display. These fields are read from the runner
228
+ * environment and are self-declared: CI attribution is not proof.
229
+ */
230
+ export function ciAttribution(env = process.env) {
231
+ const read = key => (typeof env?.[key] === 'string' ? env[key].trim() : '')
232
+ const runId = read('GITHUB_RUN_ID')
233
+ const repository = read('GITHUB_REPOSITORY')
234
+ const commit = read('GITHUB_SHA')
235
+ if (!runId && !repository && !commit) return null
236
+ const server = (read('GITHUB_SERVER_URL') || 'https://github.com').replace(/\/+$/, '')
237
+ return {
238
+ provider: 'github-actions',
239
+ runId: runId || null,
240
+ repository: repository || null,
241
+ commit: commit || null,
242
+ runUrl: runId && repository ? `${server}/${repository}/actions/runs/${runId}` : null,
243
+ }
244
+ }
245
+
147
246
  export function isIsoTimestamp(value) {
148
247
  return typeof value === 'string' && ISO_RE.test(value) && Number.isFinite(Date.parse(value))
149
248
  }
@@ -279,9 +378,15 @@ function validateV2(document) {
279
378
  if ('kind' in document.producer && document.producer.kind !== null && !['local', 'ci', 'sandbox', 'sandbox-degraded'].includes(document.producer.kind)) {
280
379
  errors.push('producer.kind must be local, ci, sandbox, or sandbox-degraded')
281
380
  }
282
- for (const key of ['runner', 'actor', 'runUrl']) {
381
+ for (const key of ['runner', 'actor', 'runUrl', 'provider', 'runId', 'repository', 'commit']) {
283
382
  if (key in document.producer && document.producer[key] !== null) requireString(document.producer[key], `producer.${key}`)
284
383
  }
384
+ if (typeof document.producer.commit === 'string' && !COMMIT_RE.test(document.producer.commit)) {
385
+ errors.push('producer.commit must be a 40-hex commit')
386
+ }
387
+ if (typeof document.producer.runUrl === 'string' && !/^https:\/\//.test(document.producer.runUrl)) {
388
+ errors.push('producer.runUrl must be an https URL')
389
+ }
285
390
  }
286
391
 
287
392
  if (requireObject(document.subject, 'subject')) {
@@ -1,11 +1,13 @@
1
1
  /** Programmatic API for dsh-plugin-verify. */
2
- export { main, parseArgs, usage, VERSION } from './cli.mjs'
2
+ export { main, parseArgs, usage, VERSION, detectHostRuntime, resolveRuntimeAnchor, declarationsOf } from './cli.mjs'
3
3
  export { runManifestChecks, checkMarketEntry, DEFAULT_RUNTIME_VERSION, inferKind, resolveEntry } from './manifest.mjs'
4
- export { checkCapabilities, checkCredentials, checkLabels, checkImportTimeAdvisory, CAPABILITIES, LABELS } from './declarations.mjs'
4
+ export { checkCapabilities, checkCredentials, checkLabels, checkImportTimeAdvisory, scanTruncationReason, CAPABILITIES, LABELS } from './declarations.mjs'
5
+ export { readSources, readSourcesWithStats, listSourceFiles, scanSourceFiles } from './util.mjs'
5
6
  export { runRuntimeChecks } from './runtime.mjs'
6
- export { buildReport, formatText, formatSarif, writeReport } from './report.mjs'
7
+ export { runStaticBundleChecks } from './bundle-static.mjs'
8
+ export { buildReport, formatText, formatSarif, writeReport, summarizeBlocking, DECLARATION_CHECKS } from './report.mjs'
7
9
  export { joinAdvice, DOCS_BASE } from './remediation.mjs'
8
10
  export { satisfies, validRange, parseVersion, compareVersions } from './semver.mjs'
9
11
  export { parseInstallSpec } from './install-spec.mjs'
10
- export { FLOW_IDS, FLOW_EVIDENCE_SCHEMA, evidenceHash, finalizeEvidence, journeyAggregate, isSandboxAttested, treeSha256, validateFlowEvidence } from './flow-evidence.mjs'
12
+ export { FLOW_IDS, FLOW_EVIDENCE_SCHEMA, evidenceHash, finalizeEvidence, journeyAggregate, isSandboxAttested, treeSha256, validateFlowEvidence, recomputeSubject, bindEvidenceSubject, ciAttribution } from './flow-evidence.mjs'
11
13
  export { sanitizedEnv, scrubProcessEnv, isSecretEnvName, isAllowedEnvName, envPolicy } from './sandbox-env.mjs'