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.
- package/40-checker/README.md +18 -1
- package/40-checker/scripts/build-offline-kit.mjs +284 -0
- package/40-checker/scripts/build-sidecar.mjs +714 -0
- package/40-checker/scripts/flow-collect.mjs +7 -1
- package/40-checker/scripts/level-audit.mjs +45 -7
- package/40-checker/scripts/run-tests.mjs +3 -7
- package/40-checker/src/author-cli.mjs +406 -0
- package/40-checker/src/author-report.mjs +271 -0
- package/40-checker/src/bundle-static.mjs +181 -0
- package/40-checker/src/cli.mjs +100 -11
- package/40-checker/src/client-shape.mjs +33 -3
- package/40-checker/src/declarations.mjs +53 -8
- package/40-checker/src/flow-evidence.mjs +106 -1
- package/40-checker/src/index.mjs +6 -4
- package/40-checker/src/manifest.mjs +87 -14
- package/40-checker/src/remediation.mjs +108 -7
- package/40-checker/src/report.mjs +203 -26
- package/40-checker/src/semver.mjs +112 -65
- package/40-checker/src/util.mjs +51 -8
- package/CHANGELOG.md +44 -0
- package/README.en.md +7 -2
- package/README.es.md +7 -2
- package/README.hi.md +7 -2
- package/README.md +35 -18
- package/README.pt.md +7 -2
- package/bin/dsh-standards.mjs +13 -2
- package/docs/assets/dsh-standards-flow-v3-dark.svg +87 -0
- package/docs/assets/dsh-standards-flow-v3-light.svg +87 -0
- package/docs/assets/dsh-standards-flow-v4-dark.svg +79 -0
- package/docs/assets/dsh-standards-flow-v4-light.svg +79 -0
- package/docs/assets/dsh-standards-hero-en-dark.webp +0 -0
- package/docs/assets/dsh-standards-hero-en-light.webp +0 -0
- package/docs/assets/dsh-standards-hero-zh-dark.webp +0 -0
- package/docs/assets/dsh-standards-hero-zh-light.webp +0 -0
- package/docs/coverage.md +82 -0
- package/package.json +1 -1
- package/spec/README.md +2 -0
- package/spec/coverage-contract.md +100 -0
- package/spec/dsc-clauses.json +766 -0
- package/spec/examples/boost-card.example.json +15 -0
- package/spec/examples/flow-evidence.example.json +113 -0
- package/spec/flow-evidence.schema.json +6 -2
- package/spec/remediation.json +54 -54
- package/spec/schemas/advice.schema.json +92 -0
- package/spec/schemas/boost-card.schema.json +107 -0
- 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 (
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
11
|
-
|
|
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('
|
|
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
|
+
}
|