dsh-standards 1.4.1 → 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.
@@ -108,7 +108,7 @@ function allowedNa(check, scope, profile, kind) {
108
108
  return false
109
109
  }
110
110
 
111
- export function buildReport({ subject, anchor, checks, g4 = null, scope, profile, kind, staticOnly = false, entryRuntime = 'require', entryClaim = 'runtime', advice = true, declarations = null }) {
111
+ export function buildReport({ subject, anchor, checks, g4 = null, scope, profile, kind, staticOnly = false, entryRuntime = 'require', entryClaim = 'runtime', advice = true, declarations = null, runtimeChecks = null }) {
112
112
  const mandatory = mandatoryFor(scope, profile, staticOnly, entryClaim)
113
113
  const adjustedChecks = scope === 'entry' ? checks.map(item => adjustEntryCheck(item, entryClaim)) : checks
114
114
  const runtimeIds = new Set(['DSC-04', 'DSC-05', 'DSC-06', 'DSC-07', 'DSC-08', 'DSC-09'])
@@ -123,7 +123,12 @@ export function buildReport({ subject, anchor, checks, g4 = null, scope, profile
123
123
  const missing = mandatory.filter(id => !byId.has(id))
124
124
  const failed = selected.filter(item => item.result === 'FAIL')
125
125
  const errored = selected.filter(item => item.result === 'ERROR')
126
- const notRun = selected.filter(item => !allowedNa(item, scope, profile, kind) && (item.coverage === 'not-run' || item.coverage === 'partial'))
126
+ // A static `partial` PASS is still a PASS for the mandatory set: it is
127
+ // recorded in buckets.partial / coverage.partial, and "what was not run" is
128
+ // its own signal. Only a check that did not run at all makes the report
129
+ // incomplete. Otherwise every bundle would be exit 2 forever, because the
130
+ // static subset can never be full without executing the package.
131
+ const notRun = selected.filter(item => !allowedNa(item, scope, profile, kind) && item.coverage === 'not-run')
127
132
  const notRunIds = [...new Set([...missing, ...errored.map(item => item.id), ...notRun.map(item => item.id)])]
128
133
  const warnings = selected.filter(item => item.result === 'WARN')
129
134
  const pass = selected.filter(item => item.result === 'PASS')
@@ -134,7 +139,7 @@ export function buildReport({ subject, anchor, checks, g4 = null, scope, profile
134
139
  const evaluatedAll = effectiveChecks.filter(item => ['PASS', 'WARN', 'FAIL'].includes(item.result))
135
140
  const naAll = effectiveChecks.filter(item => item.result === 'NA')
136
141
  const errorAll = effectiveChecks.filter(item => item.result === 'ERROR')
137
- const notRunAll = effectiveChecks.filter(item => item.result !== 'NA' && (item.coverage === 'not-run' || item.coverage === 'partial'))
142
+ const notRunAll = effectiveChecks.filter(item => item.result !== 'NA' && item.coverage === 'not-run')
138
143
  const notRunAllIds = [...new Set([...notRunAll.map(item => item.id), ...errorAll.map(item => item.id), ...missing])]
139
144
  const entryBlockingIds = scope === 'entry' ? (entryClaim === 'static' ? ENTRY_BLOCKING : ENTRY_RUNTIME_BLOCKING) : null
140
145
  const entryBlocking = entryBlockingIds ? summarizeBlocking(effectiveChecks, entryBlockingIds) : null
@@ -251,6 +256,11 @@ export function buildReport({ subject, anchor, checks, g4 = null, scope, profile
251
256
  reason: reasonParts.join('; '),
252
257
  },
253
258
  }
259
+ // Additive only: describes what actually executed (runtime/host), the
260
+ // elapsed time, and the isolation it used. It is a sibling of `checks` and
261
+ // never changes the verdict. Kept out of the 1.0.0 schema shape and only
262
+ // emitted when a runtime/host run was requested.
263
+ if (runtimeChecks) report.runtimeChecks = runtimeChecks
254
264
  // Additive only: `advice` is a sibling of `checks`, never part of a check,
255
265
  // and the verdict is computed above without reading it.
256
266
  if (advice !== false) {
@@ -302,6 +312,41 @@ function renderBucket(lines, title, rows, byId) {
302
312
  }
303
313
  }
304
314
 
315
+ /**
316
+ * Runtime provenance: what actually ran, for how long, and where. A failed
317
+ * run must say why instead of silently falling back to static rows.
318
+ */
319
+ function renderRuntime(lines, runtime) {
320
+ if (!runtime) return
321
+ if (!runtime.executed) {
322
+ // Do not repeat six not-run rows that the partial/not-checked buckets
323
+ // already explain; one line is enough to say nothing executed.
324
+ lines.push(`Runtime checks (not executed): ${runtime.reason ?? runtime.failure ?? 'no runtime requested'}`)
325
+ lines.push('')
326
+ return
327
+ }
328
+ const modes = Array.isArray(runtime.modes) && runtime.modes.length > 0 ? runtime.modes.join('+') : 'none'
329
+ const elapsed = Number.isFinite(runtime.durationMs) ? `${runtime.durationMs} ms` : 'n/a'
330
+ const isolation = runtime.isolation?.isolatedHome ? 'temporary DSH_HOME' : 'un-isolated'
331
+ lines.push(`Runtime checks (${modes}, ${elapsed}, ${isolation}):`)
332
+ if (runtime.failure) lines.push(` failure: ${String(runtime.failure).replace(/\s+/g, ' ').slice(0, 200)}`)
333
+ const rows = Array.isArray(runtime.checks) ? runtime.checks : []
334
+ if (rows.length === 0) {
335
+ lines.push(' none')
336
+ } else {
337
+ for (const row of rows) {
338
+ const reason = String(row.reason ?? '').replace(/\s+/g, ' ').slice(0, 140)
339
+ lines.push(` ${(ICON[row.result] ?? row.result).padEnd(4)} ${String(row.id).padEnd(7)} ${String(row.coverage ?? '-').padEnd(8)} ${reason}`)
340
+ }
341
+ }
342
+ const entry = runtime.entry
343
+ if (entry) {
344
+ const reason = String(entry.reason ?? '').replace(/\s+/g, ' ').slice(0, 140)
345
+ lines.push(` ${(ICON[entry.result] ?? entry.result).padEnd(4)} ${String(entry.id).padEnd(7)} ${String(entry.coverage ?? '-').padEnd(8)} ${reason}`)
346
+ }
347
+ lines.push('')
348
+ }
349
+
305
350
  export function formatText(report) {
306
351
  const lines = []
307
352
  const { subject, anchor = {}, conclusion, coverage } = report
@@ -318,6 +363,7 @@ export function formatText(report) {
318
363
  lines.push('')
319
364
  renderBucket(lines, 'Partly checked (static subset)', report.buckets?.partial ?? [], byId)
320
365
  lines.push('')
366
+ renderRuntime(lines, report.runtimeChecks)
321
367
  renderBucket(lines, 'Not checked (NA / not-run / ERROR)', report.buckets?.notChecked ?? [], byId)
322
368
  lines.push('')
323
369
  const passed = (report.checks ?? []).filter(item => item.result === 'PASS')
@@ -0,0 +1,68 @@
1
+ #!/usr/bin/env node
2
+ // Runs L1-L3 in a disposable child process so a plugin cannot block or take over
3
+ // the checker. Input: JSON on stdin. Output: one marker line + JSON on fd 3.
4
+ //
5
+ // The child is *expected to be hostile*: a plugin may leave timers, sockets or
6
+ // workers referenced after `dispose()`. Waiting for the process to exit then
7
+ // looks like a hang even though every check finished, so the result is written
8
+ // first and the process is terminated in the write callback. The parent also
9
+ // resolves on the marker line, so a plugin that keeps the event loop alive
10
+ // cannot turn a completed run into "killed after timeout".
11
+ import fs from 'node:fs'
12
+ import { runRuntimeChecks } from './runtime.mjs'
13
+ import { readSources } from './util.mjs'
14
+ import { scrubProcessEnv } from './sandbox-env.mjs'
15
+
16
+ export const MARKER = '__DSH_RUNTIME_CHECKS__'
17
+
18
+ const chunks = []
19
+ for await (const chunk of process.stdin) chunks.push(chunk)
20
+ const input = JSON.parse(Buffer.concat(chunks).toString('utf8'))
21
+
22
+ // This process imports the inspected package; keep only allowlisted env keys,
23
+ // then apply the caller-supplied disposable HOME/DSH_HOME/TMPDIR. DSH_HOME is
24
+ // deliberately not allowlisted: an inherited real profile must never reach a
25
+ // plugin, while an explicit temp directory from the checker is trusted.
26
+ scrubProcessEnv()
27
+ const overrides = {
28
+ DSH_HOME: input.home,
29
+ HOME: input.home,
30
+ USERPROFILE: input.home,
31
+ TMPDIR: input.tmp,
32
+ TEMP: input.tmp,
33
+ TMP: input.tmp,
34
+ XDG_CONFIG_HOME: input.home ? `${input.home}/.config` : undefined,
35
+ XDG_DATA_HOME: input.home ? `${input.home}/.local/share` : undefined,
36
+ XDG_CACHE_HOME: input.home ? `${input.home}/.cache` : undefined,
37
+ }
38
+ for (const [key, value] of Object.entries(overrides)) {
39
+ if (typeof value === 'string' && value !== '') process.env[key] = value
40
+ }
41
+
42
+ const started = process.hrtime.bigint()
43
+ let payload
44
+ try {
45
+ let sources = []
46
+ try { sources = readSources(input.dir) } catch { sources = [] }
47
+ const checks = await runRuntimeChecks({ ...input, sources })
48
+ payload = { checks, durationMs: Number((process.hrtime.bigint() - started) / 1_000_000n), error: null }
49
+ } catch (error) {
50
+ payload = {
51
+ checks: [],
52
+ durationMs: Number((process.hrtime.bigint() - started) / 1_000_000n),
53
+ error: String(error?.stack ?? error?.message ?? error).slice(0, 2000),
54
+ }
55
+ }
56
+
57
+ const line = `${MARKER}${JSON.stringify(payload)}\n`
58
+ // The plugin shares stdout, so results go to a dedicated pipe (fd 3) that a
59
+ // plugin printing logs cannot interleave with or truncate.
60
+ const writeResult = (callback) => {
61
+ try { fs.writeSync(3, line) } catch {
62
+ // A direct `node src/runtime-child.mjs` has no fd 3: fall back to stdout.
63
+ process.stdout.write(line, callback)
64
+ return
65
+ }
66
+ callback()
67
+ }
68
+ writeResult(() => process.exit(0))
@@ -193,7 +193,9 @@ export async function runRuntimeChecks(input) {
193
193
  const provideResult = declared.length === 0 ? 'NA' : worst(provideDetails.map(item => item.result))
194
194
  const provideCheck = check({
195
195
  id: 'DSC-05', clause: 'C-5', layer: 'L1', result: provideResult,
196
- reason: provideDetails.filter(item => item.result !== 'PASS' && item.result !== 'NA').map(item => `${item.item}: ${item.reason}`).join(' | '),
196
+ // NA is a real finding too ("nothing declared"), so keep its reason; an
197
+ // empty reason here used to make DSC-05 look unexplained in the report.
198
+ reason: provideDetails.filter(item => item.result !== 'PASS').map(item => `${item.item}: ${item.reason}`).join(' | '),
197
199
  details: provideDetails,
198
200
  })
199
201