java-dependency-analyzer 1.0.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 (31) hide show
  1. java_dependency_analyzer/__init__.py +11 -0
  2. java_dependency_analyzer/cache/__init__.py +11 -0
  3. java_dependency_analyzer/cache/db.py +101 -0
  4. java_dependency_analyzer/cache/vulnerability_cache.py +156 -0
  5. java_dependency_analyzer/cli.py +394 -0
  6. java_dependency_analyzer/models/__init__.py +11 -0
  7. java_dependency_analyzer/models/dependency.py +80 -0
  8. java_dependency_analyzer/models/report.py +108 -0
  9. java_dependency_analyzer/parsers/__init__.py +11 -0
  10. java_dependency_analyzer/parsers/base.py +150 -0
  11. java_dependency_analyzer/parsers/gradle_dep_tree_parser.py +125 -0
  12. java_dependency_analyzer/parsers/gradle_parser.py +206 -0
  13. java_dependency_analyzer/parsers/maven_dep_tree_parser.py +123 -0
  14. java_dependency_analyzer/parsers/maven_parser.py +182 -0
  15. java_dependency_analyzer/reporters/__init__.py +11 -0
  16. java_dependency_analyzer/reporters/base.py +33 -0
  17. java_dependency_analyzer/reporters/html_reporter.py +82 -0
  18. java_dependency_analyzer/reporters/json_reporter.py +52 -0
  19. java_dependency_analyzer/reporters/templates/report.html +406 -0
  20. java_dependency_analyzer/resolvers/__init__.py +11 -0
  21. java_dependency_analyzer/resolvers/transitive.py +276 -0
  22. java_dependency_analyzer/scanners/__init__.py +11 -0
  23. java_dependency_analyzer/scanners/base.py +102 -0
  24. java_dependency_analyzer/scanners/ghsa_scanner.py +204 -0
  25. java_dependency_analyzer/scanners/osv_scanner.py +167 -0
  26. java_dependency_analyzer/util/__init__.py +11 -0
  27. java_dependency_analyzer/util/logger.py +48 -0
  28. java_dependency_analyzer-1.0.0.dist-info/METADATA +193 -0
  29. java_dependency_analyzer-1.0.0.dist-info/RECORD +31 -0
  30. java_dependency_analyzer-1.0.0.dist-info/WHEEL +4 -0
  31. java_dependency_analyzer-1.0.0.dist-info/entry_points.txt +3 -0
