agent-security-scanner-mcp 4.1.0 → 4.2.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.
@@ -0,0 +1,671 @@
1
+ // Lock file parsers for transitive dependency extraction.
2
+ // Each parser returns an array of { name, version, isDev, scope } objects.
3
+ // Uses regex/string-split — no external TOML/YAML libraries.
4
+
5
+ import { readFileSync, existsSync } from 'fs';
6
+ import { join } from 'path';
7
+ import { execFileSync } from 'child_process';
8
+ import { createComponent, createEdge, createComponentList } from './sbom-component.js';
9
+
10
+ // ─── package-lock.json (npm, v2/v3) ─────────────────────────────────
11
+
12
+ export function parsePackageLockJson(root) {
13
+ const lockPath = join(root, 'package-lock.json');
14
+ if (!existsSync(lockPath)) return null;
15
+
16
+ const lock = JSON.parse(readFileSync(lockPath, 'utf-8'));
17
+ const deps = [];
18
+ const edges = [];
19
+ const packages = lock.packages || {};
20
+ const projectPurl = `pkg:npm/${lock.name || 'root'}@${lock.version || '0.0.0'}`;
21
+
22
+ // Collect direct dependency names from the root entry
23
+ const rootEntry = packages[''] || {};
24
+ const directNames = new Set([
25
+ ...Object.keys(rootEntry.dependencies || {}),
26
+ ...Object.keys(rootEntry.devDependencies || {}),
27
+ ...Object.keys(rootEntry.optionalDependencies || {}),
28
+ ]);
29
+
30
+ for (const [key, info] of Object.entries(packages)) {
31
+ if (key === '') continue; // root entry
32
+ // key looks like "node_modules/@scope/pkg" or "node_modules/pkg"
33
+ const name = key.replace(/^node_modules\//, '').replace(/^.*node_modules\//, '');
34
+ if (!name || !info.version) continue;
35
+
36
+ const isDev = !!info.dev || !!info.devOptional;
37
+ // A package is direct only if it appears in the root's dependency lists
38
+ const isDirect = directNames.has(name);
39
+ const comp = createComponent({ name, version: info.version, ecosystem: 'npm', isDev, isDirect });
40
+ deps.push(comp);
41
+
42
+ // Build edge from root to direct deps only
43
+ if (isDirect) {
44
+ edges.push(createEdge(projectPurl, comp.purl));
45
+ }
46
+ }
47
+
48
+ return { ecosystem: 'npm', deps, edges, projectName: lock.name, projectVersion: lock.version };
49
+ }
50
+
51
+ // ─── yarn.lock (Classic v1 and Berry v2+) ────────────────────────────
52
+
53
+ export function parseYarnLock(root) {
54
+ const lockPath = join(root, 'yarn.lock');
55
+ if (!existsSync(lockPath)) return null;
56
+
57
+ const content = readFileSync(lockPath, 'utf-8');
58
+ const deps = [];
59
+ const seen = new Set();
60
+
61
+ // Detect Berry format: starts with __metadata
62
+ const isBerry = content.includes('__metadata:');
63
+
64
+ if (isBerry) {
65
+ // Berry format: "express@npm:^4.18.2":
66
+ // version: 4.18.2
67
+ const blockRe = /^"(@?[^@\n]+)@npm:[^"]*":\s*$/gm;
68
+ let blockMatch;
69
+ while ((blockMatch = blockRe.exec(content))) {
70
+ const name = blockMatch[1].trim();
71
+ if (name === '__metadata') continue;
72
+ // Find version: line after the block header
73
+ const after = content.slice(blockMatch.index + blockMatch[0].length, blockMatch.index + blockMatch[0].length + 200);
74
+ const verMatch = after.match(/^\s+version:\s+"?([^"\n\s]+)"?\s*$/m);
75
+ if (verMatch) {
76
+ const key = `${name}@${verMatch[1]}`;
77
+ if (!seen.has(key)) {
78
+ seen.add(key);
79
+ deps.push(createComponent({ name, version: verMatch[1], ecosystem: 'npm' }));
80
+ }
81
+ }
82
+ }
83
+ } else {
84
+ // Classic format: "name@version", "name@version":
85
+ // version "x.y.z"
86
+ const blockRe = /^"?(@?[^@\s][^@\n]*?)@[^:\n]+"?(?:,\s*"?@?[^@\s][^@\n]*?@[^:\n]+"?)*:\s*$/gm;
87
+ const versionRe = /^\s+version\s+"([^"]+)"/gm;
88
+ let blockMatch;
89
+ while ((blockMatch = blockRe.exec(content))) {
90
+ const rawNames = blockMatch[0].replace(/:$/, '');
91
+ // Extract first name from the resolution
92
+ const nameMatch = rawNames.match(/^"?(@?[^@\s]+)/);
93
+ if (!nameMatch) continue;
94
+ const name = nameMatch[1];
95
+
96
+ versionRe.lastIndex = blockMatch.index;
97
+ const verMatch = versionRe.exec(content);
98
+ if (verMatch && verMatch.index - blockMatch.index < 500) {
99
+ const key = `${name}@${verMatch[1]}`;
100
+ if (!seen.has(key)) {
101
+ seen.add(key);
102
+ deps.push(createComponent({ name, version: verMatch[1], ecosystem: 'npm' }));
103
+ }
104
+ }
105
+ }
106
+ }
107
+
108
+ return deps.length ? { ecosystem: 'npm', deps, edges: [] } : null;
109
+ }
110
+
111
+ // ─── pnpm-lock.yaml ──────────────────────────────────────────────────
112
+
113
+ export function parsePnpmLock(root) {
114
+ const lockPath = join(root, 'pnpm-lock.yaml');
115
+ if (!existsSync(lockPath)) return null;
116
+
117
+ const content = readFileSync(lockPath, 'utf-8');
118
+ const deps = [];
119
+ const seen = new Set();
120
+
121
+ // v6+: packages section with entries like:
122
+ // /@scope/name@version: or /name@version:
123
+ // v9+: entries like:
124
+ // '@scope/name@version': or name@version:
125
+ const patterns = [
126
+ /^\s+\/?(@?[^@\s:][^@:]*?)@(\d[^:\s]*)\s*:/gm, // /name@version: or /@scope/name@version:
127
+ /^\s+'(@?[^@'\s]+)@(\d[^']*)':\s*$/gm, // 'name@version':
128
+ ];
129
+
130
+ for (const re of patterns) {
131
+ let m;
132
+ while ((m = re.exec(content))) {
133
+ const name = m[1].replace(/^\//, '');
134
+ const version = m[2];
135
+ const key = `${name}@${version}`;
136
+ if (!seen.has(key)) {
137
+ seen.add(key);
138
+ deps.push(createComponent({ name, version, ecosystem: 'npm' }));
139
+ }
140
+ }
141
+ }
142
+
143
+ return deps.length ? { ecosystem: 'npm', deps, edges: [] } : null;
144
+ }
145
+
146
+ // ─── poetry.lock (Python) ────────────────────────────────────────────
147
+
148
+ export function parsePoetryLock(root) {
149
+ const lockPath = join(root, 'poetry.lock');
150
+ if (!existsSync(lockPath)) return null;
151
+
152
+ const content = readFileSync(lockPath, 'utf-8');
153
+ const blocks = content.split(/^\[\[package\]\]\s*$/m).slice(1);
154
+ const deps = [];
155
+
156
+ for (const block of blocks) {
157
+ const nameMatch = block.match(/^name\s*=\s*"([^"]+)"/m);
158
+ const versionMatch = block.match(/^version\s*=\s*"([^"]+)"/m);
159
+ const categoryMatch = block.match(/^category\s*=\s*"([^"]+)"/m);
160
+ if (!nameMatch || !versionMatch) continue;
161
+
162
+ const isDev = categoryMatch ? categoryMatch[1] === 'dev' : false;
163
+ deps.push(createComponent({
164
+ name: nameMatch[1],
165
+ version: versionMatch[1],
166
+ ecosystem: 'pypi',
167
+ isDev,
168
+ }));
169
+ }
170
+
171
+ return deps.length ? { ecosystem: 'pypi', deps, edges: [] } : null;
172
+ }
173
+
174
+ // ─── Pipfile.lock (Python) ───────────────────────────────────────────
175
+
176
+ export function parsePipfileLock(root) {
177
+ const lockPath = join(root, 'Pipfile.lock');
178
+ if (!existsSync(lockPath)) return null;
179
+
180
+ const lock = JSON.parse(readFileSync(lockPath, 'utf-8'));
181
+ const deps = [];
182
+
183
+ for (const [section, isDev] of [['default', false], ['develop', true]]) {
184
+ const packages = lock[section] || {};
185
+ for (const [name, info] of Object.entries(packages)) {
186
+ const version = (info.version || '').replace(/^==/, '');
187
+ if (!version) continue;
188
+ deps.push(createComponent({ name, version, ecosystem: 'pypi', isDev }));
189
+ }
190
+ }
191
+
192
+ return deps.length ? { ecosystem: 'pypi', deps, edges: [] } : null;
193
+ }
194
+
195
+ // ─── Cargo.lock (Rust) ──────────────────────────────────────────────
196
+
197
+ export function parseCargoLock(root) {
198
+ const lockPath = join(root, 'Cargo.lock');
199
+ if (!existsSync(lockPath)) return null;
200
+
201
+ const content = readFileSync(lockPath, 'utf-8');
202
+ const blocks = content.split(/^\[\[package\]\]\s*$/m).slice(1);
203
+ const deps = [];
204
+
205
+ for (const block of blocks) {
206
+ const nameMatch = block.match(/^name\s*=\s*"([^"]+)"/m);
207
+ const versionMatch = block.match(/^version\s*=\s*"([^"]+)"/m);
208
+ if (!nameMatch || !versionMatch) continue;
209
+ deps.push(createComponent({
210
+ name: nameMatch[1],
211
+ version: versionMatch[1],
212
+ ecosystem: 'crates',
213
+ }));
214
+ }
215
+
216
+ return deps.length ? { ecosystem: 'crates', deps, edges: [] } : null;
217
+ }
218
+
219
+ // ─── go.sum (Go) ─────────────────────────────────────────────────────
220
+
221
+ export function parseGoSum(root) {
222
+ const sumPath = join(root, 'go.sum');
223
+ if (!existsSync(sumPath)) return null;
224
+
225
+ const content = readFileSync(sumPath, 'utf-8');
226
+ const deps = [];
227
+ const seen = new Set();
228
+
229
+ for (const line of content.split('\n')) {
230
+ const parts = line.trim().split(/\s+/);
231
+ if (parts.length < 3) continue;
232
+ const [mod, rawVersion] = parts;
233
+ // go.sum has entries like: module v1.2.3 hash and module v1.2.3/go.mod hash
234
+ const version = rawVersion.replace(/\/go\.mod$/, '');
235
+ const key = `${mod}@${version}`;
236
+ if (!seen.has(key)) {
237
+ seen.add(key);
238
+ deps.push(createComponent({ name: mod, version, ecosystem: 'go' }));
239
+ }
240
+ }
241
+
242
+ return deps.length ? { ecosystem: 'go', deps, edges: [] } : null;
243
+ }
244
+
245
+ // ─── Gemfile.lock (Ruby) ─────────────────────────────────────────────
246
+
247
+ export function parseGemfileLock(root) {
248
+ const lockPath = join(root, 'Gemfile.lock');
249
+ if (!existsSync(lockPath)) return null;
250
+
251
+ const content = readFileSync(lockPath, 'utf-8');
252
+ const deps = [];
253
+
254
+ // Find the SPECS section under GEM
255
+ const specsMatch = content.match(/GEM[\s\S]*?specs:\n([\s\S]*?)(?:\n\S|\n$)/);
256
+ if (!specsMatch) return null;
257
+
258
+ const specsBlock = specsMatch[1];
259
+ // Direct gems are indented 4 spaces, transitive 6+
260
+ const gemRe = /^\s{4}(\S+)\s+\(([^)]+)\)/gm;
261
+ let m;
262
+ while ((m = gemRe.exec(specsBlock))) {
263
+ deps.push(createComponent({ name: m[1], version: m[2], ecosystem: 'rubygems' }));
264
+ }
265
+
266
+ return deps.length ? { ecosystem: 'rubygems', deps, edges: [] } : null;
267
+ }
268
+
269
+ // ─── Manifest parsers with versions (direct deps, fallback) ─────────
270
+
271
+ export function parseManifestWithVersions(root) {
272
+ const results = [];
273
+
274
+ // package.json
275
+ const pkgPath = join(root, 'package.json');
276
+ if (existsSync(pkgPath)) {
277
+ try {
278
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
279
+ for (const [name, ver] of Object.entries(pkg.dependencies || {})) {
280
+ results.push(createComponent({ name, version: ver.replace(/^[\^~>=<\s]+/, ''), ecosystem: 'npm', isDirect: true }));
281
+ }
282
+ for (const [name, ver] of Object.entries(pkg.devDependencies || {})) {
283
+ results.push(createComponent({ name, version: ver.replace(/^[\^~>=<\s]+/, ''), ecosystem: 'npm', isDev: true, isDirect: true }));
284
+ }
285
+ } catch { /* skip malformed */ }
286
+ }
287
+
288
+ // requirements.txt
289
+ const reqPath = join(root, 'requirements.txt');
290
+ if (existsSync(reqPath)) {
291
+ try {
292
+ const lines = readFileSync(reqPath, 'utf-8').split('\n');
293
+ for (const line of lines) {
294
+ const trimmed = line.trim();
295
+ if (!trimmed || trimmed.startsWith('#') || trimmed.startsWith('-')) continue;
296
+ const match = trimmed.match(/^([a-zA-Z0-9_.-]+)\s*(?:[=!><~]+\s*(.+))?/);
297
+ if (match) {
298
+ results.push(createComponent({
299
+ name: match[1],
300
+ version: (match[2] || 'unknown').split(',')[0].trim(),
301
+ ecosystem: 'pypi',
302
+ isDirect: true,
303
+ }));
304
+ }
305
+ }
306
+ } catch { /* skip */ }
307
+ }
308
+
309
+ // pyproject.toml — [project.dependencies] and [tool.poetry.dependencies]
310
+ const pyprojectPath = join(root, 'pyproject.toml');
311
+ if (existsSync(pyprojectPath)) {
312
+ try {
313
+ const content = readFileSync(pyprojectPath, 'utf-8');
314
+
315
+ // PEP 621: [project] dependencies = ["flask>=2.0", ...]
316
+ const projDepsMatch = content.match(/\[project\][\s\S]*?dependencies\s*=\s*\[([\s\S]*?)\]/);
317
+ if (projDepsMatch) {
318
+ const items = projDepsMatch[1].match(/"([^"]+)"/g) || [];
319
+ for (const item of items) {
320
+ const raw = item.replace(/"/g, '');
321
+ const m = raw.match(/^([a-zA-Z0-9_.-]+)\s*(?:[=!><~]+\s*(.+))?/);
322
+ if (m) {
323
+ results.push(createComponent({
324
+ name: m[1],
325
+ version: (m[2] || 'unknown').split(',')[0].trim(),
326
+ ecosystem: 'pypi',
327
+ isDirect: true,
328
+ }));
329
+ }
330
+ }
331
+ }
332
+
333
+ // Poetry: [tool.poetry.dependencies]
334
+ const poetryMatch = content.match(/\[tool\.poetry\.dependencies\]\s*\n([\s\S]*?)(?:\n\[|\n$)/);
335
+ if (poetryMatch) {
336
+ const lines = poetryMatch[1].split('\n');
337
+ for (const line of lines) {
338
+ const m = line.match(/^([a-zA-Z0-9_.-]+)\s*=\s*"([^"]+)"/);
339
+ if (m && m[1] !== 'python') {
340
+ results.push(createComponent({
341
+ name: m[1],
342
+ version: m[2].replace(/^[\^~>=<\s]+/, ''),
343
+ ecosystem: 'pypi',
344
+ isDirect: true,
345
+ }));
346
+ }
347
+ }
348
+ }
349
+ } catch { /* skip */ }
350
+ }
351
+
352
+ // go.mod
353
+ const goModPath = join(root, 'go.mod');
354
+ if (existsSync(goModPath)) {
355
+ try {
356
+ const content = readFileSync(goModPath, 'utf-8');
357
+ // Single requires: require module v1.2.3
358
+ const singleRe = /^require\s+(\S+)\s+(v[\d.]+\S*)/gm;
359
+ let m;
360
+ while ((m = singleRe.exec(content))) {
361
+ results.push(createComponent({ name: m[1], version: m[2], ecosystem: 'go', isDirect: true }));
362
+ }
363
+ // Block requires
364
+ const blockMatch = content.match(/require\s*\(([\s\S]*?)\)/g) || [];
365
+ for (const block of blockMatch) {
366
+ const lineRe = /^\s*(\S+)\s+(v[\d.]+\S*)/gm;
367
+ while ((m = lineRe.exec(block))) {
368
+ results.push(createComponent({ name: m[1], version: m[2], ecosystem: 'go', isDirect: true }));
369
+ }
370
+ }
371
+ } catch { /* skip */ }
372
+ }
373
+
374
+ // Gemfile
375
+ const gemfilePath = join(root, 'Gemfile');
376
+ if (existsSync(gemfilePath)) {
377
+ try {
378
+ const content = readFileSync(gemfilePath, 'utf-8');
379
+ const gemRe = /gem\s+['"]([^'"]+)['"](?:\s*,\s*['"]([^'"]+)['"])?/g;
380
+ let m;
381
+ while ((m = gemRe.exec(content))) {
382
+ results.push(createComponent({
383
+ name: m[1],
384
+ version: m[2] ? m[2].replace(/^[~>=<\s]+/, '') : 'unknown',
385
+ ecosystem: 'rubygems',
386
+ isDirect: true,
387
+ }));
388
+ }
389
+ } catch { /* skip */ }
390
+ }
391
+
392
+ // pom.xml (Maven)
393
+ const pomPath = join(root, 'pom.xml');
394
+ if (existsSync(pomPath)) {
395
+ try {
396
+ const content = readFileSync(pomPath, 'utf-8');
397
+ const depRe = /<dependency>\s*<groupId>([^<]+)<\/groupId>\s*<artifactId>([^<]+)<\/artifactId>\s*(?:<version>([^<]+)<\/version>)?/g;
398
+ let m;
399
+ while ((m = depRe.exec(content))) {
400
+ results.push(createComponent({
401
+ name: m[2],
402
+ version: m[3] || 'unknown',
403
+ ecosystem: 'java',
404
+ namespace: m[1],
405
+ isDirect: true,
406
+ }));
407
+ }
408
+ } catch { /* skip */ }
409
+ }
410
+
411
+ // build.gradle / build.gradle.kts (Gradle)
412
+ for (const gradleFile of ['build.gradle', 'build.gradle.kts']) {
413
+ const gradlePath = join(root, gradleFile);
414
+ if (existsSync(gradlePath)) {
415
+ try {
416
+ const content = readFileSync(gradlePath, 'utf-8');
417
+ // implementation 'group:artifact:version' or implementation("group:artifact:version")
418
+ const depRe = /(?:implementation|api|compile|runtimeOnly|testImplementation|compileOnly)\s*[\('"]+([^:'"]+):([^:'"]+):([^)'"]+)/g;
419
+ let m;
420
+ while ((m = depRe.exec(content))) {
421
+ results.push(createComponent({
422
+ name: m[2],
423
+ version: m[3],
424
+ ecosystem: 'java',
425
+ namespace: m[1],
426
+ isDev: m[0].startsWith('test'),
427
+ isDirect: true,
428
+ }));
429
+ }
430
+ } catch { /* skip */ }
431
+ }
432
+ }
433
+
434
+ return results;
435
+ }
436
+
437
+ // ─── Package-manager CLI fallbacks ───────────────────────────────────
438
+
439
+ function tryExec(cmd, args, cwd, timeout = 30000) {
440
+ try {
441
+ const result = execFileSync(cmd, args, { cwd, encoding: 'utf-8', timeout, stdio: ['pipe', 'pipe', 'pipe'] });
442
+ return result;
443
+ } catch {
444
+ return null;
445
+ }
446
+ }
447
+
448
+ export function fallbackNpmLs(root) {
449
+ const output = tryExec('npm', ['ls', '--all', '--json', '--silent'], root);
450
+ if (!output) return null;
451
+
452
+ try {
453
+ const tree = JSON.parse(output);
454
+ const deps = [];
455
+ const seen = new Set();
456
+
457
+ function walk(node) {
458
+ for (const [name, info] of Object.entries(node.dependencies || {})) {
459
+ const key = `${name}@${info.version}`;
460
+ if (seen.has(key)) continue;
461
+ seen.add(key);
462
+ deps.push(createComponent({ name, version: info.version || 'unknown', ecosystem: 'npm' }));
463
+ walk(info);
464
+ }
465
+ }
466
+ walk(tree);
467
+ return deps.length ? { ecosystem: 'npm', deps, edges: [] } : null;
468
+ } catch {
469
+ return null;
470
+ }
471
+ }
472
+
473
+ export function fallbackPnpmList(root) {
474
+ const output = tryExec('pnpm', ['list', '--json', '--depth', 'Infinity'], root);
475
+ if (!output) return null;
476
+
477
+ try {
478
+ const data = JSON.parse(output);
479
+ const deps = [];
480
+ const seen = new Set();
481
+ const list = Array.isArray(data) ? data : [data];
482
+
483
+ function walk(node) {
484
+ for (const [name, info] of Object.entries(node.dependencies || {})) {
485
+ const version = info.version;
486
+ if (!version) continue;
487
+ const key = `${name}@${version}`;
488
+ if (seen.has(key)) continue;
489
+ seen.add(key);
490
+ deps.push(createComponent({ name, version, ecosystem: 'npm' }));
491
+ walk(info);
492
+ }
493
+ }
494
+ for (const entry of list) walk(entry);
495
+ return deps.length ? { ecosystem: 'npm', deps, edges: [] } : null;
496
+ } catch {
497
+ return null;
498
+ }
499
+ }
500
+
501
+ export function fallbackCargoMetadata(root) {
502
+ const output = tryExec('cargo', ['metadata', '--format-version', '1'], root, 60000);
503
+ if (!output) return null;
504
+
505
+ try {
506
+ const meta = JSON.parse(output);
507
+ const deps = [];
508
+ const edges = [];
509
+ const seen = new Set();
510
+
511
+ // All packages in the resolve graph
512
+ for (const pkg of meta.packages || []) {
513
+ if (seen.has(`${pkg.name}@${pkg.version}`)) continue;
514
+ seen.add(`${pkg.name}@${pkg.version}`);
515
+ deps.push(createComponent({ name: pkg.name, version: pkg.version, ecosystem: 'crates' }));
516
+ }
517
+
518
+ // Build dependency edges from resolve.nodes
519
+ if (meta.resolve && meta.resolve.nodes) {
520
+ for (const node of meta.resolve.nodes) {
521
+ const fromMatch = node.id.match(/^([^\s]+)\s+(\S+)/);
522
+ if (!fromMatch) continue;
523
+ const fromPurl = `pkg:cargo/${fromMatch[1]}@${fromMatch[2]}`;
524
+ for (const dep of node.deps || []) {
525
+ const toMatch = dep.pkg.match(/^([^\s]+)\s+(\S+)/);
526
+ if (toMatch) {
527
+ edges.push(createEdge(fromPurl, `pkg:cargo/${toMatch[1]}@${toMatch[2]}`));
528
+ }
529
+ }
530
+ }
531
+ }
532
+
533
+ return deps.length ? { ecosystem: 'crates', deps, edges } : null;
534
+ } catch {
535
+ return null;
536
+ }
537
+ }
538
+
539
+ export function fallbackGoListModules(root) {
540
+ const output = tryExec('go', ['list', '-m', 'all'], root);
541
+ if (!output) return null;
542
+
543
+ const deps = [];
544
+ for (const line of output.split('\n')) {
545
+ const parts = line.trim().split(/\s+/);
546
+ if (parts.length >= 2) {
547
+ deps.push(createComponent({ name: parts[0], version: parts[1], ecosystem: 'go' }));
548
+ }
549
+ }
550
+ return deps.length ? { ecosystem: 'go', deps, edges: [] } : null;
551
+ }
552
+
553
+ export function fallbackMvnDependencyTree(root) {
554
+ const output = tryExec('mvn', ['dependency:tree', '-DoutputType=text', '-q'], root, 60000);
555
+ if (!output) return null;
556
+
557
+ const deps = [];
558
+ // Lines like: [INFO] +- group:artifact:type:version:scope
559
+ const depRe = /[+-\\|]\s+([^:]+):([^:]+):([^:]+):([^:]+)/g;
560
+ let m;
561
+ while ((m = depRe.exec(output))) {
562
+ deps.push(createComponent({
563
+ name: m[2],
564
+ version: m[4],
565
+ ecosystem: 'java',
566
+ namespace: m[1],
567
+ }));
568
+ }
569
+ return deps.length ? { ecosystem: 'java', deps, edges: [] } : null;
570
+ }
571
+
572
+ // ─── Top-level discovery ─────────────────────────────────────────────
573
+
574
+ /**
575
+ * Discover all dependencies in a project.
576
+ * Tries lock file parsers first, then CLI fallbacks, then manifest-only.
577
+ * @param {string} projectRoot
578
+ * @param {{ includeDev?: boolean }} [options={}]
579
+ * @returns {ComponentList}
580
+ */
581
+ export function discoverDependencies(projectRoot, options = {}) {
582
+ const { includeDev = true } = options;
583
+
584
+ const lockParsers = [
585
+ parsePackageLockJson,
586
+ parseYarnLock,
587
+ parsePnpmLock,
588
+ parsePoetryLock,
589
+ parsePipfileLock,
590
+ parseCargoLock,
591
+ parseGoSum,
592
+ parseGemfileLock,
593
+ ];
594
+
595
+ const cliFallbacks = [
596
+ fallbackNpmLs,
597
+ fallbackPnpmList,
598
+ fallbackCargoMetadata,
599
+ fallbackGoListModules,
600
+ fallbackMvnDependencyTree,
601
+ ];
602
+
603
+ let allDeps = [];
604
+ let allEdges = [];
605
+ const discoveredEcosystems = new Set();
606
+
607
+ // Phase 1: Lock file parsers
608
+ for (const parser of lockParsers) {
609
+ const result = parser(projectRoot);
610
+ if (result) {
611
+ discoveredEcosystems.add(result.ecosystem);
612
+ allDeps.push(...result.deps);
613
+ if (result.edges) allEdges.push(...result.edges);
614
+ }
615
+ }
616
+
617
+ // Phase 2: CLI fallbacks for ecosystems not covered by lock files
618
+ for (const fallback of cliFallbacks) {
619
+ const result = fallback(projectRoot);
620
+ if (result && !discoveredEcosystems.has(result.ecosystem)) {
621
+ discoveredEcosystems.add(result.ecosystem);
622
+ allDeps.push(...result.deps);
623
+ if (result.edges) allEdges.push(...result.edges);
624
+ }
625
+ }
626
+
627
+ // Phase 3: Manifest parsers for any remaining direct deps
628
+ const manifestDeps = parseManifestWithVersions(projectRoot);
629
+ const existingPurls = new Set(allDeps.map(d => d.purl));
630
+ for (const dep of manifestDeps) {
631
+ // Only add if not already discovered from lock file/CLI
632
+ if (!existingPurls.has(dep.purl)) {
633
+ // Check if same package (different version) was already found
634
+ const sameName = allDeps.find(d => d.name === dep.name && d.ecosystem === dep.ecosystem);
635
+ if (!sameName) {
636
+ allDeps.push(dep);
637
+ }
638
+ }
639
+ }
640
+
641
+ // Filter dev dependencies if requested
642
+ if (!includeDev) {
643
+ allDeps = allDeps.filter(d => !d.isDev);
644
+ }
645
+
646
+ // Deduplicate by purl
647
+ const deduped = new Map();
648
+ for (const dep of allDeps) {
649
+ if (!deduped.has(dep.purl)) {
650
+ deduped.set(dep.purl, dep);
651
+ }
652
+ }
653
+
654
+ // Read project metadata
655
+ let projectName = 'unknown';
656
+ let projectVersion = '0.0.0';
657
+ const pkgPath = join(projectRoot, 'package.json');
658
+ if (existsSync(pkgPath)) {
659
+ try {
660
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
661
+ projectName = pkg.name || projectName;
662
+ projectVersion = pkg.version || projectVersion;
663
+ } catch { /* skip */ }
664
+ }
665
+
666
+ return createComponentList(
667
+ [...deduped.values()],
668
+ allEdges,
669
+ { name: projectName, version: projectVersion }
670
+ );
671
+ }