dsh-standards 1.3.0 → 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/scripts/build-sidecar.mjs +238 -8
- package/40-checker/src/author-cli.mjs +274 -7
- package/40-checker/src/author-report.mjs +14 -3
- package/40-checker/src/bundle-static.mjs +181 -0
- package/40-checker/src/cli.mjs +27 -3
- package/40-checker/src/client-shape.mjs +33 -3
- package/40-checker/src/declarations.mjs +53 -8
- package/40-checker/src/index.mjs +3 -1
- package/40-checker/src/manifest.mjs +87 -14
- package/40-checker/src/remediation.mjs +108 -7
- package/40-checker/src/report.mjs +83 -7
- package/40-checker/src/semver.mjs +112 -65
- package/40-checker/src/util.mjs +51 -8
- package/CHANGELOG.md +23 -0
- package/README.md +28 -16
- package/docs/coverage.md +82 -0
- package/package.json +1 -1
- 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/remediation.json +54 -54
- package/spec/schemas/boost-card.schema.json +107 -0
- package/spec/schemas/conformance.schema.json +848 -0
|
@@ -65,6 +65,7 @@ function adviceRows(report) {
|
|
|
65
65
|
const BUCKETS = [
|
|
66
66
|
{ key: 'blocking', title: 'Blocking (FAIL)' },
|
|
67
67
|
{ key: 'hygiene', title: 'Hygiene (WARN)' },
|
|
68
|
+
{ key: 'partial', title: 'Partly checked (static subset)' },
|
|
68
69
|
{ key: 'notChecked', title: 'Not checked (NA / not-run / ERROR)' },
|
|
69
70
|
]
|
|
70
71
|
|
|
@@ -142,13 +143,19 @@ export function renderMarkdown(report) {
|
|
|
142
143
|
lines.push('')
|
|
143
144
|
|
|
144
145
|
const notChecked = bucketRows(report, 'notChecked')
|
|
146
|
+
const partial = bucketRows(report, 'partial')
|
|
145
147
|
lines.push('## What this report does not check')
|
|
146
148
|
lines.push('')
|
|
147
|
-
if (notChecked.length === 0) {
|
|
149
|
+
if (notChecked.length === 0 && partial.length === 0) {
|
|
148
150
|
lines.push('- No check was skipped in this run.')
|
|
149
151
|
} else {
|
|
150
152
|
for (const row of notChecked) {
|
|
151
153
|
lines.push(`- ${text(row.id)}${row.clause ? ` (${text(row.clause)})` : ''}: ${text(row.reason) || 'not checked'}`)
|
|
154
|
+
if (row.howToEnable) lines.push(` - enable: ${text(row.howToEnable)}`)
|
|
155
|
+
}
|
|
156
|
+
for (const row of partial) {
|
|
157
|
+
lines.push(`- ${text(row.id)}${row.clause ? ` (${text(row.clause)})` : ''}: partial coverage${row.reason ? ` — ${text(row.reason)}` : ''}`)
|
|
158
|
+
if (row.howToEnable) lines.push(` - still needs: ${text(row.howToEnable)}`)
|
|
152
159
|
}
|
|
153
160
|
}
|
|
154
161
|
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' : ''}.`)
|
|
@@ -171,6 +178,7 @@ export function renderHtml(report) {
|
|
|
171
178
|
const advice = adviceRows(report)
|
|
172
179
|
const passed = passedIds(report)
|
|
173
180
|
const notChecked = bucketRows(report, 'notChecked')
|
|
181
|
+
const partial = bucketRows(report, 'partial')
|
|
174
182
|
|
|
175
183
|
const table = (key) => {
|
|
176
184
|
const rows = bucketRows(report, key)
|
|
@@ -195,9 +203,12 @@ export function renderHtml(report) {
|
|
|
195
203
|
? '<p class="none">None.</p>'
|
|
196
204
|
: `<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
205
|
|
|
198
|
-
const notCheckedList = notChecked.length === 0
|
|
206
|
+
const notCheckedList = notChecked.length === 0 && partial.length === 0
|
|
199
207
|
? '<li>No check was skipped in this run.</li>'
|
|
200
|
-
:
|
|
208
|
+
: [
|
|
209
|
+
...notChecked.map(row => `<li><strong>${escapeHtml(text(row.id))}</strong>${row.clause ? ` (${escapeHtml(text(row.clause))})` : ''}: ${escapeHtml(text(row.reason) || 'not checked')}${row.howToEnable ? ` <span class="muted">enable: ${escapeHtml(text(row.howToEnable))}</span>` : ''}</li>`),
|
|
210
|
+
...partial.map(row => `<li><strong>${escapeHtml(text(row.id))}</strong>${row.clause ? ` (${escapeHtml(text(row.clause))})` : ''}: partial coverage${row.reason ? ` — ${escapeHtml(text(row.reason))}` : ''}${row.howToEnable ? ` <span class="muted">still needs: ${escapeHtml(text(row.howToEnable))}</span>` : ''}</li>`),
|
|
211
|
+
].join('\n')
|
|
201
212
|
|
|
202
213
|
return `<!doctype html>
|
|
203
214
|
<html lang="en">
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Static partial coverage for the L1-L3 checks (DSC-04..DSC-09) when a bundle
|
|
3
|
+
* or client package cannot be executed.
|
|
4
|
+
*
|
|
5
|
+
* These checks are NOT a runtime verdict. They cover the statically decidable
|
|
6
|
+
* part of each clause (manifest/patch/entry/exports declarations, static
|
|
7
|
+
* provide/effect/teardown clues and declaration consistency) and always report
|
|
8
|
+
* `coverage: "partial"`. The report keeps them in the partial bucket and lists
|
|
9
|
+
* what still needs `--host` / `--runtime`.
|
|
10
|
+
*/
|
|
11
|
+
import path from 'node:path'
|
|
12
|
+
import { check, detail, worst } from './result.mjs'
|
|
13
|
+
import { isFile, regexHits } from './util.mjs'
|
|
14
|
+
|
|
15
|
+
const PRIVATE_SURFACE = /(?:ctx\.events\._hooks|ctx\.reflect\.store|ctx\.registry\._internal|fiber\._disposables)\s*(?:\.|\[)[^\n]{0,40}=/g
|
|
16
|
+
const EFFECT_REGISTRATION = /\bctx\.(?:effect|on)\s*\(|\.effect\s*\(/g
|
|
17
|
+
const TEARDOWN = /\b(?:onDispose|dispose|disposed)\b|\.dispose\s*\(/g
|
|
18
|
+
const RESTART = /\brestart\b|_revision|\.restart\s*\(/g
|
|
19
|
+
const PROVIDE_DECLARATION = /(?:^|\n)\s*(?:export\s+)?(?:const|let|var)\s+provide\s*=|export\s*\{[^}]*\bprovide\b[^}]*\}/g
|
|
20
|
+
const CTX_PROVIDE = /\bctx\.provide\s*\(/g
|
|
21
|
+
|
|
22
|
+
function packageNameOf(name) {
|
|
23
|
+
if (typeof name !== 'string') return ''
|
|
24
|
+
if (name.startsWith('@')) {
|
|
25
|
+
const parts = name.split('/')
|
|
26
|
+
return parts.length >= 2 ? `${parts[0]}/${parts[1]}` : name
|
|
27
|
+
}
|
|
28
|
+
return name.split('/')[0]
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function runtimeNeed(kind) {
|
|
32
|
+
if (kind === 'bundle') {
|
|
33
|
+
return 'bundle G0-G3 needs --host <dir>; L1-L3 need --treat-as-plugin --runtime <path>'
|
|
34
|
+
}
|
|
35
|
+
return 'L1-L3 need --treat-as-plugin --runtime <path>'
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function liveResult(details) {
|
|
39
|
+
const live = details.filter(item => item.result === 'FAIL' || item.result === 'WARN')
|
|
40
|
+
return live.length === 0 ? 'PASS' : worst(live.map(item => item.result))
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function staticCheck({ id, clause, layer, details, kind, scan, extra = '' }) {
|
|
44
|
+
const result = liveResult(details)
|
|
45
|
+
const failures = details
|
|
46
|
+
.filter(item => item.result === 'FAIL' || item.result === 'WARN')
|
|
47
|
+
.map(item => `${item.item}: ${item.reason}`)
|
|
48
|
+
const truncation = scan?.truncated
|
|
49
|
+
? `source scan truncated at ${scan.maxFiles} files / ${scan.maxBytes} bytes`
|
|
50
|
+
: ''
|
|
51
|
+
const reasonParts = []
|
|
52
|
+
if (failures.length > 0) reasonParts.push(failures.join(' | '))
|
|
53
|
+
else reasonParts.push(`static subset passed${extra ? `: ${extra}` : ''}`)
|
|
54
|
+
reasonParts.push(runtimeNeed(kind))
|
|
55
|
+
if (truncation) reasonParts.push(truncation)
|
|
56
|
+
return check({
|
|
57
|
+
id,
|
|
58
|
+
clause,
|
|
59
|
+
layer,
|
|
60
|
+
result,
|
|
61
|
+
coverage: 'partial',
|
|
62
|
+
reason: reasonParts.join('; '),
|
|
63
|
+
details,
|
|
64
|
+
evidence: details.flatMap(item => item.evidence ?? []).slice(0, 5),
|
|
65
|
+
})
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** @returns {object[]} the six DSC-04..DSC-09 checks with coverage "partial". */
|
|
69
|
+
export function runStaticBundleChecks({ manifest, kind, entry, patch, sources = [], g4 = null, scan = null, dir = null }) {
|
|
70
|
+
const name = manifest?.name ?? null
|
|
71
|
+
const deps = new Set([
|
|
72
|
+
...Object.keys(manifest?.dependencies ?? {}),
|
|
73
|
+
...Object.keys(manifest?.peerDependencies ?? {}),
|
|
74
|
+
...Object.keys(manifest?.optionalDependencies ?? {}),
|
|
75
|
+
])
|
|
76
|
+
const self = packageNameOf(name)
|
|
77
|
+
|
|
78
|
+
// C-4: entry / patch / client export shape, all statically resolvable.
|
|
79
|
+
const c4 = []
|
|
80
|
+
if (entry?.escape) c4.push(detail('C-4', 'FAIL', `entry escapes the package: ${entry.target}`, ['package.json#exports']))
|
|
81
|
+
else if (entry?.declaredMissing) c4.push(detail('C-4', 'FAIL', `declared entry is missing: ${entry.target}`, ['package.json#exports']))
|
|
82
|
+
else if (entry?.exists) c4.push(detail('C-4', 'PASS', `declared entry resolves: ${entry.relative}`, [entry.relative]))
|
|
83
|
+
else if (kind === 'bundle' && patch?.present) c4.push(detail('C-4', 'PASS', 'patch-only bundle; dsh.bundle.patch is the carrier'))
|
|
84
|
+
else c4.push(detail('C-4', 'NA', 'no runtime entry declared'))
|
|
85
|
+
if (patch?.present) {
|
|
86
|
+
if (patch.problems?.length > 0) {
|
|
87
|
+
c4.push(detail('C-4', 'FAIL', `bundle patch problems: ${patch.problems.join('; ')}`, patch.files?.map(file => file.relative) ?? []))
|
|
88
|
+
} else {
|
|
89
|
+
c4.push(detail('C-4', 'PASS', `bundle patch parsed (${patch.files?.length ?? 0} file(s), ${patch.rows?.length ?? 0} row(s))`))
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
if (g4?.hasClient) {
|
|
93
|
+
if (g4.result === 'FAIL') c4.push(detail('C-4', 'FAIL', g4.reason ?? 'dsh.client shape failed'))
|
|
94
|
+
else if (g4.result === 'PASS') c4.push(detail('C-4', 'PASS', 'dsh.client + exports["./client"] resolvable'))
|
|
95
|
+
}
|
|
96
|
+
const c4Check = staticCheck({ id: 'DSC-04', clause: 'C-4', layer: 'L1', details: c4, kind, scan, extra: 'entry/export/patch shape' })
|
|
97
|
+
|
|
98
|
+
// C-5: can a static provide declaration be enumerated?
|
|
99
|
+
const c5 = []
|
|
100
|
+
const provideDecl = regexHits(sources, PROVIDE_DECLARATION)
|
|
101
|
+
const ctxProvide = regexHits(sources, CTX_PROVIDE)
|
|
102
|
+
if (provideDecl.length > 0) {
|
|
103
|
+
c5.push(detail('C-5.1', 'PASS', `static provide declaration found (${provideDecl.length} site(s))`, provideDecl.slice(0, 3).map(hit => `${hit.file}:${hit.line}`)))
|
|
104
|
+
} else if (ctxProvide.length > 0) {
|
|
105
|
+
c5.push(detail('C-5.1', 'PASS', `ctx.provide registration found (${ctxProvide.length} site(s))`, ctxProvide.slice(0, 3).map(hit => `${hit.file}:${hit.line}`)))
|
|
106
|
+
} else {
|
|
107
|
+
c5.push(detail('C-5.1', 'NA', 'no static provide declaration or ctx.provide call found; service ownership needs runtime'))
|
|
108
|
+
}
|
|
109
|
+
const c5Check = staticCheck({ id: 'DSC-05', clause: 'C-5', layer: 'L1', details: c5, kind, scan, extra: 'provide declarations enumerated' })
|
|
110
|
+
|
|
111
|
+
// C-6: static private-surface writes; effect registration is runtime-only.
|
|
112
|
+
const c6 = []
|
|
113
|
+
const privateHits = regexHits(sources, PRIVATE_SURFACE)
|
|
114
|
+
if (privateHits.length > 0) {
|
|
115
|
+
c6.push(detail('C-6.1', 'FAIL', 'static write to a Cordis private registration surface', privateHits.slice(0, 3).map(hit => `${hit.file}:${hit.line}`)))
|
|
116
|
+
} else if (sources.length > 0) {
|
|
117
|
+
c6.push(detail('C-6.1', 'PASS', 'no static write to a private registration surface'))
|
|
118
|
+
}
|
|
119
|
+
const effectHits = regexHits(sources, EFFECT_REGISTRATION)
|
|
120
|
+
if (effectHits.length > 0) {
|
|
121
|
+
c6.push(detail('C-6.2', 'PASS', `${effectHits.length} static effect registration site(s); effect lifetime needs runtime`, effectHits.slice(0, 3).map(hit => `${hit.file}:${hit.line}`)))
|
|
122
|
+
} else {
|
|
123
|
+
c6.push(detail('C-6.2', 'NA', 'effect registration/lifetime needs runtime'))
|
|
124
|
+
}
|
|
125
|
+
const c6Check = staticCheck({ id: 'DSC-06', clause: 'C-6', layer: 'L1+L2', details: c6, kind, scan, extra: 'no private-surface writes found' })
|
|
126
|
+
|
|
127
|
+
// C-7: static teardown clue; effect/store/registry baselines need runtime.
|
|
128
|
+
const c7 = []
|
|
129
|
+
const teardownHits = regexHits(sources, TEARDOWN)
|
|
130
|
+
if (teardownHits.length > 0) {
|
|
131
|
+
c7.push(detail('C-7.1', 'PASS', `${teardownHits.length} static teardown site(s); dispose baseline needs runtime`, teardownHits.slice(0, 3).map(hit => `${hit.file}:${hit.line}`)))
|
|
132
|
+
} else {
|
|
133
|
+
c7.push(detail('C-7.1', 'NA', 'dispose/effect baseline needs runtime'))
|
|
134
|
+
}
|
|
135
|
+
const c7Check = staticCheck({ id: 'DSC-07', clause: 'C-7', layer: 'L2', details: c7, kind, scan, extra: 'teardown references enumerated' })
|
|
136
|
+
|
|
137
|
+
// C-8: static restart/remount clue; repeatability needs runtime.
|
|
138
|
+
const c8 = []
|
|
139
|
+
const restartHits = regexHits(sources, RESTART)
|
|
140
|
+
if (restartHits.length > 0) {
|
|
141
|
+
c8.push(detail('C-8.1', 'PASS', `${restartHits.length} static restart/revision site(s); repeatability needs runtime`, restartHits.slice(0, 3).map(hit => `${hit.file}:${hit.line}`)))
|
|
142
|
+
} else {
|
|
143
|
+
c8.push(detail('C-8.1', 'NA', 'restart/remount repeatability needs runtime'))
|
|
144
|
+
}
|
|
145
|
+
const c8Check = staticCheck({ id: 'DSC-08', clause: 'C-8', layer: 'L3', details: c8, kind, scan, extra: 'restart references enumerated' })
|
|
146
|
+
|
|
147
|
+
// C-9: declaration consistency that is decidable from the manifest + patch.
|
|
148
|
+
const c9 = []
|
|
149
|
+
const mismatched = []
|
|
150
|
+
for (const row of patch?.rows ?? []) {
|
|
151
|
+
if (!row.name || row.disabled) continue
|
|
152
|
+
// A patch row may mount a local module (a bundle's own server entry) rather
|
|
153
|
+
// than a registry package. Verify the file; do not compare it to deps.
|
|
154
|
+
if (row.name.startsWith('.') || path.isAbsolute(row.name)) {
|
|
155
|
+
const absolute = dir ? path.resolve(dir, row.name) : null
|
|
156
|
+
if (!absolute || !isFile(absolute)) mismatched.push(`${row.name} (missing local module)`)
|
|
157
|
+
continue
|
|
158
|
+
}
|
|
159
|
+
const target = packageNameOf(row.name)
|
|
160
|
+
if (!target || target === self || deps.has(target) || target.startsWith('@deepseek-ai/')) continue
|
|
161
|
+
mismatched.push(row.name)
|
|
162
|
+
}
|
|
163
|
+
if (mismatched.length > 0) {
|
|
164
|
+
c9.push(detail('C-9.2', 'WARN', `patch mounts package(s) that are not declared in dependencies/peerDependencies: ${[...new Set(mismatched)].join(', ')}`, patch.files?.map(file => file.relative) ?? []))
|
|
165
|
+
} else if (patch?.present) {
|
|
166
|
+
c9.push(detail('C-9.1', 'PASS', 'patch mount targets match package.json declarations or host-provided @deepseek-ai packages'))
|
|
167
|
+
}
|
|
168
|
+
const inject = Array.isArray(manifest?.dsh?.client?.inject) ? manifest.dsh.client.inject : []
|
|
169
|
+
const unread = []
|
|
170
|
+
for (const service of inject) {
|
|
171
|
+
if (typeof service !== 'string' || !/^[A-Za-z_$][\w$]*$/.test(service)) continue
|
|
172
|
+
if (regexHits(sources, new RegExp(`\\bctx\\.${service.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`, 'g')).length === 0) unread.push(service)
|
|
173
|
+
}
|
|
174
|
+
if (unread.length > 0) {
|
|
175
|
+
c9.push(detail('C-9.3', 'WARN', `dsh.client.inject declares service(s) with no static ctx.<service> read: ${unread.join(', ')}`))
|
|
176
|
+
}
|
|
177
|
+
if (c9.length === 0) c9.push(detail('C-9.1', 'NA', 'no statically decidable declaration mismatch found'))
|
|
178
|
+
const c9Check = staticCheck({ id: 'DSC-09', clause: 'C-9', layer: 'L1', details: c9, kind, scan, extra: 'declaration cross-check' })
|
|
179
|
+
|
|
180
|
+
return [c4Check, c5Check, c6Check, c7Check, c8Check, c9Check]
|
|
181
|
+
}
|
package/40-checker/src/cli.mjs
CHANGED
|
@@ -4,9 +4,10 @@ import { fileURLToPath } from 'node:url'
|
|
|
4
4
|
import { runManifestChecks, DEFAULT_RUNTIME_VERSION } from './manifest.mjs'
|
|
5
5
|
import { checkCapabilities, checkCredentials, checkImportTimeAdvisory, checkLabels } from './declarations.mjs'
|
|
6
6
|
import { runRuntimeChecks } from './runtime.mjs'
|
|
7
|
+
import { runStaticBundleChecks } from './bundle-static.mjs'
|
|
7
8
|
import { spawn, spawnSync } from 'node:child_process'
|
|
8
9
|
import { sanitizedEnv } from './sandbox-env.mjs'
|
|
9
|
-
import {
|
|
10
|
+
import { readSourcesWithStats } from './util.mjs'
|
|
10
11
|
import { buildReport, writeReport } from './report.mjs'
|
|
11
12
|
|
|
12
13
|
const VERSION = '0.1.0'
|
|
@@ -40,6 +41,8 @@ Options:
|
|
|
40
41
|
--market-index <file> format-1 market index JSON for C-3 listing mode
|
|
41
42
|
--plugin-name <name> plugin name inside the market index
|
|
42
43
|
--timeout <ms> L1-L3 timeout (default: 10000)
|
|
44
|
+
--max-files <n> source scan file budget (default: 400)
|
|
45
|
+
--max-bytes <n> source scan byte budget (default: 2000000)
|
|
43
46
|
--no-exec skip L1-L3 entirely (reported as not-run/NA, never PASS)
|
|
44
47
|
--static-only M1 mode: listing profile, only C-1/C-2/C-3/C-11/C-12/C-13; C-4..C-9 are out of scope
|
|
45
48
|
--treat-as-plugin run L1-L3 on a bundle's server entry (lib/index.js, index.js, lib/public-entry.js, src/index.ts)
|
|
@@ -77,6 +80,8 @@ function parseArgs(argv) {
|
|
|
77
80
|
marketIndex: null,
|
|
78
81
|
pluginName: null,
|
|
79
82
|
timeoutMs: 10_000,
|
|
83
|
+
maxFiles: 400,
|
|
84
|
+
maxBytes: 2_000_000,
|
|
80
85
|
noExec: false,
|
|
81
86
|
staticOnly: false,
|
|
82
87
|
treatAsPlugin: false,
|
|
@@ -120,6 +125,8 @@ function parseArgs(argv) {
|
|
|
120
125
|
case '--market-index': options.marketIndex = value(); break
|
|
121
126
|
case '--plugin-name': options.pluginName = value(); break
|
|
122
127
|
case '--timeout': options.timeoutMs = Number(value()); break
|
|
128
|
+
case '--max-files': options.maxFiles = Number(value()); break
|
|
129
|
+
case '--max-bytes': options.maxBytes = Number(value()); break
|
|
123
130
|
case '--out': options.out = value(); break
|
|
124
131
|
case '--no-exec': options.noExec = true; break
|
|
125
132
|
case '--static-only': options.staticOnly = true; break
|
|
@@ -144,6 +151,8 @@ function parseArgs(argv) {
|
|
|
144
151
|
if (!['package', 'listing', 'both'].includes(options.profile)) throw new Error(`unsupported --profile: ${options.profile}`)
|
|
145
152
|
if (options.staticOnly && options.treatAsPlugin) throw new Error('--static-only and --treat-as-plugin are mutually exclusive')
|
|
146
153
|
if (!Number.isFinite(options.timeoutMs) || options.timeoutMs < 100 || options.timeoutMs > 120_000) throw new Error('--timeout must be between 100 and 120000 ms')
|
|
154
|
+
if (!Number.isInteger(options.maxFiles) || options.maxFiles < 1 || options.maxFiles > 200_000) throw new Error('--max-files must be an integer between 1 and 200000')
|
|
155
|
+
if (!Number.isInteger(options.maxBytes) || options.maxBytes < 1000 || options.maxBytes > 2_000_000_000) throw new Error('--max-bytes must be an integer between 1000 and 2000000000')
|
|
147
156
|
if (options.target === null) options.target = '.'
|
|
148
157
|
return options
|
|
149
158
|
}
|
|
@@ -333,7 +342,12 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
333
342
|
})
|
|
334
343
|
|
|
335
344
|
let sources = []
|
|
336
|
-
|
|
345
|
+
let scan = null
|
|
346
|
+
try {
|
|
347
|
+
const read = readSourcesWithStats(dir, { maxFiles: options.maxFiles, maxBytes: options.maxBytes })
|
|
348
|
+
sources = read.sources
|
|
349
|
+
scan = read.scan
|
|
350
|
+
} catch { sources = [] }
|
|
337
351
|
const declarationChecks = manifest ? [
|
|
338
352
|
checkCapabilities(manifest, sources, patch),
|
|
339
353
|
checkCredentials(manifest, sources),
|
|
@@ -344,7 +358,7 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
344
358
|
{ id: 'DSC-13', clause: 'C-13', layer: 'L0', result: 'ERROR', coverage: 'not-run', reason: 'manifest unavailable', details: [], evidence: [], method: 'M', advisory: false },
|
|
345
359
|
]
|
|
346
360
|
const declarationAdjusted = declarationChecks.map(item => (item.result === 'WARN' && ['DSC-11', 'DSC-13'].includes(item.id) ? { ...item, advisory: true } : item))
|
|
347
|
-
const advisoryChecks = [checkImportTimeAdvisory(sources, entry?.relative)]
|
|
361
|
+
const advisoryChecks = [checkImportTimeAdvisory(sources, entry?.relative, scan)]
|
|
348
362
|
|
|
349
363
|
if (options.scope === 'entry' && options.entryRuntime === 'server-entry' && kind !== 'plugin') options.treatAsPlugin = true
|
|
350
364
|
let runtimeKind = kind
|
|
@@ -375,6 +389,16 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
375
389
|
})
|
|
376
390
|
}
|
|
377
391
|
|
|
392
|
+
// A bundle/client has no plugin entry for L1-L3. Instead of leaving all six
|
|
393
|
+
// runtime checks NA, cover their statically decidable part explicitly and
|
|
394
|
+
// mark the coverage partial (the report says what still needs --host/--runtime).
|
|
395
|
+
const runtimeUsable = runtimeChecks.some(item => ['PASS', 'WARN', 'FAIL'].includes(item.result))
|
|
396
|
+
if (manifest && ['bundle', 'client'].includes(kind) && !runtimeUsable) {
|
|
397
|
+
const staticChecks = runStaticBundleChecks({ manifest, kind, entry, patch, sources, g4, scan, dir })
|
|
398
|
+
const staticById = new Map(staticChecks.map(item => [item.id, item]))
|
|
399
|
+
runtimeChecks = runtimeChecks.map(item => staticById.get(item.id) ?? item)
|
|
400
|
+
}
|
|
401
|
+
|
|
378
402
|
const runCheck = await entryRunCheck(kind, runtimeChecks, options, dir)
|
|
379
403
|
const checks = [
|
|
380
404
|
manifestChecks[0], manifestChecks[1], manifestChecks[2],
|
|
@@ -1,11 +1,28 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Static G4 check: when `dsh.client` exists, the package must expose
|
|
3
|
-
* `exports["./client"]` and
|
|
3
|
+
* `exports["./client"]` and that target must exist in the package.
|
|
4
4
|
*
|
|
5
5
|
* This is a static shape check. It does not prove the client module loads or
|
|
6
6
|
* renders; that is a runtime/UI probe and is reported separately.
|
|
7
7
|
*/
|
|
8
|
-
|
|
8
|
+
import path from 'node:path'
|
|
9
|
+
import { isFile } from './util.mjs'
|
|
10
|
+
|
|
11
|
+
function clientTarget(exported) {
|
|
12
|
+
if (typeof exported === 'string') return exported
|
|
13
|
+
if (exported && typeof exported === 'object' && !Array.isArray(exported)) {
|
|
14
|
+
for (const key of ['default', 'import', 'require', 'node', 'browser']) {
|
|
15
|
+
if (typeof exported[key] === 'string') return exported[key]
|
|
16
|
+
}
|
|
17
|
+
for (const value of Object.values(exported)) {
|
|
18
|
+
const nested = clientTarget(value)
|
|
19
|
+
if (nested) return nested
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
return null
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function checkClientShape(manifest, dir = null) {
|
|
9
26
|
const hasClient = manifest?.dsh?.client !== undefined
|
|
10
27
|
if (!hasClient) {
|
|
11
28
|
return {
|
|
@@ -28,10 +45,23 @@ export function checkClientShape(manifest) {
|
|
|
28
45
|
reason: 'dsh.client exists but exports["./client"] is missing; a package-root row cannot resolve it',
|
|
29
46
|
}
|
|
30
47
|
}
|
|
48
|
+
const target = clientTarget(exported)
|
|
49
|
+
if (target && typeof dir === 'string' && dir !== '' && !target.includes('*')) {
|
|
50
|
+
const absolute = path.resolve(dir, target)
|
|
51
|
+
const relative = path.relative(dir, absolute)
|
|
52
|
+
if (relative.startsWith('..') || path.isAbsolute(relative) || !isFile(absolute)) {
|
|
53
|
+
return {
|
|
54
|
+
hasClient: true,
|
|
55
|
+
ok: false,
|
|
56
|
+
result: 'FAIL',
|
|
57
|
+
reason: `exports["./client"] points to a missing file: ${target}`,
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
31
61
|
return {
|
|
32
62
|
hasClient: true,
|
|
33
63
|
ok: true,
|
|
34
64
|
result: 'PASS',
|
|
35
|
-
reason: 'dsh.client + exports["./client"] present (static only)',
|
|
65
|
+
reason: 'dsh.client + exports["./client"] present and resolvable (static only)',
|
|
36
66
|
}
|
|
37
67
|
}
|
|
@@ -424,13 +424,28 @@ function collectSideEffectHits(ast, file, hits) {
|
|
|
424
424
|
})
|
|
425
425
|
}
|
|
426
426
|
|
|
427
|
-
function analyseImportClosure(sources, entryRelative) {
|
|
427
|
+
function analyseImportClosure(sources, entryRelative, scan = null) {
|
|
428
428
|
const byRelative = new Map()
|
|
429
429
|
for (const source of sources) byRelative.set(normalizeRelative(source.relative), source)
|
|
430
430
|
const queue = []
|
|
431
431
|
if (entryRelative) {
|
|
432
432
|
const entry = normalizeRelative(entryRelative)
|
|
433
|
-
if (!byRelative.has(entry))
|
|
433
|
+
if (!byRelative.has(entry)) {
|
|
434
|
+
// A bounded scan can stop before the entry module. That is missing
|
|
435
|
+
// coverage, not proof that the package has no entry.
|
|
436
|
+
if (scan?.truncated) {
|
|
437
|
+
return {
|
|
438
|
+
entryOutsideScan: true,
|
|
439
|
+
truncated: scan,
|
|
440
|
+
visited: new Set(),
|
|
441
|
+
hits: [],
|
|
442
|
+
parseErrors: [],
|
|
443
|
+
coverage: [],
|
|
444
|
+
localEdges: [],
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
return { error: `entry module ${entryRelative} was not found among scanned sources` }
|
|
448
|
+
}
|
|
434
449
|
queue.push(entry)
|
|
435
450
|
} else {
|
|
436
451
|
// Programmatic callers without an entry: keep the legacy "all sources" shape.
|
|
@@ -483,23 +498,30 @@ function hitEvidence(hits, limit = 3) {
|
|
|
483
498
|
return hits.slice(0, limit).map(hit => `${hit.file}:${hit.line}`)
|
|
484
499
|
}
|
|
485
500
|
|
|
501
|
+
/** The exact bounded-scan statement the report must carry when it truncates. */
|
|
502
|
+
export function scanTruncationReason(scan) {
|
|
503
|
+
if (!scan?.truncated) return ''
|
|
504
|
+
return `truncated at ${scan.maxFiles} files / ${scan.maxBytes} bytes; entry may be beyond the scan (scanned ${scan.files} files / ${scan.bytes} bytes)`
|
|
505
|
+
}
|
|
506
|
+
|
|
486
507
|
/** DSC-10: advisory only; never FAIL. */
|
|
487
|
-
export function checkImportTimeAdvisory(sources, entryRelative = null) {
|
|
508
|
+
export function checkImportTimeAdvisory(sources, entryRelative = null, scan = null) {
|
|
488
509
|
if (sources.length === 0) {
|
|
510
|
+
const truncation = scanTruncationReason(scan)
|
|
489
511
|
return check({
|
|
490
512
|
id: 'DSC-10',
|
|
491
513
|
clause: 'C-10',
|
|
492
514
|
layer: 'L0',
|
|
493
515
|
result: 'NA',
|
|
494
516
|
advisory: true,
|
|
495
|
-
coverage: 'not-run',
|
|
496
|
-
reason: 'no source files scanned; C-10 not-run',
|
|
497
|
-
details: [],
|
|
517
|
+
coverage: truncation ? 'partial' : 'not-run',
|
|
518
|
+
reason: truncation ? `partial: ${truncation}; C-10 not-run` : 'no source files scanned; C-10 not-run',
|
|
519
|
+
details: truncation ? [detail('C-10.3', 'NA', `not-run: ${truncation}`, [])] : [],
|
|
498
520
|
evidence: [],
|
|
499
521
|
})
|
|
500
522
|
}
|
|
501
523
|
|
|
502
|
-
const analysis = analyseImportClosure(sources, entryRelative)
|
|
524
|
+
const analysis = analyseImportClosure(sources, entryRelative, scan)
|
|
503
525
|
if (analysis.error) {
|
|
504
526
|
const evidence = normalizeRelative(entryRelative)
|
|
505
527
|
return check({
|
|
@@ -515,12 +537,32 @@ export function checkImportTimeAdvisory(sources, entryRelative = null) {
|
|
|
515
537
|
})
|
|
516
538
|
}
|
|
517
539
|
|
|
540
|
+
if (analysis.entryOutsideScan) {
|
|
541
|
+
const truncation = scanTruncationReason(analysis.truncated)
|
|
542
|
+
const entry = normalizeRelative(entryRelative)
|
|
543
|
+
return check({
|
|
544
|
+
id: 'DSC-10',
|
|
545
|
+
clause: 'C-10',
|
|
546
|
+
layer: 'L0',
|
|
547
|
+
result: 'NA',
|
|
548
|
+
advisory: true,
|
|
549
|
+
coverage: 'partial',
|
|
550
|
+
reason: `partial: ${truncation}; C-10 not-run`,
|
|
551
|
+
details: [detail('C-10.3', 'NA', `not-run: ${truncation}; entry module ${entryRelative} is outside the scanned set`, [entry])],
|
|
552
|
+
evidence: [entry],
|
|
553
|
+
})
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
const truncation = scanTruncationReason(scan)
|
|
518
557
|
const { visited, hits, parseErrors, coverage, localEdges } = analysis
|
|
519
558
|
const details = hits.slice(0, 20).map(hit => detail(hit.clause, 'WARN', `${hit.pattern} at ${hit.file}:${hit.line}`, [`${hit.file}:${hit.line}`]))
|
|
520
559
|
details.push(...coverage.slice(0, 10))
|
|
521
560
|
if (localEdges.length > 0) {
|
|
522
561
|
details.push(detail('C-10.3', 'PASS', `local relative import closure analysed (${visited.size} module(s))`, [...visited].slice(0, 5)))
|
|
523
562
|
}
|
|
563
|
+
if (truncation) {
|
|
564
|
+
details.push(detail('C-10.3', 'NA', `not-run: ${truncation}; the local import closure is incomplete`, []))
|
|
565
|
+
}
|
|
524
566
|
for (const error of parseErrors) {
|
|
525
567
|
details.push(detail('C-10', 'ERROR', `not-run: AST parse failed in ${error.file}:${error.line} (${error.message})`, [`${error.file}:${error.line}`]))
|
|
526
568
|
}
|
|
@@ -538,6 +580,9 @@ export function checkImportTimeAdvisory(sources, entryRelative = null) {
|
|
|
538
580
|
result = 'PASS'
|
|
539
581
|
reason = ''
|
|
540
582
|
}
|
|
583
|
+
if (truncation) {
|
|
584
|
+
reason = reason ? `${reason}; partial: ${truncation}` : `partial: ${truncation}`
|
|
585
|
+
}
|
|
541
586
|
|
|
542
587
|
const evidence = hits.length > 0
|
|
543
588
|
? hitEvidence(hits)
|
|
@@ -551,7 +596,7 @@ export function checkImportTimeAdvisory(sources, entryRelative = null) {
|
|
|
551
596
|
layer: 'L0',
|
|
552
597
|
result,
|
|
553
598
|
advisory: true,
|
|
554
|
-
coverage: result === 'ERROR' ? 'not-run' : 'full',
|
|
599
|
+
coverage: result === 'ERROR' ? 'not-run' : truncation ? 'partial' : 'full',
|
|
555
600
|
reason,
|
|
556
601
|
details,
|
|
557
602
|
evidence,
|
package/40-checker/src/index.mjs
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
/** Programmatic API for dsh-plugin-verify. */
|
|
2
2
|
export { main, parseArgs, usage, VERSION, detectHostRuntime, resolveRuntimeAnchor, declarationsOf } from './cli.mjs'
|
|
3
3
|
export { runManifestChecks, checkMarketEntry, DEFAULT_RUNTIME_VERSION, inferKind, resolveEntry } from './manifest.mjs'
|
|
4
|
-
export { checkCapabilities, checkCredentials, checkLabels, checkImportTimeAdvisory, CAPABILITIES, LABELS } from './declarations.mjs'
|
|
4
|
+
export { checkCapabilities, checkCredentials, checkLabels, checkImportTimeAdvisory, scanTruncationReason, CAPABILITIES, LABELS } from './declarations.mjs'
|
|
5
|
+
export { readSources, readSourcesWithStats, listSourceFiles, scanSourceFiles } from './util.mjs'
|
|
5
6
|
export { runRuntimeChecks } from './runtime.mjs'
|
|
7
|
+
export { runStaticBundleChecks } from './bundle-static.mjs'
|
|
6
8
|
export { buildReport, formatText, formatSarif, writeReport, summarizeBlocking, DECLARATION_CHECKS } from './report.mjs'
|
|
7
9
|
export { joinAdvice, DOCS_BASE } from './remediation.mjs'
|
|
8
10
|
export { satisfies, validRange, parseVersion, compareVersions } from './semver.mjs'
|