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.
@@ -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
- : 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')
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
+ }
@@ -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 { readSources } from './util.mjs'
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
- try { sources = readSources(dir) } catch { sources = [] }
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 declare the client at the package root.
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
- export function checkClientShape(manifest) {
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)) return { error: `entry module ${entryRelative} was not found among scanned sources` }
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,
@@ -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'