dsh-standards 1.4.0 → 1.5.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.
@@ -1,4 +1,5 @@
1
1
  import fs from 'node:fs'
2
+ import os from 'node:os'
2
3
  import path from 'node:path'
3
4
  import { fileURLToPath } from 'node:url'
4
5
  import { runManifestChecks, DEFAULT_RUNTIME_VERSION } from './manifest.mjs'
@@ -33,7 +34,8 @@ Options:
33
34
  --format text|json|sarif output format (default: text)
34
35
  --scope mvp|ds-c|entry mvp = 8 MVP ids; ds-c = full claim (default); entry = compatibility gate
35
36
  --profile package|listing|both evaluation profile (default: package; listing if --market-index is set)
36
- --runtime <path> path to a built @deepseek-ai/cordis lib/index.js; enables L1-L3
37
+ --runtime [path|auto] built @deepseek-ai/cordis lib/index.js (or its package dir); bare or
38
+ 'auto' resolves from the installed dsh; enables real L1-L3
37
39
  --runtime-version <v> dsh runtime version used for C-2 (default: host via \`dsh --version\`, else ${DEFAULT_RUNTIME_VERSION})
38
40
  --cordis-version <v> cordis version recorded in the anchor (default: ${DEFAULT_CORDIS})
39
41
  --commit <sha> harness commit recorded in the anchor (default: ${DEFAULT_COMMIT.slice(0, 12)})
@@ -48,7 +50,9 @@ Options:
48
50
  --treat-as-plugin run L1-L3 on a bundle's server entry (lib/index.js, index.js, lib/public-entry.js, src/index.ts)
49
51
  --entry-runtime require|server-entry entry scope: require full G0-G3 (default) or accept server-entry proxy (not bundle)
50
52
  --entry-claim runtime|static entry scope: runtime (default, needs DSC-14) or static recognized (no runtime claim)
51
- --host <file> app-boot lib/index.js; enables bundle G0-G3 smoke
53
+ --host [file|auto] app-boot lib/index.js (or its package dir); bare or 'auto'
54
+ resolves from the installed dsh; enables real bundle G0-G3 smoke.
55
+ Nothing executes unless this or --runtime is given.
52
56
  --base <file> optional base cordis.patch.yml for bundle smoke
53
57
  --web <file> optional web-app cordis.patch.yml for bundle smoke
54
58
  --bare <dir> optional bare-module base for bundle smoke (host farm)
@@ -117,7 +121,14 @@ function parseArgs(argv) {
117
121
  case '--format': options.format = value(); break
118
122
  case '--scope': options.scope = value(); break
119
123
  case '--profile': options.profile = value(); break
120
- case '--runtime': options.runtime = value(); break
124
+ case '--runtime': {
125
+ if (inline !== undefined) { options.runtime = inline; break }
126
+ const next = argv[index + 1]
127
+ if (next === undefined || next.startsWith('--')) { options.runtime = 'auto'; break }
128
+ index += 1
129
+ options.runtime = next
130
+ break
131
+ }
121
132
  case '--runtime-version': options.runtimeVersion = value(); break
122
133
  case '--cordis-version': options.cordisVersion = value(); break
123
134
  case '--commit': options.commit = value(); break
@@ -133,7 +144,14 @@ function parseArgs(argv) {
133
144
  case '--treat-as-plugin': options.treatAsPlugin = true; break
134
145
  case '--entry-runtime': options.entryRuntime = value(); break
135
146
  case '--entry-claim': options.entryClaim = value(); break
136
- case '--host': options.host = value(); break
147
+ case '--host': {
148
+ if (inline !== undefined) { options.host = inline; break }
149
+ const next = argv[index + 1]
150
+ if (next === undefined || next.startsWith('--')) { options.host = 'auto'; break }
151
+ index += 1
152
+ options.host = next
153
+ break
154
+ }
137
155
  case '--base': options.base = value(); break
138
156
  case '--web': options.web = value(); break
139
157
  case '--bare': options.bare = value(); break
@@ -168,77 +186,283 @@ function runtimeNotRun(reason) {
168
186
  }))
169
187
  }
170
188
 
189
+ /**
190
+ * Resolve an installed dsh's own package root. `--runtime auto` / `--host auto`
191
+ * use this so a real run needs no hand-copied path. The launcher may be a shim
192
+ * (`dsh`, `dsh.cmd`, `dsh.ps1`); its directory is the npm global prefix, and
193
+ * the package lives under node_modules/@deepseek-ai/dsh.
194
+ */
195
+ export function resolveDshInstall({ env = process.env, bin = null } = {}) {
196
+ const candidates = []
197
+ if (typeof env.DSH_INSTALL_DIR === 'string' && env.DSH_INSTALL_DIR.trim() !== '') {
198
+ candidates.push(env.DSH_INSTALL_DIR.trim())
199
+ }
200
+ const launcher = bin || env.DSH_BIN || (process.platform === 'win32' ? 'dsh.cmd' : 'dsh')
201
+ const launcherPaths = []
202
+ if (path.isAbsolute(launcher) || launcher.includes('/') || launcher.includes('\\')) {
203
+ launcherPaths.push(launcher)
204
+ } else {
205
+ // `which dsh` is not available in every child env; walk PATH directly so
206
+ // `--runtime auto` also works when dsh is only reachable as a command name.
207
+ const separator = process.platform === 'win32' ? ';' : ':'
208
+ for (const dir of String(env.PATH ?? '').split(separator)) {
209
+ if (!dir) continue
210
+ for (const name of process.platform === 'win32' ? [`${launcher}.cmd`, `${launcher}.ps1`, launcher] : [launcher]) {
211
+ const candidate = path.join(dir, name)
212
+ try { if (fs.existsSync(candidate)) launcherPaths.push(candidate) } catch { /* ignore */ }
213
+ }
214
+ }
215
+ }
216
+ for (const launcherPath of launcherPaths) {
217
+ const absolute = (() => { try { return fs.realpathSync(launcherPath) } catch { return path.resolve(launcherPath) } })()
218
+ candidates.push(path.join(path.dirname(absolute), 'node_modules', '@deepseek-ai', 'dsh'))
219
+ }
220
+ for (const prefix of [env.APPDATA && path.join(env.APPDATA, 'npm'), env.npm_config_prefix, '/usr/local', '/usr']) {
221
+ if (!prefix) continue
222
+ candidates.push(path.join(prefix, 'node_modules', '@deepseek-ai', 'dsh'))
223
+ }
224
+ for (const candidate of candidates) {
225
+ try {
226
+ if (fs.existsSync(path.join(candidate, 'package.json'))) return candidate
227
+ } catch { /* unreadable candidate */ }
228
+ }
229
+ return null
230
+ }
231
+
232
+ /**
233
+ * The file a `--runtime`/`--host` value points at. Directories are expanded to
234
+ * their package entry, and `auto` is resolved from the installed dsh. A value
235
+ * that stays missing is returned as-is on purpose: the run then fails with a
236
+ * recorded reason instead of pretending the check passed.
237
+ */
238
+ export function resolveRuntimeTarget(value, kind = 'cordis', { env = process.env } = {}) {
239
+ if (typeof value !== 'string' || value.trim() === '') return null
240
+ const raw = value.trim()
241
+ if (raw === 'auto' || raw === 'host') {
242
+ const install = resolveDshInstall({ env })
243
+ if (!install) return null
244
+ const name = kind === 'host' ? 'dsh-app-boot' : 'cordis'
245
+ const candidates = [
246
+ path.join(install, 'node_modules', '@deepseek-ai', name, 'lib', 'index.js'),
247
+ // npm may hoist the package next to @deepseek-ai/dsh instead of nesting it.
248
+ path.join(install, '..', '..', '@deepseek-ai', name, 'lib', 'index.js'),
249
+ ]
250
+ for (const candidate of candidates) {
251
+ try { if (fs.existsSync(candidate)) return path.resolve(candidate) } catch { /* keep looking */ }
252
+ }
253
+ return null
254
+ }
255
+ const absolute = path.resolve(raw)
256
+ try {
257
+ if (fs.statSync(absolute).isDirectory()) return path.join(absolute, 'lib', 'index.js')
258
+ } catch { /* not a directory; treat as a file */ }
259
+ return absolute
260
+ }
261
+
262
+ /** A disposable HOME/DSH_HOME/TMPDIR for one explicit runtime run. */
263
+ export function createRuntimeIsolation(base = os.tmpdir()) {
264
+ const root = fs.mkdtempSync(path.join(base, 'dsh-checker-runtime-'))
265
+ const home = path.join(root, 'home')
266
+ const tmp = path.join(root, 'tmp')
267
+ fs.mkdirSync(home, { recursive: true })
268
+ fs.mkdirSync(tmp, { recursive: true })
269
+ let cleaned = false
270
+ return {
271
+ root,
272
+ home,
273
+ tmp,
274
+ cleanup() {
275
+ if (cleaned) return
276
+ cleaned = true
277
+ try { fs.rmSync(root, { recursive: true, force: true }) } catch { /* best effort */ }
278
+ },
279
+ }
280
+ }
281
+
282
+ /** The only environment a child that may import plugin code receives. */
283
+ export function isolationEnv(isolation) {
284
+ if (!isolation) return {}
285
+ return {
286
+ HOME: isolation.home,
287
+ USERPROFILE: isolation.home,
288
+ DSH_HOME: isolation.home,
289
+ TMPDIR: isolation.tmp,
290
+ TEMP: isolation.tmp,
291
+ TMP: isolation.tmp,
292
+ XDG_CONFIG_HOME: path.join(isolation.home, '.config'),
293
+ XDG_DATA_HOME: path.join(isolation.home, '.local', 'share'),
294
+ XDG_CACHE_HOME: path.join(isolation.home, '.cache'),
295
+ }
296
+ }
297
+
298
+ const RUNTIME_MARKER = '__DSH_RUNTIME_CHECKS__'
299
+
300
+ function parseRuntimeOutput(text) {
301
+ const index = text.lastIndexOf(RUNTIME_MARKER)
302
+ if (index >= 0) {
303
+ const line = text.slice(index + RUNTIME_MARKER.length).split('\n', 1)[0]
304
+ try { return JSON.parse(line) } catch { /* fall through to the raw form */ }
305
+ }
306
+ const raw = text.trim()
307
+ if (raw.startsWith('{')) {
308
+ try { return JSON.parse(raw) } catch { /* not complete yet */ }
309
+ }
310
+ return null
311
+ }
312
+
171
313
  /**
172
314
  * L1-L3 run in a child process so that a plugin cannot block the event loop,
173
- * read the checker's environment, or kill the process. The child is hard-killed
174
- * after timeoutMs + 3s; a kill is reported as not-run, never as a pass.
315
+ * read the checker's environment, or kill the process. The child writes one
316
+ * marker line as soon as the checks finish; the parent resolves on that line
317
+ * and terminates the child, so a plugin that leaves timers behind cannot turn
318
+ * a completed run into "killed after timeout". A kill before the marker is
319
+ * reported as not-run, never as a pass.
175
320
  */
176
321
  function isolatedRuntimeChecks(input) {
177
322
  return new Promise((resolve) => {
178
- const script = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'scripts', 'runtime-child.mjs')
323
+ const script = path.resolve(path.dirname(fileURLToPath(import.meta.url)), 'runtime-child.mjs')
324
+ const startedAt = Date.now()
179
325
  // The child imports the inspected package: never inherit the caller's env.
180
- const child = spawn(process.execPath, [script], { stdio: ['pipe', 'pipe', 'pipe'], env: sanitizedEnv() })
326
+ const child = spawn(process.execPath, [script], {
327
+ // fd 3 is the result pipe: plugin stdout/stderr cannot forge the payload.
328
+ stdio: ['pipe', 'pipe', 'pipe', 'pipe'],
329
+ env: sanitizedEnv(isolationEnv(input.isolation)),
330
+ })
181
331
  let out = ''
182
332
  let err = ''
183
- const timer = setTimeout(() => { try { child.kill('SIGKILL') } catch {} }, input.timeoutMs + 3000)
184
- child.stdout.on('data', (chunk) => { out += chunk })
185
- child.stderr.on('data', (chunk) => { err += chunk })
186
- child.on('error', (error) => {
187
- clearTimeout(timer)
188
- resolve(runtimeNotRun(`L1 child failed to start: ${error.message}`))
333
+ let result = ''
334
+ const RESULT_LIMIT = 4 * 1024 * 1024
335
+ let settled = false
336
+ let timer = null
337
+ const finish = (payload, failure = null) => {
338
+ if (settled) return
339
+ settled = true
340
+ if (timer) clearTimeout(timer)
341
+ try { child.kill('SIGKILL') } catch { /* already gone */ }
342
+ const wallMs = Date.now() - startedAt
343
+ if (!payload) {
344
+ resolve({ checks: runtimeNotRun(failure), durationMs: wallMs, error: failure })
345
+ return
346
+ }
347
+ const error = payload.error ? String(payload.error).slice(0, 500) : null
348
+ const checks = Array.isArray(payload.checks) && payload.checks.length > 0 ? payload.checks : runtimeNotRun(error || 'L1 child produced no checks')
349
+ resolve({ checks, durationMs: Number.isFinite(payload.durationMs) ? payload.durationMs : wallMs, error })
350
+ }
351
+ timer = setTimeout(() => {
352
+ finish(null, `L1 killed after ${input.timeoutMs}ms; plugin blocked the event loop`)
353
+ }, input.timeoutMs + 3000)
354
+ child.stdout.on('data', (chunk) => {
355
+ if (out.length < RESULT_LIMIT) out += chunk
189
356
  })
357
+ child.stdio[3]?.on('data', (chunk) => {
358
+ if (result.length >= RESULT_LIMIT) return
359
+ result += chunk
360
+ const payload = parseRuntimeOutput(result)
361
+ if (payload) finish(payload)
362
+ })
363
+ child.stderr.on('data', (chunk) => {
364
+ if (err.length < RESULT_LIMIT) err += chunk
365
+ })
366
+ child.on('error', (error) => finish(null, `L1 child failed to start: ${error.message}`))
190
367
  child.on('exit', (code, signal) => {
191
- clearTimeout(timer)
192
- if (signal === 'SIGKILL' || signal === 'SIGTERM') {
193
- return resolve(runtimeNotRun(`L1 killed after ${input.timeoutMs}ms; plugin blocked the event loop`))
194
- }
195
- try {
196
- const parsed = JSON.parse(out)
197
- resolve(parsed.checks)
198
- } catch {
199
- resolve(runtimeNotRun(`L1 child produced no JSON (exit ${code}): ${String(err).slice(0, 200)}`))
200
- }
368
+ const payload = parseRuntimeOutput(result) ?? parseRuntimeOutput(out)
369
+ if (payload) return finish(payload)
370
+ if (signal === 'SIGKILL' || signal === 'SIGTERM') return finish(null, `L1 killed after ${input.timeoutMs}ms; plugin blocked the event loop`)
371
+ finish(null, `L1 child produced no JSON (exit ${code}): ${String(err).slice(0, 200)}`)
201
372
  })
202
373
  child.stdin.end(JSON.stringify(input))
203
374
  })
204
375
  }
205
376
 
206
- async function entryRunCheck(kind, runtimeChecks, options, dir) {
377
+ async function entryRunCheck(kind, runtimeChecks, options, dir, context = {}) {
207
378
  const byId = new Map(runtimeChecks.map(item => [item.id, item]))
208
379
  const c4 = byId.get('DSC-04')
380
+ const runtimeKind = context.runtimeKind ?? kind
381
+ const runtimeUsable = context.runtimeUsable === true
382
+ const noRuntime = { executed: false, modes: [], durationMs: null, failure: null }
209
383
  const base = {
210
384
  id: 'DSC-14', clause: 'C-14', layer: 'L1', method: 'M', advisory: false,
211
385
  details: [], evidence: c4?.evidence ?? [],
212
386
  }
213
387
  if (options.entryClaim === 'static') {
214
- return { ...base, result: 'NA', coverage: 'not-run', reason: 'entry static claim: no runtime evidence' }
388
+ return { check: { ...base, result: 'NA', coverage: 'not-run', reason: 'entry static claim: no runtime evidence' }, runtime: noRuntime }
215
389
  }
216
390
  if (['client', 'preset', 'skill', 'theme', 'mcp-config', 'unknown'].includes(kind)) {
217
- return { ...base, result: 'NA', coverage: 'not-run', reason: `kind=${kind}; no runtime claim` }
391
+ return { check: { ...base, result: 'NA', coverage: 'not-run', reason: `kind=${kind}; no runtime claim` }, runtime: noRuntime }
218
392
  }
219
393
  if (kind === 'bundle' && options.entryRuntime !== 'server-entry') {
220
- if (!options.host) return { ...base, result: 'ERROR', coverage: 'not-run', reason: 'bundle runtime requires --host <app-boot/lib/index.js> for G0-G3' }
221
- const script = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'scripts', 'bundle-smoke.mjs')
222
- const args = [script, '--package', dir, '--host', options.host, '--json', '--timeout', String(options.timeoutMs)]
223
- if (options.base) args.push('--base', options.base)
224
- if (options.web) args.push('--web', options.web)
225
- if (options.bare) args.push('--bare', options.bare)
226
- const run = spawnSync(process.execPath, args, { encoding: 'utf8', timeout: options.timeoutMs + 5000, maxBuffer: 8 * 1024 * 1024, env: sanitizedEnv() })
227
- if (run.error) return { ...base, result: 'ERROR', coverage: 'not-run', reason: `bundle smoke failed to start: ${run.error.message}` }
228
- let smoke
229
- try { smoke = JSON.parse(run.stdout) } catch { return { ...base, result: 'ERROR', coverage: 'not-run', reason: `bundle smoke produced no JSON: ${(run.stderr || '').slice(0, 300)}` } }
230
- const state = smoke.g3?.state
231
- if (smoke.g2?.compatibility === 'reject') return { ...base, result: 'FAIL', coverage: 'full', reason: 'G2 gate rejected: host would skip this bundle', details: [{ item: 'G2', result: 'FAIL', reason: JSON.stringify(smoke.g2) }] }
232
- if (state === 'verified' && smoke.g4?.ok === false) return { ...base, result: 'FAIL', coverage: 'full', reason: 'G4 failed: dsh.client has no package-root row or missing client export', details: [{ item: 'G4', result: 'FAIL', reason: JSON.stringify(smoke.g4) }] }
233
- if (state === 'verified') return { ...base, result: 'PASS', coverage: 'full', reason: `G0-G3 verified; rows=${smoke.g3.active}/${smoke.g3.rows.length}${smoke.g4?.hasClient ? '; G4 static ok' : ''}`, details: [{ item: 'G0', result: 'PASS', reason: 'patch parsed' }, { item: 'G1', result: 'PASS', reason: `rows ${smoke.g1.targetRows}` }, { item: 'G3', result: 'PASS', reason: `rows ${smoke.g3.active}/${smoke.g3.rows.length} ACTIVE` }] }
234
- if (state === 'partial') return { ...base, result: 'ERROR', coverage: 'not-run', reason: `G3 partial: ${smoke.g3.active}/${smoke.g3.rows.length} ACTIVE; target profile does not provide all services`, details: [{ item: 'G3', result: 'WARN', reason: JSON.stringify(smoke.g3.rows) }] }
235
- return { ...base, result: 'FAIL', coverage: 'full', reason: `bundle smoke failed: ${JSON.stringify(smoke.g3?.rows ?? [])}` }
394
+ if (options.host) {
395
+ const startedAt = Date.now()
396
+ const script = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'scripts', 'bundle-smoke.mjs')
397
+ const smokeTimeout = Math.max(options.timeoutMs, 5000)
398
+ const args = [script, '--package', dir, '--host', options.host, '--json', '--timeout', String(smokeTimeout)]
399
+ if (options.base) args.push('--base', options.base)
400
+ if (options.web) args.push('--web', options.web)
401
+ if (options.bare) args.push('--bare', options.bare)
402
+ const run = spawnSync(process.execPath, args, {
403
+ encoding: 'utf8',
404
+ timeout: smokeTimeout + 5000,
405
+ maxBuffer: 8 * 1024 * 1024,
406
+ env: sanitizedEnv(isolationEnv(context.isolation)),
407
+ })
408
+ const durationMs = Date.now() - startedAt
409
+ const meta = mode => ({ executed: true, modes: ['bundle-host'], durationMs, failure: null, ...mode })
410
+ if (run.error) {
411
+ const reason = `bundle smoke failed to start: ${run.error.message}`
412
+ return { check: { ...base, result: 'ERROR', coverage: 'not-run', reason }, runtime: meta({ failure: reason }) }
413
+ }
414
+ let smoke
415
+ try { smoke = JSON.parse(run.stdout) } catch {
416
+ const reason = `bundle smoke produced no JSON: ${(run.stderr || '').slice(0, 300)}`
417
+ return { check: { ...base, result: 'ERROR', coverage: 'not-run', reason }, runtime: meta({ failure: reason }) }
418
+ }
419
+ const state = smoke.g3?.state
420
+ if (smoke.g2?.compatibility === 'reject') {
421
+ const reason = 'G2 gate rejected: host would skip this bundle'
422
+ return { check: { ...base, result: 'FAIL', coverage: 'full', reason, details: [{ item: 'G2', result: 'FAIL', reason: JSON.stringify(smoke.g2) }] }, runtime: meta({ failure: reason }) }
423
+ }
424
+ if (state === 'verified' && smoke.g4?.ok === false) {
425
+ const reason = 'G4 failed: dsh.client has no package-root row or missing client export'
426
+ return { check: { ...base, result: 'FAIL', coverage: 'full', reason, details: [{ item: 'G4', result: 'FAIL', reason: JSON.stringify(smoke.g4) }] }, runtime: meta({}) }
427
+ }
428
+ if (state === 'verified') {
429
+ const reason = `G0-G3 verified; rows=${smoke.g3.active}/${smoke.g3.rows.length}${smoke.g4?.hasClient ? '; G4 static ok' : ''}`
430
+ return {
431
+ check: { ...base, result: 'PASS', coverage: 'full', reason, details: [{ item: 'G0', result: 'PASS', reason: 'patch parsed' }, { item: 'G1', result: 'PASS', reason: `rows ${smoke.g1.targetRows}` }, { item: 'G3', result: 'PASS', reason: `rows ${smoke.g3.active}/${smoke.g3.rows.length} ACTIVE` }] },
432
+ runtime: meta({}),
433
+ }
434
+ }
435
+ if (state === 'partial') {
436
+ const reason = `G3 partial: ${smoke.g3.active}/${smoke.g3.rows.length} ACTIVE; target profile does not provide all services`
437
+ return { check: { ...base, result: 'ERROR', coverage: 'not-run', reason, details: [{ item: 'G3', result: 'WARN', reason: JSON.stringify(smoke.g3.rows) }] }, runtime: meta({ failure: reason }) }
438
+ }
439
+ const reason = `bundle smoke failed: ${JSON.stringify(smoke.g3?.rows ?? [])}`
440
+ return { check: { ...base, result: 'FAIL', coverage: 'full', reason }, runtime: meta({ failure: reason }) }
441
+ }
442
+ // A bundle with a real server entry can still be exercised by L1-L3 with
443
+ // --treat-as-plugin. That is runtime evidence for the entry, but it is not
444
+ // a full G0-G3 boot; the reason says so instead of requiring --host forever.
445
+ if (runtimeKind === 'plugin' && runtimeUsable) {
446
+ return {
447
+ check: { ...base, result: 'PASS', coverage: 'full', reason: 'server-entry L1-L3 PASS; bundle G0-G3 not run (pass --host <app-boot/lib/index.js> for the full boot)' },
448
+ runtime: { executed: true, modes: [], durationMs: 0, failure: null },
449
+ }
450
+ }
451
+ const reason = 'bundle runtime requires --host <app-boot/lib/index.js> or --treat-as-plugin --runtime <path>'
452
+ return { check: { ...base, result: 'ERROR', coverage: 'not-run', reason }, runtime: { ...noRuntime, failure: reason } }
236
453
  }
237
454
  if (c4?.result === 'PASS') {
238
- return { ...base, result: 'PASS', coverage: 'full', reason: kind === 'bundle' ? 'server-entry ACTIVE only; bundle G0-G4 not run' : '' }
455
+ return {
456
+ check: { ...base, result: 'PASS', coverage: 'full', reason: kind === 'bundle' ? 'server-entry ACTIVE only; bundle G0-G4 not run' : '' },
457
+ runtime: { executed: true, modes: [], durationMs: 0, failure: null },
458
+ }
239
459
  }
240
- if (c4?.result === 'FAIL') return { ...base, result: 'FAIL', coverage: 'full', reason: c4.reason || 'runtime failed' }
241
- return { ...base, result: 'ERROR', coverage: 'not-run', reason: c4?.reason || 'no runtime evidence' }
460
+ if (c4?.result === 'FAIL') {
461
+ const reason = c4.reason || 'runtime failed'
462
+ return { check: { ...base, result: 'FAIL', coverage: 'full', reason }, runtime: { executed: true, modes: [], durationMs: 0, failure: reason } }
463
+ }
464
+ const reason = c4?.reason || 'no runtime evidence'
465
+ return { check: { ...base, result: 'ERROR', coverage: 'not-run', reason }, runtime: { ...noRuntime, failure: reason } }
242
466
  }
243
467
 
244
468
  /**
@@ -334,6 +558,25 @@ export async function main(argv = process.argv.slice(2)) {
334
558
  const host = detectHostRuntime()
335
559
  const runtimeInfo = resolveRuntimeAnchor(options.runtimeVersion, host.version)
336
560
 
561
+ // `--runtime auto` / `--host auto` resolve from the installed dsh. A value
562
+ // that still cannot be resolved is a usage error: silently skipping the run
563
+ // would turn "asked for runtime" into a static report.
564
+ try {
565
+ if (options.runtime) {
566
+ const resolved = resolveRuntimeTarget(options.runtime, 'cordis')
567
+ if (!resolved) throw new Error("--runtime auto could not locate the installed dsh's cordis lib/index.js; pass an explicit path")
568
+ options.runtime = resolved
569
+ }
570
+ if (options.host) {
571
+ const resolved = resolveRuntimeTarget(options.host, 'host')
572
+ if (!resolved) throw new Error("--host auto could not locate the installed dsh's dsh-app-boot/lib/index.js; pass an explicit path")
573
+ options.host = resolved
574
+ }
575
+ } catch (error) {
576
+ process.stderr.write(`dsh-plugin-verify: ${error.message}\n`)
577
+ return 3
578
+ }
579
+
337
580
  const { manifest, kind, kindInferred, entry, patch, g4, checks: manifestChecks } = runManifestChecks(dir, {
338
581
  runtimeVersion: runtimeInfo.dsh,
339
582
  installSpec: options.installSpec,
@@ -363,7 +606,11 @@ export async function main(argv = process.argv.slice(2)) {
363
606
  if (options.scope === 'entry' && options.entryRuntime === 'server-entry' && kind !== 'plugin') options.treatAsPlugin = true
364
607
  let runtimeKind = kind
365
608
  let runtimeEntry = entry
366
- if (options.treatAsPlugin && manifest) {
609
+ // An explicit --runtime is already an opt-in to execute code. A bundle that
610
+ // carries a real server entry can therefore be exercised without repeating
611
+ // --treat-as-plugin; a patch-only bundle still has no entry and needs --host.
612
+ const runServerEntry = options.treatAsPlugin || (Boolean(options.runtime) && kind === 'bundle' && entry?.exists === true)
613
+ if (runServerEntry && manifest) {
367
614
  runtimeKind = 'plugin'
368
615
  if (!runtimeEntry?.exists) {
369
616
  const candidates = ['lib/index.js', 'index.js', 'lib/public-entry.js', 'src/index.ts', 'src/index.js', 'src/index.mjs']
@@ -374,32 +621,100 @@ export async function main(argv = process.argv.slice(2)) {
374
621
  }
375
622
  }
376
623
 
377
- let runtimeChecks
378
- if (options.noExec || !manifest) {
379
- const reason = options.noExec ? '--no-exec' : 'manifest unavailable'
380
- runtimeChecks = ['DSC-04', 'DSC-05', 'DSC-06', 'DSC-07', 'DSC-08', 'DSC-09'].map(id => ({
381
- id, clause: { 'DSC-04': 'C-4', 'DSC-05': 'C-5', 'DSC-06': 'C-6', 'DSC-07': 'C-7', 'DSC-08': 'C-8', 'DSC-09': 'C-9' }[id],
382
- layer: { 'DSC-04': 'L1', 'DSC-05': 'L1', 'DSC-06': 'L1+L2', 'DSC-07': 'L2', 'DSC-08': 'L3', 'DSC-09': 'L1' }[id],
383
- result: 'ERROR', coverage: 'not-run', reason, details: [], evidence: [], method: 'M', advisory: false,
384
- }))
385
- } else {
386
- runtimeChecks = await isolatedRuntimeChecks({
387
- dir, manifest, kind: runtimeKind, entry: runtimeEntry,
388
- runtimePath: options.runtime, timeoutMs: options.timeoutMs,
389
- })
390
- }
624
+ const runtimeWillExecute = !options.noExec && Boolean(manifest) && Boolean(options.runtime) && runtimeKind === 'plugin' && runtimeEntry?.exists === true
625
+ const hostWillExecute = !options.noExec && Boolean(manifest) && Boolean(options.host) && kind === 'bundle'
626
+ const isolation = runtimeWillExecute || hostWillExecute ? createRuntimeIsolation() : null
627
+
628
+ let runtimeChecks = null
629
+ let runCheck = null
630
+ let runtimeRun = { checks: [], durationMs: null, error: null }
631
+ let entryRun = { executed: false, modes: [], durationMs: null, failure: null }
632
+ let runtimeSummary = null
633
+ try {
634
+ if (options.noExec || !manifest) {
635
+ const reason = options.noExec ? '--no-exec' : 'manifest unavailable'
636
+ runtimeChecks = ['DSC-04', 'DSC-05', 'DSC-06', 'DSC-07', 'DSC-08', 'DSC-09'].map(id => ({
637
+ id, clause: { 'DSC-04': 'C-4', 'DSC-05': 'C-5', 'DSC-06': 'C-6', 'DSC-07': 'C-7', 'DSC-08': 'C-8', 'DSC-09': 'C-9' }[id],
638
+ layer: { 'DSC-04': 'L1', 'DSC-05': 'L1', 'DSC-06': 'L1+L2', 'DSC-07': 'L2', 'DSC-08': 'L3', 'DSC-09': 'L1' }[id],
639
+ result: 'ERROR', coverage: 'not-run', reason, details: [], evidence: [], method: 'M', advisory: false,
640
+ }))
641
+ } else if (!options.runtime) {
642
+ runtimeChecks = runtimeNotRun('no --runtime supplied; L1-L3 not run')
643
+ } else if (runtimeKind !== 'plugin') {
644
+ const reason = kind === 'bundle' && !entry?.exists
645
+ ? 'kind=bundle has no server entry; L1-L3 not run (use --host <app-boot/lib/index.js> for G0-G3)'
646
+ : `kind=${runtimeKind}; L1-L3 apply to plugin entries only (pass --treat-as-plugin for a bundle's server entry)`
647
+ runtimeChecks = runtimeNotRun(reason)
648
+ } else if (!runtimeEntry?.exists) {
649
+ runtimeChecks = runtimeNotRun('no resolvable runtime entry; L1-L3 not run')
650
+ } else {
651
+ runtimeRun = await isolatedRuntimeChecks({
652
+ dir, manifest, kind: runtimeKind, entry: runtimeEntry,
653
+ runtimePath: options.runtime, timeoutMs: options.timeoutMs,
654
+ home: isolation?.home, tmp: isolation?.tmp, isolation,
655
+ })
656
+ runtimeChecks = runtimeRun.checks
657
+ }
658
+ const rawRuntimeChecks = runtimeChecks
659
+ const rawRuntimeUsable = runtimeChecks.some(item => ['PASS', 'WARN', 'FAIL'].includes(item.result))
660
+ if (runtimeWillExecute && !rawRuntimeUsable && !runtimeRun.error) {
661
+ // The child may exit cleanly while every check is not-run (bad
662
+ // --runtime path, missing entry, blocked import). That is a failed run,
663
+ // not a quiet static result: surface its reason in runtimeChecks.
664
+ const reason = runtimeChecks.find(item => String(item.reason ?? '') !== '')?.reason || 'runtime produced no usable result'
665
+ runtimeRun = { ...runtimeRun, error: reason }
666
+ }
667
+
668
+ // A bundle/client has no plugin entry for L1-L3. Instead of leaving all six
669
+ // runtime checks NA, cover their statically decidable part explicitly and
670
+ // mark the coverage partial (the report says what still needs --host/--runtime).
671
+ const runtimeUsable = runtimeChecks.some(item => ['PASS', 'WARN', 'FAIL'].includes(item.result))
672
+ if (manifest && ['bundle', 'client'].includes(kind) && !runtimeUsable) {
673
+ const staticChecks = runStaticBundleChecks({ manifest, kind, entry, patch, sources, g4, scan, dir })
674
+ const staticById = new Map(staticChecks.map(item => [item.id, item]))
675
+ runtimeChecks = runtimeChecks.map(item => staticById.get(item.id) ?? item)
676
+ }
391
677
 
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)
678
+ const run = await entryRunCheck(kind, runtimeChecks, options, dir, { runtimeKind, runtimeUsable, isolation })
679
+ runCheck = run.check
680
+ entryRun = run.runtime
681
+
682
+ // Runtime provenance is additive: what actually ran, for how long, under
683
+ // which isolation, and why it failed. Static rows stay exactly as they are.
684
+ const modes = []
685
+ if (runtimeWillExecute) modes.push('cordis')
686
+ if (entryRun.modes.includes('bundle-host')) modes.push('bundle-host')
687
+ const failure = [runtimeRun.error, entryRun.executed ? entryRun.failure : null].filter(Boolean).join('; ') || null
688
+ const durationMs = (Number.isFinite(runtimeRun.durationMs) ? runtimeRun.durationMs : 0) + (Number.isFinite(entryRun.durationMs) ? entryRun.durationMs : 0)
689
+ let notRunReason = null
690
+ if (!runtimeWillExecute && !hostWillExecute) {
691
+ if (options.noExec) notRunReason = '--no-exec: nothing executed'
692
+ else if (options.runtime) notRunReason = (rawRuntimeChecks ?? []).find(item => String(item.reason ?? '') !== '')?.reason ?? '--runtime supplied but it was not executed'
693
+ else if (options.host) notRunReason = `--host supplied but kind=${kind} is not a bundle; G0-G3 not run`
694
+ else notRunReason = 'no --runtime/--host supplied; nothing executed'
695
+ }
696
+ const summary = {
697
+ schema: 'dsh-runtime-checks/1',
698
+ executed: runtimeWillExecute || entryRun.executed === true,
699
+ modes,
700
+ durationMs,
701
+ isolation: {
702
+ isolatedHome: Boolean(isolation),
703
+ env: 'allowlist',
704
+ timeoutMs: options.timeoutMs,
705
+ },
706
+ reason: notRunReason,
707
+ failure,
708
+ checks: (rawRuntimeChecks ?? []).map(item => ({
709
+ id: item.id, result: item.result, coverage: item.coverage, reason: item.reason ?? '',
710
+ })),
711
+ entry: runCheck ? { id: 'DSC-14', result: runCheck.result, coverage: runCheck.coverage, reason: runCheck.reason ?? '', durationMs: entryRun.durationMs ?? null } : null,
712
+ }
713
+ runtimeSummary = summary
714
+ } finally {
715
+ isolation?.cleanup()
400
716
  }
