dsh-standards 1.2.2 → 1.3.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 +484 -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 +139 -0
- package/40-checker/src/author-report.mjs +260 -0
- package/40-checker/src/cli.mjs +73 -8
- package/40-checker/src/flow-evidence.mjs +106 -1
- package/40-checker/src/index.mjs +3 -3
- package/40-checker/src/report.mjs +125 -24
- package/CHANGELOG.md +21 -0
- package/README.en.md +7 -2
- package/README.es.md +7 -2
- package/README.hi.md +7 -2
- package/README.md +7 -2
- 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/package.json +1 -1
- package/spec/README.md +2 -0
- package/spec/flow-evidence.schema.json +6 -2
- package/spec/schemas/advice.schema.json +92 -0
|
@@ -0,0 +1,139 @@
|
|
|
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
|
+
import fs from 'node:fs'
|
|
10
|
+
import path from 'node:path'
|
|
11
|
+
import { spawnSync } from 'node:child_process'
|
|
12
|
+
import { fileURLToPath } from 'node:url'
|
|
13
|
+
import { renderHtml, renderMarkdown } from './author-report.mjs'
|
|
14
|
+
|
|
15
|
+
const HERE = path.dirname(fileURLToPath(import.meta.url))
|
|
16
|
+
const CHECKER = path.resolve(HERE, '..', 'bin', 'dsh-plugin-verify.mjs')
|
|
17
|
+
const MAX_BUFFER = 64 * 1024 * 1024
|
|
18
|
+
|
|
19
|
+
/** Checker flags that take a value, so `check --scope entry` is not read as a directory. */
|
|
20
|
+
const VALUE_FLAGS = new Set([
|
|
21
|
+
'--scope', '--profile', '--runtime', '--runtime-version', '--cordis-version', '--commit',
|
|
22
|
+
'--install-spec', '--market-index', '--plugin-name', '--timeout', '--host', '--base',
|
|
23
|
+
'--web', '--bare', '--entry-runtime', '--entry-claim',
|
|
24
|
+
])
|
|
25
|
+
|
|
26
|
+
export function checkUsage() {
|
|
27
|
+
return `dsh-standards check — author-facing standards report
|
|
28
|
+
|
|
29
|
+
Usage:
|
|
30
|
+
dsh-standards check [dir] [options]
|
|
31
|
+
|
|
32
|
+
dir defaults to the current directory. Output defaults to Markdown.
|
|
33
|
+
|
|
34
|
+
Options:
|
|
35
|
+
--json print the raw dsh-conformance/1 report
|
|
36
|
+
--html <file> write a single-file HTML report (inline CSS, no assets)
|
|
37
|
+
--help show this help
|
|
38
|
+
|
|
39
|
+
Any other flag is forwarded to the checker, e.g.:
|
|
40
|
+
--scope entry --entry-claim static --no-exec
|
|
41
|
+
--runtime-version 0.2.2-rc.1
|
|
42
|
+
--market-index index.json --plugin-name @scope/name
|
|
43
|
+
|
|
44
|
+
Exit codes (same as the checker):
|
|
45
|
+
0 completed, no FAIL (PASS or WARN)
|
|
46
|
+
1 completed, at least one FAIL
|
|
47
|
+
2 incomplete: a mandatory check did not run
|
|
48
|
+
3 usage error
|
|
49
|
+
`
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function parseCheckArgs(argv = []) {
|
|
53
|
+
const options = { dir: null, json: false, html: null, help: false, passthrough: [] }
|
|
54
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
55
|
+
const arg = argv[index]
|
|
56
|
+
if (arg === '--help' || arg === '-h') { options.help = true; continue }
|
|
57
|
+
if (arg === '--json') { options.json = true; continue }
|
|
58
|
+
if (arg === '--html' || arg.startsWith('--html=')) {
|
|
59
|
+
const inline = arg.startsWith('--html=') ? arg.slice('--html='.length) : undefined
|
|
60
|
+
if (inline !== undefined) {
|
|
61
|
+
if (inline.trim() === '') throw new Error('--html needs a file path')
|
|
62
|
+
options.html = inline
|
|
63
|
+
} else {
|
|
64
|
+
const next = argv[index + 1]
|
|
65
|
+
if (next === undefined || next.startsWith('--')) throw new Error('--html needs a file path')
|
|
66
|
+
options.html = next
|
|
67
|
+
index += 1
|
|
68
|
+
}
|
|
69
|
+
continue
|
|
70
|
+
}
|
|
71
|
+
if (arg === '--format' || arg.startsWith('--format=')) throw new Error('`check` owns the output format; use --json or --html <file>')
|
|
72
|
+
if (arg === '--out' || arg.startsWith('--out=')) throw new Error('`check` writes to stdout or --html <file>; --out is not forwarded')
|
|
73
|
+
if (arg.startsWith('-')) {
|
|
74
|
+
options.passthrough.push(arg)
|
|
75
|
+
if (arg.includes('=')) continue
|
|
76
|
+
if (VALUE_FLAGS.has(arg)) {
|
|
77
|
+
const next = argv[index + 1]
|
|
78
|
+
if (next === undefined) throw new Error(`${arg} needs a value`)
|
|
79
|
+
options.passthrough.push(next)
|
|
80
|
+
index += 1
|
|
81
|
+
}
|
|
82
|
+
continue
|
|
83
|
+
}
|
|
84
|
+
if (options.dir !== null) throw new Error(`unexpected positional argument: ${arg}`)
|
|
85
|
+
options.dir = arg
|
|
86
|
+
}
|
|
87
|
+
if (options.json && options.html) throw new Error('--json and --html are mutually exclusive')
|
|
88
|
+
if (options.dir === null) options.dir = '.'
|
|
89
|
+
options.dir = path.resolve(options.dir)
|
|
90
|
+
return options
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function invokeChecker(options) {
|
|
94
|
+
const args = [CHECKER, options.dir, '--format', 'json', ...options.passthrough]
|
|
95
|
+
const run = spawnSync(process.execPath, args, { encoding: 'utf8', timeout: 600_000, maxBuffer: MAX_BUFFER })
|
|
96
|
+
if (run.error) throw new Error(`could not run the checker: ${run.error.message}`)
|
|
97
|
+
let report
|
|
98
|
+
try {
|
|
99
|
+
report = JSON.parse(run.stdout)
|
|
100
|
+
} catch {
|
|
101
|
+
throw new Error(`checker produced no JSON (exit ${run.status}): ${String(run.stderr ?? '').slice(0, 400)}`)
|
|
102
|
+
}
|
|
103
|
+
return { report, status: run.status }
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function runCheck(argv = process.argv.slice(2)) {
|
|
107
|
+
let options
|
|
108
|
+
try {
|
|
109
|
+
options = parseCheckArgs(argv)
|
|
110
|
+
} catch (error) {
|
|
111
|
+
process.stderr.write(`dsh-standards check: ${error.message}\n`)
|
|
112
|
+
process.stderr.write(checkUsage())
|
|
113
|
+
return 3
|
|
114
|
+
}
|
|
115
|
+
if (options.help) {
|
|
116
|
+
process.stdout.write(checkUsage())
|
|
117
|
+
return 0
|
|
118
|
+
}
|
|
119
|
+
let result
|
|
120
|
+
try {
|
|
121
|
+
result = invokeChecker(options)
|
|
122
|
+
} catch (error) {
|
|
123
|
+
process.stderr.write(`dsh-standards check: ${error.message}\n`)
|
|
124
|
+
return 3
|
|
125
|
+
}
|
|
126
|
+
const { report, status } = result
|
|
127
|
+
const exitCode = Number.isInteger(report?.conclusion?.exitCode) ? report.conclusion.exitCode : (status ?? 3)
|
|
128
|
+
if (options.html) {
|
|
129
|
+
const out = path.resolve(options.html)
|
|
130
|
+
fs.mkdirSync(path.dirname(out), { recursive: true })
|
|
131
|
+
fs.writeFileSync(out, renderHtml(report))
|
|
132
|
+
process.stdout.write(`wrote ${out}\n`)
|
|
133
|
+
} else if (options.json) {
|
|
134
|
+
process.stdout.write(`${JSON.stringify(report, null, 2)}\n`)
|
|
135
|
+
} else {
|
|
136
|
+
process.stdout.write(renderMarkdown(report))
|
|
137
|
+
}
|
|
138
|
+
return exitCode
|
|
139
|
+
}
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Author-facing renderers for a `dsh-conformance/1` report.
|
|
3
|
+
*
|
|
4
|
+
* Markdown is the default `dsh-standards check` output; HTML is a single
|
|
5
|
+
* self-contained file with inline CSS, no scripts, no external assets, and
|
|
6
|
+
* every dynamic value escaped. Both read the same report object the checker
|
|
7
|
+
* already produced, so the verdict and exit code never change here.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const DISCLAIMER =
|
|
11
|
+
'Non-official report. This is not a certification, security audit, endorsement, or ranking. '
|
|
12
|
+
+ 'Hygiene warnings are not a clean pass; items under "Not checked" were not inspected.'
|
|
13
|
+
|
|
14
|
+
function text(value) {
|
|
15
|
+
return String(value ?? '').replace(/\s+/g, ' ').trim()
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function mdCell(value) {
|
|
19
|
+
return text(value).replace(/\|/g, '\\|')
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function escapeHtml(value) {
|
|
23
|
+
return String(value ?? '').replace(/[&<>"']/g, character => ({
|
|
24
|
+
'&': '&',
|
|
25
|
+
'<': '<',
|
|
26
|
+
'>': '>',
|
|
27
|
+
'"': '"',
|
|
28
|
+
"'": ''',
|
|
29
|
+
}[character]))
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function headlineOf(report) {
|
|
33
|
+
return text(report?.conclusion?.headline) || text(report?.conclusion?.reason) || 'no conclusion'
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function priorityFixes(report, limit = 3) {
|
|
37
|
+
const advice = Array.isArray(report?.advice) ? report.advice : []
|
|
38
|
+
const seen = new Set()
|
|
39
|
+
const fixes = []
|
|
40
|
+
for (const item of advice) {
|
|
41
|
+
const key = item?.key ?? item?.title
|
|
42
|
+
if (key === undefined || seen.has(key)) continue
|
|
43
|
+
seen.add(key)
|
|
44
|
+
fixes.push(item)
|
|
45
|
+
if (fixes.length >= limit) break
|
|
46
|
+
}
|
|
47
|
+
if (fixes.length > 0) return fixes
|
|
48
|
+
// Advice may be disabled; fall back to the FAIL/WARN rows themselves.
|
|
49
|
+
const rows = [...(report?.buckets?.blocking ?? []), ...(report?.buckets?.hygiene ?? [])]
|
|
50
|
+
for (const row of rows) {
|
|
51
|
+
if (fixes.length >= limit) break
|
|
52
|
+
fixes.push({ key: row.id, gate: row.result, severity: row.result, source: row.id, fix: row.reason, why: null, docsUrl: null })
|
|
53
|
+
}
|
|
54
|
+
return fixes
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function passedIds(report) {
|
|
58
|
+
return (report?.checks ?? []).filter(item => item.result === 'PASS').map(item => item.id)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function adviceRows(report) {
|
|
62
|
+
return Array.isArray(report?.advice) ? report.advice : []
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const BUCKETS = [
|
|
66
|
+
{ key: 'blocking', title: 'Blocking (FAIL)' },
|
|
67
|
+
{ key: 'hygiene', title: 'Hygiene (WARN)' },
|
|
68
|
+
{ key: 'notChecked', title: 'Not checked (NA / not-run / ERROR)' },
|
|
69
|
+
]
|
|
70
|
+
|
|
71
|
+
function bucketRows(report, key) {
|
|
72
|
+
return Array.isArray(report?.buckets?.[key]) ? report.buckets[key] : []
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function renderMarkdown(report) {
|
|
76
|
+
const subject = report?.subject ?? {}
|
|
77
|
+
const conclusion = report?.conclusion ?? {}
|
|
78
|
+
const coverage = report?.coverage ?? {}
|
|
79
|
+
const metrics = report?.metrics ?? {}
|
|
80
|
+
const anchor = report?.anchor ?? {}
|
|
81
|
+
const lines = []
|
|
82
|
+
lines.push(`# DSH standards check — ${text(subject.name) || '<unknown>'}@${text(subject.version) || '?'}`)
|
|
83
|
+
lines.push('')
|
|
84
|
+
lines.push(`**Conclusion:** ${headlineOf(report)}`)
|
|
85
|
+
lines.push('')
|
|
86
|
+
lines.push(`- Status: **${text(conclusion.status) || 'unknown'}** (exit ${conclusion.exitCode ?? '?'})`)
|
|
87
|
+
if (conclusion.level) lines.push(`- Level: ${text(conclusion.level)}`)
|
|
88
|
+
lines.push(`- Anchor: dsh=${text(anchor.dsh) || '?'}${anchor.runtimeSource ? ` (source=${anchor.runtimeSource}${anchor.suspect ? ', suspect=true' : ''})` : ''}`)
|
|
89
|
+
lines.push(`- Coverage: ${metrics.checked ?? coverage.checked ?? 0}/${metrics.total ?? coverage.total ?? (report?.checks ?? []).length} checks evaluated · declared fields ${metrics.declaredFields ?? 0}/5`)
|
|
90
|
+
lines.push('')
|
|
91
|
+
|
|
92
|
+
lines.push('## Priority fixes (up to 3)')
|
|
93
|
+
lines.push('')
|
|
94
|
+
const fixes = priorityFixes(report)
|
|
95
|
+
if (fixes.length === 0) {
|
|
96
|
+
lines.push('_None: no FAIL or WARN finding produced advice._')
|
|
97
|
+
} else {
|
|
98
|
+
fixes.forEach((item, index) => {
|
|
99
|
+
lines.push(`${index + 1}. **${text(item.severity ?? item.gate ?? 'WARN')} ${text(item.key) || text(item.title)}** (${text(item.source)}) — ${text(item.fix ?? item.title ?? item.reason)}`)
|
|
100
|
+
if (item.why) lines.push(` - Why: ${text(item.why)}`)
|
|
101
|
+
if (item.docsUrl) lines.push(` - Docs: ${text(item.docsUrl)}`)
|
|
102
|
+
})
|
|
103
|
+
}
|
|
104
|
+
lines.push('')
|
|
105
|
+
|
|
106
|
+
for (const bucket of BUCKETS) {
|
|
107
|
+
const rows = bucketRows(report, bucket.key)
|
|
108
|
+
lines.push(`## ${bucket.title} — ${rows.length}`)
|
|
109
|
+
lines.push('')
|
|
110
|
+
if (rows.length === 0) {
|
|
111
|
+
lines.push('_None._')
|
|
112
|
+
} else {
|
|
113
|
+
lines.push('| id | clause | result | coverage | reason |')
|
|
114
|
+
lines.push('|---|---|---|---|---|')
|
|
115
|
+
for (const row of rows) {
|
|
116
|
+
const tags = [row.declaration ? 'declaration' : null, row.advisory ? 'advisory' : null].filter(Boolean)
|
|
117
|
+
const reason = tags.length > 0 ? `${text(row.reason)} [${tags.join(',')}]` : text(row.reason)
|
|
118
|
+
lines.push(`| ${mdCell(row.id)} | ${mdCell(row.clause ?? '-')} | ${mdCell(row.result)} | ${mdCell(row.coverage ?? '-')} | ${mdCell(reason)} |`)
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
lines.push('')
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const passed = passedIds(report)
|
|
125
|
+
lines.push(`## Passed — ${passed.length}`)
|
|
126
|
+
lines.push('')
|
|
127
|
+
lines.push(passed.length > 0 ? passed.join(', ') : '_None._')
|
|
128
|
+
lines.push('')
|
|
129
|
+
|
|
130
|
+
const advice = adviceRows(report)
|
|
131
|
+
lines.push(`## Advice (non-blocking) — ${advice.length}`)
|
|
132
|
+
lines.push('')
|
|
133
|
+
if (advice.length === 0) {
|
|
134
|
+
lines.push('_None._')
|
|
135
|
+
} else {
|
|
136
|
+
for (const item of advice) {
|
|
137
|
+
const missing = item.ruleMissing ? ' [ruleMissing]' : ''
|
|
138
|
+
lines.push(`- ${text(item.severity)} ${text(item.key)} (${text(item.source)}${missing}): ${text(item.fix ?? item.title ?? '')}`)
|
|
139
|
+
if (item.docsUrl) lines.push(` ${text(item.docsUrl)}`)
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
lines.push('')
|
|
143
|
+
|
|
144
|
+
const notChecked = bucketRows(report, 'notChecked')
|
|
145
|
+
lines.push('## What this report does not check')
|
|
146
|
+
lines.push('')
|
|
147
|
+
if (notChecked.length === 0) {
|
|
148
|
+
lines.push('- No check was skipped in this run.')
|
|
149
|
+
} else {
|
|
150
|
+
for (const row of notChecked) {
|
|
151
|
+
lines.push(`- ${text(row.id)}${row.clause ? ` (${text(row.clause)})` : ''}: ${text(row.reason) || 'not checked'}`)
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
lines.push(`- Runtime execution beyond the checks listed above is not observed by this report${anchor.suspect ? '; the anchor differs from the host and is flagged suspect' : ''}.`)
|
|
155
|
+
lines.push('- It does not certify, rank, audit security, or endorse the package.')
|
|
156
|
+
lines.push('')
|
|
157
|
+
lines.push('---')
|
|
158
|
+
lines.push('')
|
|
159
|
+
lines.push(DISCLAIMER)
|
|
160
|
+
lines.push('')
|
|
161
|
+
return `${lines.join('\n')}\n`
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export function renderHtml(report) {
|
|
165
|
+
const subject = report?.subject ?? {}
|
|
166
|
+
const conclusion = report?.conclusion ?? {}
|
|
167
|
+
const metrics = report?.metrics ?? {}
|
|
168
|
+
const anchor = report?.anchor ?? {}
|
|
169
|
+
const title = `DSH standards check — ${text(subject.name) || '<unknown>'}@${text(subject.version) || '?'}`
|
|
170
|
+
const fixes = priorityFixes(report)
|
|
171
|
+
const advice = adviceRows(report)
|
|
172
|
+
const passed = passedIds(report)
|
|
173
|
+
const notChecked = bucketRows(report, 'notChecked')
|
|
174
|
+
|
|
175
|
+
const table = (key) => {
|
|
176
|
+
const rows = bucketRows(report, key)
|
|
177
|
+
if (rows.length === 0) return '<p class="none">None.</p>'
|
|
178
|
+
const body = rows.map(row => {
|
|
179
|
+
const tags = [row.declaration ? 'declaration' : null, row.advisory ? 'advisory' : null].filter(Boolean)
|
|
180
|
+
const reason = tags.length > 0 ? `${text(row.reason)} [${tags.join(',')}]` : text(row.reason)
|
|
181
|
+
return `<tr><td>${escapeHtml(row.id)}</td><td>${escapeHtml(row.clause ?? '-')}</td><td class="result ${escapeHtml(String(row.result).toLowerCase())}">${escapeHtml(row.result)}</td><td>${escapeHtml(row.coverage ?? '-')}</td><td>${escapeHtml(reason)}</td></tr>`
|
|
182
|
+
}).join('\n')
|
|
183
|
+
return `<table><thead><tr><th>id</th><th>clause</th><th>result</th><th>coverage</th><th>reason</th></tr></thead><tbody>\n${body}\n</tbody></table>`
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const fixList = fixes.length === 0
|
|
187
|
+
? '<p class="none">None: no FAIL or WARN finding produced advice.</p>'
|
|
188
|
+
: `<ol>${fixes.map(item => {
|
|
189
|
+
const why = item.why ? `<br><span class="muted">Why: ${escapeHtml(text(item.why))}</span>` : ''
|
|
190
|
+
const docs = item.docsUrl ? `<br><span class="muted">Docs: ${link(item.docsUrl)}</span>` : ''
|
|
191
|
+
return `<li><strong>${escapeHtml(text(item.severity ?? item.gate ?? 'WARN'))} ${escapeHtml(text(item.key) || text(item.title))}</strong> (${escapeHtml(text(item.source))}) — ${escapeHtml(text(item.fix ?? item.title ?? item.reason))}${why}${docs}</li>`
|
|
192
|
+
}).join('\n')}</ol>`
|
|
193
|
+
|
|
194
|
+
const adviceList = advice.length === 0
|
|
195
|
+
? '<p class="none">None.</p>'
|
|
196
|
+
: `<ul>${advice.map(item => `<li><strong>${escapeHtml(text(item.severity))} ${escapeHtml(text(item.key))}</strong> (${escapeHtml(text(item.source))}${item.ruleMissing ? ' [ruleMissing]' : ''}): ${escapeHtml(text(item.fix ?? item.title ?? ''))}${item.docsUrl ? ` ${link(item.docsUrl)}` : ''}</li>`).join('\n')}</ul>`
|
|
197
|
+
|
|
198
|
+
const notCheckedList = notChecked.length === 0
|
|
199
|
+
? '<li>No check was skipped in this run.</li>'
|
|
200
|
+
: notChecked.map(row => `<li><strong>${escapeHtml(text(row.id))}</strong>${row.clause ? ` (${escapeHtml(text(row.clause))})` : ''}: ${escapeHtml(text(row.reason) || 'not checked')}</li>`).join('\n')
|
|
201
|
+
|
|
202
|
+
return `<!doctype html>
|
|
203
|
+
<html lang="en">
|
|
204
|
+
<head>
|
|
205
|
+
<meta charset="utf-8">
|
|
206
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
207
|
+
<title>${escapeHtml(title)}</title>
|
|
208
|
+
<style>
|
|
209
|
+
:root{color-scheme:light dark}
|
|
210
|
+
body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,"PingFang SC","Microsoft YaHei",sans-serif;max-width:980px;margin:2rem auto;padding:0 1rem;line-height:1.55;color:#1f2328;background:#fff}
|
|
211
|
+
h1{font-size:1.45rem;margin:0 0 .35rem}
|
|
212
|
+
h2{font-size:1.1rem;margin:1.8rem 0 .5rem;border-bottom:1px solid #d0d7de;padding-bottom:.25rem}
|
|
213
|
+
.headline{font-size:1.05rem;font-weight:600;background:#f6f8fa;border:1px solid #d0d7de;border-radius:6px;padding:.65rem .8rem}
|
|
214
|
+
.meta{margin:.6rem 0 0;padding-left:1.1rem;color:#57606a}
|
|
215
|
+
.muted{color:#57606a;font-size:.9rem}
|
|
216
|
+
table{border-collapse:collapse;width:100%;margin:.4rem 0 1rem;font-size:.9rem}
|
|
217
|
+
th,td{border:1px solid #d0d7de;padding:.3rem .5rem;text-align:left;vertical-align:top}
|
|
218
|
+
th{background:#f6f8fa}
|
|
219
|
+
td.result{font-weight:600;white-space:nowrap}
|
|
220
|
+
td.result.fail{color:#b42318}
|
|
221
|
+
td.result.warn{color:#9a6700}
|
|
222
|
+
td.result.na,td.result.error,td.result.not-run{color:#57606a}
|
|
223
|
+
.none{color:#57606a}
|
|
224
|
+
footer{margin-top:2rem;padding-top:.8rem;border-top:1px solid #d0d7de;color:#57606a;font-size:.9rem}
|
|
225
|
+
code{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}
|
|
226
|
+
@media (prefers-color-scheme:dark){body{background:#0d1117;color:#e6edf3}h2,.headline,th,td,footer{border-color:#30363d}.headline,th{background:#161b22}.meta,.muted,.none,footer{color:#8b949e}td.result.fail{color:#ff7b72}td.result.warn{color:#d29922}}
|
|
227
|
+
</style>
|
|
228
|
+
</head>
|
|
229
|
+
<body>
|
|
230
|
+
<h1>${escapeHtml(title)}</h1>
|
|
231
|
+
<p class="headline">${escapeHtml(headlineOf(report))}</p>
|
|
232
|
+
<ul class="meta">
|
|
233
|
+
<li>Status: <strong>${escapeHtml(text(conclusion.status) || 'unknown')}</strong> (exit ${escapeHtml(conclusion.exitCode ?? '?')})${conclusion.level ? ` · Level: ${escapeHtml(text(conclusion.level))}` : ''}</li>
|
|
234
|
+
<li>Anchor: dsh=${escapeHtml(text(anchor.dsh) || '?')}${anchor.runtimeSource ? ` (source=${escapeHtml(anchor.runtimeSource)}${anchor.suspect ? ', suspect=true' : ''})` : ''}</li>
|
|
235
|
+
<li>Coverage: ${escapeHtml(metrics.checked ?? 0)}/${escapeHtml(metrics.total ?? (report?.checks ?? []).length)} checks evaluated · declared fields ${escapeHtml(metrics.declaredFields ?? 0)}/5</li>
|
|
236
|
+
</ul>
|
|
237
|
+
<h2>Priority fixes (up to 3)</h2>
|
|
238
|
+
${fixList}
|
|
239
|
+
${BUCKETS.map(bucket => `<h2>${escapeHtml(bucket.title)} — ${bucketRows(report, bucket.key).length}</h2>\n${table(bucket.key)}`).join('\n')}
|
|
240
|
+
<h2>Passed — ${passed.length}</h2>
|
|
241
|
+
<p>${passed.length > 0 ? passed.map(escapeHtml).join(', ') : '<span class="none">None.</span>'}</p>
|
|
242
|
+
<h2>Advice (non-blocking) — ${advice.length}</h2>
|
|
243
|
+
${adviceList}
|
|
244
|
+
<h2>What this report does not check</h2>
|
|
245
|
+
<ul>
|
|
246
|
+
${notCheckedList}
|
|
247
|
+
<li>Runtime execution beyond the checks listed above is not observed by this report${anchor.suspect ? '; the anchor differs from the host and is flagged suspect' : ''}.</li>
|
|
248
|
+
<li>It does not certify, rank, audit security, or endorse the package.</li>
|
|
249
|
+
</ul>
|
|
250
|
+
<footer>${escapeHtml(DISCLAIMER)}</footer>
|
|
251
|
+
</body>
|
|
252
|
+
</html>
|
|
253
|
+
`
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function link(url) {
|
|
257
|
+
const value = text(url)
|
|
258
|
+
if (!/^https:\/\//i.test(value)) return escapeHtml(value)
|
|
259
|
+
return `<a href="${escapeHtml(value)}" rel="noreferrer noopener">${escapeHtml(value)}</a>`
|
|
260
|
+
}
|
package/40-checker/src/cli.mjs
CHANGED
|
@@ -12,19 +12,28 @@ import { buildReport, writeReport } from './report.mjs'
|
|
|
12
12
|
const VERSION = '0.1.0'
|
|
13
13
|
const DEFAULT_COMMIT = 'ce7073d51254c26cd63b22a60835b8f948deb447'
|
|
14
14
|
const DEFAULT_CORDIS = '4.0.6-alpha.1'
|
|
15
|
+
const HOST_RUNTIME_TIMEOUT_MS = 5000
|
|
16
|
+
|
|
17
|
+
function binName() {
|
|
18
|
+
const brand = process.env.DSH_CHECKER_BRAND
|
|
19
|
+
if (typeof brand === 'string' && brand.trim() !== '') return brand.trim().split(/\s+/)[0]
|
|
20
|
+
return 'dsh-plugin-verify'
|
|
21
|
+
}
|
|
15
22
|
|
|
16
23
|
function usage() {
|
|
24
|
+
const bin = binName()
|
|
17
25
|
return `${process.env.DSH_CHECKER_BRAND || 'dsh-plugin-verify ' + VERSION} — DS-C conformance checker (MVP)
|
|
18
26
|
|
|
19
27
|
Usage:
|
|
20
|
-
|
|
28
|
+
${bin} check [dir] [--json] [--html <file>] author-facing report (Markdown by default)
|
|
29
|
+
${bin} <package-dir> [options] raw conformance report
|
|
21
30
|
|
|
22
31
|
Options:
|
|
23
32
|
--format text|json|sarif output format (default: text)
|
|
24
33
|
--scope mvp|ds-c|entry mvp = 8 MVP ids; ds-c = full claim (default); entry = compatibility gate
|
|
25
34
|
--profile package|listing|both evaluation profile (default: package; listing if --market-index is set)
|
|
26
35
|
--runtime <path> path to a built @deepseek-ai/cordis lib/index.js; enables L1-L3
|
|
27
|
-
--runtime-version <v> dsh runtime version used for C-2 (default: ${DEFAULT_RUNTIME_VERSION})
|
|
36
|
+
--runtime-version <v> dsh runtime version used for C-2 (default: host via \`dsh --version\`, else ${DEFAULT_RUNTIME_VERSION})
|
|
28
37
|
--cordis-version <v> cordis version recorded in the anchor (default: ${DEFAULT_CORDIS})
|
|
29
38
|
--commit <sha> harness commit recorded in the anchor (default: ${DEFAULT_COMMIT.slice(0, 12)})
|
|
30
39
|
--install-spec <spec> recommended install spec for C-3 (listing/install check)
|
|
@@ -61,7 +70,7 @@ function parseArgs(argv) {
|
|
|
61
70
|
scope: 'ds-c',
|
|
62
71
|
profile: null,
|
|
63
72
|
runtime: null,
|
|
64
|
-
runtimeVersion:
|
|
73
|
+
runtimeVersion: null,
|
|
65
74
|
cordisVersion: DEFAULT_CORDIS,
|
|
66
75
|
commit: DEFAULT_COMMIT,
|
|
67
76
|
installSpec: null,
|
|
@@ -223,14 +232,66 @@ async function entryRunCheck(kind, runtimeChecks, options, dir) {
|
|
|
223
232
|
return { ...base, result: 'ERROR', coverage: 'not-run', reason: c4?.reason || 'no runtime evidence' }
|
|
224
233
|
}
|
|
225
234
|
|
|
226
|
-
|
|
235
|
+
/**
|
|
236
|
+
* Read the runtime version the host actually runs. The anchor used to be a
|
|
237
|
+
* hard-coded `0.2.2-alpha.2`, which manufactured dozens of C-2.2 failures on a
|
|
238
|
+
* host that had already moved on. Detection is best-effort: when `dsh` is not
|
|
239
|
+
* usable the anchor falls back to the checked-in constant and says so.
|
|
240
|
+
*/
|
|
241
|
+
export function detectHostRuntime({ bin = process.env.DSH_BIN || (process.platform === 'win32' ? 'dsh.cmd' : 'dsh'), timeoutMs = HOST_RUNTIME_TIMEOUT_MS } = {}) {
|
|
242
|
+
const run = spawnSync(bin, ['--version'], { encoding: 'utf8', timeout: timeoutMs })
|
|
243
|
+
if (run.error || run.status !== 0) return { version: null, bin, error: run.error?.message ?? `exit ${run.status}` }
|
|
244
|
+
const text = `${run.stdout ?? ''}\n${run.stderr ?? ''}`
|
|
245
|
+
const match = /(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?)/.exec(text)
|
|
246
|
+
if (!match) return { version: null, bin, error: 'no semver in `dsh --version` output' }
|
|
247
|
+
return { version: match[1], bin, error: null }
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Resolve what `anchor.dsh` means:
|
|
252
|
+
* cli the caller passed --runtime-version; suspect when it differs from the host
|
|
253
|
+
* host no flag: the host version is the anchor, so it cannot be suspect
|
|
254
|
+
* fallback dsh unavailable: keep the constant, flagged as not host-derived
|
|
255
|
+
*/
|
|
256
|
+
export function resolveRuntimeAnchor(requestedVersion = null, hostVersion = null) {
|
|
257
|
+
if (typeof requestedVersion === 'string' && requestedVersion.trim() !== '') {
|
|
258
|
+
const requested = requestedVersion.trim()
|
|
259
|
+
return {
|
|
260
|
+
dsh: requested,
|
|
261
|
+
runtimeSource: 'cli',
|
|
262
|
+
hostVersion: hostVersion ?? null,
|
|
263
|
+
suspect: typeof hostVersion === 'string' && hostVersion !== '' && hostVersion !== requested,
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
if (typeof hostVersion === 'string' && hostVersion !== '') {
|
|
267
|
+
return { dsh: hostVersion, runtimeSource: 'host', hostVersion, suspect: false }
|
|
268
|
+
}
|
|
269
|
+
return { dsh: DEFAULT_RUNTIME_VERSION, runtimeSource: 'fallback', hostVersion: null, suspect: false }
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** The five optional declaration surfaces, counted but never scored. */
|
|
273
|
+
export function declarationsOf(manifest) {
|
|
274
|
+
const dsh = manifest?.dsh ?? {}
|
|
275
|
+
const present = []
|
|
276
|
+
if (Array.isArray(dsh.capabilities) && dsh.capabilities.length > 0) present.push('capabilities')
|
|
277
|
+
if (Array.isArray(dsh.labels) && dsh.labels.length > 0) present.push('labels')
|
|
278
|
+
if (dsh.commitments && typeof dsh.commitments === 'object' && !Array.isArray(dsh.commitments)) present.push('commitments')
|
|
279
|
+
if (dsh.disclosure && typeof dsh.disclosure === 'object' && !Array.isArray(dsh.disclosure)) present.push('disclosure')
|
|
280
|
+
if (typeof manifest?.engines?.dsh === 'string' && manifest.engines.dsh.trim() !== '') present.push('engines.dsh')
|
|
281
|
+
return { total: 5, present }
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function anchorOf(options, runtimeInfo, runtimePath) {
|
|
227
285
|
return {
|
|
228
286
|
repo: 'deepseek-ai/deepseek-harness',
|
|
229
287
|
commit: options.commit,
|
|
230
288
|
scanner: `dsh-plugin-verify@${VERSION}`,
|
|
231
|
-
dsh:
|
|
289
|
+
dsh: runtimeInfo.dsh,
|
|
232
290
|
cordis: options.cordisVersion,
|
|
233
|
-
runtime:
|
|
291
|
+
runtime: runtimePath ? path.resolve(runtimePath) : null,
|
|
292
|
+
runtimeSource: runtimeInfo.runtimeSource,
|
|
293
|
+
hostVersion: runtimeInfo.hostVersion,
|
|
294
|
+
suspect: runtimeInfo.suspect,
|
|
234
295
|
}
|
|
235
296
|
}
|
|
236
297
|
|
|
@@ -261,8 +322,11 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
261
322
|
return 3
|
|
262
323
|
}
|
|
263
324
|
|
|
325
|
+
const host = detectHostRuntime()
|
|
326
|
+
const runtimeInfo = resolveRuntimeAnchor(options.runtimeVersion, host.version)
|
|
327
|
+
|
|
264
328
|
const { manifest, kind, kindInferred, entry, patch, g4, checks: manifestChecks } = runManifestChecks(dir, {
|
|
265
|
-
runtimeVersion:
|
|
329
|
+
runtimeVersion: runtimeInfo.dsh,
|
|
266
330
|
installSpec: options.installSpec,
|
|
267
331
|
marketIndex: options.marketIndex,
|
|
268
332
|
pluginName: options.pluginName,
|
|
@@ -341,7 +405,7 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
341
405
|
}
|
|
342
406
|
const report = buildReport({
|
|
343
407
|
subject,
|
|
344
|
-
anchor: anchorOf(options, options.runtime),
|
|
408
|
+
anchor: anchorOf(options, runtimeInfo, options.runtime),
|
|
345
409
|
checks,
|
|
346
410
|
g4,
|
|
347
411
|
scope: options.scope,
|
|
@@ -351,6 +415,7 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
351
415
|
entryRuntime: options.entryRuntime,
|
|
352
416
|
entryClaim: options.entryClaim,
|
|
353
417
|
advice: options.advice,
|
|
418
|
+
declarations: declarationsOf(manifest),
|
|
354
419
|
})
|
|
355
420
|
const output = writeReport(report, options.format)
|
|
356
421
|
if (options.out) {
|
|
@@ -25,7 +25,9 @@
|
|
|
25
25
|
*/
|
|
26
26
|
import crypto from 'node:crypto'
|
|
27
27
|
import fs from 'node:fs'
|
|
28
|
+
import os from 'node:os'
|
|
28
29
|
import path from 'node:path'
|
|
30
|
+
import { spawnSync } from 'node:child_process'
|
|
29
31
|
|
|
30
32
|
export const FLOW_IDS = ['J1', 'J2', 'J3', 'J4', 'J5', 'J6', 'J7', 'J8']
|
|
31
33
|
export const FLOW_EVIDENCE_SCHEMA = 'dsh-flow-evidence/2'
|
|
@@ -144,6 +146,103 @@ export function treeSha256(dir, { exclude = [] } = {}) {
|
|
|
144
146
|
return { hash: sha256Value(rows.join('\n')), files: rows.length, bytes }
|
|
145
147
|
}
|
|
146
148
|
|
|
149
|
+
const NPM = process.platform === 'win32' ? 'npm.cmd' : 'npm'
|
|
150
|
+
|
|
151
|
+
function subjectFields(subject) {
|
|
152
|
+
if (!isPlainObject(subject)) return null
|
|
153
|
+
return {
|
|
154
|
+
name: subject.name ?? null,
|
|
155
|
+
version: subject.version ?? null,
|
|
156
|
+
integrity: subject.integrity ?? null,
|
|
157
|
+
tarballSha256: subject.tarballSha256 ?? null,
|
|
158
|
+
treeSha256: subject.treeSha256 ?? null,
|
|
159
|
+
commit: subject.commit ?? null,
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Recompute the subject binding from a package directory: package.json
|
|
165
|
+
* name/version, the deterministic tree hash, and the npm-pack tarball hash +
|
|
166
|
+
* integrity. npm packs the same tree byte-identically, so this is the same
|
|
167
|
+
* binding `dsh-flow-collect` recorded.
|
|
168
|
+
*/
|
|
169
|
+
export function recomputeSubject(dir, { exclude = [], timeoutMs = 120_000 } = {}) {
|
|
170
|
+
const root = path.resolve(dir)
|
|
171
|
+
const manifest = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8'))
|
|
172
|
+
const tree = treeSha256(root, { exclude })
|
|
173
|
+
const destination = fs.mkdtempSync(path.join(os.tmpdir(), 'dsh-subject-'))
|
|
174
|
+
try {
|
|
175
|
+
const run = spawnSync(NPM, ['pack', '--ignore-scripts', '--json', '--pack-destination', destination], {
|
|
176
|
+
cwd: root, encoding: 'utf8', timeout: timeoutMs, maxBuffer: 16 * 1024 * 1024,
|
|
177
|
+
})
|
|
178
|
+
if (run.error) throw new Error(`npm pack failed to start: ${run.error.message}`)
|
|
179
|
+
if (run.status !== 0) throw new Error(`npm pack failed (exit ${run.status}): ${String(run.stderr ?? '').replace(/\s+/g, ' ').slice(0, 300)}`)
|
|
180
|
+
let parsed
|
|
181
|
+
try { parsed = JSON.parse(run.stdout) } catch { throw new Error('npm pack did not print JSON') }
|
|
182
|
+
const info = Array.isArray(parsed) ? parsed[0] : parsed
|
|
183
|
+
if (!info?.filename) throw new Error('npm pack did not report a tarball filename')
|
|
184
|
+
return {
|
|
185
|
+
name: typeof manifest.name === 'string' ? manifest.name : null,
|
|
186
|
+
version: typeof manifest.version === 'string' ? manifest.version : null,
|
|
187
|
+
treeSha256: tree.hash,
|
|
188
|
+
tarballSha256: sha256File(path.join(destination, info.filename)),
|
|
189
|
+
integrity: typeof info.integrity === 'string' ? info.integrity : null,
|
|
190
|
+
}
|
|
191
|
+
} finally {
|
|
192
|
+
fs.rmSync(destination, { recursive: true, force: true })
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Bind a flow-evidence document to a package directory. Any mismatch in
|
|
198
|
+
* name/version/treeSha256/tarballSha256 (or a declared integrity) is a hard
|
|
199
|
+
* failure: the audit must not accept evidence for a different subject.
|
|
200
|
+
*/
|
|
201
|
+
export function bindEvidenceSubject(document, dir, options = {}) {
|
|
202
|
+
const declared = isPlainObject(document?.subject) ? document.subject : null
|
|
203
|
+
const base = { dir: path.resolve(dir), declared: subjectFields(declared), recomputed: null, mismatches: [], errors: [] }
|
|
204
|
+
if (!declared) return { ...base, ok: false, errors: ['subject is missing'] }
|
|
205
|
+
let recomputed
|
|
206
|
+
try {
|
|
207
|
+
recomputed = recomputeSubject(dir, {
|
|
208
|
+
exclude: Array.isArray(declared.treeExclude) ? declared.treeExclude : [],
|
|
209
|
+
timeoutMs: options.timeoutMs,
|
|
210
|
+
})
|
|
211
|
+
} catch (error) {
|
|
212
|
+
return { ...base, ok: false, errors: [error.message] }
|
|
213
|
+
}
|
|
214
|
+
const mismatches = []
|
|
215
|
+
for (const key of ['name', 'version', 'treeSha256', 'tarballSha256']) {
|
|
216
|
+
if (declared[key] !== recomputed[key]) {
|
|
217
|
+
mismatches.push(`${key}: declared ${declared[key] ?? 'null'} != recomputed ${recomputed[key] ?? 'null'}`)
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
if (typeof declared.integrity === 'string' && recomputed.integrity !== null && declared.integrity !== recomputed.integrity) {
|
|
221
|
+
mismatches.push('integrity: declared value != recomputed value')
|
|
222
|
+
}
|
|
223
|
+
return { ...base, ok: mismatches.length === 0, recomputed, mismatches }
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Plain CI attribution for display. These fields are read from the runner
|
|
228
|
+
* environment and are self-declared: CI attribution is not proof.
|
|
229
|
+
*/
|
|
230
|
+
export function ciAttribution(env = process.env) {
|
|
231
|
+
const read = key => (typeof env?.[key] === 'string' ? env[key].trim() : '')
|
|
232
|
+
const runId = read('GITHUB_RUN_ID')
|
|
233
|
+
const repository = read('GITHUB_REPOSITORY')
|
|
234
|
+
const commit = read('GITHUB_SHA')
|
|
235
|
+
if (!runId && !repository && !commit) return null
|
|
236
|
+
const server = (read('GITHUB_SERVER_URL') || 'https://github.com').replace(/\/+$/, '')
|
|
237
|
+
return {
|
|
238
|
+
provider: 'github-actions',
|
|
239
|
+
runId: runId || null,
|
|
240
|
+
repository: repository || null,
|
|
241
|
+
commit: commit || null,
|
|
242
|
+
runUrl: runId && repository ? `${server}/${repository}/actions/runs/${runId}` : null,
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
147
246
|
export function isIsoTimestamp(value) {
|
|
148
247
|
return typeof value === 'string' && ISO_RE.test(value) && Number.isFinite(Date.parse(value))
|
|
149
248
|
}
|
|
@@ -279,9 +378,15 @@ function validateV2(document) {
|
|
|
279
378
|
if ('kind' in document.producer && document.producer.kind !== null && !['local', 'ci', 'sandbox', 'sandbox-degraded'].includes(document.producer.kind)) {
|
|
280
379
|
errors.push('producer.kind must be local, ci, sandbox, or sandbox-degraded')
|
|
281
380
|
}
|
|
282
|
-
for (const key of ['runner', 'actor', 'runUrl']) {
|
|
381
|
+
for (const key of ['runner', 'actor', 'runUrl', 'provider', 'runId', 'repository', 'commit']) {
|
|
283
382
|
if (key in document.producer && document.producer[key] !== null) requireString(document.producer[key], `producer.${key}`)
|
|
284
383
|
}
|
|
384
|
+
if (typeof document.producer.commit === 'string' && !COMMIT_RE.test(document.producer.commit)) {
|
|
385
|
+
errors.push('producer.commit must be a 40-hex commit')
|
|
386
|
+
}
|
|
387
|
+
if (typeof document.producer.runUrl === 'string' && !/^https:\/\//.test(document.producer.runUrl)) {
|
|
388
|
+
errors.push('producer.runUrl must be an https URL')
|
|
389
|
+
}
|
|
285
390
|
}
|
|
286
391
|
|
|
287
392
|
if (requireObject(document.subject, 'subject')) {
|