dsh-capdiff 0.0.0-stage → 0.1.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/src/core.js ADDED
@@ -0,0 +1,914 @@
1
+ /**
2
+ * dsh-capdiff core: static capability extraction and declaration diffing.
3
+ *
4
+ * This module deliberately uses regular expressions instead of a JavaScript
5
+ * parser so it can run offline with zero dependencies on arbitrary plugin
6
+ * source. It is a heuristic scanner: it reports what is *statically visible*,
7
+ * never what a plugin can actually do at runtime. See README.md / DESIGN.md.
8
+ *
9
+ * @module dsh-capdiff/core
10
+ */
11
+
12
+ import { readdir, readFile, stat } from 'node:fs/promises'
13
+ import { basename, dirname, extname, join, relative, resolve } from 'node:path'
14
+
15
+ export const TOOL_NAME = 'dsh-capdiff'
16
+ export const TOOL_VERSION = '0.1.0'
17
+
18
+ /** Source extensions scanned by {@link collectSourceFiles}. */
19
+ export const SOURCE_EXTENSIONS = Object.freeze(['.js', '.mjs', '.cjs'])
20
+
21
+ const SOURCE_EXTENSION_SET = new Set(SOURCE_EXTENSIONS)
22
+ const SKIPPED_DIRECTORIES = new Set(['node_modules', '.git'])
23
+
24
+ /**
25
+ * Whether a path has a source extension understood by this package.
26
+ *
27
+ * @param {string} file
28
+ * @returns {boolean}
29
+ */
30
+ export function isSourceFile(file) {
31
+ return SOURCE_EXTENSION_SET.has(extname(String(file)).toLowerCase())
32
+ }
33
+
34
+ /**
35
+ * Import specifier / language construct -> capability.
36
+ *
37
+ * Bare and `node:`-prefixed builtins are both listed where useful. The three
38
+ * non-module keys (`process.env`, `eval`, `new Function`) are language-level
39
+ * evidence; they are detected by scanning source text, not by imports.
40
+ *
41
+ * @type {Readonly<Record<string, string>>}
42
+ */
43
+ export const CAPABILITY_MAP = Object.freeze({
44
+ 'node:fs': 'filesystem',
45
+ fs: 'filesystem',
46
+ 'node:fs/promises': 'filesystem',
47
+ 'fs/promises': 'filesystem',
48
+ 'node:net': 'network',
49
+ net: 'network',
50
+ 'node:http': 'network',
51
+ http: 'network',
52
+ 'node:https': 'network',
53
+ https: 'network',
54
+ 'node:http2': 'network',
55
+ http2: 'network',
56
+ 'node:tls': 'network',
57
+ tls: 'network',
58
+ 'node:dgram': 'network',
59
+ dgram: 'network',
60
+ fetch: 'network',
61
+ 'node:child_process': 'process',
62
+ child_process: 'process',
63
+ 'node:crypto': 'crypto',
64
+ crypto: 'crypto',
65
+ 'process.env': 'env',
66
+ eval: 'eval',
67
+ 'new Function': 'eval',
68
+ })
69
+
70
+ const CANONICAL_CAPABILITIES = new Set([
71
+ 'filesystem',
72
+ 'network',
73
+ 'process',
74
+ 'env',
75
+ 'eval',
76
+ 'crypto',
77
+ 'unknown',
78
+ ])
79
+
80
+ // Friendly aliases accepted in manifests. Underscores and whitespace are
81
+ // normalised to "-" before this table is consulted.
82
+ const DECLARATION_ALIASES = Object.freeze({
83
+ fs: 'filesystem',
84
+ file: 'filesystem',
85
+ files: 'filesystem',
86
+ 'file-system': 'filesystem',
87
+ net: 'network',
88
+ networking: 'network',
89
+ http: 'network',
90
+ https: 'network',
91
+ fetch: 'network',
92
+ 'child-process': 'process',
93
+ subprocess: 'process',
94
+ spawn: 'process',
95
+ environment: 'env',
96
+ 'environment-variable': 'env',
97
+ 'environment-variables': 'env',
98
+ 'code-execution': 'eval',
99
+ 'dynamic-code': 'eval',
100
+ 'new-function': 'eval',
101
+ 'process-env': 'env',
102
+ cryptography: 'crypto',
103
+ })
104
+
105
+ /** @typedef {{ specifier: string, kind: 'static'|'dynamic'|'require'|'re-export', index: number }} ImportRef */
106
+ /** @typedef {{ capability: string, origin: 'import'|'code', kind: string, specifier: string|null, evidence: string, index: number, line: number }} CapabilityEvidence */
107
+ /** @typedef {{ matched: string[], undeclared: string[], unused: string[] }} CapabilityDiff */
108
+
109
+ /**
110
+ * Replace comments and string/template literal contents with spaces while
111
+ * preserving offsets and newlines. This keeps line numbers stable and lets the
112
+ * import/code regexes ignore commented-out code and string examples.
113
+ *
114
+ * Regex literals and template `${...}` expressions are not parsed. Masking the
115
+ * whole template is intentional: missing a capability inside `${...}` is a
116
+ * false negative, documented in DESIGN.md.
117
+ *
118
+ * @param {string} code
119
+ * @returns {string}
120
+ */
121
+ function maskNonCode(code) {
122
+ const source = String(code)
123
+ const output = source.split('')
124
+ const length = source.length
125
+ let index = 0
126
+ /** @type {'code'|'line-comment'|'block-comment'|'single'|'double'|'template'} */
127
+ let state = 'code'
128
+
129
+ while (index < length) {
130
+ const char = source[index]
131
+ const next = source[index + 1]
132
+
133
+ if (state === 'code') {
134
+ if (char === '/' && next === '/') {
135
+ output[index] = ' '
136
+ output[index + 1] = ' '
137
+ index += 2
138
+ state = 'line-comment'
139
+ } else if (char === '/' && next === '*') {
140
+ output[index] = ' '
141
+ output[index + 1] = ' '
142
+ index += 2
143
+ state = 'block-comment'
144
+ } else if (char === "'") {
145
+ output[index] = ' '
146
+ index += 1
147
+ state = 'single'
148
+ } else if (char === '"') {
149
+ output[index] = ' '
150
+ index += 1
151
+ state = 'double'
152
+ } else if (char === '`') {
153
+ output[index] = ' '
154
+ index += 1
155
+ state = 'template'
156
+ } else {
157
+ index += 1
158
+ }
159
+ continue
160
+ }
161
+
162
+ if (state === 'line-comment') {
163
+ if (char === '\n') {
164
+ state = 'code'
165
+ } else {
166
+ output[index] = ' '
167
+ }
168
+ index += 1
169
+ continue
170
+ }
171
+
172
+ if (state === 'block-comment') {
173
+ if (char === '*' && next === '/') {
174
+ output[index] = ' '
175
+ output[index + 1] = ' '
176
+ index += 2
177
+ state = 'code'
178
+ } else {
179
+ if (char !== '\n' && char !== '\r') output[index] = ' '
180
+ index += 1
181
+ }
182
+ continue
183
+ }
184
+
185
+ // String / template literal state.
186
+ if (char === '\\') {
187
+ output[index] = ' '
188
+ if (index + 1 < length) {
189
+ const escaped = source[index + 1]
190
+ if (escaped !== '\n' && escaped !== '\r') output[index + 1] = ' '
191
+ }
192
+ index += 2
193
+ continue
194
+ }
195
+
196
+ const closing = state === 'single' ? "'" : state === 'double' ? '"' : '`'
197
+ if (char === closing) {
198
+ output[index] = ' '
199
+ index += 1
200
+ state = 'code'
201
+ continue
202
+ }
203
+
204
+ // A raw newline terminates a normal string (invalid JS, but recover
205
+ // gracefully); template literals may span lines.
206
+ if ((state === 'single' || state === 'double') && char === '\n') {
207
+ state = 'code'
208
+ index += 1
209
+ continue
210
+ }
211
+
212
+ if (char !== '\n' && char !== '\r') output[index] = ' '
213
+ index += 1
214
+ }
215
+
216
+ return output.join('')
217
+ }
218
+
219
+ // Clause characters: identifiers, whitespace, braces, commas, star. Keeping the
220
+ // clause bounded (instead of [\s\S]*?) avoids swallowing unrelated lines when
221
+ // malformed source lacks a `from`.
222
+ const STATIC_IMPORT_RE =
223
+ /(?<![\w$.])import\s+[\w$\s{},*]+?\s+from\s*(['"])([^'"\r\n]+)\1/g
224
+ const SIDE_EFFECT_IMPORT_RE =
225
+ /(?<![\w$.])import\s*(?!\()(['"])([^'"\r\n]+)\1/g
226
+ const DYNAMIC_IMPORT_RE =
227
+ /(?<![\w$.])import\s*\(\s*(['"])([^'"\r\n]+)\1\s*\)/g
228
+ const REQUIRE_RE = /(?<![\w$.])require\s*\(\s*(['"])([^'"\r\n]+)\1\s*\)/g
229
+ const EXPORT_FROM_RE =
230
+ /(?<![\w$.])export\s+[\w$\s{},*]+?\s+from\s*(['"])([^'"\r\n]+)\1/g
231
+
232
+ /**
233
+ * Find statically written module references.
234
+ *
235
+ * Recognised kinds:
236
+ * - `static` `import x from 'm'`, `import { x } from 'm'`, `import 'm'`
237
+ * - `dynamic` `import('m')` (string literals only)
238
+ * - `require` `require('m')` (string literals only)
239
+ * - `re-export` `export ... from 'm'`
240
+ *
241
+ * Computed specifiers (`import(name)`) are invisible to a regex scanner and are
242
+ * ignored. Imports inside comments or string literals are ignored as well.
243
+ *
244
+ * @param {string} code
245
+ * @returns {ImportRef[]} matches sorted by source offset
246
+ */
247
+ export function extractImports(code) {
248
+ const source = typeof code === 'string' ? code : String(code ?? '')
249
+ const masked = maskNonCode(source)
250
+ /** @type {ImportRef[]} */
251
+ const imports = []
252
+ const seen = new Set()
253
+
254
+ /**
255
+ * @param {RegExp} pattern
256
+ * @param {ImportRef['kind']} kind
257
+ */
258
+ const collect = (pattern, kind) => {
259
+ const regex = new RegExp(pattern.source, pattern.flags)
260
+ for (const match of source.matchAll(regex)) {
261
+ const index = match.index ?? 0
262
+ // The keyword must be visible code, not a comment/string placeholder.
263
+ if (masked[index] !== source[index]) continue
264
+ const specifier = match[2]
265
+ if (!specifier) continue
266
+ const key = `${index}:${kind}:${specifier}`
267
+ if (seen.has(key)) continue
268
+ seen.add(key)
269
+ imports.push({ specifier, kind, index })
270
+ }
271
+ }
272
+
273
+ collect(STATIC_IMPORT_RE, 'static')
274
+ collect(SIDE_EFFECT_IMPORT_RE, 'static')
275
+ collect(DYNAMIC_IMPORT_RE, 'dynamic')
276
+ collect(REQUIRE_RE, 'require')
277
+ collect(EXPORT_FROM_RE, 're-export')
278
+
279
+ return imports.sort((a, b) => a.index - b.index)
280
+ }
281
+
282
+ /**
283
+ * Map one module specifier to a capability. Subpaths such as
284
+ * `node:fs/promises` inherit the capability of their builtin root.
285
+ *
286
+ * @param {string} specifier
287
+ * @returns {string}
288
+ */
289
+ export function capabilityForSpecifier(specifier) {
290
+ if (typeof specifier !== 'string') return 'unknown'
291
+ const value = specifier.trim()
292
+ if (!value) return 'unknown'
293
+ if (Object.hasOwn(CAPABILITY_MAP, value)) return CAPABILITY_MAP[value]
294
+
295
+ const bare = value.startsWith('node:') ? value.slice(5) : value
296
+ if (Object.hasOwn(CAPABILITY_MAP, bare)) return CAPABILITY_MAP[bare]
297
+
298
+ const [base] = bare.split('/')
299
+ if (base && Object.hasOwn(CAPABILITY_MAP, base)) return CAPABILITY_MAP[base]
300
+ return 'unknown'
301
+ }
302
+
303
+ /**
304
+ * 1-based line number of a UTF-16 offset.
305
+ *
306
+ * @param {string} code
307
+ * @param {number} index
308
+ * @returns {number}
309
+ */
310
+ export function lineOfIndex(code, index) {
311
+ const source = typeof code === 'string' ? code : String(code ?? '')
312
+ const limit = Math.max(0, Math.min(Number.isInteger(index) ? index : 0, source.length))
313
+ let line = 1
314
+ for (let i = 0; i < limit; i += 1) {
315
+ if (source.charCodeAt(i) === 10) line += 1
316
+ }
317
+ return line
318
+ }
319
+
320
+ // Language-level capabilities that are not imports. Lookbehinds avoid matching
321
+ // property access such as `foo.eval(...)`.
322
+ const CODE_CAPABILITY_PATTERNS = Object.freeze([
323
+ {
324
+ capability: 'env',
325
+ evidence: 'process.env',
326
+ regex: /(?<![\w$.])process\s*\.\s*env\b/g,
327
+ },
328
+ {
329
+ capability: 'env',
330
+ evidence: "process['env']",
331
+ regex: /(?<![\w$.])process\s*\[\s*(['"])env\1\s*\]/g,
332
+ },
333
+ {
334
+ capability: 'eval',
335
+ evidence: 'eval()',
336
+ regex: /(?<![\w$.])eval\s*\(/g,
337
+ },
338
+ {
339
+ capability: 'eval',
340
+ evidence: 'new Function()',
341
+ regex: /(?<![\w$.])new\s+Function\s*\(/g,
342
+ },
343
+ ])
344
+
345
+ /**
346
+ * Collect every capability observation from a source string.
347
+ *
348
+ * @param {string} code
349
+ * @param {ImportRef[]|string[]} [imports] pre-extracted imports; when omitted
350
+ * they are derived from `code`
351
+ * @returns {CapabilityEvidence[]} sorted by source offset
352
+ */
353
+ export function collectCapabilityEvidence(code, imports) {
354
+ const source = typeof code === 'string' ? code : ''
355
+ const masked = maskNonCode(source)
356
+ const importList = Array.isArray(imports) ? imports : extractImports(source)
357
+ /** @type {CapabilityEvidence[]} */
358
+ const evidence = []
359
+
360
+ for (const entry of importList) {
361
+ const specifier =
362
+ typeof entry === 'string'
363
+ ? entry
364
+ : entry && typeof entry === 'object' && typeof entry.specifier === 'string'
365
+ ? entry.specifier
366
+ : null
367
+ if (!specifier) continue
368
+ const kind =
369
+ entry && typeof entry === 'object' && typeof entry.kind === 'string' && entry.kind
370
+ ? entry.kind
371
+ : 'static'
372
+ const index =
373
+ entry && typeof entry === 'object' && Number.isInteger(entry.index) && entry.index >= 0
374
+ ? entry.index
375
+ : 0
376
+ evidence.push({
377
+ capability: capabilityForSpecifier(specifier),
378
+ origin: 'import',
379
+ kind,
380
+ specifier,
381
+ evidence: specifier,
382
+ index,
383
+ line: lineOfIndex(source, index),
384
+ })
385
+ }
386
+
387
+ for (const pattern of CODE_CAPABILITY_PATTERNS) {
388
+ const regex = new RegExp(pattern.regex.source, pattern.regex.flags)
389
+ // Match raw source so patterns containing string literals (process['env'])
390
+ // still work, but require the token to start in visible code. A match that
391
+ // starts inside a comment or string is skipped, matching extractImports.
392
+ for (const match of source.matchAll(regex)) {
393
+ const index = match.index ?? 0
394
+ if (masked[index] !== source[index]) continue
395
+ evidence.push({
396
+ capability: pattern.capability,
397
+ origin: 'code',
398
+ kind: 'usage',
399
+ specifier: null,
400
+ evidence: pattern.evidence,
401
+ index,
402
+ line: lineOfIndex(source, index),
403
+ })
404
+ }
405
+ }
406
+
407
+ return evidence.sort(
408
+ (a, b) =>
409
+ a.index - b.index ||
410
+ a.capability.localeCompare(b.capability) ||
411
+ a.evidence.localeCompare(b.evidence),
412
+ )
413
+ }
414
+
415
+ /**
416
+ * Map imports plus language-level uses to a de-duplicated capability list.
417
+ * Order follows the first observation in source order.
418
+ *
419
+ * @param {ImportRef[]|string} [imports]
420
+ * @param {string} [code]
421
+ * @returns {string[]}
422
+ */
423
+ export function mapCapabilities(imports = [], code = '') {
424
+ if (typeof imports === 'string') {
425
+ code = imports
426
+ imports = extractImports(code)
427
+ }
428
+
429
+ const source = typeof code === 'string' ? code : ''
430
+ const importList = Array.isArray(imports) ? imports : extractImports(source)
431
+ const evidence = collectCapabilityEvidence(source, importList)
432
+ /** @type {string[]} */
433
+ const capabilities = []
434
+ for (const item of evidence) {
435
+ if (!capabilities.includes(item.capability)) capabilities.push(item.capability)
436
+ }
437
+ return capabilities
438
+ }
439
+
440
+ /**
441
+ * Normalise a declared capability token. Unknown custom names are preserved
442
+ * (lower-cased, whitespace/underscores collapsed) so callers can still diff
443
+ * vendor-specific declarations.
444
+ *
445
+ * @param {unknown} value
446
+ * @returns {string|null}
447
+ */
448
+ export function normalizeCapability(value) {
449
+ if (typeof value !== 'string') return null
450
+ const lower = value.trim().toLowerCase()
451
+ if (!lower) return null
452
+
453
+ // Check exact specifiers first so keys containing underscores
454
+ // (e.g. node:child_process) are not mangled by separator normalisation.
455
+ if (Object.hasOwn(CAPABILITY_MAP, lower)) {
456
+ const mapped = CAPABILITY_MAP[lower]
457
+ if (CANONICAL_CAPABILITIES.has(mapped)) return mapped
458
+ }
459
+
460
+ const name = lower.replace(/[\s_]+/g, '-')
461
+ if (CANONICAL_CAPABILITIES.has(name)) return name
462
+ if (Object.hasOwn(DECLARATION_ALIASES, name)) return DECLARATION_ALIASES[name]
463
+ if (Object.hasOwn(CAPABILITY_MAP, name)) {
464
+ const mapped = CAPABILITY_MAP[name]
465
+ if (CANONICAL_CAPABILITIES.has(mapped)) return mapped
466
+ }
467
+ return name
468
+ }
469
+
470
+ /**
471
+ * Parse a plugin manifest into a canonical capability array.
472
+ *
473
+ * Accepts both forms:
474
+ * { dsh: { capabilities: ['filesystem', 'network'] } }
475
+ * { dsh: { capabilities: { filesystem: true, network: false } } }
476
+ *
477
+ * Array entries may be strings or objects with `capability`/`name`/`id`/`type`.
478
+ * `manifest.capabilities` and a bare array are accepted as conveniences.
479
+ *
480
+ * @param {unknown} manifest
481
+ * @returns {string[]} normalised, de-duplicated, declaration order preserved
482
+ */
483
+ export function parseDeclared(manifest) {
484
+ if (manifest == null) return []
485
+
486
+ /** @type {unknown} */
487
+ let raw
488
+ if (Array.isArray(manifest)) {
489
+ raw = manifest
490
+ } else if (typeof manifest === 'object') {
491
+ const record = /** @type {Record<string, unknown>} */ (manifest)
492
+ const dsh = record.dsh
493
+ if (
494
+ dsh &&
495
+ typeof dsh === 'object' &&
496
+ !Array.isArray(dsh) &&
497
+ Object.hasOwn(/** @type {object} */ (dsh), 'capabilities')
498
+ ) {
499
+ raw = /** @type {Record<string, unknown>} */ (dsh).capabilities
500
+ } else if (Object.hasOwn(record, 'capabilities')) {
501
+ raw = record.capabilities
502
+ }
503
+ }
504
+
505
+ if (raw == null) return []
506
+
507
+ /** @type {string[]} */
508
+ const tokens = []
509
+ if (Array.isArray(raw)) {
510
+ for (const entry of raw) {
511
+ if (typeof entry === 'string') {
512
+ tokens.push(entry)
513
+ } else if (entry && typeof entry === 'object') {
514
+ const named = ['capability', 'name', 'id', 'type']
515
+ .map((key) => /** @type {Record<string, unknown>} */ (entry)[key])
516
+ .find((value) => typeof value === 'string')
517
+ if (typeof named === 'string') {
518
+ tokens.push(named)
519
+ }
520
+ }
521
+ }
522
+ } else if (typeof raw === 'string') {
523
+ tokens.push(...raw.split(','))
524
+ } else if (typeof raw === 'object') {
525
+ for (const [key, value] of Object.entries(raw)) {
526
+ if (!value) continue
527
+ if (typeof value === 'string' && /^(?:false|no|off|none)$/i.test(value.trim())) continue
528
+ tokens.push(key)
529
+ }
530
+ }
531
+
532
+ /** @type {string[]} */
533
+ const declared = []
534
+ for (const token of tokens) {
535
+ const name = normalizeCapability(token)
536
+ if (name && !declared.includes(name)) declared.push(name)
537
+ }
538
+ return declared
539
+ }
540
+
541
+ /**
542
+ * Normalise a list of capability values (strings or declaration objects).
543
+ *
544
+ * @param {unknown} value
545
+ * @returns {string[]}
546
+ */
547
+ function normalizeCapabilityList(value) {
548
+ /** @type {unknown[]} */
549
+ let list
550
+ if (Array.isArray(value)) list = value
551
+ else if (value instanceof Set) list = [...value]
552
+ else if (typeof value === 'string') list = [value]
553
+ else list = []
554
+
555
+ /** @type {string[]} */
556
+ const normalized = []
557
+ for (const entry of list) {
558
+ const raw =
559
+ typeof entry === 'string'
560
+ ? entry
561
+ : entry && typeof entry === 'object'
562
+ ? (/** @type {Record<string, unknown>} */ (entry).capability ??
563
+ /** @type {Record<string, unknown>} */ (entry).name)
564
+ : null
565
+ const name = normalizeCapability(raw)
566
+ if (name && !normalized.includes(name)) normalized.push(name)
567
+ }
568
+ return normalized
569
+ }
570
+
571
+ /**
572
+ * Set-diff declarations against statically visible capabilities.
573
+ *
574
+ * @param {unknown} declared
575
+ * @param {unknown} actual
576
+ * @returns {CapabilityDiff}
577
+ */
578
+ export function diffCapabilities(declared, actual) {
579
+ const declaredList = normalizeCapabilityList(declared)
580
+ const actualList = normalizeCapabilityList(actual)
581
+ const declaredSet = new Set(declaredList)
582
+ const actualSet = new Set(actualList)
583
+
584
+ return {
585
+ matched: declaredList.filter((capability) => actualSet.has(capability)),
586
+ undeclared: actualList.filter((capability) => !declaredSet.has(capability)),
587
+ unused: declaredList.filter((capability) => !actualSet.has(capability)),
588
+ }
589
+ }
590
+
591
+ /**
592
+ * Turn a capability diff into SARIF-friendly findings. Undeclared findings get
593
+ * the first evidence location when available; unused findings point at the
594
+ * manifest (or target) when known.
595
+ *
596
+ * @param {CapabilityDiff} diff
597
+ * @param {Record<string, CapabilityEvidence & { file?: string }>|Map<string, CapabilityEvidence & { file?: string }>} [firstEvidence]
598
+ * @param {{ manifestFile?: string, target?: string }} [options]
599
+ * @returns {Array<{ kind: 'undeclared'|'unused', capability: string, message: string, file?: string, line?: number, evidence?: string }>}
600
+ */
601
+ export function buildFindings(diff, firstEvidence = {}, options = {}) {
602
+ if (!diff || typeof diff !== 'object') return []
603
+ const opts = options && typeof options === 'object' ? options : {}
604
+ const lookup =
605
+ firstEvidence instanceof Map
606
+ ? (capability) => firstEvidence.get(capability)
607
+ : (capability) =>
608
+ firstEvidence && typeof firstEvidence === 'object'
609
+ ? firstEvidence[capability]
610
+ : undefined
611
+
612
+ /** @type {Array<{ kind: 'undeclared'|'unused', capability: string, message: string, file?: string, line?: number, evidence?: string }>} */
613
+ const findings = []
614
+
615
+ for (const capability of Array.isArray(diff.undeclared) ? diff.undeclared : []) {
616
+ const evidence = lookup(capability)
617
+ /** @type {{ kind: 'undeclared', capability: string, message: string, file?: string, line?: number, evidence?: string }} */
618
+ const finding = {
619
+ kind: 'undeclared',
620
+ capability,
621
+ message: `Capability "${capability}" is used in code but is not declared in dsh.capabilities.`,
622
+ }
623
+ const file = evidence?.file ?? opts.target
624
+ if (file) finding.file = file
625
+ if (Number.isInteger(evidence?.line)) finding.line = evidence.line
626
+ if (evidence?.evidence) finding.evidence = evidence.evidence
627
+ if (file && Number.isInteger(evidence?.line)) {
628
+ finding.message = `Capability "${capability}" is used in code (${evidence?.evidence}) at ${file}:${evidence.line} but is not declared in dsh.capabilities.`
629
+ }
630
+ findings.push(finding)
631
+ }
632
+
633
+ for (const capability of Array.isArray(diff.unused) ? diff.unused : []) {
634
+ /** @type {{ kind: 'unused', capability: string, message: string, file?: string }} */
635
+ const finding = {
636
+ kind: 'unused',
637
+ capability,
638
+ message: `Capability "${capability}" is declared in dsh.capabilities but is not statically visible in the scanned files.`,
639
+ }
640
+ const file = opts.manifestFile ?? opts.target
641
+ if (file) finding.file = file
642
+ findings.push(finding)
643
+ }
644
+
645
+ return findings
646
+ }
647
+
648
+ const SARIF_SCHEMA = 'https://json.schemastore.org/sarif-2.1.0.json'
649
+ const HELP_URI = 'https://github.com/PerryLink/dsh-next-suite/tree/main/packages/dsh-capdiff#limitations'
650
+
651
+ /**
652
+ * @param {'undeclared'|'unused'} kind
653
+ * @returns {Record<string, unknown>}
654
+ */
655
+ function sarifRule(kind) {
656
+ const unused = kind === 'unused'
657
+ return {
658
+ id: `dsh-capdiff/${kind}`,
659
+ name: unused ? 'UnusedCapabilityDeclaration' : 'UndeclaredCapability',
660
+ shortDescription: {
661
+ text: unused
662
+ ? 'Capability declared but not statically visible'
663
+ : 'Capability used in code but not declared',
664
+ },
665
+ fullDescription: {
666
+ text: unused
667
+ ? 'The manifest declares this capability, but the scanner found no import or language-level use in the scanned files. This is a review hint, not a security verdict.'
668
+ : 'The scanner found a statically visible use of this capability, but the manifest does not declare it. Static heuristics can produce false positives.',
669
+ },
670
+ defaultConfiguration: { level: unused ? 'note' : 'warning' },
671
+ helpUri: HELP_URI,
672
+ }
673
+ }
674
+
675
+ /**
676
+ * @param {unknown} input
677
+ * @returns {unknown[]}
678
+ */
679
+ function normalizeFindingsInput(input) {
680
+ if (Array.isArray(input)) return input
681
+ if (input && typeof input === 'object') {
682
+ const record = /** @type {Record<string, unknown>} */ (input)
683
+ if (Array.isArray(record.findings)) return record.findings
684
+ if (Array.isArray(record.undeclared) || Array.isArray(record.unused)) {
685
+ return [
686
+ ...(Array.isArray(record.undeclared)
687
+ ? record.undeclared.map((capability) => ({ kind: 'undeclared', capability }))
688
+ : []),
689
+ ...(Array.isArray(record.unused)
690
+ ? record.unused.map((capability) => ({ kind: 'unused', capability }))
691
+ : []),
692
+ ]
693
+ }
694
+ }
695
+ return []
696
+ }
697
+
698
+ /**
699
+ * @param {string} file
700
+ * @returns {string}
701
+ */
702
+ function sarifUri(file) {
703
+ const normalized = String(file).replace(/\\/g, '/')
704
+ if (/^[a-z][a-z0-9+.-]*:/i.test(normalized)) return normalized
705
+ return encodeURI(normalized)
706
+ }
707
+
708
+ /**
709
+ * Build a minimal SARIF 2.1.0 log. SARIF treats unknown `ruleId`s as errors in
710
+ * some consumers, so only the two rule kinds emitted here are declared.
711
+ *
712
+ * Findings may also be a raw diff object (`{undeclared, unused}`), which is a
713
+ * convenience for scripted callers.
714
+ *
715
+ * @param {unknown} findings
716
+ * @param {{ file?: string, toolVersion?: string }} [options]
717
+ * @returns {Record<string, unknown>}
718
+ */
719
+ export function toSarif(findings, options = {}) {
720
+ const opts = options && typeof options === 'object' ? options : {}
721
+ const fallbackFile = typeof opts.file === 'string' && opts.file ? opts.file : undefined
722
+
723
+ /** @type {Record<string, unknown>[]} */
724
+ const rules = []
725
+ /** @type {Map<string, number>} */
726
+ const ruleIndexByKind = new Map()
727
+ /** @type {Record<string, unknown>[]} */
728
+ const results = []
729
+
730
+ for (const raw of normalizeFindingsInput(findings)) {
731
+ if (!raw) continue
732
+ const finding =
733
+ typeof raw === 'string'
734
+ ? { kind: 'undeclared', capability: raw }
735
+ : typeof raw === 'object'
736
+ ? /** @type {Record<string, unknown>} */ (raw)
737
+ : null
738
+ if (!finding) continue
739
+
740
+ const kind = finding.kind === 'unused' ? 'unused' : 'undeclared'
741
+ const capability =
742
+ typeof finding.capability === 'string'
743
+ ? finding.capability
744
+ : typeof finding.name === 'string'
745
+ ? finding.name
746
+ : 'unknown'
747
+
748
+ if (!ruleIndexByKind.has(kind)) {
749
+ ruleIndexByKind.set(kind, rules.length)
750
+ rules.push(sarifRule(kind))
751
+ }
752
+
753
+ const message =
754
+ typeof finding.message === 'string' && finding.message.trim()
755
+ ? finding.message
756
+ : kind === 'unused'
757
+ ? `Capability "${capability}" is declared but not statically visible in scanned code.`
758
+ : `Capability "${capability}" is used in code but not declared in dsh.capabilities.`
759
+
760
+ /** @type {Record<string, unknown>} */
761
+ const result = {
762
+ ruleId: `dsh-capdiff/${kind}`,
763
+ ruleIndex: ruleIndexByKind.get(kind),
764
+ level: kind === 'unused' ? 'note' : 'warning',
765
+ message: { text: message },
766
+ properties: { capability, kind },
767
+ }
768
+
769
+ const file =
770
+ typeof finding.file === 'string' && finding.file ? finding.file : fallbackFile
771
+ if (file) {
772
+ /** @type {Record<string, unknown>} */
773
+ const physicalLocation = {
774
+ artifactLocation: { uri: sarifUri(file) },
775
+ }
776
+ if (
777
+ typeof finding.line === 'number' &&
778
+ Number.isInteger(finding.line) &&
779
+ finding.line > 0
780
+ ) {
781
+ physicalLocation.region = { startLine: finding.line }
782
+ }
783
+ result.locations = [{ physicalLocation }]
784
+ }
785
+
786
+ results.push(result)
787
+ }
788
+
789
+ return {
790
+ $schema: SARIF_SCHEMA,
791
+ version: '2.1.0',
792
+ runs: [
793
+ {
794
+ tool: {
795
+ driver: {
796
+ name: TOOL_NAME,
797
+ version:
798
+ typeof opts.toolVersion === 'string' && opts.toolVersion
799
+ ? opts.toolVersion
800
+ : TOOL_VERSION,
801
+ informationUri: HELP_URI,
802
+ rules,
803
+ },
804
+ },
805
+ results,
806
+ },
807
+ ],
808
+ }
809
+ }
810
+
811
+ /**
812
+ * Analyze one source string. Convenience wrapper around extraction + mapping
813
+ * that keeps the evidence (line, trigger) for reporting.
814
+ *
815
+ * @param {string} code
816
+ * @param {string|null} [file]
817
+ * @returns {{ file: string|null, imports: ImportRef[], capabilities: string[], observations: CapabilityEvidence[] }}
818
+ */
819
+ export function analyzeSource(code, file = null) {
820
+ const source = typeof code === 'string' ? code : ''
821
+ const imports = extractImports(source)
822
+ const observations = collectCapabilityEvidence(source, imports)
823
+ /** @type {string[]} */
824
+ const capabilities = []
825
+ for (const observation of observations) {
826
+ if (!capabilities.includes(observation.capability)) {
827
+ capabilities.push(observation.capability)
828
+ }
829
+ }
830
+ return { file: file ?? null, imports, capabilities, observations }
831
+ }
832
+
833
+ /**
834
+ * Recursively collect `.js` / `.mjs` / `.cjs` files. `node_modules` and `.git`
835
+ * directories are skipped; symlinks are not followed. Results are sorted.
836
+ *
837
+ * @param {string} target file or directory
838
+ * @returns {Promise<string[]>} absolute paths
839
+ */
840
+ export async function collectSourceFiles(target) {
841
+ const root = resolve(String(target))
842
+ const info = await stat(root)
843
+
844
+ if (info.isFile()) return isSourceFile(root) ? [root] : []
845
+ if (!info.isDirectory()) return []
846
+
847
+ /** @type {string[]} */
848
+ const files = []
849
+
850
+ /**
851
+ * @param {string} directory
852
+ * @returns {Promise<void>}
853
+ */
854
+ const walk = async (directory) => {
855
+ const entries = await readdir(directory, { withFileTypes: true })
856
+ entries.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))
857
+ for (const entry of entries) {
858
+ if (entry.isSymbolicLink()) continue
859
+ if (entry.isDirectory()) {
860
+ if (SKIPPED_DIRECTORIES.has(entry.name.toLowerCase())) continue
861
+ await walk(join(directory, entry.name))
862
+ } else if (entry.isFile() && isSourceFile(entry.name)) {
863
+ files.push(join(directory, entry.name))
864
+ }
865
+ }
866
+ }
867
+
868
+ await walk(root)
869
+ return files.sort((a, b) => (a < b ? -1 : a > b ? 1 : 0))
870
+ }
871
+
872
+ /**
873
+ * Scan a file or directory and aggregate capabilities across all files.
874
+ *
875
+ * @param {string} target
876
+ * @returns {Promise<{
877
+ * target: string,
878
+ * root: string,
879
+ * files: Array<{ file: string|null, absolutePath: string, imports: ImportRef[], capabilities: string[], observations: CapabilityEvidence[] }>,
880
+ * capabilities: string[],
881
+ * firstEvidence: Record<string, CapabilityEvidence & { file: string|null }>
882
+ * }>}
883
+ */
884
+ export async function scanTarget(target) {
885
+ const resolvedTarget = resolve(String(target))
886
+ const info = await stat(resolvedTarget)
887
+ const root = info.isDirectory() ? resolvedTarget : dirname(resolvedTarget)
888
+ const paths = await collectSourceFiles(resolvedTarget)
889
+
890
+ /** @type {Array<{ file: string|null, absolutePath: string, imports: ImportRef[], capabilities: string[], observations: CapabilityEvidence[] }>} */
891
+ const files = []
892
+ /** @type {string[]} */
893
+ const capabilities = []
894
+ /** @type {Record<string, CapabilityEvidence & { file: string|null }>} */
895
+ const firstEvidence = Object.create(null)
896
+
897
+ for (const absolutePath of paths) {
898
+ const code = await readFile(absolutePath, 'utf8')
899
+ const relativePath = relative(root, absolutePath) || basename(absolutePath)
900
+ const analysis = analyzeSource(code, relativePath)
901
+ files.push({ ...analysis, absolutePath })
902
+
903
+ for (const observation of analysis.observations) {
904
+ if (!capabilities.includes(observation.capability)) {
905
+ capabilities.push(observation.capability)
906
+ }
907
+ if (!firstEvidence[observation.capability]) {
908
+ firstEvidence[observation.capability] = { ...observation, file: relativePath }
909
+ }
910
+ }
911
+ }
912
+
913
+ return { target: resolvedTarget, root, files, capabilities, firstEvidence }
914
+ }