From 42b83fb68138b97bd6453f75942be6f883a16d2b Mon Sep 17 00:00:00 2001 From: "omer.roth" Date: Sun, 8 Nov 2026 12:03:17 +0200 Subject: [PATCH 1/3] CM-76621: Fix Gradle dependency restore on Gradle 9 and remapped subprojects Gradle 9 removed the -b (--build-file) option, so the default restore failed. Run `gradle -p dependencies` instead: Gradle finds the root settings file and selects the project whose projectDir matches. With --gradle-all-sub-projects, the project path was guessed from the directory name, which missed remapped projectDirs and nested projects and skipped them silently. Map manifest directories to project paths using the "Project locations" section of `gradle projects` (Gradle 9.1+), anchored to the build root even when the scan root is a subproject. Older Gradle versions keep the directory-name fallback, which now also handles the root manifest. Skipped manifests are logged at debug level. Co-Authored-By: Claude Opus 5.5 --- .../sca/maven/restore_gradle_dependencies.py | 87 +++++++++-- .../maven/test_restore_gradle_dependencies.py | 147 +++++++++++++++++- 2 files changed, 218 insertions(+), 16 deletions(-) diff --git a/cycode/cli/files_collector/sca/maven/restore_gradle_dependencies.py b/cycode/cli/files_collector/sca/maven/restore_gradle_dependencies.py index 4ffd3e96..946b01aa 100644 --- a/cycode/cli/files_collector/sca/maven/restore_gradle_dependencies.py +++ b/cycode/cli/files_collector/sca/maven/restore_gradle_dependencies.py @@ -1,6 +1,7 @@ import os import platform import re +from pathlib import Path import typer @@ -8,11 +9,20 @@ from cycode.cli.models import Document from cycode.cli.utils.path_utils import get_path_from_context from cycode.cli.utils.shell_executor import shell +from cycode.logger import get_logger + +logger = get_logger('Gradle Restore') BUILD_GRADLE_FILE_NAME = 'build.gradle' BUILD_GRADLE_KTS_FILE_NAME = 'build.gradle.kts' BUILD_GRADLE_DEP_TREE_FILE_NAME = 'gradle-dependencies-generated.txt' ALL_PROJECTS_REGEX = r"[+-]{3} Project '(.*?)'" +# A line of the "Project locations" section of `gradle projects` (Gradle 9.1+), relative to the root project: +# project ':a-b' - /x/y +PROJECT_LOCATION_REGEX = r"^project '(.+?)' - (.+?)\s*$" +# The header of `gradle projects` when it runs in a subproject directory (the root project prints "Root project") +CURRENT_PROJECT_REGEX = r"^Project '(.+?)'\s*$" +ROOT_PROJECT_PATH = ':' GRADLE_EXECUTABLE = 'gradle' GRADLEW_FILE_NAME = 'gradlew' @@ -27,6 +37,7 @@ def __init__( self.gradle_executable = self._resolve_gradle_executable() if projects is None: projects = set() + self.project_locations: dict[Path, str] = {} self.projects = self.get_all_projects() if self.is_gradle_sub_projects() else projects def _resolve_gradle_executable(self) -> str: @@ -45,11 +56,13 @@ def is_project(self, document: Document) -> bool: return document.path.endswith(BUILD_GRADLE_FILE_NAME) or document.path.endswith(BUILD_GRADLE_KTS_FILE_NAME) def get_commands(self, manifest_file_path: str) -> list[list[str]]: - return ( - self.get_commands_for_sub_projects(manifest_file_path) - if self.is_gradle_sub_projects() - else [[self.gradle_executable, 'dependencies', '-b', manifest_file_path, '-q', '--console', 'plain']] - ) + if self.is_gradle_sub_projects(): + return self.get_commands_for_sub_projects(manifest_file_path) + + # Gradle 9 removed -b (--build-file). -p starts Gradle in the manifest directory: Gradle finds the root + # settings file above it and selects the project whose projectDir is that directory. + manifest_dir = str(Path(manifest_file_path).parent) + return [[self.gradle_executable, '-p', manifest_dir, 'dependencies', '-q', '--console', 'plain']] def get_lock_file_name(self) -> str: return BUILD_GRADLE_DEP_TREE_FILE_NAME @@ -61,21 +74,69 @@ def get_working_directory(self, document: Document) -> str | None: return get_path_from_context(self.ctx) if self.is_gradle_sub_projects() else None def get_all_projects(self) -> set[str]: + scan_root = get_path_from_context(self.ctx) output = shell( command=[self.gradle_executable, 'projects'], timeout=self.command_timeout, - working_directory=get_path_from_context(self.ctx), + working_directory=scan_root, ) if not output: return set() + if scan_root: + self.project_locations = self._parse_project_locations(output, Path(scan_root)) + return set(re.findall(ALL_PROJECTS_REGEX, output)) + @staticmethod + def _parse_project_locations(output: str, scan_root: Path) -> dict[Path, str]: + """Map each project directory to its Gradle project path. + + Reads the "Project locations" section of `gradle projects`. The section includes projectDir overrides + from the settings file, so remapped and nested projects get their real path. Gradle versions before 9.1 + do not print the section; the result is then empty. + """ + # A location starts with a separator ("/x/y"). Strip it, or pathlib treats it as an absolute path. + relative_dirs = { + project_path: Path(relative_dir.lstrip('/\\')) + for project_path, relative_dir in re.findall(PROJECT_LOCATION_REGEX, output, flags=re.MULTILINE) + } + if not relative_dirs: + return {} + + # Locations are relative to the build root. When the scan root is a subproject directory, Gradle reports + # that subproject as the current project; walk up from the scan root to the build root. + build_root = scan_root.resolve() + current_project = re.search(CURRENT_PROJECT_REGEX, output, flags=re.MULTILINE) + if current_project and current_project.group(1) in relative_dirs: + for _ in relative_dirs[current_project.group(1)].parts: + build_root = build_root.parent + + locations = {(build_root / relative_dir).resolve(): path for path, relative_dir in relative_dirs.items()} + locations[build_root] = ROOT_PROJECT_PATH + return locations + + def _get_sub_project_path(self, manifest_file_path: str) -> str | None: + manifest_dir = Path(manifest_file_path).parent + if self.project_locations: + return self.project_locations.get(manifest_dir.resolve()) + + # Without project locations (Gradle < 9.1), guess the project path from the directory name. + scan_root = get_path_from_context(self.ctx) + if self.projects and scan_root and manifest_dir.resolve() == Path(scan_root).resolve(): + return ROOT_PROJECT_PATH + + project_path = f':{manifest_dir.name}' + return project_path if project_path in self.projects else None + def get_commands_for_sub_projects(self, manifest_file_path: str) -> list[list[str]]: - project_name = os.path.basename(os.path.dirname(manifest_file_path)) - project_name = f':{project_name}' - return ( - [[self.gradle_executable, f'{project_name}:dependencies', '-q', '--console', 'plain']] - if project_name in self.projects - else [] - ) + project_path = self._get_sub_project_path(manifest_file_path) + if project_path is None: + logger.debug( + 'Manifest does not match a Gradle project, skipping restore, %s', + {'manifest_file_path': manifest_file_path}, + ) + return [] + + task = 'dependencies' if project_path == ROOT_PROJECT_PATH else f'{project_path}:dependencies' + return [[self.gradle_executable, task, '-q', '--console', 'plain']] diff --git a/tests/cli/files_collector/sca/maven/test_restore_gradle_dependencies.py b/tests/cli/files_collector/sca/maven/test_restore_gradle_dependencies.py index cb7520af..0628c3bd 100644 --- a/tests/cli/files_collector/sca/maven/test_restore_gradle_dependencies.py +++ b/tests/cli/files_collector/sca/maven/test_restore_gradle_dependencies.py @@ -1,3 +1,4 @@ +import sys from pathlib import Path from unittest.mock import MagicMock, patch @@ -80,9 +81,7 @@ def test_wrapper_is_threaded_into_get_commands(self, mock_ctx: typer.Context, tm with patch(f'{_GRADLE_MODULE}.platform.system', return_value='Linux'): restore = RestoreGradleDependencies(mock_ctx, is_git_diff=False, command_timeout=30) commands = restore.get_commands(str(tmp_path / BUILD_GRADLE_FILE_NAME)) - assert commands == [ - [str(wrapper), 'dependencies', '-b', str(tmp_path / BUILD_GRADLE_FILE_NAME), '-q', '--console', 'plain'] - ] + assert commands == [[str(wrapper), '-p', str(tmp_path), 'dependencies', '-q', '--console', 'plain']] def test_wrapper_is_threaded_into_sub_project_commands(self, tmp_path: Path) -> None: wrapper = tmp_path / GRADLEW_FILE_NAME @@ -104,6 +103,148 @@ def test_wrapper_is_threaded_into_sub_project_commands(self, tmp_path: Path) -> assert commands == [[str(wrapper), ':module-a:dependencies', '-q', '--console', 'plain']] +class TestDefaultCommand: + def test_uses_project_dir_instead_of_build_file( + self, restore_gradle: RestoreGradleDependencies, tmp_path: Path + ) -> None: + manifest = tmp_path / 'x' / 'y' / BUILD_GRADLE_KTS_FILE_NAME + + commands = restore_gradle.get_commands(str(manifest)) + + assert commands == [ + [GRADLE_EXECUTABLE, '-p', str(tmp_path / 'x' / 'y'), 'dependencies', '-q', '--console', 'plain'] + ] + assert '-b' not in commands[0] + + +# `gradle projects` output on Gradle 9.1+. The settings file includes ':a', ':a:b' and +# ':tenant-service-implementation', and moves the projectDir of the last one to +# components/services/tenant/implementation. +_PROJECTS_OUTPUT_WITH_LOCATIONS = """ +Projects: + +------------------------------------------------------------ +Root project 'root' +------------------------------------------------------------ + +Location: /repo + +Project hierarchy: + +Root project 'root' ++--- Project ':a' +| \\--- Project ':a:b' +\\--- Project ':tenant-service-implementation' + +Project locations: + +project ':a' - /a +project ':a:b' - /a/b +project ':tenant-service-implementation' - /components/services/tenant/implementation + +To see a list of the tasks of a project, run gradle :tasks +""" + +# `gradle projects` output before Gradle 9.1: no "Project locations" section +_PROJECTS_OUTPUT_WITHOUT_LOCATIONS = """ +Root project 'root' ++--- Project ':a' +| \\--- Project ':a:b' +\\--- Project ':tenant-service-implementation' +""" + + +def _sub_projects_restore(tmp_path: Path, projects_output: str) -> RestoreGradleDependencies: + ctx = MagicMock(spec=typer.Context) + ctx.obj = {'monitor': False, 'gradle_all_sub_projects': True} + ctx.params = {'path': str(tmp_path)} + with patch(f'{_GRADLE_MODULE}.shell', return_value=projects_output): + return RestoreGradleDependencies(ctx, is_git_diff=False, command_timeout=30) + + +class TestSubProjectCommands: + def test_remapped_project_dir_uses_real_project_path(self, tmp_path: Path) -> None: + restore = _sub_projects_restore(tmp_path, _PROJECTS_OUTPUT_WITH_LOCATIONS) + manifest = tmp_path / 'components' / 'services' / 'tenant' / 'implementation' / BUILD_GRADLE_FILE_NAME + + assert restore.get_commands(str(manifest)) == [ + [GRADLE_EXECUTABLE, ':tenant-service-implementation:dependencies', '-q', '--console', 'plain'] + ] + + def test_nested_project_uses_full_path(self, tmp_path: Path) -> None: + restore = _sub_projects_restore(tmp_path, _PROJECTS_OUTPUT_WITH_LOCATIONS) + manifest = tmp_path / 'a' / 'b' / BUILD_GRADLE_KTS_FILE_NAME + + assert restore.get_commands(str(manifest)) == [ + [GRADLE_EXECUTABLE, ':a:b:dependencies', '-q', '--console', 'plain'] + ] + + def test_root_manifest_runs_root_dependencies(self, tmp_path: Path) -> None: + restore = _sub_projects_restore(tmp_path, _PROJECTS_OUTPUT_WITH_LOCATIONS) + + assert restore.get_commands(str(tmp_path / BUILD_GRADLE_FILE_NAME)) == [ + [GRADLE_EXECUTABLE, 'dependencies', '-q', '--console', 'plain'] + ] + + def test_manifest_outside_the_build_is_skipped(self, tmp_path: Path) -> None: + restore = _sub_projects_restore(tmp_path, _PROJECTS_OUTPUT_WITH_LOCATIONS) + + assert restore.get_commands(str(tmp_path / 'implementation' / BUILD_GRADLE_FILE_NAME)) == [] + + def test_relative_manifest_path_is_resolved(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + restore = _sub_projects_restore(tmp_path, _PROJECTS_OUTPUT_WITH_LOCATIONS) + monkeypatch.chdir(tmp_path / '..') + + manifest = Path(tmp_path.name) / 'a' / BUILD_GRADLE_FILE_NAME + assert restore.get_commands(str(manifest)) == [ + [GRADLE_EXECUTABLE, ':a:dependencies', '-q', '--console', 'plain'] + ] + + def test_parses_crlf_line_endings(self, tmp_path: Path) -> None: + output = "Project locations:\r\n\r\nproject ':a:b' - /a/b\r\n" + + locations = RestoreGradleDependencies._parse_project_locations(output, tmp_path) + + assert locations == {tmp_path.resolve(): ':', (tmp_path / 'a' / 'b').resolve(): ':a:b'} + + def test_scan_root_in_subproject_maps_from_build_root(self, tmp_path: Path) -> None: + # `gradle projects` run in the ':a' directory reports locations relative to the build root above it + output = ( + "Project ':a'\n\nProject hierarchy:\n\nProject ':a'\n\\--- Project ':a:b'\n\n" + "Project locations:\n\nproject ':a' - /a\nproject ':a:b' - /a/b\n" + ) + scan_root = tmp_path / 'a' + + locations = RestoreGradleDependencies._parse_project_locations(output, scan_root) + + assert locations == { + tmp_path.resolve(): ':', + scan_root.resolve(): ':a', + (scan_root / 'b').resolve(): ':a:b', + } + + @pytest.mark.skipif(sys.platform != 'win32', reason='backslash is a path separator only on Windows') + def test_parses_windows_separators_case_insensitively(self, tmp_path: Path) -> None: + output = "Project locations:\r\n\r\nproject ':a:b' - \\A\\B\r\n" + restore = _sub_projects_restore(tmp_path, output) + + assert restore._get_sub_project_path(str(tmp_path / 'a' / 'b' / BUILD_GRADLE_FILE_NAME)) == ':a:b' + + def test_older_gradle_falls_back_to_directory_name(self, tmp_path: Path) -> None: + restore = _sub_projects_restore(tmp_path, _PROJECTS_OUTPUT_WITHOUT_LOCATIONS) + + assert restore.project_locations == {} + assert restore.get_commands(str(tmp_path / 'a' / BUILD_GRADLE_FILE_NAME)) == [ + [GRADLE_EXECUTABLE, ':a:dependencies', '-q', '--console', 'plain'] + ] + assert restore.get_commands(str(tmp_path / BUILD_GRADLE_FILE_NAME)) == [ + [GRADLE_EXECUTABLE, 'dependencies', '-q', '--console', 'plain'] + ] + # The fallback cannot resolve remapped projects + remapped = tmp_path / 'components' / 'services' / 'tenant' / 'implementation' / BUILD_GRADLE_FILE_NAME + assert restore.get_commands(str(remapped)) == [] + + class TestCleanup: def test_generated_dep_tree_file_is_deleted_after_restore( self, restore_gradle: RestoreGradleDependencies, tmp_path: Path From 6bbeaab378fc9c9be838713e6ba082be34f11166 Mon Sep 17 00:00:00 2001 From: Omer Roth Date: Sun, 8 Nov 2026 12:19:34 +0200 Subject: [PATCH 2/3] Apply suggestion from @omer-roth CM-76621 remove narration commetn --- .../files_collector/sca/maven/restore_gradle_dependencies.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cycode/cli/files_collector/sca/maven/restore_gradle_dependencies.py b/cycode/cli/files_collector/sca/maven/restore_gradle_dependencies.py index 946b01aa..af603192 100644 --- a/cycode/cli/files_collector/sca/maven/restore_gradle_dependencies.py +++ b/cycode/cli/files_collector/sca/maven/restore_gradle_dependencies.py @@ -59,8 +59,8 @@ def get_commands(self, manifest_file_path: str) -> list[list[str]]: if self.is_gradle_sub_projects(): return self.get_commands_for_sub_projects(manifest_file_path) - # Gradle 9 removed -b (--build-file). -p starts Gradle in the manifest directory: Gradle finds the root - # settings file above it and selects the project whose projectDir is that directory. + # Gradle finds the root settings file above it and selects the project whose projectDir + # is that directory. manifest_dir = str(Path(manifest_file_path).parent) return [[self.gradle_executable, '-p', manifest_dir, 'dependencies', '-q', '--console', 'plain']] From 91dae8da6ef2f933d1eeea275ff12b7a24cfc62b Mon Sep 17 00:00:00 2001 From: "omer.roth" Date: Sun, 8 Nov 2026 12:26:21 +0200 Subject: [PATCH 3/3] CM-76621: Remove narration comments from Gradle restore Drop comments that narrate Gradle version history and extract the build-root lookup into _find_build_root so the code reads on its own. Co-Authored-By: Claude Opus 5.5 --- .../sca/maven/restore_gradle_dependencies.py | 24 +++++++------------ .../maven/test_restore_gradle_dependencies.py | 6 +---- 2 files changed, 10 insertions(+), 20 deletions(-) diff --git a/cycode/cli/files_collector/sca/maven/restore_gradle_dependencies.py b/cycode/cli/files_collector/sca/maven/restore_gradle_dependencies.py index af603192..6b403ead 100644 --- a/cycode/cli/files_collector/sca/maven/restore_gradle_dependencies.py +++ b/cycode/cli/files_collector/sca/maven/restore_gradle_dependencies.py @@ -17,10 +17,7 @@ BUILD_GRADLE_KTS_FILE_NAME = 'build.gradle.kts' BUILD_GRADLE_DEP_TREE_FILE_NAME = 'gradle-dependencies-generated.txt' ALL_PROJECTS_REGEX = r"[+-]{3} Project '(.*?)'" -# A line of the "Project locations" section of `gradle projects` (Gradle 9.1+), relative to the root project: -# project ':a-b' - /x/y PROJECT_LOCATION_REGEX = r"^project '(.+?)' - (.+?)\s*$" -# The header of `gradle projects` when it runs in a subproject directory (the root project prints "Root project") CURRENT_PROJECT_REGEX = r"^Project '(.+?)'\s*$" ROOT_PROJECT_PATH = ':' @@ -90,12 +87,7 @@ def get_all_projects(self) -> set[str]: @staticmethod def _parse_project_locations(output: str, scan_root: Path) -> dict[Path, str]: - """Map each project directory to its Gradle project path. - - Reads the "Project locations" section of `gradle projects`. The section includes projectDir overrides - from the settings file, so remapped and nested projects get their real path. Gradle versions before 9.1 - do not print the section; the result is then empty. - """ + """Map each project directory to its Gradle project path, from the "Project locations" section.""" # A location starts with a separator ("/x/y"). Strip it, or pathlib treats it as an absolute path. relative_dirs = { project_path: Path(relative_dir.lstrip('/\\')) @@ -104,24 +96,26 @@ def _parse_project_locations(output: str, scan_root: Path) -> dict[Path, str]: if not relative_dirs: return {} - # Locations are relative to the build root. When the scan root is a subproject directory, Gradle reports - # that subproject as the current project; walk up from the scan root to the build root. + build_root = RestoreGradleDependencies._find_build_root(output, scan_root, relative_dirs) + locations = {(build_root / relative_dir).resolve(): path for path, relative_dir in relative_dirs.items()} + locations[build_root] = ROOT_PROJECT_PATH + return locations + + @staticmethod + def _find_build_root(output: str, scan_root: Path, relative_dirs: dict[str, Path]) -> Path: build_root = scan_root.resolve() current_project = re.search(CURRENT_PROJECT_REGEX, output, flags=re.MULTILINE) if current_project and current_project.group(1) in relative_dirs: for _ in relative_dirs[current_project.group(1)].parts: build_root = build_root.parent - locations = {(build_root / relative_dir).resolve(): path for path, relative_dir in relative_dirs.items()} - locations[build_root] = ROOT_PROJECT_PATH - return locations + return build_root def _get_sub_project_path(self, manifest_file_path: str) -> str | None: manifest_dir = Path(manifest_file_path).parent if self.project_locations: return self.project_locations.get(manifest_dir.resolve()) - # Without project locations (Gradle < 9.1), guess the project path from the directory name. scan_root = get_path_from_context(self.ctx) if self.projects and scan_root and manifest_dir.resolve() == Path(scan_root).resolve(): return ROOT_PROJECT_PATH diff --git a/tests/cli/files_collector/sca/maven/test_restore_gradle_dependencies.py b/tests/cli/files_collector/sca/maven/test_restore_gradle_dependencies.py index 0628c3bd..2ba8abc3 100644 --- a/tests/cli/files_collector/sca/maven/test_restore_gradle_dependencies.py +++ b/tests/cli/files_collector/sca/maven/test_restore_gradle_dependencies.py @@ -117,8 +117,7 @@ def test_uses_project_dir_instead_of_build_file( assert '-b' not in commands[0] -# `gradle projects` output on Gradle 9.1+. The settings file includes ':a', ':a:b' and -# ':tenant-service-implementation', and moves the projectDir of the last one to +# The settings file moves the projectDir of ':tenant-service-implementation' to # components/services/tenant/implementation. _PROJECTS_OUTPUT_WITH_LOCATIONS = """ Projects: @@ -145,7 +144,6 @@ def test_uses_project_dir_instead_of_build_file( To see a list of the tasks of a project, run gradle :tasks """ -# `gradle projects` output before Gradle 9.1: no "Project locations" section _PROJECTS_OUTPUT_WITHOUT_LOCATIONS = """ Root project 'root' +--- Project ':a' @@ -208,7 +206,6 @@ def test_parses_crlf_line_endings(self, tmp_path: Path) -> None: assert locations == {tmp_path.resolve(): ':', (tmp_path / 'a' / 'b').resolve(): ':a:b'} def test_scan_root_in_subproject_maps_from_build_root(self, tmp_path: Path) -> None: - # `gradle projects` run in the ':a' directory reports locations relative to the build root above it output = ( "Project ':a'\n\nProject hierarchy:\n\nProject ':a'\n\\--- Project ':a:b'\n\n" "Project locations:\n\nproject ':a' - /a\nproject ':a:b' - /a/b\n" @@ -240,7 +237,6 @@ def test_older_gradle_falls_back_to_directory_name(self, tmp_path: Path) -> None assert restore.get_commands(str(tmp_path / BUILD_GRADLE_FILE_NAME)) == [ [GRADLE_EXECUTABLE, 'dependencies', '-q', '--console', 'plain'] ] - # The fallback cannot resolve remapped projects remapped = tmp_path / 'components' / 'services' / 'tenant' / 'implementation' / BUILD_GRADLE_FILE_NAME assert restore.get_commands(str(remapped)) == []