dsh-standards 1.0.3 → 1.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/40-checker/fixtures/bundle-patch-only/cordis.patch.yml +3 -0
  2. package/40-checker/fixtures/bundle-patch-only/package.json +12 -0
  3. package/40-checker/fixtures/c12-command-arg/index.mjs +7 -0
  4. package/40-checker/fixtures/c12-command-arg/package.json +10 -0
  5. package/40-checker/fixtures/c12-comment/index.mjs +3 -0
  6. package/40-checker/fixtures/c12-comment/package.json +10 -0
  7. package/40-checker/fixtures/c12-compare/index.mjs +3 -0
  8. package/40-checker/fixtures/c12-compare/package.json +10 -0
  9. package/40-checker/fixtures/c12-doc-string/index.mjs +13 -0
  10. package/40-checker/fixtures/c12-doc-string/package.json +10 -0
  11. package/40-checker/fixtures/c12-grant/index.mjs +3 -0
  12. package/40-checker/fixtures/c12-grant/package.json +10 -0
  13. package/40-checker/fixtures/c12-string/index.mjs +3 -0
  14. package/40-checker/fixtures/c12-string/package.json +10 -0
  15. package/40-checker/fixtures/declaration-file/index.d.ts +1 -0
  16. package/40-checker/fixtures/declaration-file/index.mjs +9 -0
  17. package/40-checker/fixtures/declaration-file/package.json +10 -0
  18. package/40-checker/fixtures/import-local-side-effect/index.mjs +11 -0
  19. package/40-checker/fixtures/import-local-side-effect/package.json +21 -0
  20. package/40-checker/fixtures/import-local-side-effect/side-effectful.mjs +5 -0
  21. package/40-checker/fixtures/import-open-readonly/index.mjs +14 -0
  22. package/40-checker/fixtures/import-open-readonly/package.json +19 -0
  23. package/40-checker/fixtures/import-package-only/index.mjs +11 -0
  24. package/40-checker/fixtures/import-package-only/package.json +21 -0
  25. package/40-checker/fixtures/import-parse-error/index.mjs +5 -0
  26. package/40-checker/fixtures/import-parse-error/package.json +10 -0
  27. package/40-checker/fixtures/import-side-effect/index.mjs +14 -0
  28. package/40-checker/fixtures/import-side-effect/package.json +19 -0
  29. package/40-checker/fixtures/import-side-effect-nonentry/index.mjs +9 -0
  30. package/40-checker/fixtures/import-side-effect-nonentry/package.json +19 -0
  31. package/40-checker/fixtures/import-side-effect-nonentry/selfcheck.mjs +8 -0
  32. package/40-checker/fixtures/import-void-main/index.mjs +16 -0
  33. package/40-checker/fixtures/import-void-main/package.json +19 -0
  34. package/40-checker/fixtures/no-source/package.json +19 -0
  35. package/40-checker/fixtures/parse-error-nonentry/index.mjs +2 -0
  36. package/40-checker/fixtures/parse-error-nonentry/package.json +10 -0
  37. package/40-checker/fixtures/parse-error-nonentry/references/broken.js +1 -0
  38. package/40-checker/src/cli.mjs +1 -1
  39. package/40-checker/src/declarations.mjs +514 -19
  40. package/40-checker/src/manifest.mjs +5 -0
  41. package/40-checker/test/checks.test.mjs +243 -1
  42. package/40-checker/vendor/babel/README.md +10 -0
  43. package/40-checker/vendor/babel/index.cjs +3 -0
  44. package/40-checker/vendor/babel/node_modules/@babel/helper-string-parser/LICENSE +22 -0
  45. package/40-checker/vendor/babel/node_modules/@babel/helper-string-parser/README.md +19 -0
  46. package/40-checker/vendor/babel/node_modules/@babel/helper-string-parser/lib/index.d.ts +41 -0
  47. package/40-checker/vendor/babel/node_modules/@babel/helper-string-parser/lib/index.js +286 -0
  48. package/40-checker/vendor/babel/node_modules/@babel/helper-string-parser/package.json +31 -0
  49. package/40-checker/vendor/babel/node_modules/@babel/helper-validator-identifier/LICENSE +22 -0
  50. package/40-checker/vendor/babel/node_modules/@babel/helper-validator-identifier/README.md +19 -0
  51. package/40-checker/vendor/babel/node_modules/@babel/helper-validator-identifier/lib/identifier-browser.js +62 -0
  52. package/40-checker/vendor/babel/node_modules/@babel/helper-validator-identifier/lib/identifier.js +60 -0
  53. package/40-checker/vendor/babel/node_modules/@babel/helper-validator-identifier/lib/index.d.ts +28 -0
  54. package/40-checker/vendor/babel/node_modules/@babel/helper-validator-identifier/lib/index.js +27 -0
  55. package/40-checker/vendor/babel/node_modules/@babel/helper-validator-identifier/package.json +45 -0
  56. package/40-checker/vendor/babel/node_modules/@babel/parser/CHANGELOG.md +1073 -0
  57. package/40-checker/vendor/babel/node_modules/@babel/parser/LICENSE +19 -0
  58. package/40-checker/vendor/babel/node_modules/@babel/parser/README.md +19 -0
  59. package/40-checker/vendor/babel/node_modules/@babel/parser/bin/babel-parser.js +14 -0
  60. package/40-checker/vendor/babel/node_modules/@babel/parser/lib/index.d.ts +1047 -0
  61. package/40-checker/vendor/babel/node_modules/@babel/parser/lib/index.js +14612 -0
  62. package/40-checker/vendor/babel/node_modules/@babel/parser/package.json +57 -0
  63. package/40-checker/vendor/babel/node_modules/@babel/parser/typings/babel-parser.d.ts +636 -0
  64. package/40-checker/vendor/babel/node_modules/@babel/types/LICENSE +22 -0
  65. package/40-checker/vendor/babel/node_modules/@babel/types/README.md +19 -0
  66. package/40-checker/vendor/babel/node_modules/@babel/types/lib/index-legacy.d.ts +2765 -0
  67. package/40-checker/vendor/babel/node_modules/@babel/types/lib/index.d.ts +3668 -0
  68. package/40-checker/vendor/babel/node_modules/@babel/types/lib/index.js +10758 -0
  69. package/40-checker/vendor/babel/node_modules/@babel/types/lib/index.js.flow +2618 -0
  70. package/40-checker/vendor/babel/node_modules/@babel/types/package.json +38 -0
  71. package/CHANGELOG.md +46 -0
  72. package/package.json +2 -1