401
717
 
402
- const runCheck = await entryRunCheck(kind, runtimeChecks, options, dir)
403
718
  const checks = [
404
719
  manifestChecks[0], manifestChecks[1], manifestChecks[2],
405
720
  ...runtimeChecks,
@@ -440,7 +755,16 @@ export async function main(argv = process.argv.slice(2)) {
440
755
  entryClaim: options.entryClaim,
441
756
  advice: options.advice,
442
757
  declarations: declarationsOf(manifest),
758
+ runtimeChecks: runtimeSummary,
443
759
  })
760
+ // An explicitly requested runtime/host run that could not produce a result
761
+ // is not a clean static pass: the caller asked for execution, so say the run
762
+ // is incomplete. A static report (no flag) is unaffected.
763
+ if ((runtimeWillExecute || hostWillExecute) && runtimeSummary?.failure && report.conclusion.status !== 'fail') {
764
+ report.conclusion.status = 'incomplete'
765
+ report.conclusion.exitCode = 2
766
+ report.conclusion.reason = [report.conclusion.reason, `runtime/host did not complete: ${runtimeSummary.failure}`].filter(Boolean).join('; ')
767
+ }
444
768
  const output = writeReport(report, options.format)
445
769
  if (options.out) {
446
770
  const outPath = path.resolve(options.out)
@@ -28,6 +28,15 @@ import fs from 'node:fs'
28
28
  import os from 'node:os'
29
29
  import path from 'node:path'
30
30
  import { spawnSync } from 'node:child_process'
31
+ import { signEvidence } from './signature.mjs'
32
+
33
+ // Signing helpers live in signature.mjs; re-exported here so callers that
34
+ // already import the evidence model do not need a second module path.
35
+ export {
36
+ SIGNATURE_ALG, SIGNATURE_SCHEMA, canonicalJson as signatureCanonicalJson,
37
+ evidenceSigningPayload, generateEd25519KeyPair, keyIdFromPublicKey,
38
+ signEvidence, verifyEvidenceSignature,
39
+ } from './signature.mjs'
31
40
 
32
41
  export const FLOW_IDS = ['J1', 'J2', 'J3', 'J4', 'J5', 'J6', 'J7', 'J8']
33
42
  export const FLOW_EVIDENCE_SCHEMA = 'dsh-flow-evidence/2'
@@ -107,6 +116,18 @@ export function evidenceHashMatches(document) {
107
116
  return declared === evidenceHash(document)
108
117
  }
109
118
 
119
+ /**
120
+ * Attach an Ed25519 signature to a flow-evidence document. The signature
121
+ * covers canonical JSON minus `signature` (the body plus `chain`), so it binds
122
+ * the declared chain hash while never changing `chain.evidenceHash`; it never
123
+ * upgrades a claim level. Verification is `verifyEvidenceSignature()`
124
+ * (re-exported from signature.mjs); structural acceptance remains
125
+ * `validateFlowEvidence()`.
126
+ */
127
+ export function attachEvidenceSignature(document, privateKey, options = {}) {
128
+ return signEvidence(document, privateKey, options)
129
+ }
130
+
110
131
  const DEFAULT_TREE_EXCLUDE = ['.git', 'node_modules']
111
132
  const TREE_EXCLUDE_ALWAYS = new Set(['.git', 'node_modules'])
112
133
 
@@ -13,7 +13,7 @@ import path from 'node:path'
13
13
  import packageJson from '../../package.json' with { type: 'json' }
14
14
 
15
15
  /** Release-pinned guide base. `docs` in the rule table is a relative path. */
16
- export const DOCS_BASE = 'https://github.com/PerryLink/dsh-plugin-guide/blob/v0.4.4/'
16
+ export const DOCS_BASE = 'https://github.com/PerryLink/dsh-plugin-guide/blob/v0.4.6/'
17
17
 
18
18
  const LIVE = new Set(['FAIL', 'WARN'])
19
19
  const SEVERITY_RANK = { FAIL: 2, WARN: 1 }