codedd-cli 0.1.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. codedd_cli/__init__.py +3 -0
  2. codedd_cli/__main__.py +19 -0
  3. codedd_cli/api/__init__.py +4 -0
  4. codedd_cli/api/client.py +120 -0
  5. codedd_cli/api/endpoints.py +44 -0
  6. codedd_cli/api/exceptions.py +24 -0
  7. codedd_cli/auditor/__init__.py +6 -0
  8. codedd_cli/auditor/architecture_analyzer.py +1251 -0
  9. codedd_cli/auditor/architecture_prompts.py +173 -0
  10. codedd_cli/auditor/complexity_analyzer.py +1739 -0
  11. codedd_cli/auditor/dependency_scanner.py +2485 -0
  12. codedd_cli/auditor/file_auditor.py +578 -0
  13. codedd_cli/auditor/git_stats_collector.py +417 -0
  14. codedd_cli/auditor/response_parser.py +484 -0
  15. codedd_cli/auditor/vulnerability_validator.py +323 -0
  16. codedd_cli/auth/__init__.py +4 -0
  17. codedd_cli/auth/session.py +40 -0
  18. codedd_cli/auth/token_manager.py +86 -0
  19. codedd_cli/cli.py +69 -0
  20. codedd_cli/commands/__init__.py +1 -0
  21. codedd_cli/commands/audit_cmd.py +1987 -0
  22. codedd_cli/commands/audits_cmd.py +276 -0
  23. codedd_cli/commands/auth_cmd.py +235 -0
  24. codedd_cli/commands/config_cmd.py +421 -0
  25. codedd_cli/commands/scope_cmd.py +1016 -0
  26. codedd_cli/config/__init__.py +4 -0
  27. codedd_cli/config/constants.py +22 -0
  28. codedd_cli/config/settings.py +389 -0
  29. codedd_cli/llm/__init__.py +1 -0
  30. codedd_cli/llm/key_manager.py +267 -0
  31. codedd_cli/models/__init__.py +5 -0
  32. codedd_cli/models/account.py +13 -0
  33. codedd_cli/models/audit.py +31 -0
  34. codedd_cli/models/local_directory.py +25 -0
  35. codedd_cli/scanner/__init__.py +18 -0
  36. codedd_cli/scanner/file_classifier.py +752 -0
  37. codedd_cli/scanner/file_walker.py +213 -0
  38. codedd_cli/scanner/line_counter.py +80 -0
  39. codedd_cli/utils/__init__.py +1 -0
  40. codedd_cli/utils/directory_validator.py +178 -0
  41. codedd_cli/utils/display.py +497 -0
  42. codedd_cli/utils/payload_inspector.py +178 -0
  43. codedd_cli/utils/security.py +14 -0
  44. codedd_cli/utils/validators.py +37 -0
  45. codedd_cli-0.1.1.dist-info/METADATA +306 -0
  46. codedd_cli-0.1.1.dist-info/RECORD +49 -0
  47. codedd_cli-0.1.1.dist-info/WHEEL +4 -0
  48. codedd_cli-0.1.1.dist-info/entry_points.txt +3 -0
  49. codedd_cli-0.1.1.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,2485 @@
