Skip to content

Commit dd107d5

Browse files
HeshamHM28claude
andcommitted
fix: resolve missing project JARs in Gradle multi-module classpath
Gradle's testRuntimeClasspath resolves project dependencies to JAR files (build/libs/*.jar), but testClasses only compiles classes without building JARs. This caused all tests to fail in multi-module projects like OpenRewrite where 11 critical dependency JARs were missing from the classpath. Changes: - Add _resolve_project_classpath() to detect missing project JARs and replace them with build/classes/*/main + build/resources/main directories - Add _compile_dependency_modules() to compile testRuntimeOnly project deps (e.g. rewrite-java-21) that testClasses skips - Fix _extract_modules_from_settings_gradle() to parse multi-line include(), Kotlin listOf() variable declarations, and Groovy-style includes - Remove old partial multi-module classpath supplement (superseded) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent accb245 commit dd107d5

4 files changed

Lines changed: 374 additions & 13 deletions

File tree

codeflash/languages/java/gradle_strategy.py

Lines changed: 122 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -589,6 +589,13 @@ def _get_classpath_uncached(
589589
logger.error("Classpath not found in Gradle output")
590590
return None
591591

592+
# Replace missing project dependency JARs with class/resource directories.
593+
# Gradle's testRuntimeClasspath resolves project deps to JAR files
594+
# (build/libs/*.jar), but testClasses doesn't build JARs for dependency
595+
# modules. This also compiles any testRuntimeOnly project deps that
596+
# weren't compiled by testClasses.
597+
classpath = self._resolve_project_classpath(classpath, build_root, env, timeout)
598+
592599
if test_module:
593600
module_path = build_root / module_to_dir(test_module)
594601
else:
@@ -603,15 +610,6 @@ def _get_classpath_uncached(
603610
if main_classes.exists():
604611
cp_parts.append(str(main_classes))
605612

606-
if test_module:
607-
module_dir_name = module_to_dir(test_module)
608-
for module_dir in build_root.iterdir():
609-
if module_dir.is_dir() and module_dir.name != module_dir_name:
610-
module_classes = module_dir / "build" / "classes" / "java" / "main"
611-
if module_classes.exists():
612-
logger.debug("Adding multi-module classpath: %s", module_classes)
613-
cp_parts.append(str(module_classes))
614-
615613
if "console-standalone" not in classpath and "ConsoleLauncher" not in classpath:
616614
console_jar = _find_junit_console_standalone()
617615
if console_jar:
@@ -639,6 +637,121 @@ def _parse_classpath_output(stdout: str) -> str | None:
639637
return line.strip()
640638
return None
641639

640+
def _resolve_project_classpath(self, classpath: str, build_root: Path, env: dict[str, str], timeout: int) -> str:
641+
"""Replace missing project dependency JARs with class/resource directories.
642+
643+
Gradle's ``testRuntimeClasspath`` resolves project dependencies to JAR
644+
files (``build/libs/*.jar``), but ``testClasses`` only compiles classes —
645+
it does not package JARs for dependency modules. This method:
646+
647+
1. Scans the classpath for project-local JARs that don't exist on disk.
648+
2. Compiles any dependency modules whose classes haven't been built yet
649+
(e.g. ``testRuntimeOnly`` project deps skipped by ``testClasses``).
650+
3. Replaces each missing JAR entry with the module's compiled-class and
651+
processed-resource directories.
652+
"""
653+
build_root_str = str(build_root)
654+
build_libs_sep = os.sep + "build" + os.sep + "libs" + os.sep
655+
656+
entries = classpath.split(os.pathsep)
657+
missing_jar_modules: dict[int, Path] = {} # index -> module_dir
658+
uncompiled_modules: list[str] = []
659+
660+
# Phase 1: identify missing project dependency JARs
661+
for i, entry in enumerate(entries):
662+
if not entry.startswith(build_root_str) or build_libs_sep not in entry:
663+
continue
664+
if Path(entry).exists():
665+
continue
666+
667+
idx = entry.find(build_libs_sep)
668+
module_dir = Path(entry[:idx])
669+
if not module_dir.is_dir():
670+
continue
671+
672+
missing_jar_modules[i] = module_dir
673+
674+
classes_dir = module_dir / "build" / "classes"
675+
if not classes_dir.exists() or not any(classes_dir.iterdir()):
676+
try:
677+
rel = module_dir.relative_to(build_root)
678+
uncompiled_modules.append(str(rel).replace(os.sep, ":"))
679+
except ValueError:
680+
pass
681+
682+
if not missing_jar_modules:
683+
return classpath
684+
685+
# Phase 2: compile uncompiled dependency modules in one Gradle call
686+
if uncompiled_modules:
687+
self._compile_dependency_modules(build_root, env, uncompiled_modules, timeout)
688+
689+
# Phase 3: replace each missing JAR with class + resource directories
690+
result_entries: list[str] = []
691+
for i, entry in enumerate(entries):
692+
if i not in missing_jar_modules:
693+
result_entries.append(entry)
694+
continue
695+
696+
module_dir = missing_jar_modules[i]
697+
added = False
698+
699+
classes_dir = module_dir / "build" / "classes"
700+
if classes_dir.exists():
701+
for lang_dir in classes_dir.iterdir():
702+
if lang_dir.is_dir():
703+
main_dir = lang_dir / "main"
704+
if main_dir.exists():
705+
result_entries.append(str(main_dir))
706+
added = True
707+
708+
resources_main = module_dir / "build" / "resources" / "main"
709+
if resources_main.exists():
710+
result_entries.append(str(resources_main))
711+
added = True
712+
713+
if not added:
714+
logger.warning("No class/resource directories found for missing JAR: %s", entry)
715+
716+
logger.debug(
717+
"Replaced %d missing project dependency JARs with class/resource directories (compiled %d modules)",
718+
len(missing_jar_modules),
719+
len(uncompiled_modules),
720+
)
721+
return os.pathsep.join(result_entries)
722+
723+
def _compile_dependency_modules(
724+
self, build_root: Path, env: dict[str, str], module_names: list[str], timeout: int
725+
) -> None:
726+
"""Compile project dependency modules that haven't been compiled yet.
727+
728+
This handles ``testRuntimeOnly`` project dependencies that ``testClasses``
729+
does not compile. All modules are compiled in a single Gradle invocation.
730+
"""
731+
from codeflash.languages.java.test_runner import _run_cmd_kill_pg_on_timeout
732+
733+
gradle = self.find_executable(build_root)
734+
if not gradle:
735+
logger.warning("Gradle not found — cannot compile dependency modules")
736+
return
737+
738+
tasks = [f":{module}:classes" for module in module_names]
739+
cmd = [gradle, *tasks, "--no-daemon"]
740+
cmd.extend(["--init-script", _get_skip_validation_init_script()])
741+
742+
logger.info("Compiling %d uncompiled project dependencies: %s", len(module_names), module_names)
743+
744+
try:
745+
result = _run_cmd_kill_pg_on_timeout(cmd, cwd=build_root, env=env, timeout=timeout)
746+
if result.returncode != 0:
747+
logger.warning(
748+
"Failed to compile dependency modules (exit %d): %s",
749+
result.returncode,
750+
result.stderr[-1000:] if result.stderr else "",
751+
)
752+
except Exception:
753+
logger.exception("Exception compiling dependency modules")
754+
642755
def get_reports_dir(self, build_root: Path, test_module: str | None) -> Path:
643756
build_dir = self.get_build_output_dir(build_root, test_module)
644757
return build_dir / "test-results" / "test"

codeflash/languages/java/test_runner.py

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -202,15 +202,29 @@ def _validate_test_filter(test_filter: str) -> str:
202202
def _extract_modules_from_settings_gradle(content: str) -> list[str]:
203203
"""Extract module names from settings.gradle(.kts) content.
204204
205-
Looks for include directives like:
206-
include("module-a", "module-b") // Kotlin DSL
207-
include 'module-a', 'module-b' // Groovy DSL
205+
Handles several patterns:
206+
include("module-a", "module-b") // Kotlin DSL (single/multi-line)
207+
include 'module-a', 'module-b' // Groovy DSL
208+
val projects = listOf("module-a", "module-b") // Kotlin variable lists
208209
Module names may be prefixed with ':' which is stripped.
209210
"""
210211
modules: list[str] = []
211-
for match in re.findall(r"""include\s*\(?[^)\n]*\)?""", content):
212+
# Match include(...) calls, including multi-line ones
213+
for match in re.findall(r"""include\s*\(([^)]*)\)""", content, re.DOTALL):
212214
for name in re.findall(r"""['"]([^'"]+)['"]""", match):
213215
modules.append(name.lstrip(":"))
216+
# Match Groovy-style: include 'mod-a', 'mod-b' (no parens, single line)
217+
for match in re.findall(r"""include\s+(?=['"])([^\n]+)""", content):
218+
for name in re.findall(r"""['"]([^'"]+)['"]""", match):
219+
clean = name.lstrip(":")
220+
if clean not in modules:
221+
modules.append(clean)
222+
# Match Kotlin-style variable lists: val x = listOf("mod-a", "mod-b")
223+
for match in re.findall(r"""listOf\s*\(([^)]*)\)""", content, re.DOTALL):
224+
for name in re.findall(r"""['"]([^'"]+)['"]""", match):
225+
clean = name.lstrip(":")
226+
if clean not in modules:
227+
modules.append(clean)
214228
return modules
215229

216230

Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,211 @@
1+
"""Tests for GradleStrategy._resolve_project_classpath and _compile_dependency_modules."""
2+
3+
import os
4+
import subprocess
5+
from pathlib import Path
6+
from unittest.mock import patch
7+
8+
import pytest
9+
10+
from codeflash.languages.java.gradle_strategy import GradleStrategy
11+
12+
13+
@pytest.fixture()
14+
def strategy():
15+
return GradleStrategy()
16+
17+
18+
@pytest.fixture()
19+
def build_root(tmp_path):
20+
"""Create a multi-module Gradle project layout with missing JARs."""
21+
root = (tmp_path / "project").resolve()
22+
root.mkdir()
23+
24+
# Module A: compiled (has classes but no JAR)
25+
mod_a = root / "module-a"
26+
(mod_a / "build" / "classes" / "java" / "main" / "com" / "example").mkdir(parents=True)
27+
(mod_a / "build" / "classes" / "java" / "main" / "com" / "example" / "A.class").write_bytes(b"")
28+
(mod_a / "build" / "resources" / "main" / "META-INF").mkdir(parents=True)
29+
(mod_a / "build" / "resources" / "main" / "META-INF" / "services.txt").write_bytes(b"")
30+
31+
# Module B: compiled with Kotlin (has kotlin classes but no JAR)
32+
mod_b = root / "module-b"
33+
(mod_b / "build" / "classes" / "kotlin" / "main" / "com" / "example").mkdir(parents=True)
34+
(mod_b / "build" / "classes" / "kotlin" / "main" / "com" / "example" / "B.class").write_bytes(b"")
35+
(mod_b / "build" / "classes" / "java" / "main" / "com" / "example").mkdir(parents=True)
36+
(mod_b / "build" / "classes" / "java" / "main" / "com" / "example" / "BHelper.class").write_bytes(b"")
37+
38+
# Module C: uncompiled (no build/classes at all — testRuntimeOnly dep)
39+
mod_c = root / "module-c"
40+
mod_c.mkdir()
41+
42+
# External dependency JAR (exists)
43+
ext_dir = tmp_path / "gradle-cache"
44+
ext_dir.mkdir()
45+
ext_jar = ext_dir / "some-lib-1.0.jar"
46+
ext_jar.write_bytes(b"")
47+
48+
return root
49+
50+
51+
def _make_classpath(build_root: Path, tmp_path: Path) -> str:
52+
"""Build a classpath string mimicking Gradle's testRuntimeClasspath output."""
53+
sep = os.pathsep
54+
ext_jar = str(tmp_path / "gradle-cache" / "some-lib-1.0.jar")
55+
return sep.join([
56+
str(build_root / "module-a" / "build" / "libs" / "module-a-1.0.jar"),
57+
ext_jar,
58+
str(build_root / "module-b" / "build" / "libs" / "module-b-1.0.jar"),
59+
str(build_root / "module-c" / "build" / "libs" / "module-c-1.0.jar"),
60+
])
61+
62+
63+
def test_replaces_missing_jars_with_class_dirs(strategy, build_root, tmp_path):
64+
"""Missing project JARs should be replaced with class/resource directories."""
65+
classpath = _make_classpath(build_root, tmp_path)
66+
67+
with (
68+
patch.object(GradleStrategy, "find_executable", return_value="gradle"),
69+
patch("codeflash.languages.java.test_runner._run_cmd_kill_pg_on_timeout") as mock_run,
70+
):
71+
# Mock the compilation of module-c
72+
mock_run.return_value = subprocess.CompletedProcess(args=["gradle"], returncode=0, stdout="", stderr="")
73+
# Simulate that compilation creates the class directory
74+
(build_root / "module-c" / "build" / "classes" / "java" / "main").mkdir(parents=True)
75+
76+
result = strategy._resolve_project_classpath(classpath, build_root, {}, timeout=60)
77+
78+
entries = result.split(os.pathsep)
79+
80+
# Module A: JAR replaced with java/main classes + resources/main
81+
mod_a_java = str(build_root / "module-a" / "build" / "classes" / "java" / "main")
82+
mod_a_resources = str(build_root / "module-a" / "build" / "resources" / "main")
83+
assert mod_a_java in entries
84+
assert mod_a_resources in entries
85+
86+
# Module B: JAR replaced with both kotlin/main and java/main classes
87+
mod_b_kotlin = str(build_root / "module-b" / "build" / "classes" / "kotlin" / "main")
88+
mod_b_java = str(build_root / "module-b" / "build" / "classes" / "java" / "main")
89+
assert mod_b_kotlin in entries
90+
assert mod_b_java in entries
91+
92+
# External JAR preserved as-is
93+
ext_jar = str(tmp_path / "gradle-cache" / "some-lib-1.0.jar")
94+
assert ext_jar in entries
95+
96+
# Original JAR paths should NOT be present
97+
assert str(build_root / "module-a" / "build" / "libs" / "module-a-1.0.jar") not in entries
98+
assert str(build_root / "module-b" / "build" / "libs" / "module-b-1.0.jar") not in entries
99+
100+
101+
def test_compiles_uncompiled_modules(strategy, build_root, tmp_path):
102+
"""Modules with no compiled classes should trigger a Gradle compilation."""
103+
classpath = _make_classpath(build_root, tmp_path)
104+
105+
with (
106+
patch.object(GradleStrategy, "find_executable", return_value="gradle"),
107+
patch("codeflash.languages.java.test_runner._run_cmd_kill_pg_on_timeout") as mock_run,
108+
):
109+
mock_run.return_value = subprocess.CompletedProcess(args=["gradle"], returncode=0, stdout="", stderr="")
110+
111+
strategy._resolve_project_classpath(classpath, build_root, {"JAVA_HOME": "/jdk"}, timeout=120)
112+
113+
# Should have been called once to compile module-c
114+
mock_run.assert_called_once()
115+
cmd = mock_run.call_args[0][0]
116+
assert cmd[0] == "gradle"
117+
assert ":module-c:classes" in cmd
118+
assert "--no-daemon" in cmd
119+
120+
121+
def test_no_compilation_when_all_compiled(strategy, build_root, tmp_path):
122+
"""When all modules have compiled classes, no compilation should be triggered."""
123+
# Give module-c some compiled classes
124+
(build_root / "module-c" / "build" / "classes" / "java" / "main").mkdir(parents=True)
125+
(build_root / "module-c" / "build" / "classes" / "java" / "main" / "C.class").write_bytes(b"")
126+
127+
classpath = _make_classpath(build_root, tmp_path)
128+
129+
with (
130+
patch.object(GradleStrategy, "find_executable", return_value="gradle"),
131+
patch("codeflash.languages.java.test_runner._run_cmd_kill_pg_on_timeout") as mock_run,
132+
):
133+
strategy._resolve_project_classpath(classpath, build_root, {}, timeout=60)
134+
135+
# No Gradle call should have been made
136+
mock_run.assert_not_called()
137+
138+
139+
def test_noop_when_no_missing_jars(strategy, build_root, tmp_path):
140+
"""When all JARs exist, the classpath should be returned unchanged."""
141+
# Create all the JAR files
142+
for mod in ["module-a", "module-b", "module-c"]:
143+
jar_dir = build_root / mod / "build" / "libs"
144+
jar_dir.mkdir(parents=True, exist_ok=True)
145+
(jar_dir / f"{mod}-1.0.jar").write_bytes(b"")
146+
147+
classpath = _make_classpath(build_root, tmp_path)
148+
result = strategy._resolve_project_classpath(classpath, build_root, {}, timeout=60)
149+
assert result == classpath
150+
151+
152+
def test_external_missing_jar_preserved(strategy, tmp_path):
153+
"""Missing external JARs (not under build_root) should be kept as-is."""
154+
root = (tmp_path / "project").resolve()
155+
root.mkdir()
156+
157+
external_jar = "/some/external/path/lib.jar"
158+
classpath = external_jar
159+
160+
result = strategy._resolve_project_classpath(classpath, root, {}, timeout=60)
161+
assert result == external_jar
162+
163+
164+
def test_nested_gradle_module(strategy, tmp_path):
165+
"""Nested Gradle modules (connect/runtime) should be handled correctly."""
166+
root = (tmp_path / "project").resolve()
167+
root.mkdir()
168+
169+
# Nested module: connect/runtime
170+
nested = root / "connect" / "runtime"
171+
(nested / "build" / "classes" / "java" / "main").mkdir(parents=True)
172+
(nested / "build" / "classes" / "java" / "main" / "R.class").write_bytes(b"")
173+
174+
jar_path = str(root / "connect" / "runtime" / "build" / "libs" / "runtime-1.0.jar")
175+
classpath = jar_path
176+
177+
result = strategy._resolve_project_classpath(classpath, root, {}, timeout=60)
178+
entries = result.split(os.pathsep)
179+
180+
assert str(root / "connect" / "runtime" / "build" / "classes" / "java" / "main") in entries
181+
assert jar_path not in entries
182+
183+
184+
def test_compile_dependency_modules_single_call(strategy, tmp_path):
185+
"""Multiple uncompiled modules should be compiled in a single Gradle invocation."""
186+
root = (tmp_path / "project").resolve()
187+
root.mkdir()
188+
189+
with (
190+
patch.object(GradleStrategy, "find_executable", return_value="gradle"),
191+
patch("codeflash.languages.java.test_runner._run_cmd_kill_pg_on_timeout") as mock_run,
192+
):
193+
mock_run.return_value = subprocess.CompletedProcess(args=["gradle"], returncode=0, stdout="", stderr="")
194+
195+
strategy._compile_dependency_modules(root, {}, ["module-a", "module-b", "connect:runtime"], timeout=120)
196+
197+
mock_run.assert_called_once()
198+
cmd = mock_run.call_args[0][0]
199+
assert ":module-a:classes" in cmd
200+
assert ":module-b:classes" in cmd
201+
assert ":connect:runtime:classes" in cmd
202+
203+
204+
def test_compile_dependency_modules_gradle_not_found(strategy, tmp_path):
205+
"""Should not crash when Gradle executable is not found."""
206+
root = (tmp_path / "project").resolve()
207+
root.mkdir()
208+
209+
with patch.object(GradleStrategy, "find_executable", return_value=None):
210+
# Should not raise
211+
strategy._compile_dependency_modules(root, {}, ["module-a"], timeout=60)

0 commit comments

Comments
 (0)