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