1
+ """
2
+ Local dependency scanning for CLI audits.
3
+
4
+ Ports three server-side modules into a single CLI module:
5
+ - **DependencyProcessor** — manifest file parsing (60+ formats)
6
+ - **ExtensionPackageParser** — source import extraction (25+ languages)
7
+ - **Vulnerability scanner** — OSV API queries via ``httpx``
8
+
9
+ All operations run locally. No source code leaves the machine.
10
+ Only structured metadata (package names, versions, vulnerability counts)
11
+ is transmitted to CodeDD.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ import logging
18
+ import os
19
+ import re
20
+ import time
21
+ from collections import defaultdict
22
+ from collections.abc import Callable
23
+ from dataclasses import dataclass
24
+ from datetime import datetime
25
+
26
+ import httpx
27
+
28
+ logger = logging.getLogger("codedd_cli")
29
+
30
+ # ---------------------------------------------------------------------------
31
+ # Result dataclasses
32
+ # ---------------------------------------------------------------------------
33
+
34
+
35
+ @dataclass
36
+ class ManifestResult:
37
+ """Result from parsing a single dependency manifest file."""
38
+
39
+ manifest_path: str # relative path of the manifest file
40
+ repo_name: str
41
+ registry: str # e.g. "npm", "pypi"
42
+ packages: list[dict] # [{name, version, latest_version}]
43
+ error: str | None = None
44
+
45
+
46
+ @dataclass
47
+ class ImportResult:
48
+ """Result from extracting imports from a single source file."""
49
+
50
+ file_path: str # cli:// path
51
+ registry_prefix: str # e.g. "$!pypi$!_"
52
+ packages: list[str] # just names (no versions)
53
+ error: str | None = None
54
+
55
+
56
+ # ═══════════════════════════════════════════════════════════════════════════
57
+ # SECTION 1 — Manifest Parsers (ported from DependencyProcessor)
58
+ # ═══════════════════════════════════════════════════════════════════════════
59
+
60
+
61
+ class ManifestParser:
62
+ """
63
+ Pure-Python manifest file parser supporting 60+ dependency file formats.
64
+
65
+ Ported wholesale from ``DependencyProcessor`` — all parsing logic is
66
+ identical to the server-side implementation. No Django/TypeDB
67
+ dependencies.
68
+ """
69
+
70
+ # ----- Validation / normalisation helpers -----
71
+
72
+ @staticmethod
73
+ def _validate_package_name(name: str) -> bool:
74
+ """Return True if *name* looks like a valid package identifier."""
75
+ if not name or not isinstance(name, str):
76
+ return False
77
+ name = name.strip()
78
+ if not name or name in ("", "null", "undefined", "None"):
79
+ return False
80
+ return True
81
+
82
+ @staticmethod
83
+ def _normalize_version(version: str) -> str | None:
84
+ """Normalise a version string, returning None for wildcards/empties."""
85
+ if not version or not isinstance(version, str):
86
+ return None
87
+ version = version.strip()
88
+ if not version:
89
+ return None
90
+ if version in ("*", "latest", "master", "main", "HEAD"):
91
+ return None
92
+ if version.startswith("v") and len(version) > 1 and version[1].isdigit():
93
+ version = version[1:]
94
+ if not any(c.isalnum() for c in version):
95
+ return None
96
+ return version
97
+
98
+ @staticmethod
99
+ def _extract_version_range_bounds(spec: str) -> tuple[str | None, str | None]:
100
+ """
101
+ Extract representative lower/upper bounds from a version spec
102
+ like ``"^10.0|^11.0|^12.0"`` or ``"^1.0.0 || ^2.0.0"``.
103
+ """
104
+ if not isinstance(spec, str):
105
+ return None, None
106
+ spec = spec.strip()
107
+ if not spec or spec in ("*", "dev-master"):
108
+ return None, None
109
+
110
+ parts = re.split(r"\s*\|\|?\s*", spec)
111
+ cleaned: list[str] = []
112
+ for part in parts:
113
+ p = part.strip()
114
+ if not p:
115
+ continue
116
+ p = re.sub(r"^[\^~><=!*]+", "", p)
117
+ m = re.search(r"[0-9][0-9A-Za-z.\-]*", p)
118
+ if m:
119
+ cleaned.append(m.group(0))
120
+
121
+ if not cleaned:
122
+ return None, None
123
+ return cleaned[0], cleaned[-1]
124
+
125
+ @staticmethod
126
+ def _deduplicate_packages(
127
+ packages: list[tuple[str, str | None, str | None]],
128
+ ) -> list[tuple[str, str | None, str | None]]:
129
+ """Remove duplicates (case-insensitive) and filter invalid names."""
130
+ seen: set[tuple[str, str | None]] = set()
131
+ result: list[tuple[str, str | None, str | None]] = []
132
+ for pkg, ver, latest in packages:
133
+ if not ManifestParser._validate_package_name(pkg):
134
+ continue
135
+ ver = ManifestParser._normalize_version(ver)
136
+ latest = ManifestParser._normalize_version(latest)
137
+ key = (pkg.lower(), ver)
138
+ if key not in seen:
139
+ seen.add(key)
140
+ result.append((pkg, ver, latest))
141
+ return result
142
+
143
+ # ----- Dispatch table -----
144
+
145
+ @staticmethod
146
+ def _get_parser_dispatch_table() -> dict:
147
+ """Return filename → parser-function mapping (O(1) lookup)."""
148
+ return {
149
+ # JavaScript / Node.js
150
+ "package.json": ManifestParser._parse_package_json,
151
+ "package-lock.json": ManifestParser._parse_package_lock_json,
152
+ "yarn.lock": ManifestParser._parse_yarn_lock,
153
+ "pnpm-lock.yaml": ManifestParser._parse_pnpm_lock_yaml,
154
+ # Python
155
+ "pyproject.toml": ManifestParser._parse_pyproject_toml,
156
+ "Pipfile": ManifestParser._parse_pipfile,
157
+ "Pipfile.lock": ManifestParser._parse_pipfile_lock,
158
+ "poetry.lock": ManifestParser._parse_poetry_lock,
159
+ "uv.lock": ManifestParser._parse_uv_lock,
160
+ "pdm.lock": ManifestParser._parse_pdm_lock,
161
+ "environment.yml": ManifestParser._parse_conda_environment,
162
+ "conda.yaml": ManifestParser._parse_conda_environment,
163
+ "environment.yaml": ManifestParser._parse_conda_environment,
164
+ # Go
165
+ "go.mod": ManifestParser._parse_go_mod,
166
+ "go.sum": ManifestParser._parse_go_sum,
167
+ "Gopkg.toml": ManifestParser._parse_gopkg_toml,
168
+ "Gopkg.lock": ManifestParser._parse_gopkg_lock,
169
+ # PHP
170
+ "composer.json": ManifestParser._parse_composer_json,
171
+ "composer.lock": ManifestParser._parse_composer_lock,
172
+ # Rust
173
+ "Cargo.toml": ManifestParser._parse_cargo_toml,
174
+ "Cargo.lock": ManifestParser._parse_cargo_lock,
175
+ # Java / Maven
176
+ "pom.xml": ManifestParser._parse_pom_xml,
177
+ # Ruby
178
+ "Gemfile": ManifestParser._parse_gemfile,
179
+ "Gemfile.lock": ManifestParser._parse_gemfile_lock,
180
+ # Gradle / Kotlin
181
+ "build.gradle": ManifestParser._parse_gradle_build,
182
+ "build.gradle.kts": ManifestParser._parse_gradle_build,
183
+ "gradle.properties": ManifestParser._parse_gradle_properties,
184
+ # Scala
185
+ "build.sbt": ManifestParser._parse_build_sbt,
186
+ # .NET / C#
187
+ "packages.config": ManifestParser._parse_dotnet_packages,
188
+ "packages.lock.json": ManifestParser._parse_packages_lock_json,
189
+ # Dart / Flutter
190
+ "pubspec.yaml": ManifestParser._parse_pubspec_yaml,
191
+ "pubspec.lock": ManifestParser._parse_pubspec_lock,
192
+ # Elixir
193
+ "mix.exs": ManifestParser._parse_mix_exs,
194
+ "mix.lock": ManifestParser._parse_mix_lock,
195
+ # Swift
196
+ "Package.swift": ManifestParser._parse_package_swift,
197
+ "Package.resolved": ManifestParser._parse_package_resolved,
198
+ # CocoaPods / Carthage
199
+ "Podfile": ManifestParser._parse_podfile,
200
+ "Podfile.lock": ManifestParser._parse_podfile_lock,
201
+ "Cartfile": ManifestParser._parse_cartfile,
202
+ "Cartfile.resolved": ManifestParser._parse_cartfile_resolved,
203
+ # Haskell
204
+ "stack.yaml": ManifestParser._parse_stack_yaml,
205
+ ".cabal": ManifestParser._parse_cabal,
206
+ # Julia
207
+ "Project.toml": ManifestParser._parse_julia_project_toml,
208
+ "Manifest.toml": ManifestParser._parse_julia_manifest_toml,
209
+ # C++
210
+ "vcpkg.json": ManifestParser._parse_vcpkg_json,
211
+ "conanfile.txt": ManifestParser._parse_conanfile_txt,
212
+ "conanfile.py": ManifestParser._parse_conanfile_py,
213
+ "CMakeLists.txt": ManifestParser._parse_cmake_lists,
214
+ # Deno
215
+ "deno.json": ManifestParser._parse_deno_json,
216
+ "deno.jsonc": ManifestParser._parse_deno_json,
217
+ "deno.lock": ManifestParser._parse_deno_lock,
218
+ # Perl
219
+ "cpanfile": ManifestParser._parse_cpanfile,
220
+ "Makefile.PL": ManifestParser._parse_makefile_pl,
221
+ "Build.PL": ManifestParser._parse_makefile_pl,
222
+ # R
223
+ "DESCRIPTION": ManifestParser._parse_r_description,
224
+ "renv.lock": ManifestParser._parse_renv_lock,
225
+ "packrat.lock": ManifestParser._parse_packrat_lock,
226
+ # Lua
227
+ ".rockspec": ManifestParser._parse_rockspec,
228
+ # Clojure
229
+ "project.clj": ManifestParser._parse_project_clj,
230
+ "deps.edn": ManifestParser._parse_deps_edn,
231
+ # OCaml
232
+ "dune-project": ManifestParser._parse_dune_project,
233
+ # Nim
234
+ ".nimble": ManifestParser._parse_nimble,
235
+ # Zig
236
+ "build.zig.zon": ManifestParser._parse_build_zig_zon,
237
+ # Erlang
238
+ "rebar.config": ManifestParser._parse_rebar_config,
239
+ "rebar.lock": ManifestParser._parse_rebar_lock,
240
+ }
241
+
242
+ # ----- Lock-file preference (prefer lock over manifest) -----
243
+
244
+ # When both a lock and its manifest exist in the same directory,
245
+ # prefer the lock file (contains exact resolved versions).
246
+ LOCK_FILE_PREFERENCES: dict[str, list[str]] = {
247
+ "package-lock.json": ["package.json"],
248
+ "yarn.lock": ["package.json"],
249
+ "pnpm-lock.yaml": ["package.json"],
250
+ "composer.lock": ["composer.json"],
251
+ "Gemfile.lock": ["Gemfile"],
252
+ "Cargo.lock": ["Cargo.toml"],
253
+ "Pipfile.lock": ["Pipfile"],
254
+ "poetry.lock": ["pyproject.toml"],
255
+ "pubspec.lock": ["pubspec.yaml"],
256
+ "mix.lock": ["mix.exs"],
257
+ "Podfile.lock": ["Podfile"],
258
+ "Cartfile.resolved": ["Cartfile"],
259
+ "deno.lock": ["deno.json", "deno.jsonc"],
260
+ "renv.lock": ["DESCRIPTION"],
261
+ "packrat.lock": ["DESCRIPTION"],
262
+ "rebar.lock": ["rebar.config"],
263
+ "uv.lock": ["pyproject.toml"],
264
+ "pdm.lock": ["pyproject.toml"],
265
+ }
266
+
267
+ # ----- Main dispatcher -----
268
+
269
+ @staticmethod
270
+ def parse_dependency_file(
271
+ file_path: str,
272
+ file_content: str,
273
+ ) -> list[tuple[str, str | None, str | None]]:
274
+ """
275
+ Parse a dependency manifest and return a list of
276
+ ``(package_name, version, latest_version)`` tuples.
277
+ """
278
+ try:
279
+ filename = os.path.basename(file_path)
280
+ filename_lower = filename.lower()
281
+ normalized_path = os.path.normpath(file_path).lower()
282
+
283
+ dispatch_table = ManifestParser._get_parser_dispatch_table()
284
+ raw_packages: list[tuple] = []
285
+
286
+ # Special cases (path-based patterns)
287
+ if ".github/workflows" in normalized_path and filename_lower.endswith((".yml", ".yaml")):
288
+ raw_packages = ManifestParser._parse_github_actions_workflow(file_content)
289
+ elif filename_lower == "opam" or filename_lower.endswith(".opam"):
290
+ raw_packages = ManifestParser._parse_opam(file_content)
291
+ elif filename in dispatch_table:
292
+ raw_packages = dispatch_table[filename](file_content)
293
+ elif filename_lower in dispatch_table:
294
+ raw_packages = dispatch_table[filename_lower](file_content)
295
+ else:
296
+ _, ext = os.path.splitext(filename_lower)
297
+ if ext in dispatch_table:
298
+ raw_packages = dispatch_table[ext](file_content)
299
+ else:
300
+ for pattern, parser_func in dispatch_table.items():
301
+ if file_path.endswith(pattern):
302
+ raw_packages = parser_func(file_content)
303
+ break
304
+ else:
305
+ raw_packages = ManifestParser._parse_requirements_txt(file_content)
306
+
307
+ return ManifestParser._deduplicate_packages(raw_packages)
308
+
309
+ except Exception as exc:
310
+ logger.error("Error parsing dependency file %s: %s", file_path, exc)
311
+ return []
312
+
313
+ @staticmethod
314
+ def is_dependency_file(file_path: str) -> bool:
315
+ """
316
+ Return True if *file_path* looks like a dependency manifest.
317
+
318
+ Uses the dispatch-table keys plus a set of well-known patterns
319
+ (equivalent to the server-side ``FileIdentifier``-backed check).
320
+ """
321
+ normalized_path = os.path.normpath(file_path).lower()
322
+ filename = os.path.basename(normalized_path)
323
+
324
+ # Build pattern set from dispatch table keys + extras
325
+ dispatch_keys = set(ManifestParser._get_parser_dispatch_table().keys())
326
+
327
+ # Additional known patterns not in dispatch table
328
+ extras = {
329
+ "requirements.txt",
330
+ "requirements-dev.txt",
331
+ "requirements-test.txt",
332
+ "requirements-prod.txt",
333
+ "constraints.txt",
334
+ "dev-requirements.txt",
335
+ "test-requirements.txt",
336
+ "prod-requirements.txt",
337
+ "settings.gradle.kts",
338
+ "ivy.xml",
339
+ "bun.lockb",
340
+ "cabal.project",
341
+ }
342
+ patterns = dispatch_keys | extras
343
+
344
+ for raw in patterns:
345
+ p = raw.lower()
346
+ if p.startswith("."):
347
+ if filename.endswith(p):
348
+ return True
349
+ elif filename == p:
350
+ return True
351
+
352
+ # Path-based checks
353
+ if ".github/workflows" in normalized_path and filename.endswith((".yml", ".yaml")):
354
+ return True
355
+ if filename == "opam" or filename.endswith(".opam"):
356
+ return True
357
+ if filename.endswith("requirements.txt"):
358
+ return True
359
+
360
+ return False
361
+
362
+ @staticmethod
363
+ def filter_duplicate_dependency_files(file_paths: list[str]) -> list[str]:
364
+ """
365
+ When both a manifest and its lock file exist in the same directory,
366
+ keep only the lock file to avoid double-counting.
367
+ """
368
+ files_by_dir: dict[str, list[str]] = defaultdict(list)
369
+ for fp in file_paths:
370
+ if ManifestParser.is_dependency_file(fp):
371
+ dir_path = os.path.dirname(os.path.normpath(fp))
372
+ files_by_dir[dir_path].append(fp)
373
+
374
+ result: list[str] = []
375
+ for dir_path, files in files_by_dir.items():
376
+ file_basenames = {os.path.basename(f).lower(): f for f in files}
377
+ skip_manifests: set[str] = set()
378
+
379
+ for lock_pattern, manifest_patterns in ManifestParser.LOCK_FILE_PREFERENCES.items():
380
+ lock_lower = lock_pattern.lower()
381
+ if lock_lower in file_basenames:
382
+ result.append(file_basenames[lock_lower])
383
+ for mp in manifest_patterns:
384
+ ml = mp.lower()
385
+ if ml in file_basenames:
386
+ skip_manifests.add(file_basenames[ml])
387
+
388
+ for fp in files:
389
+ if fp not in skip_manifests and fp not in result:
390
+ result.append(fp)
391
+
392
+ return result
393
+
394
+ # ═══════════════════════════════════════════════════════════════════════
395
+ # Individual parser methods (ported verbatim from server)
396
+ # ═══════════════════════════════════════════════════════════════════════
397
+
398
+ # --- JavaScript / Node.js ---
399
+
400
+ @staticmethod
401
+ def _parse_package_json(file_content: str) -> list[tuple]:
402
+ """Parse package.json file."""
403
+ package_data = json.loads(file_content)
404
+ pkg_info: list[tuple] = []
405
+ for section in ("dependencies", "devDependencies", "peerDependencies", "optionalDependencies"):
406
+ deps = package_data.get(section, {})
407
+ if deps and isinstance(deps, dict):
408
+ pkg_info.extend(ManifestParser._parse_npm_dependencies(deps))
409
+ return pkg_info
410
+
411
+ @staticmethod
412
+ def _parse_npm_dependencies(dependencies: dict) -> list[tuple]:
413
+ """Parse NPM-style dependency dict {name: version_spec}."""
414
+ pkg_info: list[tuple] = []
415
+ try:
416
+ for name, version_spec in dependencies.items():
417
+ version, latest_version = ManifestParser._extract_version_range_bounds(version_spec)
418
+ pkg_info.append((name, version, latest_version))
419
+ except Exception:
420
+ pass
421
+ return pkg_info
422
+
423
+ @staticmethod
424
+ def _parse_package_lock_json(file_content: str) -> list[tuple]:
425
+ """Parse package-lock.json (v1 and v2+ formats)."""
426
+ pkg_info: list[tuple] = []
427
+
428
+ def _extract_npm_name(pkg_path: str) -> str | None:
429
+ if not pkg_path:
430
+ return None
431
+ if "node_modules/" in pkg_path:
432
+ name_part = pkg_path.split("node_modules/")[-1]
433
+ else:
434
+ name_part = pkg_path
435
+ name_part = name_part.strip("/")
436
+ if not name_part:
437
+ return None
438
+ if name_part.startswith("@"):
439
+ parts = name_part.split("/")
440
+ return f"{parts[0]}/{parts[1]}" if len(parts) >= 2 else None
441
+ return name_part.split("/")[0]
442
+
443
+ try:
444
+ lock_data = json.loads(file_content)
445
+ lockfile_version = lock_data.get("lockfileVersion", 1)
446
+
447
+ if lockfile_version >= 2:
448
+ packages = lock_data.get("packages", {})
449
+ for pkg_path, info in packages.items():
450
+ if pkg_path and isinstance(info, dict) and "version" in info:
451
+ name = _extract_npm_name(pkg_path)
452
+ if name:
453
+ pkg_info.append((name, info["version"], None))
454
+ else:
455
+ dependencies = lock_data.get("dependencies", {})
456
+ for name, info in dependencies.items():
457
+ if isinstance(info, dict) and "version" in info:
458
+ pkg_info.append((name, info["version"], None))
459
+ except Exception:
460
+ pass
461
+ return pkg_info
462
+
463
+ @staticmethod
464
+ def _parse_yarn_lock(file_content: str) -> list[tuple]:
465
+ """Parse yarn.lock file."""
466
+ pkg_info: list[tuple] = []
467
+ current_package = None
468
+ for line in file_content.splitlines():
469
+ pkg_match = re.match(r'^"?(@?[^@"]+)@', line)
470
+ if pkg_match:
471
+ current_package = pkg_match.group(1)
472
+ continue
473
+ if current_package:
474
+ ver_match = re.match(r'^\s+version\s+"([^"]+)"', line)
475
+ if ver_match:
476
+ pkg_info.append((current_package, ver_match.group(1), None))
477
+ current_package = None
478
+ return pkg_info
479
+
480
+ @staticmethod
481
+ def _parse_pnpm_lock_yaml(file_content: str) -> list[tuple]:
482
+ """Parse pnpm-lock.yaml file."""
483
+ pkg_info: list[tuple] = []
484
+
485
+ def _extract_pnpm_name(pkg_path: str) -> str | None:
486
+ if not isinstance(pkg_path, str):
487
+ return None
488
+ key = pkg_path.lstrip("/")
489
+ key = key.split("(", 1)[0]
490
+ if "@" in key:
491
+ name_part = key.split("@", 1)[0]
492
+ else:
493
+ name_part = key
494
+ name_part = name_part.strip("/")
495
+ if not name_part:
496
+ return None
497
+ segments = name_part.split("/")
498
+ if not segments:
499
+ return None
500
+ for i, seg in enumerate(segments):
501
+ if seg.startswith("@") and i + 1 < len(segments):
502
+ return f"{seg}/{segments[i + 1]}"
503
+ return segments[-1]
504
+
505
+ try:
506
+ import yaml
507
+
508
+ lock_data = yaml.safe_load(file_content)
509
+ packages = lock_data.get("packages", {})
510
+ for pkg_path, pkg_data in packages.items():
511
+ if isinstance(pkg_data, dict) and "version" in pkg_data:
512
+ name = _extract_pnpm_name(pkg_path)
513
+ if name:
514
+ pkg_info.append((name, pkg_data["version"], None))
515
+ except Exception:
516
+ # Fallback to regex if yaml not available
517
+ current_raw_key = None
518
+ for line in file_content.splitlines():
519
+ m = re.match(r"^\s*/([^:]+):", line)
520
+ if m:
521
+ current_raw_key = m.group(1)
522
+ ver_match = re.match(r"^\s+version:\s+([0-9][0-9a-zA-Z.\-]+)", line)
523
+ if ver_match and current_raw_key:
524
+ name = _extract_pnpm_name(current_raw_key)
525
+ if name:
526
+ pkg_info.append((name, ver_match.group(1), None))
527
+ current_raw_key = None
528
+ return pkg_info
529
+
530
+ # --- Python ---
531
+
532
+ @staticmethod
533
+ def _parse_pyproject_toml(file_content: str) -> list[tuple]:
534
+ """Parse pyproject.toml (PEP 621 and Poetry formats)."""
535
+ pkg_info: list[tuple] = []
536
+ try:
537
+ import tomllib # Python 3.11+
538
+ except ModuleNotFoundError:
539
+ try:
540
+ import tomli as tomllib # type: ignore[no-redef]
541
+ except ModuleNotFoundError:
542
+ tomllib = None # type: ignore[assignment]
543
+
544
+ toml_data: dict = {}
545
+ if tomllib:
546
+ try:
547
+ toml_data = tomllib.loads(file_content)
548
+ except Exception:
549
+ toml_data = {}
550
+
551
+ if toml_data:
552
+ # PEP 621 format
553
+ for dep_str in toml_data.get("project", {}).get("dependencies", []):
554
+ if isinstance(dep_str, str):
555
+ m = re.match(
556
+ r"^([A-Za-z0-9_.\-]+)(?:\[[^\]]*\])?\s*"
557
+ r"(?:\(([^)]+)\)|([><=!~]+)\s*([0-9][0-9a-zA-Z.\-]+))?",
558
+ dep_str,
559
+ )
560
+ if m:
561
+ pkg_name = m.group(1)
562
+ version = m.group(2) or m.group(4) if m.group(3) else None
563
+ if version:
564
+ version = re.sub(r"[><=!~]+", "", version).split(",")[0].strip()
565
+ pkg_info.append((pkg_name, version, None))
566
+
567
+ # Poetry format
568
+ poetry_deps = toml_data.get("tool", {}).get("poetry", {}).get("dependencies", {})
569
+ for name, spec in poetry_deps.items():
570
+ if name.lower() == "python":
571
+ continue
572
+ if isinstance(spec, str):
573
+ version = spec
574
+ elif isinstance(spec, dict):
575
+ version = spec.get("version") or None
576
+ else:
577
+ version = None
578
+ pkg_info.append((name, version, None))
579
+ else:
580
+ # Regex fallback for [tool.poetry.dependencies]
581
+ in_block = False
582
+ for line in file_content.splitlines():
583
+ if line.strip().startswith("[tool.poetry.dependencies]"):
584
+ in_block = True
585
+ continue
586
+ if in_block:
587
+ if line.startswith("["):
588
+ break
589
+ m = re.match(
590
+ r'\s*([A-Za-z0-9_\-]+)\s*=\s*(?:"([^"\n]+)"'
591
+ r'|\{[^}]*version\s*=\s*"([^"\n]+)"[^}]*\})',
592
+ line,
593
+ )
594
+ if m:
595
+ ver = m.group(2) or m.group(3)
596
+ pkg_info.append((m.group(1), ver, None))
597
+ return pkg_info
598
+
599
+ @staticmethod
600
+ def _parse_requirements_txt(file_content: str) -> list[tuple]:
601
+ """Parse requirements.txt and similar Python dependency files."""
602
+ pkg_info: list[tuple] = []
603
+ req_pattern = re.compile(
604
+ r"^\s*"
605
+ r"(?P<name>[A-Za-z0-9_.\-]+)"
606
+ r"(?:\[[^\]]*\])?"
607
+ r"\s*"
608
+ r"(?:(?P<op>===|==|~=|!=|>=|<=|>|<)\s*(?P<ver>[A-Za-z0-9_.\-]+))?"
609
+ r"(?:\s*;.*)?"
610
+ r"\s*$"
611
+ )
612
+ for raw_line in file_content.split("\n"):
613
+ line = raw_line.strip()
614
+ if not line or line.startswith("#"):
615
+ continue
616
+ if (
617
+ line.startswith(("-r ", "--requirement ", "-e ", "--editable "))
618
+ or "://" in line
619
+ or line.startswith("git+")
620
+ ):
621
+ continue
622
+ m = req_pattern.match(line)
623
+ if not m:
624
+ continue
625
+ pkg = m.group("name")
626
+ op = m.group("op")
627
+ ver = m.group("ver")
628
+ version = ver if ver else None
629
+ latest_version = ver if ver and op in (">=", "~=") else None
630
+ pkg_info.append((pkg, version, latest_version))
631
+ return pkg_info
632
+
633
+ @staticmethod
634
+ def _parse_pipfile(file_content: str) -> list[tuple]:
635
+ """Parse Pipfile."""
636
+ pkg_info: list[tuple] = []
637
+ in_packages = None
638
+ for line in file_content.splitlines():
639
+ stripped = line.strip()
640
+ if stripped.startswith("[packages]"):
641
+ in_packages = "packages"
642
+ continue
643
+ if stripped.startswith("[dev-packages]"):
644
+ in_packages = "dev"
645
+ continue
646
+ if stripped.startswith("["):
647
+ in_packages = None
648
+ if in_packages and "=" in stripped:
649
+ m = re.match(r'([A-Za-z0-9_\-]+)\s*=\s*["{]*([^"}\n]+)', stripped)
650
+ if m:
651
+ version = m.group(2).strip().lstrip("=~><*")
652
+ version = None if version in ("*", "") else version
653
+ pkg_info.append((m.group(1), version, None))
654
+ return pkg_info
655
+
656
+ @staticmethod
657
+ def _parse_pipfile_lock(file_content: str) -> list[tuple]:
658
+ """Parse Pipfile.lock."""
659
+ pkg_info: list[tuple] = []
660
+ try:
661
+ lock_data = json.loads(file_content)
662
+ for section in ("default", "develop"):
663
+ deps = lock_data.get(section, {})
664
+ for pkg_name, pkg_data in deps.items():
665
+ if isinstance(pkg_data, dict):
666
+ version = pkg_data.get("version", "").lstrip("=")
667
+ if version:
668
+ pkg_info.append((pkg_name, version, None))
669
+ except Exception:
670
+ pass
671
+ return pkg_info
672
+
673
+ @staticmethod
674
+ def _parse_poetry_lock(file_content: str) -> list[tuple]:
675
+ """Parse poetry.lock."""
676
+ pkg_info: list[tuple] = []
677
+ current_package: dict | None = None
678
+ for line in file_content.splitlines():
679
+ if line.strip() == "[[package]]":
680
+ current_package = {}
681
+ continue
682
+ if current_package is not None:
683
+ name_match = re.match(r'^name\s*=\s*"([^"]+)"', line)
684
+ if name_match:
685
+ current_package["name"] = name_match.group(1)
686
+ version_match = re.match(r'^version\s*=\s*"([^"]+)"', line)
687
+ if version_match:
688
+ current_package["version"] = version_match.group(1)
689
+ if "name" in current_package and "version" in current_package:
690
+ pkg_info.append((current_package["name"], current_package["version"], None))
691
+ current_package = None
692
+ return pkg_info
693
+
694
+ @staticmethod
695
+ def _parse_uv_lock(file_content: str) -> list[tuple]:
696
+ """Parse uv.lock file."""
697
+ pkg_info: list[tuple] = []
698
+ current_package: dict | None = None
699
+ for line in file_content.splitlines():
700
+ if line.strip() == "[[package]]":
701
+ current_package = {}
702
+ continue
703
+ if current_package is not None:
704
+ name_match = re.match(r'^name\s*=\s*"([^"]+)"', line)
705
+ if name_match:
706
+ current_package["name"] = name_match.group(1)
707
+ version_match = re.match(r'^version\s*=\s*"([^"]+)"', line)
708
+ if version_match:
709
+ current_package["version"] = version_match.group(1)
710
+ if "name" in current_package and "version" in current_package:
711
+ pkg_info.append((current_package["name"], current_package["version"], None))
712
+ current_package = None
713
+ return pkg_info
714
+
715
+ @staticmethod
716
+ def _parse_pdm_lock(file_content: str) -> list[tuple]:
717
+ """Parse pdm.lock file."""
718
+ pkg_info: list[tuple] = []
719
+ current_package: dict | None = None
720
+ for line in file_content.splitlines():
721
+ if line.strip() == "[[package]]":
722
+ current_package = {}
723
+ continue
724
+ if current_package is not None:
725
+ name_match = re.match(r'^name\s*=\s*"([^"]+)"', line)
726
+ if name_match:
727
+ current_package["name"] = name_match.group(1)
728
+ version_match = re.match(r'^version\s*=\s*"([^"]+)"', line)
729
+ if version_match:
730
+ current_package["version"] = version_match.group(1)
731
+ if "name" in current_package and "version" in current_package:
732
+ pkg_info.append((current_package["name"], current_package["version"], None))
733
+ current_package = None
734
+ return pkg_info
735
+
736
+ @staticmethod
737
+ def _parse_conda_environment(file_content: str) -> list[tuple]:
738
+ """Parse conda environment.yml files."""
739
+ pkg_info: list[tuple] = []
740
+ in_deps = False
741
+ in_pip = False
742
+ for line in file_content.splitlines():
743
+ if line.strip().startswith("dependencies:"):
744
+ in_deps = True
745
+ continue
746
+ if in_deps:
747
+ if line.strip() == "- pip:":
748
+ in_pip = True
749
+ continue
750
+ if in_pip and line and not line.startswith(" ") and not line.strip().startswith("#"):
751
+ in_pip = False
752
+ if in_pip:
753
+ m = re.match(r"\s+-\s*([A-Za-z0-9_.\-]+)\s*([><=!~]+)\s*([A-Za-z0-9.\-]+)", line)
754
+ if m:
755
+ pkg_info.append((m.group(1), m.group(3), None))
756
+ continue
757
+ m2 = re.match(r"\s+-\s*([A-Za-z0-9_.\-]+)\s*$", line)
758
+ if m2:
759
+ pkg_info.append((m2.group(1), None, None))
760
+ continue
761
+ if not in_pip:
762
+ m = re.match(r"\s*-\s*([A-Za-z0-9_\-]+)(?:[=]+([A-Za-z0-9.\-]+))?", line)
763
+ if m:
764
+ pkg_info.append((m.group(1), m.group(2), None))
765
+ elif line and not line.startswith(" -") and not line.strip().startswith("#"):
766
+ break
767
+ return pkg_info
768
+
769
+ # --- Go ---
770
+
771
+ @staticmethod
772
+ def _parse_go_mod(file_content: str) -> list[tuple]:
773
+ """Parse go.mod file."""
774
+ pkg_info: list[tuple] = []
775
+ in_block = False
776
+ for line in file_content.splitlines():
777
+ if re.match(r"^\s*require\s*\(", line):
778
+ in_block = True
779
+ continue
780
+ if in_block and line.strip() == ")":
781
+ in_block = False
782
+ continue
783
+ if in_block:
784
+ m = re.match(r"^\s*([\w\-./]+)\s+(v?[0-9][^\s]*)", line)
785
+ if m:
786
+ pkg_info.append((m.group(1), m.group(2), None))
787
+ continue
788
+ m2 = re.match(r"^\s*require\s+([\w\-./]+)\s+(v?[0-9][^\s]*)", line)
789
+ if m2:
790
+ pkg_info.append((m2.group(1), m2.group(2), None))
791
+ return pkg_info
792
+
793
+ @staticmethod
794
+ def _parse_go_sum(file_content: str) -> list[tuple]:
795
+ """Parse go.sum file (deduplicated — skips /go.mod entries)."""
796
+ pkg_info: list[tuple] = []
797
+ seen: set[tuple[str, str]] = set()
798
+ for line in file_content.splitlines():
799
+ m = re.match(r"^([\w\-./]+)\s+(v?[0-9][^\s/]+)", line)
800
+ if m:
801
+ key = (m.group(1), m.group(2))
802
+ if key not in seen:
803
+ seen.add(key)
804
+ pkg_info.append((m.group(1), m.group(2), None))
805
+ return pkg_info
806
+
807
+ @staticmethod
808
+ def _parse_gopkg_toml(file_content: str) -> list[tuple]:
809
+ """Parse Gopkg.toml file."""
810
+ pkg_info: list[tuple] = []
811
+ current_package: dict | None = None
812
+ for line in file_content.splitlines():
813
+ if line.strip() in ("[[constraint]]", "[[override]]"):
814
+ current_package = {}
815
+ continue
816
+ if current_package is not None:
817
+ name_match = re.match(r'^\s*name\s*=\s*"([^"]+)"', line)
818
+ if name_match:
819
+ current_package["name"] = name_match.group(1)
820
+ version_match = re.match(r'^\s*version\s*=\s*"([^"]+)"', line)
821
+ if version_match:
822
+ current_package["version"] = version_match.group(1)
823
+ if "name" in current_package and "version" in current_package:
824
+ pkg_info.append((current_package["name"], current_package["version"], None))
825
+ current_package = None
826
+ return pkg_info
827
+
828
+ @staticmethod
829
+ def _parse_gopkg_lock(file_content: str) -> list[tuple]:
830
+ """Parse Gopkg.lock file."""
831
+ pkg_info: list[tuple] = []
832
+ current_package: dict | None = None
833
+ for line in file_content.splitlines():
834
+ if line.strip() == "[[projects]]":
835
+ current_package = {}
836
+ continue
837
+ if current_package is not None:
838
+ name_match = re.match(r'^\s*name\s*=\s*"([^"]+)"', line)
839
+ if name_match:
840
+ current_package["name"] = name_match.group(1)
841
+ version_match = re.match(r'^\s*version\s*=\s*"([^"]+)"', line)
842
+ if version_match:
843
+ current_package["version"] = version_match.group(1).lstrip("v")
844
+ revision_match = re.match(r'^\s*revision\s*=\s*"([^"]+)"', line)
845
+ if revision_match and "version" not in current_package:
846
+ current_package["version"] = revision_match.group(1)[:7]
847
+ if "name" in current_package and "version" in current_package:
848
+ pkg_info.append((current_package["name"], current_package["version"], None))
849
+ current_package = None
850
+ return pkg_info
851
+
852
+ # --- PHP ---
853
+
854
+ @staticmethod
855
+ def _parse_composer_json(file_content: str) -> list[tuple]:
856
+ """Parse composer.json file."""
857
+ pkg_info: list[tuple] = []
858
+ comp_data = json.loads(file_content)
859
+ require_deps = comp_data.get("require", {}) or {}
860
+ dev_deps = comp_data.get("require-dev", {}) or {}
861
+ merged = dict(require_deps)
862
+ for k, v in dev_deps.items():
863
+ merged.setdefault(k, v)
864
+ for pkg, raw_spec in merged.items():
865
+ version, latest_version = ManifestParser._extract_version_range_bounds(raw_spec)
866
+ pkg_info.append((pkg, version, latest_version))
867
+ return pkg_info
868
+
869
+ @staticmethod
870
+ def _parse_composer_lock(file_content: str) -> list[tuple]:
871
+ """Parse composer.lock file."""
872
+ pkg_info: list[tuple] = []
873
+ try:
874
+ lock_data = json.loads(file_content)
875
+ for section in ("packages", "packages-dev"):
876
+ for pkg in lock_data.get(section, []):
877
+ if isinstance(pkg, dict):
878
+ name = pkg.get("name")
879
+ version = (pkg.get("version") or "").lstrip("v")
880
+ if name and version:
881
+ pkg_info.append((name, version, version))
882
+ except Exception:
883
+ pass
884
+ return pkg_info
885
+
886
+ # --- Rust ---
887
+
888
+ @staticmethod
889
+ def _parse_cargo_toml(file_content: str) -> list[tuple]:
890
+ """Parse Cargo.toml file."""
891
+ pkg_info: list[tuple] = []
892
+ in_block = False
893
+ for line in file_content.splitlines():
894
+ if re.match(r"^\s*\[(dependencies|dev-dependencies|build-dependencies)\]", line):
895
+ in_block = True
896
+ continue
897
+ if in_block:
898
+ if line.strip().startswith("["):
899
+ in_block = False
900
+ continue
901
+ m = re.match(r'\s*([A-Za-z0-9_\-]+)\s*=\s*"([^"]+)"', line)
902
+ if m:
903
+ pkg_info.append((m.group(1), m.group(2), None))
904
+ continue
905
+ m2 = re.match(r'\s*([A-Za-z0-9_\-]+)\s*=\s*\{[^}]*version\s*=\s*"([^"]+)"', line)
906
+ if m2:
907
+ pkg_info.append((m2.group(1), m2.group(2), None))
908
+ return pkg_info
909
+
910
+ @staticmethod
911
+ def _parse_cargo_lock(file_content: str) -> list[tuple]:
912
+ """Parse Cargo.lock file."""
913
+ pkg_info: list[tuple] = []
914
+ current_package: dict | None = None
915
+ for line in file_content.splitlines():
916
+ if line.strip() == "[[package]]":
917
+ current_package = {}
918
+ continue
919
+ if current_package is not None:
920
+ name_match = re.match(r'^name\s*=\s*"([^"]+)"', line)
921
+ if name_match:
922
+ current_package["name"] = name_match.group(1)
923
+ version_match = re.match(r'^version\s*=\s*"([^"]+)"', line)
924
+ if version_match:
925
+ current_package["version"] = version_match.group(1)
926
+ if "name" in current_package and "version" in current_package:
927
+ pkg_info.append((current_package["name"], current_package["version"], None))
928
+ current_package = None
929
+ return pkg_info
930
+
931
+ # --- Java / Maven ---
932
+
933
+ @staticmethod
934
+ def _parse_pom_xml(file_content: str) -> list[tuple]:
935
+ """Parse pom.xml file."""
936
+ pkg_info: list[tuple] = []
937
+ try:
938
+ from defusedxml import ElementTree
939
+
940
+ root = ElementTree.fromstring(file_content)
941
+ for dep in root.iterfind(".//{*}dependency"):
942
+ g = dep.find("{*}groupId")
943
+ a = dep.find("{*}artifactId")
944
+ v = dep.find("{*}version")
945
+ if g is not None and a is not None:
946
+ name = f"{g.text}:{a.text}"
947
+ version = v.text if v is not None else None
948
+ pkg_info.append((name, version, None))
949
+ except Exception as exc:
950
+ logger.debug("Failed to parse pom.xml content: %s", exc)
951
+ return pkg_info
952
+
953
+ # --- Ruby ---
954
+
955
+ @staticmethod
956
+ def _parse_gemfile(file_content: str) -> list[tuple]:
957
+ """Parse Gemfile."""
958
+ pkg_info: list[tuple] = []
959
+ for line in file_content.splitlines():
960
+ if line.strip().startswith("#") or not line.strip():
961
+ continue
962
+ m = re.match(r"""^\s*gem\s+['"]([^'"]+)['"]\s*,\s*['"]([^'"]+)['"]""", line)
963
+ if m:
964
+ pkg_info.append((m.group(1), m.group(2).lstrip("~>>=<"), None))
965
+ else:
966
+ m2 = re.match(r"""^\s*gem\s+['"]([^'"]+)['"]""", line)
967
+ if m2:
968
+ pkg_info.append((m2.group(1), None, None))
969
+ return pkg_info
970
+
971
+ @staticmethod
972
+ def _parse_gemfile_lock(file_content: str) -> list[tuple]:
973
+ """Parse Gemfile.lock."""
974
+ pkg_info: list[tuple] = []
975
+ in_specs = False
976
+ for line in file_content.splitlines():
977
+ if line.strip() == "specs:":
978
+ in_specs = True
979
+ continue
980
+ if in_specs:
981
+ m = re.match(r"^\s{4,}([A-Za-z0-9_\-]+)\s+\(([0-9][0-9a-zA-Z.\-]*)\)", line)
982
+ if m:
983
+ pkg_info.append((m.group(1), m.group(2), None))
984
+ elif line and not line.startswith(" "):
985
+ in_specs = False
986
+ return pkg_info
987
+
988
+ # --- Gradle / Kotlin ---
989
+
990
+ @staticmethod
991
+ def _parse_gradle_build(file_content: str) -> list[tuple]:
992
+ """Parse build.gradle or build.gradle.kts."""
993
+ pkg_info: list[tuple] = []
994
+ gradle_pattern = re.compile(
995
+ r"\b(?:implementation|api|compile|compileOnly|testImplementation|runtimeOnly|"
996
+ r"kapt|annotationProcessor|classpath|testCompile|testRuntime|runtimeClasspath|"
997
+ r"compileClasspath|testRuntimeClasspath)\s*[\(\[\{]?\s*['\"]([^:'\"]+"
998
+ r"):([^:'\"]+):([^:'\"]+)['\"]"
999
+ )
1000
+ for m in gradle_pattern.finditer(file_content):
1001
+ group, artifact, version = m.groups()
1002
+ pkg_info.append((f"{group}:{artifact}", version, None))
1003
+
1004
+ map_pattern = re.compile(
1005
+ r"group:\s*['\"]([^'\"]+)['\"],\s*name:\s*['\"]([^'\"]+)['\"]"
1006
+ r",\s*version:\s*['\"]([^'\"]+)['\"]"
1007
+ )
1008
+ for m in map_pattern.finditer(file_content):
1009
+ group, artifact, version = m.groups()
1010
+ pkg_info.append((f"{group}:{artifact}", version, None))
1011
+ return pkg_info
1012
+
1013
+ @staticmethod
1014
+ def _parse_gradle_properties(file_content: str) -> list[tuple]:
1015
+ """Parse gradle.properties file."""
1016
+ pkg_info: list[tuple] = []
1017
+ for line in file_content.splitlines():
1018
+ m = re.match(r"^\s*([A-Za-z0-9_]+Version)\s*=\s*([0-9][0-9a-zA-Z.\-]*)", line)
1019
+ if m:
1020
+ pkg_info.append((m.group(1), m.group(2), None))
1021
+ return pkg_info
1022
+
1023
+ # --- Scala ---
1024
+
1025
+ @staticmethod
1026
+ def _parse_build_sbt(file_content: str) -> list[tuple]:
1027
+ """Parse build.sbt file."""
1028
+ pkg_info: list[tuple] = []
1029
+ for line in file_content.splitlines():
1030
+ m = re.match(
1031
+ r'^\s*libraryDependencies\s*\+[=+]\s*"([^"]+)"\s*%%?\s*"([^"]+)"\s*%\s*"([^"]+)"',
1032
+ line,
1033
+ )
1034
+ if m:
1035
+ org, artifact, version = m.groups()
1036
+ pkg_info.append((f"{org}:{artifact}", version, None))
1037
+ continue
1038
+ for org, artifact, version in re.findall(r'"([^"]+)"\s*%%?\s*"([^"]+)"\s*%\s*"([^"]+)"', line):
1039
+ pkg_info.append((f"{org}:{artifact}", version, None))
1040
+ return pkg_info
1041
+
1042
+ # --- .NET / C# ---
1043
+
1044
+ @staticmethod
1045
+ def _parse_dotnet_packages(file_content: str) -> list[tuple]:
1046
+ """Parse .csproj or packages.config files."""
1047
+ pkg_info: list[tuple] = []
1048
+ try:
1049
+ from defusedxml import ElementTree
1050
+
1051
+ root = ElementTree.fromstring(file_content)
1052
+ for pr in root.iterfind(".//{*}PackageReference"):
1053
+ inc = pr.attrib.get("Include") or pr.attrib.get("Update")
1054
+ ver = pr.attrib.get("Version") or pr.findtext("{*}Version")
1055
+ if inc:
1056
+ pkg_info.append((inc, ver, None))
1057
+ for pkg in root.iterfind(".//{*}package"):
1058
+ id_ = pkg.attrib.get("id")
1059
+ ver = pkg.attrib.get("version")
1060
+ if id_:
1061
+ pkg_info.append((id_, ver, None))
1062
+ except Exception as exc:
1063
+ logger.debug("Failed to parse .NET package XML content: %s", exc)
1064
+ return pkg_info
1065
+
1066
+ @staticmethod
1067
+ def _parse_packages_lock_json(file_content: str) -> list[tuple]:
1068
+ """Parse packages.lock.json file."""
1069
+ pkg_info: list[tuple] = []
1070
+ try:
1071
+ lock_data = json.loads(file_content)
1072
+ dependencies = lock_data.get("dependencies", {})
1073
+ for _target, deps in dependencies.items():
1074
+ if isinstance(deps, dict):
1075
+ for pkg_name, pkg_data in deps.items():
1076
+ if isinstance(pkg_data, dict):
1077
+ version = pkg_data.get("resolved") or pkg_data.get("version")
1078
+ if version:
1079
+ pkg_info.append((pkg_name, version, None))
1080
+ except Exception:
1081
+ pass
1082
+ return pkg_info
1083
+
1084
+ # --- Dart / Flutter ---
1085
+
1086
+ @staticmethod
1087
+ def _parse_pubspec_yaml(file_content: str) -> list[tuple]:
1088
+ """Parse pubspec.yaml file."""
1089
+ pkg_info: list[tuple] = []
1090
+ in_deps = False
1091
+ for line in file_content.splitlines():
1092
+ if re.match(r"^(dependencies|dev_dependencies):\s*$", line):
1093
+ in_deps = True
1094
+ continue
1095
+ if in_deps:
1096
+ if re.match(r"^\S", line):
1097
+ in_deps = False
1098
+ continue
1099
+ m = re.match(r"\s*([A-Za-z0-9_\-]+):\s*[\^~]?([0-9][0-9.\-]*)", line)
1100
+ if m:
1101
+ pkg_info.append((m.group(1), m.group(2), None))
1102
+ return pkg_info
1103
+
1104
+ @staticmethod
1105
+ def _parse_pubspec_lock(file_content: str) -> list[tuple]:
1106
+ """Parse pubspec.lock file."""
1107
+ pkg_info: list[tuple] = []
1108
+ in_packages = False
1109
+ current_package = None
1110
+ for line in file_content.splitlines():
1111
+ if line.strip() == "packages:":
1112
+ in_packages = True
1113
+ continue
1114
+ if in_packages:
1115
+ pkg_match = re.match(r"^\s{2}([A-Za-z0-9_\-]+):", line)
1116
+ if pkg_match:
1117
+ current_package = pkg_match.group(1)
1118
+ continue
1119
+ if current_package:
1120
+ ver_match = re.match(r'^\s+version:\s*"([^"]+)"', line)
1121
+ if ver_match:
1122
+ pkg_info.append((current_package, ver_match.group(1), None))
1123
+ current_package = None
1124
+ return pkg_info
1125
+
1126
+ # --- Elixir ---
1127
+
1128
+ @staticmethod
1129
+ def _parse_mix_exs(file_content: str) -> list[tuple]:
1130
+ """Parse mix.exs file."""
1131
+ pkg_info: list[tuple] = []
1132
+ for m in re.finditer(r'\{\s*:([A-Za-z0-9_]+)\s*,\s*"([^"]+)"', file_content):
1133
+ pkg, ver = m.groups()
1134
+ pkg_info.append((pkg, ver.lstrip("~><="), None))
1135
+ return pkg_info
1136
+
1137
+ @staticmethod
1138
+ def _parse_mix_lock(file_content: str) -> list[tuple]:
1139
+ """Parse mix.lock file."""
1140
+ pkg_info: list[tuple] = []
1141
+ for m in re.finditer(
1142
+ r'"([A-Za-z0-9_]+)":\s*\{:hex,\s*:[A-Za-z0-9_]+,\s*"([0-9.]+)"',
1143
+ file_content,
1144
+ ):
1145
+ pkg_info.append((m.group(1), m.group(2), None))
1146
+ return pkg_info
1147
+
1148
+ # --- Swift ---
1149
+
1150
+ @staticmethod
1151
+ def _parse_package_swift(file_content: str) -> list[tuple]:
1152
+ """Parse Package.swift file."""
1153
+ pkg_info: list[tuple] = []
1154
+ for m in re.finditer(
1155
+ r'\.package\s*\(.*?url:\s*"https://github\.com/([A-Za-z0-9_.\-]+/[A-Za-z0-9_.\-]+)'
1156
+ r'.*?from:\s*"([0-9.]+)"',
1157
+ file_content,
1158
+ re.DOTALL,
1159
+ ):
1160
+ repo, ver = m.groups()
1161
+ pkg_info.append((f"github.com/{repo}", ver, None))
1162
+ for m in re.finditer(
1163
+ r'\.package\s*\(.*?url:\s*"https://github\.com/([A-Za-z0-9_.\-]+/[A-Za-z0-9_.\-]+)'
1164
+ r'.*?\.exact\("([0-9.]+)"\)',
1165
+ file_content,
1166
+ re.DOTALL,
1167
+ ):
1168
+ repo, ver = m.groups()
1169
+ pkg_info.append((f"github.com/{repo}", ver, None))
1170
+ return pkg_info
1171
+
1172
+ @staticmethod
1173
+ def _parse_package_resolved(file_content: str) -> list[tuple]:
1174
+ """Parse Package.resolved file."""
1175
+ pkg_info: list[tuple] = []
1176
+ try:
1177
+ resolved_data = json.loads(file_content)
1178
+ pins = resolved_data.get("pins", []) or resolved_data.get("object", {}).get("pins", [])
1179
+ for pin in pins:
1180
+ if isinstance(pin, dict):
1181
+ identity = pin.get("identity") or pin.get("package")
1182
+ location = pin.get("location", "")
1183
+ state = pin.get("state", {})
1184
+ version = state.get("version")
1185
+ branch = state.get("branch")
1186
+ revision = state.get("revision")
1187
+ version_id = None
1188
+ if version:
1189
+ version_id = version
1190
+ elif branch:
1191
+ version_id = f"branch:{branch}"
1192
+ elif revision:
1193
+ version_id = revision[:12] if len(revision) > 12 else revision
1194
+ package_name = identity
1195
+ if location:
1196
+ github_match = re.search(r"github\.com[:/]([^/]+/[^/]+)", location)
1197
+ if github_match:
1198
+ repo_path = github_match.group(1).rstrip(".git")
1199
+ package_name = f"github.com/{repo_path}"
1200
+ if identity and version_id:
1201
+ pkg_info.append((package_name, version_id, None))
1202
+ except Exception:
1203
+ pass
1204
+ return pkg_info
1205
+
1206
+ # --- CocoaPods / Carthage ---
1207
+
1208
+ @staticmethod
1209
+ def _parse_podfile(file_content: str) -> list[tuple]:
1210
+ """Parse Podfile."""
1211
+ pkg_info: list[tuple] = []
1212
+ for line in file_content.splitlines():
1213
+ m = re.match(r"""^\s*pod\s+['"]([^'"]+)['"]\s*,\s*['"]([^'"]+)['"]""", line)
1214
+ if m:
1215
+ pkg_info.append((m.group(1), m.group(2).lstrip("~><="), None))
1216
+ return pkg_info
1217
+
1218
+ @staticmethod
1219
+ def _parse_podfile_lock(file_content: str) -> list[tuple]:
1220
+ """Parse Podfile.lock."""
1221
+ pkg_info: list[tuple] = []
1222
+ in_pods = False
1223
+ for line in file_content.splitlines():
1224
+ if line.strip() == "PODS:":
1225
+ in_pods = True
1226
+ continue
1227
+ if in_pods:
1228
+ if line and not line.startswith(" "):
1229
+ in_pods = False
1230
+ continue
1231
+ m = re.match(r"^\s*-\s+([A-Za-z0-9_\-]+)\s+\(([0-9.]+)\)", line)
1232
+ if m:
1233
+ pkg_info.append((m.group(1), m.group(2), None))
1234
+ return pkg_info
1235
+
1236
+ @staticmethod
1237
+ def _parse_cartfile(file_content: str) -> list[tuple]:
1238
+ """Parse Cartfile."""
1239
+ pkg_info: list[tuple] = []
1240
+ for line in file_content.splitlines():
1241
+ m = re.match(r'^\s*github\s+"([^/]+/[^"]+)"\s+[~=]?>=?\s*([0-9.]+)', line)
1242
+ if m:
1243
+ pkg_info.append((f"github.com/{m.group(1)}", m.group(2), None))
1244
+ continue
1245
+ m2 = re.match(r'^\s*git\s+"([^"]+)"\s+[~=]?>=?\s*([0-9.]+)', line)
1246
+ if m2:
1247
+ repo_name = m2.group(1).split("/")[-1].replace(".git", "")
1248
+ pkg_info.append((repo_name, m2.group(2), None))
1249
+ return pkg_info
1250
+
1251
+ @staticmethod
1252
+ def _parse_cartfile_resolved(file_content: str) -> list[tuple]:
1253
+ """Parse Cartfile.resolved."""
1254
+ pkg_info: list[tuple] = []
1255
+ for line in file_content.splitlines():
1256
+ m = re.match(r'^\s*github\s+"([^/]+/[^"]+)"\s+"([0-9.]+)"', line)
1257
+ if m:
1258
+ pkg_info.append((f"github.com/{m.group(1)}", m.group(2), None))
1259
+ continue
1260
+ m2 = re.match(r'^\s*git\s+"([^"]+)"\s+"([0-9.]+)"', line)
1261
+ if m2:
1262
+ repo_name = m2.group(1).split("/")[-1].replace(".git", "")
1263
+ pkg_info.append((repo_name, m2.group(2), None))
1264
+ return pkg_info
1265
+
1266
+ # --- Haskell ---
1267
+
1268
+ @staticmethod
1269
+ def _parse_stack_yaml(file_content: str) -> list[tuple]:
1270
+ """Parse stack.yaml file."""
1271
+ pkg_info: list[tuple] = []
1272
+ in_deps = False
1273
+ for line in file_content.splitlines():
1274
+ if line.strip().startswith("extra-deps:"):
1275
+ in_deps = True
1276
+ continue
1277
+ if in_deps:
1278
+ if line and not line.startswith(" ") and not line.startswith("-"):
1279
+ in_deps = False
1280
+ continue
1281
+ m = re.match(r"^\s*-\s*([A-Za-z0-9_\-]+)-([0-9][0-9.]*)", line)
1282
+ if m:
1283
+ pkg_info.append((m.group(1), m.group(2), None))
1284
+ return pkg_info
1285
+
1286
+ @staticmethod
1287
+ def _parse_cabal(file_content: str) -> list[tuple]:
1288
+ """Parse .cabal file."""
1289
+ pkg_info: list[tuple] = []
1290
+ in_deps = False
1291
+ for line in file_content.splitlines():
1292
+ if line.strip().startswith("build-depends:"):
1293
+ in_deps = True
1294
+ deps_str = line.split(":", 1)[1] if ":" in line else ""
1295
+ for dep in deps_str.split(","):
1296
+ m = re.match(r"\s*([A-Za-z0-9_\-]+)\s*>=?\s*([0-9][0-9.]*)", dep)
1297
+ if m:
1298
+ pkg_info.append((m.group(1), m.group(2), None))
1299
+ continue
1300
+ if in_deps:
1301
+ if line and not line.startswith(" "):
1302
+ in_deps = False
1303
+ continue
1304
+ for dep in line.split(","):
1305
+ m = re.match(r"\s*([A-Za-z0-9_\-]+)\s*>=?\s*([0-9][0-9.]*)", dep)
1306
+ if m:
1307
+ pkg_info.append((m.group(1), m.group(2), None))
1308
+ return pkg_info
1309
+
1310
+ # --- Julia ---
1311
+
1312
+ @staticmethod
1313
+ def _parse_julia_project_toml(file_content: str) -> list[tuple]:
1314
+ """Parse Julia Project.toml file."""
1315
+ pkg_info: list[tuple] = []
1316
+ in_deps = False
1317
+ for line in file_content.splitlines():
1318
+ if line.strip() == "[deps]":
1319
+ in_deps = True
1320
+ continue
1321
+ if in_deps:
1322
+ if line.startswith("["):
1323
+ break
1324
+ m = re.match(r"^([A-Za-z0-9_]+)\s*=", line)
1325
+ if m:
1326
+ pkg_info.append((m.group(1), None, None))
1327
+ return pkg_info
1328
+
1329
+ @staticmethod
1330
+ def _parse_julia_manifest_toml(file_content: str) -> list[tuple]:
1331
+ """Parse Julia Manifest.toml file."""
1332
+ pkg_info: list[tuple] = []
1333
+ current_package = None
1334
+ for line in file_content.splitlines():
1335
+ pkg_match = re.match(r"^\[\[(?:deps\.)?([A-Za-z0-9_]+)\]\]", line)
1336
+ if pkg_match:
1337
+ current_package = pkg_match.group(1)
1338
+ continue
1339
+ if current_package:
1340
+ ver_match = re.match(r'^version\s*=\s*"([^"]+)"', line)
1341
+ if ver_match:
1342
+ pkg_info.append((current_package, ver_match.group(1), None))
1343
+ current_package = None
1344
+ return pkg_info
1345
+
1346
+ # --- C++ ---
1347
+
1348
+ @staticmethod
1349
+ def _parse_vcpkg_json(file_content: str) -> list[tuple]:
1350
+ """Parse vcpkg.json file."""
1351
+ pkg_info: list[tuple] = []
1352
+ try:
1353
+ vcpkg_data = json.loads(file_content)
1354
+ deps = vcpkg_data.get("dependencies", [])
1355
+ for dep in deps:
1356
+ if isinstance(dep, str):
1357
+ pkg_info.append((dep, None, None))
1358
+ elif isinstance(dep, dict):
1359
+ name = dep.get("name")
1360
+ version = dep.get("version>=") or dep.get("version") or dep.get("version-string")
1361
+ if name:
1362
+ pkg_info.append((name, version, None))
1363
+ except Exception:
1364
+ pass
1365
+ return pkg_info
1366
+
1367
+ @staticmethod
1368
+ def _parse_conanfile_txt(file_content: str) -> list[tuple]:
1369
+ """Parse conanfile.txt file."""
1370
+ pkg_info: list[tuple] = []
1371
+ in_requires = False
1372
+ for line in file_content.splitlines():
1373
+ if line.strip() == "[requires]":
1374
+ in_requires = True
1375
+ continue
1376
+ if in_requires:
1377
+ if line.startswith("["):
1378
+ break
1379
+ m = re.match(r"^\s*([A-Za-z0-9_\-]+)/([0-9][0-9.\-]*)(?:@[^\s]*)?", line)
1380
+ if m:
1381
+ pkg_info.append((m.group(1), m.group(2), None))
1382
+ return pkg_info
1383
+
1384
+ @staticmethod
1385
+ def _parse_conanfile_py(file_content: str) -> list[tuple]:
1386
+ """Parse conanfile.py file."""
1387
+ pkg_info: list[tuple] = []
1388
+ for line in file_content.splitlines():
1389
+ if "requires" in line and "=" in line:
1390
+ for m in re.finditer(r'["\']([A-Za-z0-9_\-]+)/([0-9][0-9.\-]*)["\']', line):
1391
+ pkg_info.append((m.group(1), m.group(2), None))
1392
+ return pkg_info
1393
+
1394
+ @staticmethod
1395
+ def _parse_cmake_lists(file_content: str) -> list[tuple]:
1396
+ """Parse CMakeLists.txt file."""
1397
+ pkg_info: list[tuple] = []
1398
+ for line in file_content.splitlines():
1399
+ m = re.match(
1400
+ r"^\s*find_package\s*\(\s*([A-Za-z0-9_]+)(?:\s+([0-9][0-9.]*))?",
1401
+ line,
1402
+ re.IGNORECASE,
1403
+ )
1404
+ if m:
1405
+ pkg_info.append((m.group(1), m.group(2) if m.group(2) else None, None))
1406
+ return pkg_info
1407
+
1408
+ # --- Deno ---
1409
+
1410
+ @staticmethod
1411
+ def _parse_deno_json(file_content: str) -> list[tuple]:
1412
+ """Parse deno.json or deno.jsonc file."""
1413
+ pkg_info: list[tuple] = []
1414
+ try:
1415
+ content_to_parse = file_content
1416
+ if file_content.startswith("//") or "/*" in file_content:
1417
+ content_to_parse = re.sub(r"//.*$", "", file_content, flags=re.MULTILINE)
1418
+ content_to_parse = re.sub(r"/\*.*?\*/", "", content_to_parse, flags=re.DOTALL)
1419
+ deno_data = json.loads(content_to_parse)
1420
+ imports = deno_data.get("imports", {})
1421
+ for _alias, url in imports.items():
1422
+ if url.startswith("npm:"):
1423
+ pkg_with_ver = url[4:]
1424
+ if "@" in pkg_with_ver:
1425
+ if pkg_with_ver.startswith("@"):
1426
+ parts = pkg_with_ver.split("@")
1427
+ if len(parts) >= 3:
1428
+ pkg_info.append((f"@{parts[1]}", parts[2], None))
1429
+ else:
1430
+ parts = pkg_with_ver.split("@")
1431
+ pkg_info.append((parts[0], parts[1] if len(parts) > 1 else None, None))
1432
+ elif "esm.sh" in url:
1433
+ m = re.search(r"esm\.sh/([^@/]+)@([0-9][0-9.\-]*)", url)
1434
+ if m:
1435
+ pkg_info.append((m.group(1), m.group(2), None))
1436
+ except Exception:
1437
+ pass
1438
+ return pkg_info
1439
+
1440
+ @staticmethod
1441
+ def _parse_deno_lock(file_content: str) -> list[tuple]:
1442
+ """Parse deno.lock file."""
1443
+ pkg_info: list[tuple] = []
1444
+ try:
1445
+ lock_data = json.loads(file_content)
1446
+ npm_section = lock_data.get("npm", {})
1447
+ specifiers = npm_section.get("specifiers", {})
1448
+ for _alias, full_spec in specifiers.items():
1449
+ if "@" in full_spec:
1450
+ parts = full_spec.split("@")
1451
+ if len(parts) >= 2:
1452
+ version = parts[-1]
1453
+ pkg_name = "@".join(parts[:-1])
1454
+ pkg_info.append((pkg_name, version, None))
1455
+ remote_section = lock_data.get("remote", {})
1456
+ std_versions: set[str] = set()
1457
+ for url in remote_section.keys():
1458
+ m = re.search(r"deno\.land/std@([0-9][0-9.\-]*)", url)
1459
+ if m:
1460
+ std_versions.add(m.group(1))
1461
+ for ver in std_versions:
1462
+ pkg_info.append(("deno_std", ver, None))
1463
+ except Exception:
1464
+ pass
1465
+ return pkg_info
1466
+
1467
+ # --- Perl ---
1468
+
1469
+ @staticmethod
1470
+ def _parse_cpanfile(file_content: str) -> list[tuple]:
1471
+ """Parse cpanfile."""
1472
+ pkg_info: list[tuple] = []
1473
+ cpan_pattern = re.compile(
1474
+ r"""^\s*(?:on\s+['"][^'"]+['"]\s+)?"""
1475
+ r"""(requires|recommends|suggests|feature|test_requires)\s+"""
1476
+ r"""['"]([A-Za-z0-9_:]+)['"]"""
1477
+ r"""(?:\s*(?:=>|,)\s*['"]([^'"]+)['"])?""",
1478
+ re.MULTILINE,
1479
+ )
1480
+ for match in cpan_pattern.finditer(file_content):
1481
+ pkg_info.append((match.group(2), match.group(3), None))
1482
+ return pkg_info
1483
+
1484
+ @staticmethod
1485
+ def _parse_makefile_pl(file_content: str) -> list[tuple]:
1486
+ """Parse Makefile.PL or Build.PL."""
1487
+ pkg_info: list[tuple] = []
1488
+ pattern = re.compile(
1489
+ r"""^\s*(requires|recommends|test_requires|build_requires|configure_requires)\s+"""
1490
+ r"""['"]([A-Za-z0-9_:]+)['"]"""
1491
+ r"""\s*=>\s*['"]?([0-9][0-9a-zA-Z._-]*|0)['"]?""",
1492
+ re.MULTILINE,
1493
+ )
1494
+ for match in pattern.finditer(file_content):
1495
+ module = match.group(2)
1496
+ version = match.group(3)
1497
+ version = None if version == "0" else version
1498
+ pkg_info.append((module, version, None))
1499
+ return pkg_info
1500
+
1501
+ # --- R ---
1502
+
1503
+ @staticmethod
1504
+ def _parse_r_description(file_content: str) -> list[tuple]:
1505
+ """Parse R DESCRIPTION file."""
1506
+ pkg_info: list[tuple] = []
1507
+ in_imports = False
1508
+ in_depends = False
1509
+ for line in file_content.splitlines():
1510
+ if line.startswith("Imports:"):
1511
+ in_imports = True
1512
+ line = line.replace("Imports:", "")
1513
+ elif line.startswith("Depends:"):
1514
+ in_depends = True
1515
+ line = line.replace("Depends:", "")
1516
+ elif line and not line.startswith(" "):
1517
+ in_imports = False
1518
+ in_depends = False
1519
+ continue
1520
+ if in_imports or in_depends:
1521
+ for pkg_str in line.split(","):
1522
+ m = re.match(r"\s*([A-Za-z0-9._]+)\s*(?:\(>=?\s*([0-9][0-9.]*)\))?", pkg_str)
1523
+ if m:
1524
+ pkg_name = m.group(1)
1525
+ if pkg_name.lower() == "r":
1526
+ continue
1527
+ pkg_info.append((pkg_name, m.group(2), None))
1528
+ return pkg_info
1529
+
1530
+ @staticmethod
1531
+ def _parse_renv_lock(file_content: str) -> list[tuple]:
1532
+ """Parse renv.lock file."""
1533
+ pkg_info: list[tuple] = []
1534
+ try:
1535
+ lock_data = json.loads(file_content)
1536
+ packages = lock_data.get("Packages", {})
1537
+ for pkg_name, pkg_data in packages.items():
1538
+ if isinstance(pkg_data, dict):
1539
+ version = pkg_data.get("Version")
1540
+ if version:
1541
+ pkg_info.append((pkg_name, version, None))
1542
+ except Exception:
1543
+ pass
1544
+ return pkg_info
1545
+
1546
+ @staticmethod
1547
+ def _parse_packrat_lock(file_content: str) -> list[tuple]:
1548
+ """Parse packrat.lock file."""
1549
+ pkg_info: list[tuple] = []
1550
+ current_package = None
1551
+ for line in file_content.splitlines():
1552
+ pkg_match = re.match(r"^Package:\s*([A-Za-z0-9._]+)", line)
1553
+ if pkg_match:
1554
+ current_package = pkg_match.group(1)
1555
+ continue
1556
+ if current_package:
1557
+ ver_match = re.match(r"^Version:\s*([0-9][0-9.\-]*)", line)
1558
+ if ver_match:
1559
+ pkg_info.append((current_package, ver_match.group(1), None))
1560
+ current_package = None
1561
+ return pkg_info
1562
+
1563
+ # --- Lua ---
1564
+
1565
+ @staticmethod
1566
+ def _parse_rockspec(file_content: str) -> list[tuple]:
1567
+ """Parse .rockspec file."""
1568
+ pkg_info: list[tuple] = []
1569
+ in_deps = False
1570
+ for line in file_content.splitlines():
1571
+ if "dependencies" in line and "=" in line:
1572
+ in_deps = True
1573
+ continue
1574
+ if in_deps:
1575
+ if line.strip() == "}":
1576
+ break
1577
+ m = re.match(r"""^\s*['"]([A-Za-z0-9_\-]+)\s*[>~]=?\s*([0-9][0-9.]*)['"]""", line)
1578
+ if m:
1579
+ if m.group(1).lower() != "lua":
1580
+ pkg_info.append((m.group(1), m.group(2), None))
1581
+ return pkg_info
1582
+
1583
+ # --- Clojure ---
1584
+
1585
+ @staticmethod
1586
+ def _parse_project_clj(file_content: str) -> list[tuple]:
1587
+ """Parse project.clj file."""
1588
+ pkg_info: list[tuple] = []
1589
+ for line in file_content.splitlines():
1590
+ m = re.match(r'^\s*\[([A-Za-z0-9_\-./]+)\s+"([^"]+)"\]', line)
1591
+ if m:
1592
+ pkg_info.append((m.group(1), m.group(2), None))
1593
+ return pkg_info
1594
+
1595
+ @staticmethod
1596
+ def _parse_deps_edn(file_content: str) -> list[tuple]:
1597
+ """Parse deps.edn file."""
1598
+ pkg_info: list[tuple] = []
1599
+ for line in file_content.splitlines():
1600
+ m = re.match(r'^\s*([A-Za-z0-9_\-./]+)\s+\{[^}]*:mvn/version\s+"([^"]+)"', line)
1601
+ if m:
1602
+ pkg_info.append((m.group(1), m.group(2), None))
1603
+ return pkg_info
1604
+
1605
+ # --- OCaml ---
1606
+
1607
+ @staticmethod
1608
+ def _parse_opam(file_content: str) -> list[tuple]:
1609
+ """Parse .opam or opam file."""
1610
+ pkg_info: list[tuple] = []
1611
+ in_depends = False
1612
+ for line in file_content.splitlines():
1613
+ if line.strip().startswith("depends:"):
1614
+ in_depends = True
1615
+ continue
1616
+ if in_depends:
1617
+ if line.strip() == "]":
1618
+ break
1619
+ m = re.match(r'^\s*"([A-Za-z0-9_\-]+)"\s*\{[^}]*>=?\s*"([^"]+)"', line)
1620
+ if m:
1621
+ if m.group(1) != "ocaml":
1622
+ pkg_info.append((m.group(1), m.group(2), None))
1623
+ return pkg_info
1624
+
1625
+ @staticmethod
1626
+ def _parse_dune_project(file_content: str) -> list[tuple]:
1627
+ """Parse dune-project file."""
1628
+ pkg_info: list[tuple] = []
1629
+ in_depends = False
1630
+ for line in file_content.splitlines():
1631
+ if line.strip().startswith("(depends"):
1632
+ in_depends = True
1633
+ continue
1634
+ if in_depends:
1635
+ if line.strip() == ")" and not line.strip().startswith("("):
1636
+ break
1637
+ m = re.match(r"^\s*\(([A-Za-z0-9_\-]+)\s*\([^)]*>=?\s*([0-9][0-9.]*)", line)
1638
+ if m:
1639
+ if m.group(1) != "ocaml":
1640
+ pkg_info.append((m.group(1), m.group(2), None))
1641
+ return pkg_info
1642
+
1643
+ # --- Nim ---
1644
+
1645
+ @staticmethod
1646
+ def _parse_nimble(file_content: str) -> list[tuple]:
1647
+ """Parse .nimble file."""
1648
+ pkg_info: list[tuple] = []
1649
+ for line in file_content.splitlines():
1650
+ m = re.match(r'^\s*requires\s+"([A-Za-z0-9_\-]+)\s*>=?\s*([0-9][0-9.]*)"', line)
1651
+ if m:
1652
+ if m.group(1).lower() != "nim":
1653
+ pkg_info.append((m.group(1), m.group(2), None))
1654
+ return pkg_info
1655
+
1656
+ # --- Zig ---
1657
+
1658
+ @staticmethod
1659
+ def _parse_build_zig_zon(file_content: str) -> list[tuple]:
1660
+ """Parse build.zig.zon file."""
1661
+ pkg_info: list[tuple] = []
1662
+ in_dependencies = False
1663
+ for line in file_content.splitlines():
1664
+ stripped = line.strip()
1665
+ if ".dependencies" in stripped and "=" in stripped:
1666
+ in_dependencies = True
1667
+ continue
1668
+ if in_dependencies:
1669
+ if stripped in ("},", "}"):
1670
+ in_dependencies = False
1671
+ continue
1672
+ pkg_match = re.match(r"^\.([A-Za-z0-9_\-]+)\s*=", stripped)
1673
+ if pkg_match:
1674
+ pkg_info.append((pkg_match.group(1), None, None))
1675
+ return pkg_info
1676
+
1677
+ # --- Erlang ---
1678
+
1679
+ @staticmethod
1680
+ def _parse_rebar_config(file_content: str) -> list[tuple]:
1681
+ """Parse rebar.config file."""
1682
+ pkg_info: list[tuple] = []
1683
+ for line in file_content.splitlines():
1684
+ m = re.match(r'^\s*\{([A-Za-z0-9_\-]+),\s*"([0-9][0-9.]*)"', line)
1685
+ if m:
1686
+ pkg_info.append((m.group(1), m.group(2), None))
1687
+ return pkg_info
1688
+
1689
+ @staticmethod
1690
+ def _parse_rebar_lock(file_content: str) -> list[tuple]:
1691
+ """Parse rebar.lock file."""
1692
+ pkg_info: list[tuple] = []
1693
+ for line in file_content.splitlines():
1694
+ m = re.match(r'^\s*\{([A-Za-z0-9_\-]+),\s*"([0-9][0-9.]*)"', line)
1695
+ if m:
1696
+ pkg_info.append((m.group(1), m.group(2), None))
1697
+ return pkg_info
1698
+
1699
+ # --- GitHub Actions ---
1700
+
1701
+ @staticmethod
1702
+ def _parse_github_actions_workflow(file_content: str) -> list[tuple]:
1703
+ """Parse GitHub Actions workflow files."""
1704
+ pkg_info: list[tuple] = []
1705
+ for line in file_content.splitlines():
1706
+ m = re.match(r"^\s*uses:\s*([^@\s]+)@([^\s]+)", line)
1707
+ if m:
1708
+ action = m.group(1)
1709
+ version = m.group(2)
1710
+ if re.match(r"^v?\d", version) or version in ("main", "master"):
1711
+ pkg_info.append((action, version, None))
1712
+ return pkg_info
1713
+
1714
+
1715
+ # ═══════════════════════════════════════════════════════════════════════════
1716
+ # SECTION 2 — Source Import Extractors (ported from ExtensionPackageParser)
1717
+ # ═══════════════════════════════════════════════════════════════════════════
1718
+
1719
+
1720
+ class SourceImportExtractor:
1721
+ """
1722
+ Regex-based extraction of external package names from source files,
1723
+ supporting 25+ languages.
1724
+
1725
+ Ported from ``ExtensionPackageParser`` with no Django dependencies.
1726
+ """
1727
+
1728
+ @staticmethod
1729
+ def parse_packages(extension: str, file_content: str) -> list[str]:
1730
+ """Route to the correct language-specific parser by extension."""
1731
+ try:
1732
+ extension = extension.lower()
1733
+ parser_map = {
1734
+ ".py": SourceImportExtractor._parse_python,
1735
+ ".pyx": SourceImportExtractor._parse_python,
1736
+ ".pyi": SourceImportExtractor._parse_python,
1737
+ ".js": SourceImportExtractor._parse_js_ts,
1738
+ ".jsx": SourceImportExtractor._parse_js_ts,
1739
+ ".mjs": SourceImportExtractor._parse_js_ts,
1740
+ ".cjs": SourceImportExtractor._parse_js_ts,
1741
+ ".ts": SourceImportExtractor._parse_js_ts,
1742
+ ".tsx": SourceImportExtractor._parse_js_ts,
1743
+ ".go": SourceImportExtractor._parse_go,
1744
+ ".java": SourceImportExtractor._parse_java,
1745
+ ".php": SourceImportExtractor._parse_php,
1746
+ ".php5": SourceImportExtractor._parse_php,
1747
+ ".phps": SourceImportExtractor._parse_php,
1748
+ ".phtml": SourceImportExtractor._parse_php,
1749
+ ".rb": SourceImportExtractor._parse_ruby,
1750
+ ".rs": SourceImportExtractor._parse_rust,
1751
+ ".cs": SourceImportExtractor._parse_csharp,
1752
+ ".swift": SourceImportExtractor._parse_swift,
1753
+ ".kt": SourceImportExtractor._parse_kotlin,
1754
+ ".kts": SourceImportExtractor._parse_kotlin,
1755
+ ".scala": SourceImportExtractor._parse_scala,
1756
+ ".dart": SourceImportExtractor._parse_dart,
1757
+ ".c": SourceImportExtractor._parse_cpp,
1758
+ ".cc": SourceImportExtractor._parse_cpp,
1759
+ ".cpp": SourceImportExtractor._parse_cpp,
1760
+ ".h": SourceImportExtractor._parse_cpp,
1761
+ ".hpp": SourceImportExtractor._parse_cpp,
1762
+ ".gradle": SourceImportExtractor._parse_gradle,
1763
+ ".ex": SourceImportExtractor._parse_elixir,
1764
+ ".exs": SourceImportExtractor._parse_elixir,
1765
+ ".hs": SourceImportExtractor._parse_haskell,
1766
+ ".r": SourceImportExtractor._parse_r,
1767
+ ".lua": SourceImportExtractor._parse_lua,
1768
+ ".pl": SourceImportExtractor._parse_perl,
1769
+ ".pm": SourceImportExtractor._parse_perl,
1770
+ ".m": SourceImportExtractor._parse_objc,
1771
+ ".mm": SourceImportExtractor._parse_objc,
1772
+ ".cmake": SourceImportExtractor._parse_cmake,
1773
+ }
1774
+ parser_fn = parser_map.get(extension)
1775
+ return parser_fn(file_content) if parser_fn else []
1776
+ except Exception:
1777
+ return []
1778
+
1779
+ # --- Language parsers ---
1780
+
1781
+ @staticmethod
1782
+ def _parse_python(content: str) -> list[str]:
1783
+ imports: set[str] = set()
1784
+ for match in re.finditer(r"^\s*import\s+([a-zA-Z0-9_.]+)", content, re.MULTILINE):
1785
+ root = match.group(1).split(",")[0].strip().split(".")[0]
1786
+ if root and not root.startswith("."):
1787
+ imports.add(root)
1788
+ for match in re.finditer(r"^\s*from\s+([a-zA-Z0-9_.]+)\s+import", content, re.MULTILINE):
1789
+ root = match.group(1).split(".")[0]
1790
+ if root and not root.startswith("."):
1791
+ imports.add(root)
1792
+ return list(imports)
1793
+
1794
+ @staticmethod
1795
+ def _parse_js_ts(content: str) -> list[str]:
1796
+ imports: set[str] = set()
1797
+
1798
+ def _is_internal(pkg: str) -> bool:
1799
+ if pkg.startswith((".", "/", "#")):
1800
+ return True
1801
+ if pkg.startswith("@/") or pkg.startswith("~/"):
1802
+ return True
1803
+ if "://" in pkg:
1804
+ return True
1805
+ return False
1806
+
1807
+ def _root_pkg(pkg: str) -> str | None:
1808
+ if pkg.startswith("@"):
1809
+ parts = pkg.split("/")
1810
+ return f"{parts[0]}/{parts[1]}" if len(parts) >= 2 else None
1811
+ return pkg.split("/")[0]
1812
+
1813
+ for match in re.finditer(r"""require\(["']([^"']+)["']\)""", content):
1814
+ pkg = match.group(1)
1815
+ if not _is_internal(pkg):
1816
+ root = _root_pkg(pkg)
1817
+ if root:
1818
+ imports.add(root)
1819
+
1820
+ for match in re.finditer(r"""import[^;]*?from\s+["']([^"']+)["']""", content):
1821
+ pkg = match.group(1)
1822
+ if not _is_internal(pkg):
1823
+ root = _root_pkg(pkg)
1824
+ if root:
1825
+ imports.add(root)
1826
+
1827
+ for match in re.finditer(r"""import\s+["']([^"']+)["']""", content):
1828
+ pkg = match.group(1)
1829
+ if not _is_internal(pkg):
1830
+ root = _root_pkg(pkg)
1831
+ if root:
1832
+ imports.add(root)
1833
+
1834
+ return list(imports)
1835
+
1836
+ @staticmethod
1837
+ def _root_go_path(path: str) -> str:
1838
+ parts = path.split("/")
1839
+ return "/".join(parts[:3] if len(parts) >= 3 else parts)
1840
+
1841
+ @staticmethod
1842
+ def _parse_go(content: str) -> list[str]:
1843
+ imports: set[str] = set()
1844
+ single_re = re.compile(
1845
+ r'^\s*import\s+(?:[A-Za-z_][A-Za-z0-9_]*\s+)?"([^"]+)"',
1846
+ re.MULTILINE,
1847
+ )
1848
+ for match in single_re.finditer(content):
1849
+ path = match.group(1)
1850
+ if "/" in path:
1851
+ imports.add(SourceImportExtractor._root_go_path(path))
1852
+ else:
1853
+ imports.add(path)
1854
+
1855
+ block_match = re.search(r"^\s*import\s*\((.*?)\)", content, re.DOTALL | re.MULTILINE)
1856
+ if block_match:
1857
+ for raw_line in block_match.group(1).split("\n"):
1858
+ line = raw_line.strip()
1859
+ if not line:
1860
+ continue
1861
+ m = re.match(r'(?:[A-Za-z_][A-Za-z0-9_]*\s+)?"([^"]+)"', line)
1862
+ if not m:
1863
+ continue
1864
+ path = m.group(1)
1865
+ if path.startswith((".", "/")):
1866
+ continue
1867
+ if "/" in path:
1868
+ imports.add(SourceImportExtractor._root_go_path(path))
1869
+ else:
1870
+ imports.add(path)
1871
+ return list(imports)
1872
+
1873
+ @staticmethod
1874
+ def _parse_java(content: str) -> list[str]:
1875
+ imports: set[str] = set()
1876
+ for match in re.finditer(r"^\s*import\s+([a-zA-Z0-9_.]+);", content, re.MULTILINE):
1877
+ root = ".".join(match.group(1).split(".")[:3])
1878
+ imports.add(root)
1879
+ return list(imports)
1880
+
1881
+ @staticmethod
1882
+ def _parse_php(content: str) -> list[str]:
1883
+ imports: set[str] = set()
1884
+ for match in re.finditer(r"^\s*use\s+([A-Za-z0-9_\\\\]+)", content, re.MULTILINE):
1885
+ vendor = match.group(1).strip("\\").split("\\")[0]
1886
+ if vendor:
1887
+ imports.add(vendor)
1888
+ for match in re.finditer(r"""\b(?:require|include)(?:_once)?\s*\(\s*["']([^"']+)["']""", content):
1889
+ path = match.group(1)
1890
+ if not path.startswith((".", "/")) and "/" in path:
1891
+ imports.add(path.split("/")[0])
1892
+ return list(imports)
1893
+
1894
+ @staticmethod
1895
+ def _parse_ruby(content: str) -> list[str]:
1896
+ imports: set[str] = set()
1897
+ for match in re.finditer(r"""^\s*require\s+["']([^"']+)["']""", content, re.MULTILINE):
1898
+ gem = match.group(1)
1899
+ if not gem.startswith("."):
1900
+ imports.add(gem.split("/")[-1])
1901
+ return list(imports)
1902
+
1903
+ @staticmethod
1904
+ def _parse_rust(content: str) -> list[str]:
1905
+ imports: set[str] = set()
1906
+ for match in re.finditer(r"^\s*extern\s+crate\s+([A-Za-z0-9_]+);", content, re.MULTILINE):
1907
+ imports.add(match.group(1))
1908
+ for match in re.finditer(r"^\s*use\s+([A-Za-z0-9_]+)::", content, re.MULTILINE):
1909
+ imports.add(match.group(1))
1910
+ return list(imports)
1911
+
1912
+ @staticmethod
1913
+ def _parse_csharp(content: str) -> list[str]:
1914
+ imports: set[str] = set()
1915
+ for match in re.finditer(r"^\s*using\s+([A-Za-z0-9_.]+);", content, re.MULTILINE):
1916
+ root = match.group(1).split(".")[0]
1917
+ if root:
1918
+ imports.add(root)
1919
+ return list(imports)
1920
+
1921
+ @staticmethod
1922
+ def _parse_swift(content: str) -> list[str]:
1923
+ imports: set[str] = set()
1924
+ for match in re.finditer(r"^\s*import\s+([A-Za-z0-9_]+)", content, re.MULTILINE):
1925
+ imports.add(match.group(1))
1926
+ return list(imports)
1927
+
1928
+ @staticmethod
1929
+ def _parse_kotlin(content: str) -> list[str]:
1930
+ imports: set[str] = set()
1931
+ for match in re.finditer(r"^\s*import\s+([a-zA-Z0-9_.]+)", content, re.MULTILINE):
1932
+ root = ".".join(match.group(1).split(".")[:3])
1933
+ imports.add(root)
1934
+ return list(imports)
1935
+
1936
+ @staticmethod
1937
+ def _parse_scala(content: str) -> list[str]:
1938
+ imports: set[str] = set()
1939
+ for match in re.finditer(r"^\s*import\s+([a-zA-Z0-9_.]+)", content, re.MULTILINE):
1940
+ root = ".".join(match.group(1).split(".")[:3])
1941
+ imports.add(root)
1942
+ return list(imports)
1943
+
1944
+ @staticmethod
1945
+ def _parse_dart(content: str) -> list[str]:
1946
+ imports: set[str] = set()
1947
+ for match in re.finditer(r"""\bimport\s+["']package:([^/'"]+)""", content):
1948
+ imports.add(match.group(1))
1949
+ return list(imports)
1950
+
1951
+ @staticmethod
1952
+ def _parse_cpp(content: str) -> list[str]:
1953
+ imports: set[str] = set()
1954
+ for match in re.finditer(r'^\s*#include\s+[<"]([^">]+)[">]', content, re.MULTILINE):
1955
+ header = match.group(1)
1956
+ if "/" in header:
1957
+ imports.add(header.split("/")[0])
1958
+ else:
1959
+ imports.add(header.split(".")[0])
1960
+ return list(imports)
1961
+
1962
+ @staticmethod
1963
+ def _parse_gradle(content: str) -> list[str]:
1964
+ imports: set[str] = set()
1965
+ for match in re.finditer(
1966
+ r"""\b(?:implementation|api|compile|compileOnly|testImplementation|runtimeOnly|kapt)\s+['"]([^:'"]+):([^:'"]+):[^'"]+['"]""",
1967
+ content,
1968
+ ):
1969
+ imports.add(match.group(2))
1970
+ return list(imports)
1971
+
1972
+ @staticmethod
1973
+ def _parse_elixir(content: str) -> list[str]:
1974
+ imports: set[str] = set()
1975
+ for match in re.finditer(r"\{\s*:(\w+)\s*,", content):
1976
+ imports.add(match.group(1))
1977
+ return list(imports)
1978
+
1979
+ @staticmethod
1980
+ def _parse_haskell(content: str) -> list[str]:
1981
+ imports: set[str] = set()
1982
+ for match in re.finditer(r"^\s*import\s+(?:qualified\s+)?([A-Z][A-Za-z0-9_.]+)", content, re.MULTILINE):
1983
+ imports.add(match.group(1).split(".")[0])
1984
+ return list(imports)
1985
+
1986
+ @staticmethod
1987
+ def _parse_r(content: str) -> list[str]:
1988
+ imports: set[str] = set()
1989
+ for match in re.finditer(r"""\b(?:library|require)\s*\(\s*["']?([A-Za-z0-9_.]+)["']?\s*\)""", content):
1990
+ imports.add(match.group(1))
1991
+ return list(imports)
1992
+
1993
+ @staticmethod
1994
+ def _parse_lua(content: str) -> list[str]:
1995
+ imports: set[str] = set()
1996
+ for match in re.finditer(r"""\brequire\s*\(?\s*["']([^"']+)["']""", content):
1997
+ module = match.group(1)
1998
+ if not module.startswith((".", "/")):
1999
+ imports.add(module.split(".")[0])
2000
+ return list(imports)
2001
+
2002
+ @staticmethod
2003
+ def _parse_perl(content: str) -> list[str]:
2004
+ imports: set[str] = set()
2005
+ for match in re.finditer(r"^\s*use\s+([A-Za-z0-9_:]+)", content, re.MULTILINE):
2006
+ imports.add(match.group(1))
2007
+ return list(imports)
2008
+
2009
+ @staticmethod
2010
+ def _parse_objc(content: str) -> list[str]:
2011
+ imports: set[str] = set()
2012
+ for match in re.finditer(r'^\s*#import\s+[<"]([^">]+)[">]', content, re.MULTILINE):
2013
+ header = match.group(1)
2014
+ if "/" in header:
2015
+ imports.add(header.split("/")[0])
2016
+ else:
2017
+ imports.add(header.split(".")[0])
2018
+ return list(imports)
2019
+
2020
+ @staticmethod
2021
+ def _parse_cmake(content: str) -> list[str]:
2022
+ imports: set[str] = set()
2023
+ for match in re.finditer(r"\bfind_package\s*\(\s*([A-Za-z0-9_]+)", content):
2024
+ imports.add(match.group(1))
2025
+ return list(imports)
2026
+
2027
+
2028
+ # ═══════════════════════════════════════════════════════════════════════════
2029
+ # SECTION 3 — Vulnerability Scanner (ported from vulnerability_scanner.py)
2030
+ # ═══════════════════════════════════════════════════════════════════════════
2031
+
2032
+ # Default empty-result template
2033
+ _EMPTY_VULN: dict[str, object] = {
2034
+ "vulnerability_count": 0,
2035
+ "critical_vulnerabilities": 0,
2036
+ "high_vulnerabilities": 0,
2037
+ "medium_vulnerabilities": 0,
2038
+ "low_vulnerabilities": 0,
2039
+ "vulnerability_summary": "No known vulnerabilities",
2040
+ "latest_vulnerability_date": None,
2041
+ "vulnerability_ids": "",
2042
+ "vulnerability_details": "",
2043
+ }
2044
+
2045
+
2046
+ def _process_osv_response(response_data: dict) -> dict:
2047
+ """Parse an OSV single-package response into a structured vuln summary."""
2048
+ if not response_data or "vulns" not in response_data:
2049
+ return dict(_EMPTY_VULN)
2050
+
2051
+ vulns = response_data["vulns"]
2052
+ vulnerability_count = len(vulns)
2053
+ critical_count = high_count = medium_count = low_count = 0
2054
+ vuln_ids: list[str] = []
2055
+ vuln_details: list[str] = []
2056
+ latest_date: datetime | None = None
2057
+
2058
+ for vuln in vulns:
2059
+ vuln_id = vuln.get("id", "")
2060
+ vuln_ids.append(vuln_id)
2061
+
2062
+ summary = vuln.get("summary", "")
2063
+ if summary:
2064
+ vuln_details.append(f"{vuln_id}: {summary[:200]}")
2065
+
2066
+ published = vuln.get("published")
2067
+ if published:
2068
+ try:
2069
+ vuln_date = datetime.fromisoformat(published.replace("Z", "+00:00"))
2070
+ if latest_date is None or vuln_date > latest_date:
2071
+ latest_date = vuln_date
2072
+ except Exception:
2073
+ pass
2074
+
2075
+ severity = None
2076
+ if "severity" in vuln:
2077
+ for sev_entry in vuln["severity"]:
2078
+ if sev_entry.get("type") == "CVSS_V3":
2079
+ score = sev_entry.get("score")
2080
+ if score:
2081
+ try:
2082
+ cvss = float(score)
2083
+ if cvss >= 9.0:
2084
+ severity = "CRITICAL"
2085
+ elif cvss >= 7.0:
2086
+ severity = "HIGH"
2087
+ elif cvss >= 4.0:
2088
+ severity = "MEDIUM"
2089
+ else:
2090
+ severity = "LOW"
2091
+ break
2092
+ except ValueError:
2093
+ pass
2094
+
2095
+ if not severity and "database_specific" in vuln:
2096
+ db_sev = vuln["database_specific"].get("severity", "").upper()
2097
+ if db_sev in ("CRITICAL", "HIGH", "MEDIUM", "LOW"):
2098
+ severity = db_sev
2099
+
2100
+ if severity == "CRITICAL":
2101
+ critical_count += 1
2102
+ elif severity == "HIGH":
2103
+ high_count += 1
2104
+ elif severity == "MEDIUM":
2105
+ medium_count += 1
2106
+ elif severity == "LOW":
2107
+ low_count += 1
2108
+ else:
2109
+ medium_count += 1
2110
+
2111
+ if critical_count > 0:
2112
+ summary_str = f"{critical_count} critical, {high_count} high severity vulnerabilities found"
2113
+ elif high_count > 0:
2114
+ summary_str = f"{high_count} high, {medium_count} medium severity vulnerabilities found"
2115
+ elif medium_count > 0:
2116
+ summary_str = f"{medium_count} medium, {low_count} low severity vulnerabilities found"
2117
+ elif low_count > 0:
2118
+ summary_str = f"{low_count} low severity vulnerabilities found"
2119
+ else:
2120
+ summary_str = f"{vulnerability_count} vulnerabilities found"
2121
+
2122
+ return {
2123
+ "vulnerability_count": vulnerability_count,
2124
+ "critical_vulnerabilities": critical_count,
2125
+ "high_vulnerabilities": high_count,
2126
+ "medium_vulnerabilities": medium_count,
2127
+ "low_vulnerabilities": low_count,
2128
+ "vulnerability_summary": summary_str,
2129
+ "latest_vulnerability_date": latest_date.isoformat() if latest_date else None,
2130
+ "vulnerability_ids": ", ".join(vuln_ids[:10]) + ("..." if len(vuln_ids) > 10 else ""),
2131
+ "vulnerability_details": " | ".join(vuln_details[:5]) + ("..." if len(vuln_details) > 5 else ""),
2132
+ }
2133
+
2134
+
2135
+ class VulnerabilityScanner:
2136
+ """
2137
+ Query the OSV public API for known vulnerabilities.
2138
+
2139
+ Uses ``httpx`` for HTTP calls (replacing server-side ``requests``).
2140
+ All configuration (URLs, retries, ecosystem mappings) is supplied
2141
+ via the *config* dict fetched from the server.
2142
+ """
2143
+
2144
+ def __init__(self, config: dict, on_debug: Callable[[str], None] | None = None):
2145
+ self._osv_api_url = config.get("osv_api_url", "https://api.osv.dev/v1/query")
2146
+ self._osv_batch_url = config.get("osv_batch_url", "https://api.osv.dev/v1/querybatch")
2147
+ self._max_retries = config.get("max_retries", 3)
2148
+ self._retry_backoff = config.get("retry_backoff", 1.5)
2149
+ self._ecosystem_map: dict[str, str] = config.get("osv_ecosystem_map", {})
2150
+ self._prefix_to_registry: dict[str, str] = config.get("prefix_to_registry", {})
2151
+ self._debug = on_debug or (lambda _: None)
2152
+
2153
+ def _http_post_json(self, url: str, data: dict) -> dict:
2154
+ """HTTP POST with retry and exponential back-off."""
2155
+ attempt = 0
2156
+ while attempt < self._max_retries:
2157
+ try:
2158
+ resp = httpx.post(url, json=data, timeout=30.0)
2159
+ if resp.status_code == 200:
2160
+ return resp.json()
2161
+ except Exception as exc:
2162
+ logger.debug("HTTP POST failed for %s (attempt %d): %s", url, attempt + 1, exc)
2163
+ attempt += 1
2164
+ if attempt < self._max_retries:
2165
+ time.sleep(self._retry_backoff * (2 ** (attempt - 1)))
2166
+ return {}
2167
+
2168
+ def _strip_prefix(self, package_name: str) -> tuple[str, str]:
2169
+ """Remove our internal registry prefix and return (bare_name, registry)."""
2170
+ for prefix, registry in self._prefix_to_registry.items():
2171
+ if package_name.startswith(prefix):
2172
+ return package_name[len(prefix) :], registry
2173
+ return package_name, "unknown"
2174
+
2175
+ def scan_batch(self, packages: list[dict]) -> dict[str, dict]:
2176
+ """
2177
+ Scan a list of packages for vulnerabilities via OSV batch API.
2178
+
2179
+ Each entry in *packages* must have ``prefixed_name`` and optionally
2180
+ ``version``. Returns a dict keyed by ``prefixed_name``.
2181
+ """
2182
+ results: dict[str, dict] = {}
2183
+ if not packages:
2184
+ return results
2185
+
2186
+ # Build batch queries grouped by ecosystem
2187
+ queries: list[dict] = []
2188
+ query_keys: list[str] = []
2189
+
2190
+ for pkg in packages:
2191
+ prefixed = pkg.get("prefixed_name", "")
2192
+ version = pkg.get("version")
2193
+ bare_name, registry = self._strip_prefix(prefixed)
2194
+ ecosystem = self._ecosystem_map.get(registry)
2195
+
2196
+ if not ecosystem:
2197
+ results[prefixed] = dict(_EMPTY_VULN)
2198
+ continue
2199
+
2200
+ query: dict = {"package": {"name": bare_name, "ecosystem": ecosystem}}
2201
+ if version and version.strip() and version != "latest":
2202
+ query["version"] = version.strip()
2203
+
2204
+ queries.append(query)
2205
+ query_keys.append(prefixed)
2206
+
2207
+ if not queries:
2208
+ return results
2209
+
2210
+ # OSV batch API accepts up to 1000 queries per request
2211
+ batch_size = 1000
2212
+ for i in range(0, len(queries), batch_size):
2213
+ batch_queries = queries[i : i + batch_size]
2214
+ batch_keys = query_keys[i : i + batch_size]
2215
+
2216
+ self._debug(f"Querying OSV for {len(batch_queries)} packages (batch {i // batch_size + 1})")
2217
+
2218
+ batch_data = {"queries": batch_queries}
2219
+ resp_data = self._http_post_json(self._osv_batch_url, batch_data)
2220
+
2221
+ if not resp_data or "results" not in resp_data:
2222
+ for key in batch_keys:
2223
+ results[key] = dict(_EMPTY_VULN)
2224
+ results[key]["vulnerability_summary"] = "Batch query failed"
2225
+ continue
2226
+
2227
+ for idx, raw_result in enumerate(resp_data["results"]):
2228
+ key = batch_keys[idx]
2229
+ if raw_result and "vulns" in raw_result:
2230
+ results[key] = _process_osv_response(raw_result)
2231
+ else:
2232
+ results[key] = dict(_EMPTY_VULN)
2233
+
2234
+ # Brief pause between batches to respect rate limits
2235
+ if i + batch_size < len(queries):
2236
+ time.sleep(0.5)
2237
+
2238
+ return results
2239
+
2240
+
2241
+ # ═══════════════════════════════════════════════════════════════════════════
2242
+ # SECTION 4 — Orchestrator (LocalDependencyScanner)
2243
+ # ═══════════════════════════════════════════════════════════════════════════
2244
+
2245
+
2246
+ class LocalDependencyScanner:
2247
+ """
2248
+ High-level scanner that orchestrates manifest parsing, source-import
2249
+ extraction, and OSV vulnerability scanning — all running locally.
2250
+
2251
+ ``config`` is fetched from the server's hidden-config endpoint
2252
+ (``GET /api/cli/audit/dependency-config/``) and contains registry
2253
+ mappings, OSV settings, etc.
2254
+ """
2255
+
2256
+ def __init__(
2257
+ self,
2258
+ config: dict,
2259
+ on_debug: Callable[[str], None] | None = None,
2260
+ on_progress: Callable[[str], None] | None = None,
2261
+ ):
2262
+ self._config = config
2263
+ self._registry_prefix_map: dict[str, str] = config.get("registry_prefix_map", {})
2264
+ self._dep_file_prefixes: dict[str, str] = config.get("dependency_file_prefixes", {})
2265
+ self._debug = on_debug or (lambda _: None)
2266
+ self._progress = on_progress or (lambda _: None)
2267
+ self._vuln_scanner = VulnerabilityScanner(
2268
+ config=config.get("vuln_scan", {}),
2269
+ on_debug=on_debug,
2270
+ )
2271
+
2272
+ # ----- Manifest scanning -----
2273
+
2274
+ def scan_manifests(
2275
+ self,
2276
+ scope_dirs: dict[str, str],
2277
+ ) -> list[ManifestResult]:
2278
+ """
2279
+ Walk local directories, find dependency manifest files, and parse them.
2280
+
2281
+ Args:
2282
+ scope_dirs: Mapping of ``repo_name → local_path``.
2283
+
2284
+ Returns:
2285
+ One ``ManifestResult`` per manifest file found.
2286
+ """
2287
+ results: list[ManifestResult] = []
2288
+
2289
+ for repo_name, local_path in scope_dirs.items():
2290
+ if not local_path or not os.path.isdir(local_path):
2291
+ self._debug(f"Skipping repo '{repo_name}': directory not found")
2292
+ continue
2293
+
2294
+ manifest_paths: list[str] = []
2295
+ for root, _dirs, files in os.walk(local_path):
2296
+ # Skip common non-project directories
2297
+ rel_root = os.path.relpath(root, local_path)
2298
+ skip_dirs = {"node_modules", ".git", "__pycache__", ".tox", "venv", ".venv", "vendor", "dist", "build"}
2299
+ if any(part in skip_dirs for part in rel_root.split(os.sep)):
2300
+ continue
2301
+ for fname in files:
2302
+ abs_path = os.path.join(root, fname)
2303
+ rel_path = os.path.relpath(abs_path, local_path)
2304
+ if ManifestParser.is_dependency_file(rel_path):
2305
+ manifest_paths.append(abs_path)
2306
+
2307
+ # Filter duplicates (prefer lock files)
2308
+ manifest_paths = ManifestParser.filter_duplicate_dependency_files(manifest_paths)
2309
+ self._debug(f"Found {len(manifest_paths)} manifest file(s) in '{repo_name}'")
2310
+
2311
+ for abs_path in manifest_paths:
2312
+ rel_path = os.path.relpath(abs_path, local_path)
2313
+ try:
2314
+ with open(abs_path, encoding="utf-8", errors="replace") as fh:
2315
+ content = fh.read()
2316
+
2317
+ packages = ManifestParser.parse_dependency_file(rel_path, content)
2318
+ registry = self._get_registry_for_manifest(rel_path)
2319
+
2320
+ pkg_dicts = [
2321
+ {"name": name, "version": ver, "latest_version": latest} for name, ver, latest in packages
2322
+ ]
2323
+
2324
+ results.append(
2325
+ ManifestResult(
2326
+ manifest_path=rel_path,
2327
+ repo_name=repo_name,
2328
+ registry=registry,
2329
+ packages=pkg_dicts,
2330
+ )
2331
+ )
2332
+ self._debug(f" {rel_path}: {len(pkg_dicts)} packages ({registry})")
2333
+
2334
+ except Exception as exc:
2335
+ results.append(
2336
+ ManifestResult(
2337
+ manifest_path=rel_path,
2338
+ repo_name=repo_name,
2339
+ registry="unknown",
2340
+ packages=[],
2341
+ error=str(exc),
2342
+ )
2343
+ )
2344
+
2345
+ return results
2346
+
2347
+ # ----- Source-import extraction -----
2348
+
2349
+ def scan_source_imports(
2350
+ self,
2351
+ files: list[dict],
2352
+ scope_dirs: dict[str, str],
2353
+ ) -> list[ImportResult]:
2354
+ """
2355
+ Extract external package names from source file imports.
2356
+
2357
+ Args:
2358
+ files: File dicts from the audit plan (must include
2359
+ ``file_path``, ``relative_path``, ``repo_name``).
2360
+ scope_dirs: Mapping of ``repo_name → local_path``.
2361
+
2362
+ Returns:
2363
+ One ``ImportResult`` per file that yielded imports.
2364
+ """
2365
+ results: list[ImportResult] = []
2366
+
2367
+ for f in files:
2368
+ file_path = f.get("file_path", "")
2369
+ rel_path = f.get("relative_path", "")
2370
+ repo_name = f.get("repo_name", "")
2371
+ local_root = scope_dirs.get(repo_name, "")
2372
+
2373
+ if not local_root:
2374
+ continue
2375
+
2376
+ _, ext = os.path.splitext(rel_path)
2377
+ if not ext:
2378
+ continue
2379
+
2380
+ # Get registry prefix for this extension from server config
2381
+ registry_prefix = self._registry_prefix_map.get(ext.lower(), "")
2382
+ if not registry_prefix:
2383
+ continue
2384
+
2385
+ abs_path = os.path.join(local_root, rel_path)
2386
+ if not os.path.isfile(abs_path):
2387
+ continue
2388
+
2389
+ try:
2390
+ with open(abs_path, encoding="utf-8", errors="replace") as fh:
2391
+ content = fh.read()
2392
+
2393
+ packages = SourceImportExtractor.parse_packages(ext, content)
2394
+ if packages:
2395
+ results.append(
2396
+ ImportResult(
2397
+ file_path=file_path,
2398
+ registry_prefix=registry_prefix,
2399
+ packages=packages,
2400
+ )
2401
+ )
2402
+ except Exception as exc:
2403
+ results.append(
2404
+ ImportResult(
2405
+ file_path=file_path,
2406
+ registry_prefix=registry_prefix,
2407
+ packages=[],
2408
+ error=str(exc),
2409
+ )
2410
+ )
2411
+
2412
+ if results:
2413
+ total_pkgs = sum(len(r.packages) for r in results)
2414
+ self._debug(f"Extracted imports from {len(results)} file(s) ({total_pkgs} packages total)")
2415
+
2416
+ return results
2417
+
2418
+ # ----- Vulnerability scanning -----
2419
+
2420
+ def scan_vulnerabilities(
2421
+ self,
2422
+ manifest_results: list[ManifestResult],
2423
+ import_results: list[ImportResult],
2424
+ ) -> dict[str, dict]:
2425
+ """
2426
+ Query OSV for vulnerabilities of all discovered packages.
2427
+
2428
+ Returns a dict keyed by prefixed package name (e.g.
2429
+ ``"$!npm$!_react"``) with vulnerability summary dicts.
2430
+ """
2431
+ # Collect unique (prefixed_name, version) pairs
2432
+ seen: set[str] = set()
2433
+ queries: list[dict] = []
2434
+
2435
+ for mr in manifest_results:
2436
+ # Map registry to prefix
2437
+ prefix = ""
2438
+ for ext_prefix, reg in (self._config.get("prefix_to_registry") or {}).items():
2439
+ if reg == mr.registry:
2440
+ prefix = ext_prefix
2441
+ break
2442
+ if not prefix:
2443
+ # Construct from registry name
2444
+ prefix = f"$!{mr.registry}$!_"
2445
+
2446
+ for pkg in mr.packages:
2447
+ prefixed = f"{prefix}{pkg['name']}"
2448
+ if prefixed not in seen:
2449
+ seen.add(prefixed)
2450
+ queries.append(
2451
+ {
2452
+ "prefixed_name": prefixed,
2453
+ "version": pkg.get("version"),
2454
+ }
2455
+ )
2456
+
2457
+ for ir in import_results:
2458
+ for pkg_name in ir.packages:
2459
+ prefixed = f"{ir.registry_prefix}{pkg_name}"
2460
+ if prefixed not in seen:
2461
+ seen.add(prefixed)
2462
+ queries.append({"prefixed_name": prefixed, "version": None})
2463
+
2464
+ self._debug(f"Scanning {len(queries)} unique packages for vulnerabilities")
2465
+ return self._vuln_scanner.scan_batch(queries)
2466
+
2467
+ # ----- Helpers -----
2468
+
2469
+ def _get_registry_for_manifest(self, rel_path: str) -> str:
2470
+ """Determine the package registry for a manifest file path."""
2471
+ filename = os.path.basename(rel_path)
2472
+ filename_lower = filename.lower()
2473
+
2474
+ # Check server-provided mapping first
2475
+ if filename in self._dep_file_prefixes:
2476
+ return self._dep_file_prefixes[filename].lstrip("!")
2477
+ if filename_lower in self._dep_file_prefixes:
2478
+ return self._dep_file_prefixes[filename_lower].lstrip("!")
2479
+
2480
+ # Fallback: check path patterns
2481
+ for pattern, prefix in self._dep_file_prefixes.items():
2482
+ if rel_path.endswith(pattern):
2483
+ return prefix.lstrip("!")
2484
+
2485
+ return "unknown"