@@ -1,3 +1,8 @@
1
+ import path from 'node:path'
2
+ import { createRequire } from 'node:module'
3
+
4
+ const requireVendor = createRequire(import.meta.url)
5
+ const { parse: parseBabel } = requireVendor('../vendor/babel/index.cjs')
1
6
  import { isObject, regexHits } from './util.mjs'
2
7
  import { check, detail, worst } from './result.mjs'
3
8
  import { validateCommitments, validateDisclosure } from './permissions.mjs'
@@ -47,33 +52,509 @@ function labelList(manifest) {
47
52
  return { defined: true, list: value, error: null }
48
53
  }
49
54
 
55
+
56
+ // ---------------------------------------------------------------------------
57
+ // AST layer (vendored @babel/parser, no runtime dependency)
58
+ // ---------------------------------------------------------------------------
59
+
60
+ function parsePlugins(relative) {
61
+ const extension = path.extname(String(relative || '')).toLowerCase()
62
+ const plugins = ['estree', 'decorators-legacy', 'importAttributes']
63
+ if (extension === '.ts' || extension === '.tsx') plugins.push('typescript')
64
+ if (extension !== '.ts' && extension !== '.tsx') plugins.push('jsx')
65
+ if (extension === '.tsx') plugins.push('jsx')
66
+ return plugins
67
+ }
68
+
69
+ const SOURCE_EXTENSIONS = ['.js', '.mjs', '.cjs', '.ts', '.tsx', '.jsx']
70
+ const FUNCTION_NODES = new Set(['FunctionDeclaration', 'FunctionExpression', 'ArrowFunctionExpression'])
71
+ const TRANSPARENT_NODES = new Set([
72
+ 'TemplateLiteral', 'TemplateElement', 'BinaryExpression', 'LogicalExpression',
73
+ 'ConditionalExpression', 'ParenthesizedExpression', 'ChainExpression',
74
+ ])
75
+ const EQUALITY_OPERATORS = new Set(['==', '===', '!=', '!=='])
76
+ const GRANT_METHODS = new Set(['set', 'resolve'])
77
+ const CONSOLE_METHODS = new Set(['log', 'info', 'warn', 'error', 'debug'])
78
+ const DOC_PROPERTY_KEYS = new Set(['desc', 'description', 'message', 'reason', 'help', 'label', 'title', 'comment'])
79
+ const COMMAND_PROPERTY_KEYS = new Set(['mode', 'sandboxPolicy', 'sandbox_permissions'])
80
+ const SPAWN_ARG_METHODS = new Set(['spawn', 'spawnSync', 'exec', 'execSync', 'execFile', 'execFileSync'])
81
+ const FS_WRITE_METHODS = new Set([
82
+ 'writeFileSync', 'appendFileSync', 'mkdirSync', 'rmSync', 'unlinkSync', 'rmdirSync', 'renameSync',
83
+ 'copyFileSync', 'chmodSync', 'symlinkSync', 'truncateSync', 'writeSync',
84
+ ])
85
+ const C10_SUBPROCESS_METHODS = new Set(['execSync', 'execFileSync', 'spawnSync', 'execFile', 'spawn'])
86
+
87
+ const PARSE_CACHE = new WeakMap()
88
+
89
+ function parseSource(source) {
90
+ const cached = PARSE_CACHE.get(source)
91
+ if (cached) return cached
92
+ const record = { ast: null, error: null }
93
+ try {
94
+ const parsed = parseBabel(source.text, {
95
+ sourceType: 'module',
96
+ allowHashBang: true,
97
+ plugins: parsePlugins(source.relative),
98
+ })
99
+ record.ast = parsed.program ?? parsed
100
+ } catch (error) {
101
+ record.error = {
102
+ message: String(error?.message ?? error),
103
+ line: Number.isInteger(error?.loc?.line) && error.loc.line > 0 ? error.loc.line : 1,
104
+ }
105
+ }
106
+ PARSE_CACHE.set(source, record)
107
+ return record
108
+ }
109
+
110
+ function normalizeRelative(relative) {
111
+ return String(relative ?? '').replace(/\\/g, '/').replace(/^\.\//, '')
112
+ }
113
+
114
+ // Declaration files have no runtime behavior; parsing them for import-time
115
+ // side effects or execution strings only creates noise.
116
+ function isDeclarationFile(relative) {
117
+ return /\.d\.[cm]?ts$/i.test(String(relative ?? ''))
118
+ }
119
+
120
+ function identifierName(node) {
121
+ return node?.type === 'Identifier' ? node.name : null
122
+ }
123
+
124
+ function memberPropertyName(node) {
125
+ if (!node || node.type !== 'MemberExpression') return null
126
+ if (!node.computed && node.property?.type === 'Identifier') return node.property.name
127
+ if (node.property?.type === 'Literal' && typeof node.property.value === 'string') return node.property.value
128
+ return null
129
+ }
130
+
131
+ function propertyKeyName(property) {
132
+ if (!property.computed && property.key?.type === 'Identifier') return property.key.name
133
+ if (property.key?.type === 'Literal' && typeof property.key.value === 'string') return property.key.value
134
+ return null
135
+ }
136
+
137
+ function calledFunctionName(call) {
138
+ if (!call || call.type !== 'CallExpression') return null
139
+ if (call.callee.type === 'Identifier') return call.callee.name
140
+ if (call.callee.type === 'MemberExpression') return memberPropertyName(call.callee)
141
+ return null
142
+ }
143
+
144
+ function walkAst(root, visit) {
145
+ const ancestors = []
146
+ const walkNode = (node) => {
147
+ if (!node || typeof node.type !== 'string') return
148
+ visit(node, ancestors)
149
+ ancestors.push(node)
150
+ for (const key of Object.keys(node)) {
151
+ if (key === 'loc') continue
152
+ const value = node[key]
153
+ if (Array.isArray(value)) {
154
+ for (const child of value) if (child && typeof child.type === 'string') walkNode(child)
155
+ } else if (value && typeof value.type === 'string') {
156
+ walkNode(value)
157
+ }
158
+ }
159
+ ancestors.pop()
160
+ }
161
+ walkNode(root)
162
+ }
163
+
164
+ // Module scope means "runs while the module is evaluated". Function bodies and
165
+ // class method bodies are skipped; top-level if/try/loop bodies are not.
166
+ function walkModuleScope(ast, visit) {
167
+ const ancestors = []
168
+ const walkNode = (node) => {
169
+ if (!node || typeof node.type !== 'string') return
170
+ if (FUNCTION_NODES.has(node.type) || node.type === 'MethodDefinition') return
171
+ visit(node, ancestors)
172
+ ancestors.push(node)
173
+ for (const key of Object.keys(node)) {
174
+ if (key === 'loc') continue
175
+ const value = node[key]
176
+ if (Array.isArray(value)) {
177
+ for (const child of value) if (child && typeof child.type === 'string') walkNode(child)
178
+ } else if (value && typeof value.type === 'string') {
179
+ walkNode(value)
180
+ }
181
+ }
182
+ ancestors.pop()
183
+ }
184
+ for (const statement of ast.body) walkNode(statement)
185
+ }
186
+
187
+ function callArgumentContext(ancestors, node, predicate) {
188
+ for (let index = ancestors.length - 1; index >= 0; index -= 1) {
189
+ const ancestor = ancestors[index]
190
+ if (ancestor.type !== 'CallExpression' || !predicate(ancestor)) continue
191
+ const child = index + 1 < ancestors.length ? ancestors[index + 1] : node
192
+ if (ancestor.arguments.includes(child)) return ancestor
193
+ }
194
+ return null
195
+ }
196
+
197
+ function isEqualityOperand(ancestors) {
198
+ for (let index = ancestors.length - 1; index >= 0; index -= 1) {
199
+ const ancestor = ancestors[index]
200
+ if (ancestor.type === 'BinaryExpression' && EQUALITY_OPERATORS.has(ancestor.operator)) return true
201
+ if (!TRANSPARENT_NODES.has(ancestor.type)) return false
202
+ }
203
+ return false
204
+ }
205
+
206
+ function isGrantCallArgument(ancestors, node) {
207
+ return Boolean(callArgumentContext(ancestors, node, (call) => (
208
+ call.callee.type === 'MemberExpression' && GRANT_METHODS.has(memberPropertyName(call.callee))
209
+ )))
210
+ }
211
+
212
+ function isConsoleArgument(ancestors, node) {
213
+ return Boolean(callArgumentContext(ancestors, node, (call) => (
214
+ call.callee.type === 'MemberExpression'
215
+ && identifierName(call.callee.object) === 'console'
216
+ && CONSOLE_METHODS.has(memberPropertyName(call.callee))
217
+ )))
218
+ }
219
+
220
+ function isSpawnArrayElement(ancestors, node) {
221
+ for (let index = 0; index < ancestors.length; index += 1) {
222
+ if (ancestors[index].type !== 'ArrayExpression') continue
223
+ for (let outer = index + 1; outer < ancestors.length; outer += 1) {
224
+ const call = ancestors[outer]
225
+ if (call.type !== 'CallExpression') continue
226
+ const child = outer + 1 < ancestors.length ? ancestors[outer + 1] : node
227
+ if (call.arguments.includes(child) && SPAWN_ARG_METHODS.has(calledFunctionName(call))) return true
228
+ }
229
+ }
230
+ return false
231
+ }
232
+
233
+ function isSwitchCaseValue(ancestors, node) {
234
+ for (let index = ancestors.length - 1; index >= 0; index -= 1) {
235
+ const ancestor = ancestors[index]
236
+ if (ancestor.type === 'SwitchCase') {
237
+ const child = index + 1 < ancestors.length ? ancestors[index + 1] : node
238
+ return ancestor.test === child
239
+ }
240
+ if (!TRANSPARENT_NODES.has(ancestor.type)) return false
241
+ }
242
+ return false
243
+ }
244
+
245
+ function propertyContext(ancestors, node) {
246
+ let child = node
247
+ for (let index = ancestors.length - 1; index >= 0; index -= 1) {
248
+ const ancestor = ancestors[index]
249
+ if (ancestor.type === 'Property') {
250
+ if (ancestor.value !== child) return null
251
+ return { key: propertyKeyName(ancestor) }
252
+ }
253
+ if (ancestor.type === 'ObjectExpression' || ancestor.type === 'ArrayExpression' || TRANSPARENT_NODES.has(ancestor.type)) {
254
+ child = ancestor
255
+ continue
256
+ }
257
+ return null
258
+ }
259
+ return null
260
+ }
261
+
262
+ function hasCommandProperty(ancestors, node) {
263
+ let child = node
264
+ for (let index = ancestors.length - 1; index >= 0; index -= 1) {
265
+ const ancestor = ancestors[index]
266
+ if (ancestor.type === 'Property') {
267
+ if (ancestor.value !== child) return false
268
+ if (COMMAND_PROPERTY_KEYS.has(propertyKeyName(ancestor))) return true
269
+ child = ancestor
270
+ continue
271
+ }
272
+ if (ancestor.type === 'ObjectExpression' || ancestor.type === 'ArrayExpression' || TRANSPARENT_NODES.has(ancestor.type)) {
273
+ child = ancestor
274
+ continue
275
+ }
276
+ return false
277
+ }
278
+ return false
279
+ }
280
+
281
+ /**
282
+ * Classify a string literal. Comments never reach the AST. Equality comparisons
283
+ * and documentation strings are ignored; grant/command contexts and unknown
284
+ * contexts warn (unknown stays conservative).
285
+ */
286
+ function classifyLiteral(node, ancestors) {
287
+ if (isEqualityOperand(ancestors)) return 'ignore'
288
+ if (isGrantCallArgument(ancestors, node)) return 'command'
289
+ if (isSpawnArrayElement(ancestors, node)) return 'command'
290
+ if (isSwitchCaseValue(ancestors, node)) return 'command'
291
+ if (hasCommandProperty(ancestors, node)) return 'command'
292
+ const property = propertyContext(ancestors, node)
293
+ if (isConsoleArgument(ancestors, node)) return 'doc'
294
+ if (property && DOC_PROPERTY_KEYS.has(property.key)) return 'doc'
295
+ return 'unknown'
296
+ }
297
+
298
+ function literalStringValue(node) {
299
+ if (node.type === 'Literal' && typeof node.value === 'string') return node.value
300
+ if (node.type === 'TemplateElement' && node.value) {
301
+ if (typeof node.value.cooked === 'string') return node.value.cooked
302
+ if (typeof node.value.raw === 'string') return node.value.raw
303
+ }
304
+ return null
305
+ }
306
+
307
+ function findLiteralHits(sources, values) {
308
+ const wanted = new Set(values)
309
+ const hits = []
310
+ const parseErrors = []
311
+ let parsedCount = 0
312
+ for (const source of sources) {
313
+ if (isDeclarationFile(source.relative)) continue
314
+ const parsed = parseSource(source)
315
+ if (parsed.error) {
316
+ parseErrors.push({ file: source.relative, line: parsed.error.line, message: parsed.error.message })
317
+ continue
318
+ }
319
+ parsedCount += 1
320
+ walkAst(parsed.ast, (node, ancestors) => {
321
+ const value = literalStringValue(node)
322
+ if (value === null || !wanted.has(value)) return
323
+ const context = classifyLiteral(node, ancestors)
324
+ if (context === 'ignore' || context === 'doc') return
325
+ hits.push({ file: source.relative, line: node.loc?.start?.line ?? 1, text: value })
326
+ })
327
+ }
328
+ return { hits, parseErrors, parsedCount }
329
+ }
330
+
331
+ function literalCandidates(base) {
332
+ const candidates = [base]
333
+ const extension = path.posix.extname(base)
334
+ if (SOURCE_EXTENSIONS.includes(extension)) {
335
+ const stem = base.slice(0, -extension.length)
336
+ for (const candidate of SOURCE_EXTENSIONS) if (candidate !== extension) candidates.push(`${stem}${candidate}`)
337
+ } else {
338
+ for (const candidate of SOURCE_EXTENSIONS) candidates.push(`${base}${candidate}`)
339
+ }
340
+ for (const candidate of SOURCE_EXTENSIONS) candidates.push(path.posix.join(base, `index${candidate}`))
341
+ return candidates
342
+ }
343
+
344
+ function resolveLocalSpecifier(fromRelative, specifier, byRelative) {
345
+ const spec = String(specifier).replace(/\\/g, '/')
346
+ if (!(spec.startsWith('./') || spec.startsWith('../') || spec === '.' || spec === '..')) return { kind: 'package' }
347
+ const base = path.posix.normalize(path.posix.join(path.posix.dirname(normalizeRelative(fromRelative)), spec))
348
+ if (base === '.' || base === '..' || base.startsWith('../') || path.posix.isAbsolute(base)) return { kind: 'unresolved' }
349
+ for (const candidate of literalCandidates(base)) {
350
+ if (byRelative.has(candidate)) return { kind: 'local', relative: candidate }
351
+ }
352
+ return { kind: 'unresolved' }
353
+ }
354
+
355
+ function collectModuleEdges(ast) {
356
+ const edges = []
357
+ walkAst(ast, (node) => {
358
+ if (node.type === 'ImportDeclaration' || node.type === 'ExportNamedDeclaration' || node.type === 'ExportAllDeclaration') {
359
+ if (node.source?.type === 'Literal' && typeof node.source.value === 'string') {
360
+ edges.push({ specifier: node.source.value, line: node.loc?.start?.line ?? 1 })
361
+ }
362
+ }
363
+ })
364
+ // require() is only import-time when it runs at module scope; a lazy require
365
+ // inside apply()/a factory must not be treated as part of the import closure.
366
+ walkModuleScope(ast, (node) => {
367
+ if (node.type === 'CallExpression' && node.callee.type === 'Identifier' && node.callee.name === 'require') {
368
+ const argument = node.arguments[0]
369
+ if (argument?.type === 'Literal' && typeof argument.value === 'string') {
370
+ edges.push({ specifier: argument.value, line: node.loc?.start?.line ?? 1 })
371
+ }
372
+ }
373
+ })
374
+ return edges
375
+ }
376
+
377
+ function isSetIntervalCall(call) {
378
+ if (call.callee.type === 'Identifier' && call.callee.name === 'setInterval') return true
379
+ return call.callee.type === 'MemberExpression'
380
+ && identifierName(call.callee.object) === 'globalThis'
381
+ && memberPropertyName(call.callee) === 'setInterval'
382
+ }
383
+
384
+ function isProcessOnCall(call) {
385
+ return call.callee.type === 'MemberExpression'
386
+ && identifierName(call.callee.object) === 'process'
387
+ && memberPropertyName(call.callee) === 'on'
388
+ }
389
+
390
+ function assignmentTargetKind(node) {
391
+ if (node.left.type !== 'MemberExpression') return null
392
+ const property = memberPropertyName(node.left)
393
+ if (!property) return null
394
+ const object = node.left.object
395
+ if (object.type === 'MemberExpression') {
396
+ return identifierName(object.object) === 'process' && memberPropertyName(object) === 'env' ? 'process.env' : null
397
+ }
398
+ return identifierName(object) === 'globalThis' ? 'globalThis' : null
399
+ }
400
+
401
+ function collectSideEffectHits(ast, file, hits) {
402
+ const push = (clause, pattern, node) => hits.push({ clause, pattern, file, line: node.loc?.start?.line ?? 1 })
403
+ walkModuleScope(ast, (node) => {
404
+ if (node.type === 'AwaitExpression' || (node.type === 'ForOfStatement' && node.await === true)) {
405
+ push('C-10.1', 'top-level await', node)
406
+ return
407
+ }
408
+ if (node.type === 'UnaryExpression' && node.operator === 'void' && node.argument?.type === 'CallExpression') {
409
+ push('C-10.2', 'top-level immediate main call', node)
410
+ return
411
+ }
412
+ if (node.type === 'AssignmentExpression') {
413
+ const kind = assignmentTargetKind(node)
414
+ if (kind === 'process.env') push('C-10.2', 'top-level process.env assignment', node)
415
+ else if (kind === 'globalThis') push('C-10.2', 'top-level globalThis assignment', node)
416
+ return
417
+ }
418
+ if (node.type !== 'CallExpression') return
419
+ if (isSetIntervalCall(node)) push('C-10.2', 'top-level setInterval', node)
420
+ if (isProcessOnCall(node)) push('C-10.2', 'top-level process.on', node)
421
+ const name = calledFunctionName(node)
422
+ if (name && FS_WRITE_METHODS.has(name)) push('C-10.4', 'top-level fs mutation', node)
423
+ if (name && C10_SUBPROCESS_METHODS.has(name)) push('C-10.4', 'top-level subprocess spawn', node)
424
+ })
425
+ }
426
+
427
+ function analyseImportClosure(sources, entryRelative) {
428
+ const byRelative = new Map()
429
+ for (const source of sources) byRelative.set(normalizeRelative(source.relative), source)
430
+ const queue = []
431
+ if (entryRelative) {
432
+ const entry = normalizeRelative(entryRelative)
433
+ if (!byRelative.has(entry)) return { error: `entry module ${entryRelative} was not found among scanned sources` }
434
+ queue.push(entry)
435
+ } else {
436
+ // Programmatic callers without an entry: keep the legacy "all sources" shape.
437
+ for (const relative of byRelative.keys()) queue.push(relative)
438
+ }
439
+
440
+ const visited = new Set()
441
+ const hits = []
442
+ const parseErrors = []
443
+ const coverage = []
444
+ const seenCoverage = new Set()
445
+ const localEdges = []
446
+ const addCoverage = (key, record) => {
447
+ if (seenCoverage.has(key)) return
448
+ seenCoverage.add(key)
449
+ coverage.push(record)
450
+ }
451
+
452
+ while (queue.length > 0) {
453
+ const relative = queue.shift()
454
+ if (visited.has(relative)) continue
455
+ visited.add(relative)
456
+ if (isDeclarationFile(relative)) {
457
+ addCoverage(`declaration:${relative}`, detail('C-10.3', 'NA', `not-run: declaration file ${relative} has no runtime behavior`, [relative]))
458
+ continue
459
+ }
460
+ const parsed = parseSource(byRelative.get(relative))
461
+ if (parsed.error) {
462
+ parseErrors.push({ file: relative, line: parsed.error.line, message: parsed.error.message })
463
+ continue
464
+ }
465
+ collectSideEffectHits(parsed.ast, relative, hits)
466
+ for (const edge of collectModuleEdges(parsed.ast)) {
467
+ const resolution = resolveLocalSpecifier(relative, edge.specifier, byRelative)
468
+ if (resolution.kind === 'local') {
469
+ localEdges.push({ from: relative, to: resolution.relative, line: edge.line })
470
+ queue.push(resolution.relative)
471
+ } else if (resolution.kind === 'package') {
472
+ addCoverage(`package:${edge.specifier}`, detail('C-10.3', 'NA', `not-run: package import '${edge.specifier}' from ${relative}:${edge.line} is outside the local closure`, [`${relative}:${edge.line}`]))
473
+ } else {
474
+ addCoverage(`unresolved:${relative}:${edge.specifier}`, detail('C-10.3', 'NA', `not-run: local import '${edge.specifier}' from ${relative}:${edge.line} could not be resolved`, [`${relative}:${edge.line}`]))
475
+ }
476
+ }
477
+ }
478
+
479
+ return { visited, hits, parseErrors, coverage, localEdges }
480
+ }
481
+
50
482
  function hitEvidence(hits, limit = 3) {
51
483
  return hits.slice(0, limit).map(hit => `${hit.file}:${hit.line}`)
52
484
  }
53
485
 
54
486
  /** DSC-10: advisory only; never FAIL. */
55
- export function checkImportTimeAdvisory(sources) {
56
- const patterns = [
57
- { name: 'top-level await', pattern: /^await\s/m },
58
- { name: 'top-level setInterval', pattern: /^setInterval\s*\(/m },
59
- { name: 'top-level process.on', pattern: /^process\.on\s*\(/m },
60
- { name: 'top-level process.env assignment', pattern: /^process\.env\.[A-Za-z_][A-Za-z0-9_]*\s*=/m },
61
- { name: 'top-level globalThis assignment', pattern: /^globalThis\.[A-Za-z_][A-Za-z0-9_]*\s*=/m },
62
- ]
63
- const hits = []
64
- for (const item of patterns) {
65
- for (const hit of regexHits(sources, item.pattern)) hits.push({ ...hit, pattern: item.name })
487
+ export function checkImportTimeAdvisory(sources, entryRelative = null) {
488
+ if (sources.length === 0) {
489
+ return check({
490
+ id: 'DSC-10',
491
+ clause: 'C-10',
492
+ layer: 'L0',
493
+ result: 'NA',
494
+ advisory: true,
495
+ coverage: 'not-run',
496
+ reason: 'no source files scanned; C-10 not-run',
497
+ details: [],
498
+ evidence: [],
499
+ })
500
+ }
501
+
502
+ const analysis = analyseImportClosure(sources, entryRelative)
503
+ if (analysis.error) {
504
+ const evidence = normalizeRelative(entryRelative)
505
+ return check({
506
+ id: 'DSC-10',
507
+ clause: 'C-10',
508
+ layer: 'L0',
509
+ result: 'ERROR',
510
+ advisory: true,
511
+ coverage: 'not-run',
512
+ reason: `${analysis.error}; C-10 not-run`,
513
+ details: [detail('C-10', 'ERROR', `not-run: ${analysis.error}`, [evidence])],
514
+ evidence: [evidence],
515
+ })
516
+ }
517
+
518
+ const { visited, hits, parseErrors, coverage, localEdges } = analysis
519
+ const details = hits.slice(0, 20).map(hit => detail(hit.clause, 'WARN', `${hit.pattern} at ${hit.file}:${hit.line}`, [`${hit.file}:${hit.line}`]))
520
+ details.push(...coverage.slice(0, 10))
521
+ if (localEdges.length > 0) {
522
+ details.push(detail('C-10.3', 'PASS', `local relative import closure analysed (${visited.size} module(s))`, [...visited].slice(0, 5)))
523
+ }
524
+ for (const error of parseErrors) {
525
+ details.push(detail('C-10', 'ERROR', `not-run: AST parse failed in ${error.file}:${error.line} (${error.message})`, [`${error.file}:${error.line}`]))
526
+ }
527
+
528
+ let result
529
+ let reason
530
+ if (parseErrors.length > 0) {
531
+ result = 'ERROR'
532
+ reason = `AST parse failed in ${parseErrors.length} file(s); C-10 not-run`
533
+ if (hits.length > 0) reason += `; ${hits.length} side-effect clue(s) found before the gap`
534
+ } else if (hits.length > 0) {
535
+ result = 'WARN'
536
+ reason = `advisory: ${hits.length} import-time side-effect clue(s)`
537
+ } else {
538
+ result = 'PASS'
539
+ reason = ''
66
540
  }
67
- const result = hits.length > 0 ? 'WARN' : sources.length === 0 ? 'WARN' : 'PASS'
541
+
542
+ const evidence = hits.length > 0
543
+ ? hitEvidence(hits)
544
+ : parseErrors.length > 0
545
+ ? hitEvidence(parseErrors)
546
+ : coverage.flatMap(item => item.evidence).slice(0, 3)
547
+
68
548
  return check({
69
549
  id: 'DSC-10',
70
550
  clause: 'C-10',
71
551
  layer: 'L0',
72
552
  result,
73
553
  advisory: true,
74
- reason: hits.length > 0 ? `advisory: ${hits.length} import-time side-effect clue(s)` : sources.length === 0 ? 'no source files scanned' : '',
75
- details: hits.slice(0, 20).map(hit => detail('C-10.1/2', 'WARN', `${hit.pattern} at ${hit.file}:${hit.line}`, [`${hit.file}:${hit.line}`])),
76
- evidence: hitEvidence(hits),
554
+ coverage: result === 'ERROR' ? 'not-run' : 'full',
555
+ reason,
556
+ details,
557
+ evidence,
77
558
  })
78
559
  }
79
560
 
@@ -116,13 +597,20 @@ export function checkCapabilities(manifest, sources, patch) {
116
597
  if (patch?.enabledPluginManagerTools) {
117
598
  details.push(detail('C-11.3', 'FAIL', 'bundle patch enables @deepseek-ai/dsh-plugin-manager/tools', ['dsh.bundle.patch']))
118
599
  }
119
- const dangerHits = regexHits(sources, /danger-full-access/g)
120
- if (dangerHits.length > 0) details.push(detail('C-11.4', 'WARN', 'danger-full-access literal found', hitEvidence(dangerHits)))
600
+ const dangerScan = findLiteralHits(sources, ['danger-full-access'])
601
+ if (dangerScan.hits.length > 0) {
602
+ details.push(detail('C-11.4', 'WARN', `danger-full-access literal in ${dangerScan.hits.length} execution context(s)`, hitEvidence(dangerScan.hits)))
603
+ }
604
+ const dangerUnparsableAll = dangerScan.parseErrors.length > 0 && dangerScan.parsedCount === 0
605
+ if (dangerScan.parseErrors.length > 0) {
606
+ details.push(detail('C-11.4', dangerUnparsableAll ? 'ERROR' : 'NA', `not-run: ${dangerScan.parseErrors.length} source file(s) could not be parsed for string literals (${dangerScan.parseErrors[0].file}:${dangerScan.parseErrors[0].line})`, hitEvidence(dangerScan.parseErrors)))
607
+ }
121
608
  if (details.length === 0) details.push(detail('C-11.1', 'PASS', 'dsh.capabilities is declared and consistent with static triggers'))
122
609
 
123
610
  const result = worst(details.map(item => item.result))
124
611
  return check({
125
612
  id: 'DSC-11', clause: 'C-11', layer: 'L0', result,
613
+ coverage: dangerUnparsableAll ? 'not-run' : dangerScan.parseErrors.length > 0 ? 'partial' : 'full',
126
614
  reason: details.filter(item => item.result !== 'PASS').map(item => `${item.item}: ${item.reason}`).join(' | '),
127
615
  details,
128
616
  evidence: ['package.json#dsh.capabilities', 'dsh.bundle.patch'],
@@ -171,13 +659,20 @@ export function checkCredentials(manifest, sources) {
171
659
 
172
660
  const providerHits = regexHits(sources, /ctx\.provide\(\s*['"](?:shell|credentials|sandboxPolicy|sandbox|deepseekAccount)['"]/g)
173
661
  if (providerHits.length > 0) details.push(detail('C-12.3', 'WARN', 'global seam provider registration detected; declaration ownership needs review', hitEvidence(providerHits)))
174
- const dangerHits = regexHits(sources, /danger-full-access|--add-dir/g)
175
- if (dangerHits.length > 0) details.push(detail('C-12.4', 'WARN', 'danger-full-access/--add-dir literal found', hitEvidence(dangerHits)))
662
+ const literalScan = findLiteralHits(sources, ['danger-full-access', '--add-dir'])
663
+ if (literalScan.hits.length > 0) {
664
+ details.push(detail('C-12.4', 'WARN', `danger-full-access/--add-dir literal in ${literalScan.hits.length} execution context(s)`, hitEvidence(literalScan.hits)))
665
+ }
666
+ const literalUnparsableAll = literalScan.parseErrors.length > 0 && literalScan.parsedCount === 0
667
+ if (literalScan.parseErrors.length > 0) {
668
+ details.push(detail('C-12.4', literalUnparsableAll ? 'ERROR' : 'NA', `not-run: ${literalScan.parseErrors.length} source file(s) could not be parsed for string literals (${literalScan.parseErrors[0].file}:${literalScan.parseErrors[0].line})`, hitEvidence(literalScan.parseErrors)))
669
+ }
176
670
  details.push(...validateCommitments(manifest), ...validateDisclosure(manifest))
177
671
 
178
672
  const result = worst(details.map(item => item.result))
179
673
  return check({
180
674
  id: 'DSC-12', clause: 'C-12', layer: 'L0', result,
675
+ coverage: literalUnparsableAll ? 'not-run' : literalScan.parseErrors.length > 0 ? 'partial' : 'full',
181
676
  reason: details.filter(item => item.result !== 'PASS' && item.result !== 'NA').map(item => `${item.item}: ${item.reason}`).join(' | '),
182
677
  details,
183
678
  evidence: ['package.json#dsh.capabilities'],
@@ -204,6 +204,11 @@ export function runManifestChecks(dir, options = {}) {
204
204
  manifestDetails.push(detail('C-1.2', 'PASS', `entry resolves via ${entry.source}: ${entry.relative}`, [entry.relative]))
205
205
  } else if (entry.buildHint) {
206
206
  manifestDetails.push(detail('C-1.2', 'WARN', `entry not present in checkout; source/build hint exists (${entry.sourceEntry ?? 'build script'})`, ['package.json#scripts']))
207
+ } else if (kind === 'bundle' && patch.present) {
208
+ // 2026-11-04: a bundle that ships only a cordis.patch.yml has no runtime entry
209
+ // by design (dsh.bundle.patch is the carrier). Report NA, not WARN, so the
210
+ // mandatory coverage is not charged for something the package never claimed.
211
+ manifestDetails.push(detail('C-1.2', 'NA', 'patch-only bundle; no runtime entry by design', patch.files.map(f => f.relative)))
207
212
  } else if (kind === 'bundle' || kind === 'client') {
208
213
  manifestDetails.push(detail('C-1.2', 'WARN', `no runtime entry; ${kind} packages may be patch/client only`, ['package.json#exports']))
209
214
  } else if (Array.isArray(manifest.workspaces) || (manifest.workspaces && typeof manifest.workspaces === 'object')) {