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/DESIGN.md +201 -0
- package/LICENSE +202 -0
- package/README.md +209 -2
- package/package.json +45 -4
- package/src/cli.js +257 -0
- package/src/core.js +914 -0
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
|
+
}
|