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
@@ -36,7 +36,7 @@ import path from 'node:path'
36
36
  import { spawnSync } from 'node:child_process'
37
37
  import { fileURLToPath } from 'node:url'
38
38
  import {
39
- FLOW_IDS, FLOW_EVIDENCE_SCHEMA, clauseAnchor, finalizeEvidence, journeyAggregate,
39
+ FLOW_IDS, FLOW_EVIDENCE_SCHEMA, ciAttribution, clauseAnchor, finalizeEvidence, journeyAggregate,
40
40
  sha256File, sha256Value, treeSha256, validateFlowEvidence,
41
41
  } from '../src/flow-evidence.mjs'
42
42
  import { sanitizedEnv } from '../src/sandbox-env.mjs'
@@ -77,6 +77,7 @@ Options:
77
77
  --timeout <ms> per-command budget (default: 120000)
78
78
  --static skip the runtime probe (J1/J2/J4/J6 become static/blocked)
79
79
  --ci mark producer.kind=ci, claims.claimLevel=ci-attested
80
+ (CI attribution is also read from GITHUB_* and stays self-declared)
80
81
  --sandbox run the runtime probe under unshare/prlimit/process-group
81
82
  timeout with an allowlisted env; producer.kind=sandbox only
82
83
  when every isolation item is effective (otherwise
@@ -452,6 +453,10 @@ async function main() {
452
453
  ? `sandboxed collector DEGRADED (${sandboxPlan.degraded.join(', ')}): allowlisted env, temporary HOME/DSH_HOME, process-group SIGKILL timeout; every missing isolation item is recorded`
453
454
  : 'local-collector: read-only copy, allowlisted child env, no allowBuilds, no exemption'
454
455
  const sandbox = sandboxPlan ? { ...sandboxPlan, executed: sandboxExecuted, execution: sandboxExecution } : null
456
+ // CI attribution is display-only and self-declared. It is recorded from the
457
+ // runner environment even without --ci; it never upgrades the claim level.
458
+ const ci = ciAttribution()
459
+ const ciFields = ci ? Object.fromEntries(Object.entries(ci).filter(([, value]) => value !== null)) : {}
455
460
  const document = finalizeEvidence({
456
461
  schema: FLOW_EVIDENCE_SCHEMA,
457
462
  generatedAt,
@@ -460,6 +465,7 @@ async function main() {
460
465
  at: generatedAt,
461
466
  kind: producerKind,
462
467
  runner: sandboxPlan ? 'flow-collect/2-sandbox' : 'flow-collect/1',
468
+ ...ciFields,
463
469
  },
464
470
  ...(sandbox ? { sandbox } : {}),
465
471
  subject,
@@ -31,7 +31,7 @@ import fs from 'node:fs'
31
31
  import path from 'node:path'
32
32
  import { spawnSync } from 'node:child_process'
33
33
  import { fileURLToPath } from 'node:url'
34
- import { FLOW_IDS, FLOW_EVIDENCE_SCHEMA, validateFlowEvidence } from '../src/flow-evidence.mjs'
34
+ import { FLOW_IDS, FLOW_EVIDENCE_SCHEMA, bindEvidenceSubject, validateFlowEvidence } from '../src/flow-evidence.mjs'
35
35
 
36
36
  const HERE = path.dirname(fileURLToPath(import.meta.url))
37
37
  const STD = path.resolve(HERE, '..', '..')
@@ -52,6 +52,10 @@ Options:
52
52
  --flow-evidence <file> dsh-flow-evidence/2 JSON from dsh-flow-collect.mjs (J1-J8 checks
53
53
  with command/exitCode/times/stdout sha256/assertions). v1 and bare
54
54
  {Jx:{status}} files are reported deprecated and never count as pass.
55
+ --dir <dir> bind the flow evidence to this package directory: name/version,
56
+ recomputed treeSha256 and npm-pack tarball hash must match the
57
+ evidence subject, otherwise FLOW becomes invalid. If the positional
58
+ directory is omitted, --dir is used for the ENTRY check too.
55
59
  --boost-evidence <file> JSON: { "LEV-3": { "status": "measured|not-measured|failed", "evidenceCard": { ... } }, ... }
56
60
  measured requires a valid evidence card; a status-only row is downgraded to not-measured
57
61
  --timeout <ms> checker timeout, default 150000
@@ -65,7 +69,7 @@ are reported as not-run / not-measured.`)
65
69
 
66
70
  function parseArgs(argv) {
67
71
  const options = {
68
- dir: null, host: null, base: null, bare: null, runtime: true,
72
+ dir: null, bindDir: null, host: null, base: null, bare: null, runtime: true,
69
73
  flowEvidence: null, boostEvidence: null, timeout: 150_000,
70
74
  format: 'text', out: null,
71
75
  }
@@ -82,6 +86,7 @@ function parseArgs(argv) {
82
86
  else if (arg === '--bare') options.bare = next()
83
87
  else if (arg === '--no-runtime') options.runtime = false
84
88
  else if (arg === '--flow-evidence') options.flowEvidence = next()
89
+ else if (arg === '--dir') options.bindDir = next()
85
90
  else if (arg === '--boost-evidence') options.boostEvidence = next()
86
91
  else if (arg === '--timeout') options.timeout = Number(next())
87
92
  else if (arg === '--format') options.format = next()
@@ -91,11 +96,17 @@ function parseArgs(argv) {
91
96
  else if (options.dir === null) options.dir = arg
92
97
  else throw new Error(`unexpected argument: ${arg}`)
93
98
  }
94
- if (!options.dir) throw new Error('a plugin directory is required')
99
+ if (options.bindDir !== null && options.dir !== null && path.resolve(options.bindDir) !== path.resolve(options.dir)) {
100
+ throw new Error('the positional directory and --dir point at different packages')
101
+ }
102
+ if (!options.dir) options.dir = options.bindDir
103
+ if (!options.dir) throw new Error('a plugin directory is required (positional or --dir)')
95
104
  if (!['text', 'json'].includes(options.format)) throw new Error('--format must be text or json')
96
105
  if (!Number.isFinite(options.timeout) || options.timeout < 1000) throw new Error('--timeout must be >= 1000')
97
106
  options.dir = path.resolve(options.dir)
107
+ if (options.bindDir !== null) options.bindDir = path.resolve(options.bindDir)
98
108
  if (!fs.existsSync(path.join(options.dir, 'package.json'))) throw new Error(`not a package directory: ${options.dir}`)
109
+ if (options.bindDir !== null && !fs.existsSync(path.join(options.bindDir, 'package.json'))) throw new Error(`not a package directory: ${options.bindDir}`)
99
110
  return options
100
111
  }
101
112
 
@@ -158,12 +169,21 @@ function classifyEntry(report, invoked) {
158
169
  }
159
170
  }
160
171
 
161
- function classifyFlow(evidence) {
172
+ function attestationOf(evidence) {
173
+ const producer = evidence && typeof evidence.producer === 'object' ? evidence.producer : null
174
+ if (producer?.kind === 'ci') return 'ci'
175
+ if (typeof producer?.runUrl === 'string' && producer.runUrl !== '') return 'ci'
176
+ return 'self-declared'
177
+ }
178
+
179
+ function classifyFlow(evidence, binding = null) {
162
180
  const ids = FLOW_IDS
163
181
  const emptyJourneys = () => Object.fromEntries(ids.map(id => [id, { status: 'not-run', declared: null, checks: 0, passes: false }]))
164
182
  if (!evidence) {
165
183
  return {
166
184
  state: 'not-run',
185
+ attestation: 'self-declared',
186
+ subject: binding,
167
187
  schema: null,
168
188
  claimLevel: null,
169
189
  producerKind: null,
@@ -185,6 +205,8 @@ function classifyFlow(evidence) {
185
205
  return row?.status ?? null
186
206
  }
187
207
  const base = {
208
+ attestation: attestationOf(evidence),
209
+ subject: binding,
188
210
  schema: verdict.schema,
189
211
  claimLevel: verdict.claimLevel ?? null,
190
212
  producerKind: verdict.producerKind ?? null,
@@ -205,12 +227,19 @@ function classifyFlow(evidence) {
205
227
  journeys: Object.fromEntries(ids.map(id => [id, { status: 'deprecated', declared: declaredOf(id), checks: 0, passes: false }])),
206
228
  }
207
229
  }
208
- if (!verdict.valid) {
230
+ const bindingErrors = binding && !binding.ok
231
+ ? [...(binding.errors ?? []), ...(binding.mismatches ?? [])].map(message => `subject binding: ${message}`)
232
+ : []
233
+ if (!verdict.valid || bindingErrors.length > 0) {
234
+ const errors = [...verdict.errors, ...bindingErrors]
209
235
  return {
210
236
  ...base,
211
237
  state: 'invalid',
212
238
  invalid: true,
213
- reason: `${FLOW_EVIDENCE_SCHEMA} failed structural validation: ${verdict.errors.slice(0, 5).join('; ')}${verdict.errors.length > 5 ? ` (+${verdict.errors.length - 5} more)` : ''}`,
239
+ errors,
240
+ reason: bindingErrors.length > 0
241
+ ? `evidence subject does not match the inspected package: ${[...(binding.errors ?? []), ...(binding.mismatches ?? [])].join('; ')}`
242
+ : `${FLOW_EVIDENCE_SCHEMA} failed structural validation: ${verdict.errors.slice(0, 5).join('; ')}${verdict.errors.length > 5 ? ` (+${verdict.errors.length - 5} more)` : ''}`,
214
243
  evidenceHash: {
215
244
  declared: verdict.declaredHash,
216
245
  computed: verdict.computedHash,
@@ -391,6 +420,7 @@ function render(result) {
391
420
  `DSH level audit — ${result.target.dir}`,
392
421
  ` ENTRY : ${result.levels.entry.state.padEnd(12)} (${result.levels.entry.checkerLevel ?? 'no level'})`,
393
422
  ` FLOW : ${result.levels.flow.state.padEnd(12)} ${result.levels.flow.reason}`,
423
+ ` attest: ${result.levels.flow.attestation}${result.levels.flow.subject ? ` (subject ${result.levels.flow.subject.ok ? 'bound' : 'MISMATCH'})` : ''}`,
394
424
  ` BOOST : ${result.levels.boost.state.padEnd(12)} ${result.levels.boost.reason}`,
395
425
  ]
396
426
  if (result.nextActions.length > 0) {
@@ -409,7 +439,13 @@ function main() {
409
439
 
410
440
  const checker = runChecker(options)
411
441
  const entry = classifyEntry(checker.report, checker.invoked)
412
- const flow = classifyFlow(flowEvidence)
442
+ // Subject binding is opt-in via --dir: it re-reads the package and re-packs
443
+ // it, so an evidence file can no longer vouch for a different version/hash.
444
+ let binding = null
445
+ if (flowEvidence && options.bindDir) {
446
+ binding = bindEvidenceSubject(flowEvidence, options.bindDir, { timeoutMs: options.timeout })
447
+ }
448
+ const flow = classifyFlow(flowEvidence, binding)
413
449
  const boost = classifyBoost(boostEvidence)
414
450
 
415
451
  const result = {
@@ -418,6 +454,8 @@ function main() {
418
454
  target: { dir: options.dir },
419
455
  levelsSpec: { schema: levels.schema, version: levels.version, status: levels.status },
420
456
  levels: { entry, flow, boost },
457
+ attestation: flow.attestation,
458
+ subject: binding,
421
459
  nextActions: nextActions(entry, flow, boost),
422
460
  notClaimed: ['official standard', 'certification', 'security audit', 'ranking', 'production baseline'],
423
461
  checker: {
@@ -7,14 +7,10 @@ import { dirname, join } from 'node:path'
7
7
  import { fileURLToPath } from 'node:url'
8
8
 
9
9
  const here = dirname(fileURLToPath(import.meta.url))
10
- const testDir = join(here, '..', 'test')
11
- if (!existsSync(testDir)) {
12
- console.log('checker tests are not shipped in the npm package; nothing to run')
13
- process.exit(0)
14
- }
15
- const files = readdirSync(testDir).filter((f) => f.endsWith('.test.mjs')).map((f) => join(testDir, f))
10
+ const dirs = [join(here, '..', 'test'), join(here, '..', '..', 'test')]
11
+ const files = dirs.filter((dir) => existsSync(dir)).flatMap((dir) => readdirSync(dir).filter((f) => f.endsWith('.test.mjs')).map((f) => join(dir, f)))
16
12
  if (files.length === 0) {
17
- console.log('no test files found; nothing to run')
13
+ console.log('checker tests are not shipped in the npm package; nothing to run')
18
14
  process.exit(0)
19
15
  }
20
16
  const r = spawnSync(process.execPath, ['--test', ...files], { stdio: 'inherit' })
@@ -0,0 +1,406 @@
1
+ /**
2
+ * `dsh-standards check [dir]` — the author-facing entry point.
3
+ *
4
+ * It shells out to the existing checker in JSON mode and only changes how the
5
+ * same report is presented: Markdown by default, `--json` for the raw
6
+ * `dsh-conformance/1` document, `--html <file>` for a single self-contained
7
+ * file. Exit codes are the checker's, never rewritten here.
8
+ *
9
+ * Safety defaults live here, not in the checker:
10
+ * - unless the caller explicitly opts in, `--entry-claim static --no-exec` is
11
+ * forwarded, so `check` never imports the inspected plugin;
12
+ * - the PATH `dsh` is never executed unconditionally. The anchor comes from
13
+ * `--host-version`, then `$DSH_HOST_VERSION`, then an opt-in
14
+ * `--allow-host-probe` (which resolves the `dsh` JS entry and runs it with
15
+ * `node ... --version`). With no source the report says the host version is
16
+ * unknown and records `anchor.runtimeSource=default`, `suspect=true`.
17
+ */
18
+ import fs from 'node:fs'
19
+ import os from 'node:os'
20
+ import path from 'node:path'
21
+ import { spawnSync } from 'node:child_process'
22
+ import { createRequire } from 'node:module'
23
+ import { fileURLToPath } from 'node:url'
24
+ import { renderHtml, renderMarkdown } from './author-report.mjs'
25
+ import { sanitizedEnv } from './sandbox-env.mjs'
26
+ import { parseVersion } from './semver.mjs'
27
+
28
+ const HERE = path.dirname(fileURLToPath(import.meta.url))
29
+ const CHECKER = path.resolve(HERE, '..', 'bin', 'dsh-plugin-verify.mjs')
30
+ const MAX_BUFFER = 64 * 1024 * 1024
31
+ const HOST_PROBE_TIMEOUT_MS = 5000
32
+ const RUNTIME_IDS = new Set(['DSC-04', 'DSC-05', 'DSC-06', 'DSC-07', 'DSC-08', 'DSC-09'])
33
+ // The checker falls back to `dsh --version` when DSH_BIN is unset. `check` must
34
+ // never do that implicitly, so the child always gets a path that cannot exist.
35
+ const NO_PROBE_BIN = path.join(os.tmpdir(), `dsh-standards-no-host-probe-${process.pid}`)
36
+
37
+ /** Checker flags that take a value, so `check --scope entry` is not read as a directory. */
38
+ const VALUE_FLAGS = new Set([
39
+ '--scope', '--profile', '--runtime', '--runtime-version', '--cordis-version', '--commit',
40
+ '--install-spec', '--market-index', '--plugin-name', '--timeout', '--host', '--base',
41
+ '--web', '--bare', '--entry-runtime', '--entry-claim', '--max-files', '--max-bytes',
42
+ ])
43
+
44
+ export function checkUsage() {
45
+ return `dsh-standards check — author-facing standards report
46
+
47
+ Usage:
48
+ dsh-standards check [dir] [options]
49
+
50
+ dir defaults to the current directory. Output defaults to Markdown.
51
+
52
+ Options:
53
+ --json print the raw dsh-conformance/1 report
54
+ --html <file> write a single-file HTML report (inline CSS, no assets)
55
+ --host-version <v> host dsh version for the anchor (default: $DSH_HOST_VERSION)
56
+ --allow-host-probe resolve the \`dsh\` JS entry and run \`node <entry> --version\`
57
+ --help show this help
58
+
59
+ Safety:
60
+ check defaults to --entry-claim static --no-exec: the inspected plugin code
61
+ is NOT executed. Runtime checks need an explicit --runtime <path>, --host <file>,
62
+ or --entry-claim runtime. PATH \`dsh\` is never executed unless --allow-host-probe
63
+ is passed; with no host version the report says "host 版本未知" and marks the
64
+ anchor source=default, suspect=true.
65
+
66
+ Any other flag is forwarded to the checker, e.g.:
67
+ --scope entry --entry-claim static --no-exec
68
+ --runtime-version 0.2.2-rc.1
69
+ --market-index index.json --plugin-name @scope/name
70
+
71
+ Exit codes (same as the checker):
72
+ 0 completed, no FAIL (PASS or WARN)
73
+ 1 completed, at least one FAIL
74
+ 2 incomplete: a mandatory check did not run
75
+ 3 usage error
76
+ `
77
+ }
78
+
79
+ function hasFlag(args, name) {
80
+ return args.some(arg => arg === name || arg.startsWith(`${name}=`))
81
+ }
82
+
83
+ function flagValue(args, name) {
84
+ for (let index = 0; index < args.length; index += 1) {
85
+ const arg = args[index]
86
+ if (arg === name) return args[index + 1]
87
+ if (arg.startsWith(`${name}=`)) return arg.slice(name.length + 1)
88
+ }
89
+ return undefined
90
+ }
91
+
92
+ /**
93
+ * The one place that decides whether a run may execute plugin code.
94
+ *
95
+ * `--runtime`, `--host` and `--entry-claim runtime` are explicit opt-ins. Every
96
+ * other invocation gets `--no-exec`; a missing entry claim also gets the static
97
+ * claim so DSC-14 cannot pretend runtime evidence exists.
98
+ */
99
+ export function safetyDefaultsFor(passthrough = []) {
100
+ const entryClaim = flagValue(passthrough, '--entry-claim')
101
+ const claimProvided = hasFlag(passthrough, '--entry-claim')
102
+ const runtimeRequested = hasFlag(passthrough, '--runtime')
103
+ || hasFlag(passthrough, '--host')
104
+ || entryClaim === 'runtime'
105
+ const noExecRequested = hasFlag(passthrough, '--no-exec')
106
+ const args = []
107
+ if (!runtimeRequested) {
108
+ if (!claimProvided) args.push('--entry-claim', 'static')
109
+ if (!noExecRequested) args.push('--no-exec')
110
+ }
111
+ return {
112
+ args,
113
+ defaulted: !runtimeRequested,
114
+ runtimeRequested,
115
+ entryClaim: entryClaim ?? (runtimeRequested ? 'runtime' : 'static'),
116
+ noExec: runtimeRequested ? noExecRequested : true,
117
+ }
118
+ }
119
+
120
+ function normalizeVersion(value, label) {
121
+ const normalized = String(value ?? '').trim().replace(/^v/, '')
122
+ if (normalized === '' || parseVersion(normalized) === null) {
123
+ throw new Error(`${label} must be an exact semver version`)
124
+ }
125
+ return normalized
126
+ }
127
+
128
+ function realpathOrNull(value) {
129
+ try { return fs.realpathSync(value) } catch { return null }
130
+ }
131
+
132
+ /**
133
+ * Turn a `dsh` PATH entry into the JS file that `node` can run. npm installs a
134
+ * shell/cmd shim, so running the shim itself with node would fail; the shim's
135
+ * text points at `node_modules/@deepseek-ai/dsh/lib/bin.js`.
136
+ */
137
+ function nodeEntryOf(candidate) {
138
+ const resolved = realpathOrNull(candidate)
139
+ if (!resolved) return null
140
+ if (/\.(?:mjs|cjs|js)$/i.test(resolved)) return resolved
141
+ let head = ''
142
+ try { head = fs.readFileSync(resolved, 'utf8').slice(0, 8192) } catch { return null }
143
+ if (/^#![^\n]*\bnode\b/m.test(head)) return resolved
144
+ const shimRelative = head.match(/node_modules[\\/]@deepseek-ai[\\/]dsh[\\/][^\s"']+\.js/)
145
+ if (shimRelative) {
146
+ const fromShim = path.join(path.dirname(resolved), shimRelative[0].replace(/\\/g, '/'))
147
+ if (fs.existsSync(fromShim)) return fromShim
148
+ }
149
+ const fallback = path.join(path.dirname(resolved), 'node_modules', '@deepseek-ai', 'dsh', 'lib', 'bin.js')
150
+ return fs.existsSync(fallback) ? fallback : null
151
+ }
152
+
153
+ function resolveDshEntry(env = process.env) {
154
+ const candidates = []
155
+ if (typeof env.DSH_BIN === 'string' && env.DSH_BIN.trim() !== '') candidates.push(env.DSH_BIN.trim())
156
+ try {
157
+ const require = createRequire(path.join(process.cwd(), 'noop.cjs'))
158
+ candidates.push(require.resolve('@deepseek-ai/dsh/lib/bin.js'))
159
+ } catch { /* not installed next to the caller */ }
160
+ const names = process.platform === 'win32' ? ['dsh.cmd', 'dsh.exe', 'dsh'] : ['dsh']
161
+ for (const dir of String(env.PATH ?? env.Path ?? '').split(path.delimiter)) {
162
+ if (dir.trim() === '') continue
163
+ for (const name of names) candidates.push(path.join(dir, name))
164
+ }
165
+ for (const candidate of candidates) {
166
+ const entry = nodeEntryOf(candidate)
167
+ if (entry) return entry
168
+ }
169
+ return null
170
+ }
171
+
172
+ /** Opt-in only: resolve `dsh` and run its JS entry as `node <entry> --version`. */
173
+ export function probeHostVersion(env = process.env) {
174
+ const entry = resolveDshEntry(env)
175
+ if (!entry) return { version: null, source: 'none', error: 'no resolvable dsh JS entry' }
176
+ const run = spawnSync(process.execPath, [entry, '--version'], {
177
+ encoding: 'utf8',
178
+ timeout: HOST_PROBE_TIMEOUT_MS,
179
+ env: sanitizedEnv(),
180
+ })
181
+ if (run.error || run.status !== 0) {
182
+ return { version: null, source: 'none', error: run.error?.message ?? `dsh --version exited ${run.status}` }
183
+ }
184
+ const match = /(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?)/.exec(`${run.stdout ?? ''}\n${run.stderr ?? ''}`)
185
+ if (!match) return { version: null, source: 'none', error: 'no semver in the probed dsh output' }
186
+ return { version: match[1], source: 'probe', entry, error: null }
187
+ }
188
+
189
+ /**
190
+ * Priority: explicit flag, environment, opt-in probe, then unknown. An invalid
191
+ * environment value is reported as unknown rather than failing the whole run.
192
+ */
193
+ export function resolveHostVersion({ hostVersion = null, allowHostProbe = false, env = process.env } = {}) {
194
+ if (hostVersion !== null && hostVersion !== undefined) {
195
+ return { version: normalizeVersion(hostVersion, '--host-version'), source: 'flag', error: null }
196
+ }
197
+ const fromEnv = typeof env.DSH_HOST_VERSION === 'string' ? env.DSH_HOST_VERSION.trim() : ''
198
+ if (fromEnv !== '') {
199
+ try {
200
+ return { version: normalizeVersion(fromEnv, 'DSH_HOST_VERSION'), source: 'env', error: null }
201
+ } catch {
202
+ return { version: null, source: 'none', error: 'DSH_HOST_VERSION is not an exact semver; ignored' }
203
+ }
204
+ }
205
+ if (allowHostProbe) return probeHostVersion(env)
206
+ return { version: null, source: 'none', error: 'host version unknown' }
207
+ }
208
+
209
+ export function parseCheckArgs(argv = []) {
210
+ const options = {
211
+ dir: null, json: false, html: null, help: false, hostVersion: null, allowHostProbe: false, passthrough: [],
212
+ }
213
+ for (let index = 0; index < argv.length; index += 1) {
214
+ const arg = argv[index]
215
+ if (arg === '--help' || arg === '-h') { options.help = true; continue }
216
+ if (arg === '--json') { options.json = true; continue }
217
+ if (arg === '--allow-host-probe' || arg.startsWith('--allow-host-probe=')) {
218
+ const inline = arg.startsWith('--allow-host-probe=') ? arg.slice('--allow-host-probe='.length) : undefined
219
+ options.allowHostProbe = inline === undefined ? true : inline !== 'false'
220
+ continue
221
+ }
222
+ if (arg === '--host-version' || arg.startsWith('--host-version=')) {
223
+ const inline = arg.startsWith('--host-version=') ? arg.slice('--host-version='.length) : undefined
224
+ if (inline !== undefined) {
225
+ if (inline.trim() === '') throw new Error('--host-version needs a value')
226
+ options.hostVersion = inline
227
+ } else {
228
+ const next = argv[index + 1]
229
+ if (next === undefined || next.startsWith('--')) throw new Error('--host-version needs a value')
230
+ options.hostVersion = next
231
+ index += 1
232
+ }
233
+ continue
234
+ }
235
+ if (arg === '--html' || arg.startsWith('--html=')) {
236
+ const inline = arg.startsWith('--html=') ? arg.slice('--html='.length) : undefined
237
+ if (inline !== undefined) {
238
+ if (inline.trim() === '') throw new Error('--html needs a file path')
239
+ options.html = inline
240
+ } else {
241
+ const next = argv[index + 1]
242
+ if (next === undefined || next.startsWith('--')) throw new Error('--html needs a file path')
243
+ options.html = next
244
+ index += 1
245
+ }
246
+ continue
247
+ }
248
+ if (arg === '--format' || arg.startsWith('--format=')) throw new Error('`check` owns the output format; use --json or --html <file>')
249
+ if (arg === '--out' || arg.startsWith('--out=')) throw new Error('`check` writes to stdout or --html <file>; --out is not forwarded')
250
+ if (arg.startsWith('-')) {
251
+ options.passthrough.push(arg)
252
+ if (arg.includes('=')) continue
253
+ if (VALUE_FLAGS.has(arg)) {
254
+ const next = argv[index + 1]
255
+ if (next === undefined) throw new Error(`${arg} needs a value`)
256
+ options.passthrough.push(next)
257
+ index += 1
258
+ }
259
+ continue
260
+ }
261
+ if (options.dir !== null) throw new Error(`unexpected positional argument: ${arg}`)
262
+ options.dir = arg
263
+ }
264
+ if (options.json && options.html) throw new Error('--json and --html are mutually exclusive')
265
+ if (options.dir === null) options.dir = '.'
266
+ options.dir = path.resolve(options.dir)
267
+ if (options.hostVersion !== null) options.hostVersion = normalizeVersion(options.hostVersion, '--host-version')
268
+ return options
269
+ }
270
+
271
+ function invokeChecker(options, forwarded) {
272
+ const args = [CHECKER, options.dir, '--format', 'json', ...forwarded]
273
+ const run = spawnSync(process.execPath, args, {
274
+ encoding: 'utf8',
275
+ timeout: 600_000,
276
+ maxBuffer: MAX_BUFFER,
277
+ // Never let the checker fall back to the PATH `dsh`: the anchor is resolved
278
+ // here, under --host-version / DSH_HOST_VERSION / --allow-host-probe.
279
+ env: sanitizedEnv({ DSH_BIN: NO_PROBE_BIN }),
280
+ })
281
+ if (run.error) throw new Error(`could not run the checker: ${run.error.message}`)
282
+ let report
283
+ try {
284
+ report = JSON.parse(run.stdout)
285
+ } catch {
286
+ throw new Error(`checker produced no JSON (exit ${run.status}): ${String(run.stderr ?? '').slice(0, 400)}`)
287
+ }
288
+ return { report, status: run.status }
289
+ }
290
+
291
+ function applyHostAnchor(report, options, hostInfo) {
292
+ const anchor = report.anchor ?? {}
293
+ const runtimeVersionExplicit = hasFlag(options.passthrough, '--runtime-version')
294
+ if (hostInfo.version) {
295
+ if (!runtimeVersionExplicit) {
296
+ anchor.dsh = hostInfo.version
297
+ anchor.runtimeSource = 'host'
298
+ anchor.suspect = false
299
+ } else {
300
+ anchor.suspect = anchor.dsh !== hostInfo.version
301
+ }
302
+ anchor.hostVersion = hostInfo.version
303
+ anchor.hostVersionKnown = true
304
+ anchor.hostVersionSource = hostInfo.source
305
+ } else {
306
+ if (!runtimeVersionExplicit) {
307
+ anchor.runtimeSource = 'default'
308
+ anchor.suspect = true
309
+ }
310
+ anchor.hostVersion = null
311
+ anchor.hostVersionKnown = false
312
+ anchor.hostVersionSource = 'none'
313
+ }
314
+ report.anchor = anchor
315
+ return runtimeVersionExplicit
316
+ }
317
+
318
+ function applySafety(report, options, safety, hostInfo, runtimeVersionExplicit) {
319
+ applyHostAnchor(report, options, hostInfo)
320
+ const runtimeRan = (report.checks ?? []).some(check => RUNTIME_IDS.has(check.id)
321
+ && ['PASS', 'WARN', 'FAIL'].includes(check.result)
322
+ && check.coverage !== 'not-run')
323
+ const hostSmoke = hasFlag(options.passthrough, '--host') && !hasFlag(options.passthrough, '--no-exec')
324
+ const executed = !safety.noExec && (runtimeRan || hostSmoke)
325
+ report.safety = {
326
+ executed,
327
+ defaultSafety: safety.defaulted,
328
+ entryClaim: safety.entryClaim,
329
+ noExec: !executed,
330
+ hostVersionKnown: Boolean(hostInfo.version),
331
+ hostVersionSource: hostInfo.source,
332
+ hostProbeAllowed: options.allowHostProbe,
333
+ runtimeVersionExplicit,
334
+ }
335
+ if (hostInfo.error) report.safety.hostVersionError = hostInfo.error
336
+ return report
337
+ }
338
+
339
+ function safetyBanner(report) {
340
+ const safety = report.safety ?? {}
341
+ const anchor = report.anchor ?? {}
342
+ const lines = []
343
+ if (safety.executed) {
344
+ lines.push('> ⚠️ 本次已执行插件代码(显式开启运行时;L1–L3 在隔离子进程中运行,超时硬杀)。')
345
+ } else if (safety.defaultSafety) {
346
+ lines.push('> ⚠️ 本次未执行插件代码(默认安全模式:`--entry-claim static --no-exec`;运行时检查记为 not-run,不是 PASS)。')
347
+ } else {
348
+ lines.push('> ⚠️ 本次未执行插件代码(运行时检查没有产生完整装载证据;not-run 不是 PASS)。')
349
+ }
350
+ if (!safety.hostVersionKnown) {
351
+ lines.push(`> host 版本未知:anchor.dsh=\`${anchor.dsh ?? '?'}\` 是默认值(source=${anchor.runtimeSource ?? 'default'}, suspect=${anchor.suspect ?? true})。用 \`--host-version\`、\`DSH_HOST_VERSION\` 或 \`--allow-host-probe\` 指定宿主版本。`)
352
+ } else {
353
+ lines.push(`> host 版本 \`${anchor.dsh}\`(来源:${anchor.hostVersionSource ?? 'host'});anchor.source=${anchor.runtimeSource ?? 'host'}。`)
354
+ }
355
+ return lines.join('\n')
356
+ }
357
+
358
+ function insertBanner(markdown, banner) {
359
+ const newline = markdown.indexOf('\n')
360
+ if (newline < 0) return `${markdown}\n\n${banner}\n`
361
+ return `${markdown.slice(0, newline + 1)}\n${banner}\n${markdown.slice(newline + 1)}`
362
+ }
363
+
364
+ export function runCheck(argv = process.argv.slice(2)) {
365
+ let options
366
+ try {
367
+ options = parseCheckArgs(argv)
368
+ } catch (error) {
369
+ process.stderr.write(`dsh-standards check: ${error.message}\n`)
370
+ process.stderr.write(checkUsage())
371
+ return 3
372
+ }
373
+ if (options.help) {
374
+ process.stdout.write(checkUsage())
375
+ return 0
376
+ }
377
+ let result
378
+ let hostInfo
379
+ let safety
380
+ try {
381
+ safety = safetyDefaultsFor(options.passthrough)
382
+ hostInfo = resolveHostVersion({ hostVersion: options.hostVersion, allowHostProbe: options.allowHostProbe })
383
+ const forwarded = [...safety.args, ...options.passthrough]
384
+ if (hostInfo.version && !hasFlag(options.passthrough, '--runtime-version')) {
385
+ forwarded.push('--runtime-version', hostInfo.version)
386
+ }
387
+ result = invokeChecker(options, forwarded)
388
+ } catch (error) {
389
+ process.stderr.write(`dsh-standards check: ${error.message}\n`)
390
+ return 3
391
+ }
392
+ const { report, status } = result
393
+ applySafety(report, options, safety, hostInfo, hasFlag(options.passthrough, '--runtime-version'))
394
+ const exitCode = Number.isInteger(report?.conclusion?.exitCode) ? report.conclusion.exitCode : (status ?? 3)
395
+ if (options.html) {
396
+ const out = path.resolve(options.html)
397
+ fs.mkdirSync(path.dirname(out), { recursive: true })
398
+ fs.writeFileSync(out, renderHtml(report))
399
+ process.stdout.write(`wrote ${out}\n`)
400
+ } else if (options.json) {
401
+ process.stdout.write(`${JSON.stringify(report, null, 2)}\n`)
402
+ } else {
403
+ process.stdout.write(insertBanner(renderMarkdown(report), safetyBanner(report)))
404
+ }
405
+ return exitCode
406
+ }