Skip to content

Commit 9b05395

Browse files
committed
feat: handle Gradle build files and validate them
Signed-off-by: behnazh-w <[email protected]>
1 parent f786edb commit 9b05395

19 files changed

Lines changed: 714 additions & 81 deletions

File tree

src/macaron/build_spec_generator/common_spec/core.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -146,12 +146,19 @@ def get_macaron_build_tools(
146146
for fact in build_tool_facts:
147147
if fact.language.lower() == target_language:
148148
try:
149-
build_tools[MacaronBuildToolName(fact.build_tool_name).value] = {
149+
tool_name = MacaronBuildToolName(fact.build_tool_name).value
150+
build_tool_info = {
150151
"build_config_path": fact.build_config_path,
151152
"confidence_score": fact.confidence,
152153
"build_tool_version": fact.build_tool_version,
153154
"root_build_config_path": fact.root_build_config_path,
154155
}
156+
existing_build_tool_info = build_tools.get(tool_name)
157+
if (
158+
existing_build_tool_info is None
159+
or build_tool_info["confidence_score"] > existing_build_tool_info["confidence_score"]
160+
):
161+
build_tools[tool_name] = build_tool_info
155162
except ValueError:
156163
continue
157164
return build_tools or None
Lines changed: 254 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,254 @@
1+
# Copyright (c) 2026 - 2026, Oracle and/or its affiliates. All rights reserved.
2+
# Licensed under the Universal Permissive License v 1.0 as shown at https://oss.oracle.com/licenses/upl/.
3+
4+
"""This module contains helpers for parsing Gradle build configuration files."""
5+
6+
import logging
7+
import re
8+
from pathlib import Path
9+
10+
logger: logging.Logger = logging.getLogger(__name__)
11+
12+
13+
def _extract_assignment_value(file_path: Path, keys: set[str]) -> str | None:
14+
"""Extract an assignment value for a supported key from a Gradle-like file.
15+
16+
Parameters
17+
----------
18+
file_path : Path
19+
The file to inspect.
20+
keys : set[str]
21+
Accepted key names (for example ``{"group"}`` or ``{"rootProject.name"}``).
22+
23+
Returns
24+
-------
25+
str | None
26+
The extracted value if a matching ``key = value`` assignment is found;
27+
otherwise ``None``.
28+
"""
29+
if not file_path.is_file():
30+
return None
31+
32+
try:
33+
lines = file_path.read_text(encoding="utf-8", errors="ignore").splitlines()
34+
except OSError as error:
35+
logger.debug("Failed to read Gradle file %s: %s", str(file_path), error)
36+
return None
37+
38+
assignment_re = re.compile(r"^\s*([A-Za-z0-9_.]+)\s*=\s*(.+?)\s*$")
39+
for line in lines:
40+
try:
41+
match = assignment_re.match(line)
42+
except re.error as error:
43+
logger.debug("Failed to apply assignment regex on %s: %s", str(file_path), error)
44+
continue
45+
if not match:
46+
continue
47+
48+
key = match.group(1).strip()
49+
if key not in keys:
50+
continue
51+
52+
raw_value = match.group(2).strip()
53+
if len(raw_value) >= 2 and raw_value[0] == raw_value[-1] and raw_value[0] in {"'", '"'}:
54+
raw_value = raw_value[1:-1]
55+
return raw_value
56+
57+
return None
58+
59+
60+
def extract_gav_from_gradle_project(project_path: Path) -> tuple[str | None, str | None, str | None]:
61+
"""Extract Gradle coordinates (group, artifact, version) from project files.
62+
63+
Parameters
64+
----------
65+
project_path : Path
66+
Path to the root directory of a Gradle project.
67+
68+
Returns
69+
-------
70+
tuple[str | None, str | None, str | None]
71+
A tuple of ``(group_id, artifact_id, version)`` extracted from common
72+
Gradle configuration files. Any missing value is returned as ``None``.
73+
74+
Notes
75+
-----
76+
This parser is intentionally lightweight and matches direct ``key = value``
77+
assignments only. It does not evaluate expressions or variable references.
78+
"""
79+
group_id = (
80+
_extract_assignment_value(
81+
project_path.joinpath("gradle.properties"), {"group", "projectGroup", "projectGroupId"}
82+
)
83+
or _extract_assignment_value(project_path.joinpath("build.gradle"), {"group"})
84+
or _extract_assignment_value(project_path.joinpath("build.gradle.kts"), {"group"})
85+
)
86+
artifact_id = (
87+
_extract_assignment_value(project_path.joinpath("settings.gradle"), {"rootProject.name"})
88+
or _extract_assignment_value(project_path.joinpath("settings.gradle.kts"), {"rootProject.name"})
89+
or _extract_assignment_value(project_path.joinpath("gradle.properties"), {"name"})
90+
)
91+
version = (
92+
_extract_assignment_value(project_path.joinpath("gradle.properties"), {"version", "projectVersion"})
93+
or _extract_assignment_value(project_path.joinpath("build.gradle"), {"version"})
94+
or _extract_assignment_value(project_path.joinpath("build.gradle.kts"), {"version"})
95+
)
96+
97+
if group_id is None:
98+
logger.debug("Could not find group id in Gradle project: %s", str(project_path))
99+
if artifact_id is None:
100+
logger.debug("Could not find artifact id in Gradle project: %s", str(project_path))
101+
if version is None:
102+
logger.debug("Could not find version in Gradle project: %s", str(project_path))
103+
104+
return group_id, artifact_id, version
105+
106+
107+
def gradle_settings_has_modules(settings_path: Path) -> bool:
108+
"""Check whether a Gradle settings file declares one or more modules.
109+
110+
Parameters
111+
----------
112+
settings_path : Path
113+
Path to a ``settings.gradle`` or ``settings.gradle.kts`` file.
114+
115+
Returns
116+
-------
117+
bool
118+
``True`` when the file contains an ``include`` declaration; otherwise
119+
``False``.
120+
"""
121+
if not settings_path.is_file():
122+
return False
123+
124+
try:
125+
lines = settings_path.read_text(encoding="utf-8", errors="ignore").splitlines()
126+
except OSError as error:
127+
logger.debug("Failed to read Gradle settings file %s: %s", str(settings_path), error)
128+
return False
129+
130+
for line in lines:
131+
stripped = line.strip()
132+
if re.match(r"^include\s+.+", stripped) or re.match(r"^include\s*\(.+\)", stripped):
133+
return True
134+
135+
return False
136+
137+
138+
def extract_included_gradle_modules(settings_path: Path) -> list[str]:
139+
"""Extract module include entries from a Gradle settings file.
140+
141+
Parameters
142+
----------
143+
settings_path : Path
144+
Path to a ``settings.gradle`` or ``settings.gradle.kts`` file.
145+
146+
Returns
147+
-------
148+
list[str]
149+
Ordered list of module paths declared by ``include`` statements.
150+
"""
151+
if not settings_path.is_file():
152+
return []
153+
154+
try:
155+
lines = settings_path.read_text(encoding="utf-8", errors="ignore").splitlines()
156+
except OSError as error:
157+
logger.debug("Failed to read Gradle settings file %s: %s", str(settings_path), error)
158+
return []
159+
160+
modules: list[str] = []
161+
quoted_value_re = re.compile(r"""['"]([^'"]+)['"]""")
162+
for line in lines:
163+
stripped = line.strip()
164+
if not stripped.startswith("include"):
165+
continue
166+
modules.extend(match.group(1).strip() for match in quoted_value_re.finditer(stripped) if match.group(1).strip())
167+
return modules
168+
169+
170+
def find_matching_gradle_module_build_configs(repo_root: Path, artifact_id: str) -> list[Path]:
171+
"""Find module build config files likely associated with the given artifact id.
172+
173+
Parameters
174+
----------
175+
repo_root : Path
176+
Root directory of the Gradle repository.
177+
artifact_id : str
178+
Expected artifact id.
179+
180+
Returns
181+
-------
182+
list[Path]
183+
Candidate module build files (for example ``module/build.gradle``)
184+
associated with the artifact id.
185+
"""
186+
candidates: list[Path] = []
187+
seen: set[Path] = set()
188+
for settings_name in ("settings.gradle", "settings.gradle.kts"):
189+
settings_path = repo_root.joinpath(settings_name)
190+
for module in extract_included_gradle_modules(settings_path):
191+
module_path = module.strip().strip(":")
192+
if not module_path:
193+
continue
194+
module_name = module_path.split(":")[-1]
195+
if artifact_id != module_name and not artifact_id.endswith(f"-{module_name}"):
196+
continue
197+
module_dir = repo_root.joinpath(*module_path.split(":"))
198+
for build_name in ("build.gradle", "build.gradle.kts"):
199+
config_path = module_dir.joinpath(build_name)
200+
if config_path.is_file() and config_path not in seen:
201+
seen.add(config_path)
202+
candidates.append(config_path)
203+
204+
return candidates
205+
206+
207+
def find_nearest_modules_gradle_config(
208+
config_path: Path,
209+
repo_root: str | Path,
210+
*,
211+
max_depth: int = 50,
212+
) -> str | None:
213+
"""Find the nearest ancestor Gradle settings file that defines modules.
214+
215+
Parameters
216+
----------
217+
config_path : Path
218+
Path to the starting Gradle configuration file.
219+
repo_root : str | Path
220+
Repository root used to bound parent traversal and return a relative path.
221+
max_depth : int, optional
222+
Maximum number of parent-directory hops. Defaults to ``50``.
223+
224+
Returns
225+
-------
226+
str | None
227+
Path to the nearest settings file relative to ``repo_root`` if it
228+
contains ``include`` declarations. Returns ``None`` otherwise.
229+
"""
230+
repo_root = Path(repo_root).resolve()
231+
current_dir = config_path.parent.resolve()
232+
depth = 0
233+
234+
while True:
235+
for settings_name in ("settings.gradle", "settings.gradle.kts"):
236+
settings_path = current_dir.joinpath(settings_name)
237+
if gradle_settings_has_modules(settings_path):
238+
try:
239+
return str(settings_path.relative_to(repo_root))
240+
except ValueError:
241+
return None
242+
243+
if current_dir == repo_root:
244+
return None
245+
246+
depth += 1
247+
if depth > max_depth:
248+
return None
249+
250+
parent_dir = current_dir.parent
251+
if parent_dir == current_dir:
252+
return None
253+
254+
current_dir = parent_dir

src/macaron/parsers/pomparser.py

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# Copyright (c) 2024 - 2025, Oracle and/or its affiliates. All rights reserved.
1+
# Copyright (c) 2024 - 2026, Oracle and/or its affiliates. All rights reserved.
22
# Licensed under the Universal Permissive License v 1.0 as shown at https://oss.oracle.com/licenses/upl/.
33

44
"""This module contains the parser for POM files."""
@@ -76,7 +76,7 @@ def extract_gav_from_pom(pom_file: Path) -> tuple[str | None, str | None, str |
7676
pom_root = parse_pom_string(pom_content)
7777

7878
if pom_root is None:
79-
logger.debug("Could not parse pom.xml: %s", pom_file.as_posix())
79+
logger.debug("Could not parse pom.xml: %s", str(pom_file))
8080
return None, None, None
8181

8282
def _find_child_text(parent, local_name: str) -> str | None:
@@ -98,11 +98,11 @@ def _find_child_text(parent, local_name: str) -> str | None:
9898
group_id = _find_child_text(parent_elem, "groupId")
9999

100100
if group_id is None:
101-
logger.debug("Could not find groupId in pom.xml (project or parent): %s", pom_file.as_posix())
101+
logger.debug("Could not find groupId in pom.xml (project or parent): %s", str(pom_file))
102102
if artifact_id is None:
103-
logger.debug("Could not find artifactId in pom.xml: %s", pom_file.as_posix())
103+
logger.debug("Could not find artifactId in pom.xml: %s", str(pom_file))
104104
if version is None:
105-
logger.debug("Could not find version in pom.xml: %s", pom_file.as_posix())
105+
logger.debug("Could not find version in pom.xml: %s", str(pom_file))
106106

107107
return group_id, artifact_id, version
108108

@@ -114,9 +114,10 @@ def detect_parent_pom(pom_path: Path, repo_root: str | Path) -> str | None:
114114
file path using Maven semantics:
115115
116116
* If `<project>/<parent>/<relativePath>` is present and non-empty, that path
117-
(relative to the directory containing `pom.xml`) is used.
117+
(relative to the directory containing `pom.xml`) is used.
118118
* Otherwise Maven defaults to ``../pom.xml``.
119-
see https://maven.apache.org/ref/3.0/maven-model/maven.html#class_parent
119+
120+
See https://maven.apache.org/ref/3.0/maven-model/maven.html#class_parent.
120121
121122
If the resolved parent POM exists on disk and is within `repo_root`, this
122123
returns its path relative to `repo_root`. Otherwise returns ``None``.

src/macaron/slsa_analyzer/analyzer.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1050,7 +1050,7 @@ def _determine_build_tools(self, analyze_ctx: AnalyzeContext, git_service: BaseG
10501050
continue
10511051

10521052
if build_tool.match_purl_type(analyze_ctx.component.type):
1053-
if build_tool.name not in ["pip", "maven", "hatch"]:
1053+
if build_tool.name not in ["pip", "maven", "hatch", "gradle"]:
10541054
continue
10551055
logger.info(
10561056
"Checking if the repo %s uses build tool %s",
@@ -1060,8 +1060,8 @@ def _determine_build_tools(self, analyze_ctx: AnalyzeContext, git_service: BaseG
10601060

10611061
if build_tool_configs := build_tool.is_detected(
10621062
analyze_ctx.component.repository.fs_path,
1063-
groupID=analyze_ctx.component.namespace,
1064-
artifactID=analyze_ctx.component.name,
1063+
group_id=analyze_ctx.component.namespace,
1064+
artifact_id=analyze_ctx.component.name,
10651065
):
10661066
logger.info("The repo uses %s build tool.", build_tool.name)
10671067
build_tool.set_build_tool_configurations(build_tool_configs)

src/macaron/slsa_analyzer/build_tool/base_build_tool.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -234,7 +234,7 @@ def __str__(self) -> str:
234234

235235
@abstractmethod
236236
def is_detected(
237-
self, repo_path: str, groupID: str | None = None, artifactID: str | None = None
237+
self, repo_path: str, group_id: str | None = None, artifact_id: str | None = None
238238
) -> list[tuple[str, float, str | None, str | None]]:
239239
"""
240240
Return the list of build tools and their information used in the target repo.
@@ -243,11 +243,11 @@ def is_detected(
243243
----------
244244
repo_path : str
245245
The path to the target repo.
246-
groupID : str | None
246+
group_id : str | None
247247
Optional Maven `groupId` used to refine detection (e.g., selecting the
248248
correct `pom.xml` when multiple are present). If ``None``, no filtering
249249
is applied.
250-
artifactID : str | None
250+
artifact_id : str | None
251251
Optional Maven `artifactId` used to refine detection. If ``None``, no
252252
filtering is applied.
253253

src/macaron/slsa_analyzer/build_tool/conda.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ def load_defaults(self) -> None:
4343
self.ci_deploy_kws[item] = defaults.get_list("builder.conda.ci.deploy", item)
4444

4545
def is_detected(
46-
self, repo_path: str, groupID: str | None = None, artifactID: str | None = None
46+
self, repo_path: str, group_id: str | None = None, artifact_id: str | None = None
4747
) -> list[tuple[str, float, str | None, str | None]]:
4848
"""
4949
Return the list of build tools and their information used in the target repo.
@@ -52,11 +52,11 @@ def is_detected(
5252
----------
5353
repo_path : str
5454
The path to the target repo.
55-
groupID : str | None
55+
group_id : str | None
5656
Optional Maven `groupId` used to refine detection (e.g., selecting the
5757
correct `pom.xml` when multiple are present). If ``None``, no filtering
5858
is applied.
59-
artifactID : str | None
59+
artifact_id : str | None
6060
Optional Maven `artifactId` used to refine detection. If ``None``, no
6161
filtering is applied.
6262

0 commit comments

Comments
 (0)