@@ -0,0 +1,80 @@
1
+ """
2
+ dependency module.
3
+
4
+ Defines the core data models for dependencies and vulnerabilities.
5
+
6
+ :author: Ron Webb
7
+ :since: 1.0.0
8
+ """
9
+
10
+ from dataclasses import dataclass, field
11
+
12
+ __author__ = "Ron Webb"
13
+ __since__ = "1.0.0"
14
+
15
+
16
+ @dataclass
17
+ class Vulnerability:
18
+ """
19
+ Represents a single vulnerability found in a dependency.
20
+
21
+ :author: Ron Webb
22
+ :since: 1.0.0
23
+ """
24
+
25
+ cve_id: str
26
+ summary: str
27
+ severity: str
28
+ affected_versions: list[str] = field(default_factory=list)
29
+ source: str = "osv"
30
+ reference_url: str = ""
31
+
32
+
33
+ @dataclass
34
+ class Dependency:
35
+ """
36
+ Represents a Java dependency with group, artifact, and version coordinates.
37
+
38
+ :author: Ron Webb
39
+ :since: 1.0.0
40
+ """
41
+
42
+ group_id: str
43
+ artifact_id: str
44
+ version: str
45
+ scope: str = "compile"
46
+ depth: int = 0
47
+ transitive_dependencies: list["Dependency"] = field(default_factory=list)
48
+ vulnerabilities: list[Vulnerability] = field(default_factory=list)
49
+
50
+ @property
51
+ def coordinates(self) -> str:
52
+ """
53
+ Return Maven coordinates string in group:artifact:version format.
54
+
55
+ :author: Ron Webb
56
+ :since: 1.0.0
57
+ """
58
+ return f"{self.group_id}:{self.artifact_id}:{self.version}"
59
+
60
+ @property
61
+ def maven_path(self) -> str:
62
+ """
63
+ Return the relative path to this artifact in a Maven repository.
64
+
65
+ :author: Ron Webb
66
+ :since: 1.0.0
67
+ """
68
+ group_path = self.group_id.replace(".", "/")
69
+ return f"{group_path}/{self.artifact_id}/{self.version}"
70
+
71
+ def has_vulnerabilities(self) -> bool:
72
+ """
73
+ Return True if this dependency or any transitive dependency has vulnerabilities.
74
+
75
+ :author: Ron Webb
76
+ :since: 1.0.0
77
+ """
78
+ if self.vulnerabilities:
79
+ return True
80
+ return any(dep.has_vulnerabilities() for dep in self.transitive_dependencies)
@@ -0,0 +1,108 @@
1
+ """
2
+ report module.
3
+
4
+ Defines the ScanResult model for aggregating vulnerability scan findings.
5
+
6
+ :author: Ron Webb
7
+ :since: 1.0.0
8
+ """
9
+
10
+ from dataclasses import dataclass, field
11
+ from datetime import datetime
12
+
13
+ from .dependency import Dependency
14
+
15
+ __author__ = "Ron Webb"
16
+ __since__ = "1.0.0"
17
+
18
+
19
+ @dataclass
20
+ class ScanResult:
21
+ """
22
+ Aggregates all findings from a dependency vulnerability scan.
23
+
24
+ :author: Ron Webb
25
+ :since: 1.0.0
26
+ """
27
+
28
+ source_file: str
29
+ scanned_at: str = field(default_factory=lambda: datetime.now().isoformat())
30
+ dependencies: list[Dependency] = field(default_factory=list)
31
+
32
+ @property
33
+ def total_dependencies(self) -> int:
34
+ """
35
+ Return the total count of all direct and transitive dependencies.
36
+
37
+ :author: Ron Webb
38
+ :since: 1.0.0
39
+ """
40
+ return self._count_dependencies(self.dependencies)
41
+
42
+ @property
43
+ def vulnerable_dependencies(self) -> list[Dependency]:
44
+ """
45
+ Return a deduplicated flat list of all dependencies that have vulnerabilities.
46
+
47
+ Two nodes with identical (group_id, artifact_id, version) are treated as the
48
+ same dependency regardless of where they appear in the tree.
49
+
50
+ :author: Ron Webb
51
+ :since: 1.0.0
52
+ """
53
+ result: list[Dependency] = []
54
+ seen: set[tuple[str, str, str]] = set()
55
+ self._collect_vulnerable(self.dependencies, result, seen)
56
+ return result
57
+
58
+ @property
59
+ def total_vulnerabilities(self) -> int:
60
+ """
61
+ Return the total number of individual vulnerabilities found across unique dependencies.
62
+
63
+ :author: Ron Webb
64
+ :since: 1.0.0
65
+ """
66
+ return sum(len(dep.vulnerabilities) for dep in self.vulnerable_dependencies)
67
+
68
+ def _count_dependencies(self, deps: list[Dependency]) -> int:
69
+ """
70
+ Recursively count all dependencies in the tree.
71
+
72
+ :author: Ron Webb
73
+ :since: 1.0.0
74
+ """
75
+ count = len(deps)
76
+ for dep in deps:
77
+ count += self._count_dependencies(dep.transitive_dependencies)
78
+ return count
79
+
80
+ @staticmethod
81
+ def _dep_key(dep: Dependency) -> tuple[str, str, str]:
82
+ """
83
+ Return a deduplication key for a dependency based on its coordinates.
84
+
85
+ :author: Ron Webb
86
+ :since: 1.0.0
87
+ """
88
+ return (dep.group_id, dep.artifact_id, dep.version)
89
+
90
+ def _collect_vulnerable(
91
+ self,
92
+ deps: list[Dependency],
93
+ result: list[Dependency],
94
+ seen: set[tuple[str, str, str]],
95
+ ) -> None:
96
+ """
97
+ Recursively collect unique dependencies that have vulnerabilities.
98
+
99
+ :author: Ron Webb
100
+ :since: 1.0.0
101
+ """
102
+ for dep in deps:
103
+ if dep.vulnerabilities:
104
+ key = self._dep_key(dep)
105
+ if key not in seen:
106
+ seen.add(key)
107
+ result.append(dep)
108
+ self._collect_vulnerable(dep.transitive_dependencies, result, seen)
@@ -0,0 +1,11 @@
1
+ """
2
+ parsers package.
3
+
4
+ Provides dependency file parsers for Maven and Gradle build files.
5
+
6
+ :author: Ron Webb
7
+ :since: 1.0.0
8
+ """
9
+
10
+ __author__ = "Ron Webb"
11
+ __since__ = "1.0.0"
@@ -0,0 +1,150 @@
1
+ """
2
+ base module.
3
+
4
+ Defines the abstract base class for dependency file parsers and shared
5
+ tree-building utilities.
6
+
7
+ :author: Ron Webb
8
+ :since: 1.0.0
9
+ """
10
+
11
+ from abc import ABC, abstractmethod
12
+ from collections.abc import Callable
13
+ from pathlib import Path
14
+
15
+ from ..models.dependency import Dependency
16
+ from ..util.logger import setup_logger
17
+
18
+ __author__ = "Ron Webb"
19
+ __since__ = "1.0.0"
20
+
21
+ _logger = setup_logger(__name__)
22
+
23
+ # Runtime scopes that contribute to the executable classpath
24
+ RUNTIME_SCOPES = frozenset({"compile", "runtime", "implementation", "api", "runtimeOnly"})
25
+
26
+
27
+ def attach_node(
28
+ dep: Dependency,
29
+ depth: int,
30
+ is_leaf: bool,
31
+ roots: list[Dependency],
32
+ stack: list[Dependency | None],
33
+ ) -> None:
34
+ """
35
+ Attach *dep* to the correct position in the dependency tree represented
36
+ by *roots* (top-level nodes) and *stack* (depth-indexed parent cursor).
37
+
38
+ *stack[d]* holds the :class:`Dependency` that is the current parent at
39
+ depth *d*. After attaching *dep*, the stack is updated so that
40
+ ``stack[depth]`` points to *dep* (or ``None`` if *dep* is a leaf).
41
+
42
+ :author: Ron Webb
43
+ :since: 1.0.0
44
+ """
45
+ if depth == 0:
46
+ roots.append(dep)
47
+ else:
48
+ parent_depth = depth - 1
49
+ if parent_depth < len(stack) and stack[parent_depth] is not None:
50
+ stack[parent_depth].transitive_dependencies.append(dep) # type: ignore[union-attr]
51
+
52
+ new_entry: Dependency | None = None if is_leaf else dep
53
+ if depth < len(stack):
54
+ stack[depth] = new_entry
55
+ else:
56
+ while len(stack) < depth:
57
+ stack.append(None)
58
+ stack.append(new_entry)
59
+
60
+
61
+ def build_tree_from_lines(
62
+ lines: list[str],
63
+ line_to_entry: Callable[[str], tuple[int, bool, Dependency] | None],
64
+ ) -> list[Dependency]:
65
+ """
66
+ Build a nested dependency tree from raw text *lines*.
67
+
68
+ Each line is passed through *line_to_entry*, which should return a
69
+ ``(depth, is_leaf, dep)`` tuple or *None* to skip the line. The
70
+ resulting entries are assembled into a tree using :func:`attach_node`.
71
+
72
+ This function is the shared core of both :class:`GradleDepTreeParser`
73
+ and :class:`MavenDepTreeParser`.
74
+
75
+ :author: Ron Webb
76
+ :since: 1.0.0
77
+ """
78
+ roots: list[Dependency] = []
79
+ stack: list[Dependency | None] = []
80
+ for line in lines:
81
+ entry = line_to_entry(line)
82
+ if entry is None:
83
+ continue
84
+ depth, is_leaf, dep = entry
85
+ attach_node(dep, depth, is_leaf, roots, stack)
86
+ return roots
87
+
88
+
89
+ class DependencyParser(ABC):
90
+ """
91
+ Abstract base class for all dependency file parsers.
92
+
93
+ :author: Ron Webb
94
+ :since: 1.0.0
95
+ """
96
+
97
+ @abstractmethod
98
+ def parse(self, file_path: str) -> list[Dependency]:
99
+ """
100
+ Parse the given build file and return a list of direct dependencies.
101
+
102
+ Only runtime-relevant scopes are returned (compile, runtime,
103
+ implementation, api, runtimeOnly).
104
+
105
+ :author: Ron Webb
106
+ :since: 1.0.0
107
+ """
108
+
109
+
110
+ class DepTreeParser(DependencyParser):
111
+ """
112
+ Intermediate base class for parsers that read pre-generated dependency
113
+ tree text files (e.g. ``gradle dependencies`` or ``mvn dependency:tree``).
114
+
115
+ Subclasses must implement :meth:`_line_to_entry` to convert a single
116
+ text line into a ``(depth, is_leaf, dep)`` entry.
117
+
118
+ :author: Ron Webb
119
+ :since: 1.0.0
120
+ """
121
+
122
+ def parse(self, file_path: str) -> list[Dependency]:
123
+ """
124
+ Read *file_path* and build a dependency tree by passing each line
125
+ through :meth:`_line_to_entry`.
126
+
127
+ :author: Ron Webb
128
+ :since: 1.0.0
129
+ """
130
+ _logger.info("Parsing dependency tree from '%s'", file_path)
131
+ try:
132
+ content = Path(file_path).read_text(encoding="utf-8")
133
+ except OSError as exc:
134
+ _logger.error("Failed to read file: %s", exc)
135
+ return []
136
+
137
+ lines = content.splitlines()
138
+ return build_tree_from_lines(lines, self._line_to_entry)
139
+
140
+ @abstractmethod
141
+ def _line_to_entry(
142
+ self, line: str
143
+ ) -> tuple[int, bool, Dependency] | None:
144
+ """
145
+ Convert a single dependency-tree text line into a
146
+ ``(depth, is_leaf, dep)`` tuple, or return *None* to skip the line.
147
+
148
+ :author: Ron Webb
149
+ :since: 1.0.0
150
+ """
@@ -0,0 +1,125 @@
1
+ """
2
+ gradle_dep_tree_parser module.
3
+
4
+ Parses the text output of ``gradle dependencies`` to reconstruct
5
+ the full dependency tree including transitive dependencies.
6
+
7
+ :author: Ron Webb
8
+ :since: 1.0.0
9
+ """
10
+
11
+ import re
12
+
13
+ from ..models.dependency import Dependency
14
+ from ..util.logger import setup_logger
15
+ from .base import DepTreeParser
16
+
17
+ __author__ = "Ron Webb"
18
+ __since__ = "1.0.0"
19
+
20
+ _logger = setup_logger(__name__)
21
+
22
+ # Matches the tree connector characters at the start of a dependency line.
23
+ # Each level of indentation is exactly 5 characters wide (" " or "| ").
24
+ _INDENT_UNIT = 5
25
+ _CONNECTOR_RE = re.compile(r"^((?:[|\\+]\s{3,4}|\s{5})*)([+\\])--- (.+)$")
26
+
27
+ # Matches a resolved version arrow, e.g. "1.0 -> 2.0" or "RELEASE -> 4.16.0"
28
+ _VERSION_ARROW_RE = re.compile(r"\s*->\s*(\S+)$")
29
+
30
+ # Suffix appended to repeated subtree roots by Gradle
31
+ _REPEATED_SUFFIX = " (*)"
32
+
33
+ # Lines annotated as constraints (not actual dependencies)
34
+ _CONSTRAINT_SUFFIX = " (c)"
35
+
36
+ # Gradle coordinates: group:artifact:version
37
+ _COORD_RE = re.compile(r"^([^:]+):([^:]+):(.+)$")
38
+
39
+
40
+ class GradleDepTreeParser(DepTreeParser):
41
+ """
42
+ Parses the plain-text output of ``gradle dependencies`` and reconstructs
43
+ the dependency tree as a list of :class:`~java_dependency_analyzer.models.dependency.Dependency`
44
+ objects with nested ``transitive_dependencies``.
45
+
46
+ The parser is format-agnostic regarding the configuration name; it processes
47
+ the first dependency-tree block it encounters. Users should redirect the
48
+ output of the configuration they care about (e.g. ``runtimeClasspath``) into
49
+ a text file and pass that file here.
50
+
51
+ :author: Ron Webb
52
+ :since: 1.0.0
53
+ """
54
+
55
+ # ------------------------------------------------------------------
56
+ # Private helpers
57
+ # ------------------------------------------------------------------
58
+
59
+ def _line_to_entry(
60
+ self, line: str
61
+ ) -> tuple[int, bool, Dependency] | None:
62
+ """
63
+ Convert a single Gradle dep-tree line to a ``(depth, is_leaf, dep)``
64
+ entry, or return *None* to skip the line.
65
+
66
+ :author: Ron Webb
67
+ :since: 1.0.0
68
+ """
69
+ if line.rstrip().endswith(_CONSTRAINT_SUFFIX):
70
+ return None
71
+
72
+ match = _CONNECTOR_RE.match(line)
73
+ if match is None:
74
+ return None
75
+
76
+ indent_str, coord_str = match.group(1), match.group(3)
77
+ depth = len(indent_str) // _INDENT_UNIT
78
+
79
+ is_leaf = coord_str.endswith(_REPEATED_SUFFIX)
80
+ if is_leaf:
81
+ coord_str = coord_str[: -len(_REPEATED_SUFFIX)]
82
+
83
+ dep = self._parse_coordinate(coord_str, depth)
84
+ if dep is None:
85
+ return None
86
+
87
+ return depth, is_leaf, dep
88
+
89
+ def _parse_coordinate(self, coord_str: str, depth: int) -> Dependency | None:
90
+ """
91
+ Convert a Gradle coordinate string (with optional ``->`` resolution)
92
+ into a :class:`Dependency`. Returns *None* if the string cannot be
93
+ parsed.
94
+
95
+ :author: Ron Webb
96
+ :since: 1.0.0
97
+ """
98
+ # Resolve version arrows: "group:artifact:1.0 -> 2.0"
99
+ arrow_match = _VERSION_ARROW_RE.search(coord_str)
100
+ if arrow_match:
101
+ resolved_version = arrow_match.group(1)
102
+ # Strip the arrow portion from the coordinate
103
+ coord_str = coord_str[: arrow_match.start()]
104
+ else:
105
+ resolved_version = None
106
+
107
+ coord_match = _COORD_RE.match(coord_str.strip())
108
+ if coord_match is None:
109
+ _logger.debug("Could not parse coordinate: %s", coord_str)
110
+ return None
111
+
112
+ group_id = coord_match.group(1).strip()
113
+ artifact_id = coord_match.group(2).strip()
114
+ version = resolved_version or coord_match.group(3).strip()
115
+
116
+ if not group_id or not artifact_id or not version:
117
+ return None
118
+
119
+ return Dependency(
120
+ group_id=group_id,
121
+ artifact_id=artifact_id,
122
+ version=version,
123
+ scope="runtime",
124
+ depth=depth,
125
+ )
@@ -0,0 +1,206 @@
1
+ """
2
+ gradle_parser module.
3
+
4
+ Parses Gradle build files (build.gradle and build.gradle.kts) to extract
5
+ runtime dependencies using regex-based analysis.
6
+
7
+ :author: Ron Webb
8
+ :since: 1.0.0
9
+ """
10
+
11
+ import re
12
+ from pathlib import Path
13
+
14
+ from ..models.dependency import Dependency
15
+ from ..util.logger import setup_logger
16
+ from .base import DependencyParser, RUNTIME_SCOPES
17
+
18
+ __author__ = "Ron Webb"
19
+ __since__ = "1.0.0"
20
+
21
+ _logger = setup_logger(__name__)
22
+
23
+ # Matches Groovy DSL shorthand: implementation 'group:artifact:version'
24
+ _GROOVY_SHORTHAND = re.compile(
25
+ r"""(?:^|\s)(?P<config>implementation|api|compile|runtimeOnly|runtime)\s+['"]"""
26
+ r"""(?P<group>[^:'"]+):(?P<artifact>[^:'"]+):(?P<version>[^:'"]+)['"]""",
27
+ re.MULTILINE,
28
+ )
29
+
30
+ # Matches Kotlin DSL shorthand: implementation("group:artifact:version")
31
+ _KOTLIN_SHORTHAND = re.compile(
32
+ r"""(?:^|\s)(?P<config>implementation|api|compile|runtimeOnly|runtime)\s*\("""
33
+ r"""["'](?P<group>[^:'"]+):(?P<artifact>[^:'"]+):(?P<version>[^:'"]+)["']\)""",
34
+ re.MULTILINE,
35
+ )
36
+
37
+ # Matches Groovy block syntax:
38
+ # implementation group: 'g', name: 'a', version: 'v'
39
+ _GROOVY_BLOCK = re.compile(
40
+ r"""(?:^|\s)(?P<config>implementation|api|compile|runtimeOnly|runtime)\s+"""
41
+ r"""group:\s*['"](?P<group>[^'"]+)['"]\s*,\s*name:\s*"""
42
+ r"""['"](?P<artifact>[^'"]+)['"]\s*,\s*version:\s*['"](?P<version>[^'"]+)['"]""",
43
+ re.MULTILINE,
44
+ )
45
+
46
+ # Matches Groovy ext { } block content (no nested braces)
47
+ _EXT_BLOCK_PATTERN = re.compile(r"\bext\s*\{([^}]*)\}", re.DOTALL)
48
+
49
+ # Matches simple key = 'value' or key = "value" assignments inside ext blocks
50
+ _EXT_PROPERTY_PATTERN = re.compile(
51
+ r"""^[ \t]*(\w+)\s*=\s*['"]([^'"]+)['"]""",
52
+ re.MULTILINE,
53
+ )
54
+
55
+ # Matches Groovy `def varName = 'value'` or Kotlin `val varName = "value"`
56
+ _DEF_VAL_PATTERN = re.compile(
57
+ r"""(?:^|\s)(?:def|val)\s+(\w+)\s*=\s*["']([^"']+)["']""",
58
+ re.MULTILINE,
59
+ )
60
+
61
+ # Matches Kotlin block syntax:
62
+ # implementation(group = "g", name = "a", version = "v")
63
+ _KOTLIN_BLOCK = re.compile(
64
+ r"""(?:^|\s)(?P<config>implementation|api|compile|runtimeOnly|runtime)\s*\(\s*"""
65
+ r"""group\s*=\s*["'](?P<group>[^'"]+)["']\s*,\s*name\s*=\s*"""
66
+ r"""["'](?P<artifact>[^'"]+)["']\s*,\s*version\s*=\s*["'](?P<version>[^'"]+)["']\s*\)""",
67
+ re.MULTILINE,
68
+ )
69
+
70
+
71
+ class GradleParser(DependencyParser):
72
+ """
73
+ Parses Gradle build files (Groovy DSL and Kotlin DSL) to extract runtime dependencies.
74
+
75
+ Supports both shorthand notation and named-parameter block notation.
76
+ Handles build.gradle (Groovy) and build.gradle.kts (Kotlin DSL).
77
+
78
+ :author: Ron Webb
79
+ :since: 1.0.0
80
+ """
81
+
82
+ def parse(self, file_path: str) -> list[Dependency]:
83
+ """
84
+ Parse the given Gradle build file and return a list of direct dependencies.
85
+
86
+ :author: Ron Webb
87
+ :since: 1.0.0
88
+ """
89
+ _logger.info("Parsing Gradle build file: %s", file_path)
90
+ path = Path(file_path)
91
+ try:
92
+ content = path.read_text(encoding="utf-8")
93
+ except OSError as exc:
94
+ _logger.error("Failed to read Gradle file: %s", exc)
95
+ return []
96
+
97
+ is_kotlin_dsl = path.suffix == ".kts"
98
+ content = self._strip_comments(content, is_kotlin_dsl)
99
+ ext_props = self._extract_ext_properties(content)
100
+ if ext_props:
101
+ content = self._resolve_variables(content, ext_props)
102
+
103
+ seen: set[str] = set()
104
+ deps: list[Dependency] = []
105
+
106
+ for dep in self._extract_all(content):
107
+ key = dep.coordinates
108
+ if key not in seen:
109
+ seen.add(key)
110
+ deps.append(dep)
111
+
112
+ _logger.info("Found %d unique dependencies in %s", len(deps), file_path)
113
+ return deps
114
+
115
+ def _extract_ext_properties(self, content: str) -> dict[str, str]:
116
+ """
117
+ Extract simple string variable assignments from ``ext {}`` blocks and
118
+ top-level ``def``/``val`` declarations.
119
+
120
+ Returns a mapping of variable name to its string value.
121
+
122
+ :author: Ron Webb
123
+ :since: 1.0.0
124
+ """
125
+ props: dict[str, str] = {}
126
+ for block_match in _EXT_BLOCK_PATTERN.finditer(content):
127
+ for prop_match in _EXT_PROPERTY_PATTERN.finditer(block_match.group(1)):
128
+ props[prop_match.group(1)] = prop_match.group(2)
129
+ for def_match in _DEF_VAL_PATTERN.finditer(content):
130
+ props[def_match.group(1)] = def_match.group(2)
131
+ return props
132
+
133
+ def _resolve_variables(self, content: str, props: dict[str, str]) -> str:
134
+ """
135
+ Substitute ``${varName}`` placeholders in *content* using *props*.
136
+
137
+ Unrecognised variable references are left unchanged.
138
+
139
+ :author: Ron Webb
140
+ :since: 1.0.0
141
+ """
142
+ def _replacer(match: re.Match) -> str:
143
+ return props.get(match.group(1), match.group(0))
144
+
145
+ return re.sub(r"\$\{(\w+)\}", _replacer, content)
146
+
147
+ def _strip_comments(self, content: str, is_kotlin_dsl: bool) -> str: # pylint: disable=unused-argument
148
+ """
149
+ Remove single-line (//) and block (/* */) comments from the file content.
150
+
151
+ :author: Ron Webb
152
+ :since: 1.0.0
153
+ """
154
+ # Remove block comments
155
+ content = re.sub(r"/\*.*?\*/", "", content, flags=re.DOTALL)
156
+ # Remove single-line comments
157
+ content = re.sub(r"//.*", "", content)
158
+ return content
159
+
160
+ def _extract_all(self, content: str) -> list[Dependency]:
161
+ """
162
+ Run all regex patterns against the content and collect matches.
163
+
164
+ :author: Ron Webb
165
+ :since: 1.0.0
166
+ """
167
+ deps: list[Dependency] = []
168
+ for pattern in (_GROOVY_SHORTHAND, _KOTLIN_SHORTHAND, _GROOVY_BLOCK, _KOTLIN_BLOCK):
169
+ for match in pattern.finditer(content):
170
+ dep = self._match_to_dependency(match)
171
+ if dep is not None:
172
+ deps.append(dep)
173
+ return deps
174
+
175
+ def _match_to_dependency(self, match: re.Match) -> Dependency | None:
176
+ """
177
+ Convert a regex match object to a Dependency instance.
178
+
179
+ Returns None if the configuration scope is not runtime-relevant.
180
+
181
+ :author: Ron Webb
182
+ :since: 1.0.0
183
+ """
184
+ config = match.group("config")
185
+ group = match.group("group").strip()
186
+ artifact = match.group("artifact").strip()
187
+ version = match.group("version").strip()
188
+
189
+ # Normalise scope name: 'compile' -> 'compile', 'implementation' -> 'implementation'
190
+ scope = config if config in RUNTIME_SCOPES else "compile"
191
+
192
+ if not group or not artifact or not version:
193
+ return None
194
+
195
+ # Skip variable references that couldn't be resolved
196
+ if "$" in version:
197
+ _logger.debug("Skipping %s:%s — unresolved version: %s", group, artifact, version)
198
+ return None
199
+
200
+ _logger.debug("Found dependency: %s:%s:%s (scope=%s)", group, artifact, version, scope)
201
+ return Dependency(
202
+ group_id=group,
203
+ artifact_id=artifact,
204
+ version=version,
205
+ scope=scope,
206
+ )