Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
131 changes: 122 additions & 9 deletions codeflash/languages/java/gradle_strategy.py
Original file line number Diff line number Diff line change
Expand Up @@ -589,6 +589,13 @@ def _get_classpath_uncached(
logger.error("Classpath not found in Gradle output")
return None

# Replace missing project dependency JARs with class/resource directories.
# Gradle's testRuntimeClasspath resolves project deps to JAR files
# (build/libs/*.jar), but testClasses doesn't build JARs for dependency
# modules. This also compiles any testRuntimeOnly project deps that
# weren't compiled by testClasses.
classpath = self._resolve_project_classpath(classpath, build_root, env, timeout)

if test_module:
module_path = build_root / module_to_dir(test_module)
else:
Expand All @@ -603,15 +610,6 @@ def _get_classpath_uncached(
if main_classes.exists():
cp_parts.append(str(main_classes))

if test_module:
module_dir_name = module_to_dir(test_module)
for module_dir in build_root.iterdir():
if module_dir.is_dir() and module_dir.name != module_dir_name:
module_classes = module_dir / "build" / "classes" / "java" / "main"
if module_classes.exists():
logger.debug("Adding multi-module classpath: %s", module_classes)
cp_parts.append(str(module_classes))

if "console-standalone" not in classpath and "ConsoleLauncher" not in classpath:
console_jar = _find_junit_console_standalone()
if console_jar:
Expand Down Expand Up @@ -639,6 +637,121 @@ def _parse_classpath_output(stdout: str) -> str | None:
return line.strip()
return None

def _resolve_project_classpath(self, classpath: str, build_root: Path, env: dict[str, str], timeout: int) -> str:
"""Replace missing project dependency JARs with class/resource directories.

Gradle's ``testRuntimeClasspath`` resolves project dependencies to JAR
files (``build/libs/*.jar``), but ``testClasses`` only compiles classes —
it does not package JARs for dependency modules. This method:

1. Scans the classpath for project-local JARs that don't exist on disk.
2. Compiles any dependency modules whose classes haven't been built yet
(e.g. ``testRuntimeOnly`` project deps skipped by ``testClasses``).
3. Replaces each missing JAR entry with the module's compiled-class and
processed-resource directories.
"""
build_root_str = str(build_root)
build_libs_sep = os.sep + "build" + os.sep + "libs" + os.sep

entries = classpath.split(os.pathsep)
missing_jar_modules: dict[int, Path] = {} # index -> module_dir
uncompiled_modules: list[str] = []

# Phase 1: identify missing project dependency JARs
for i, entry in enumerate(entries):
if not entry.startswith(build_root_str) or build_libs_sep not in entry:
continue
if Path(entry).exists():
continue

idx = entry.find(build_libs_sep)
module_dir = Path(entry[:idx])
if not module_dir.is_dir():
continue

missing_jar_modules[i] = module_dir

classes_dir = module_dir / "build" / "classes"
if not classes_dir.exists() or not any(classes_dir.iterdir()):
try:
rel = module_dir.relative_to(build_root)
uncompiled_modules.append(str(rel).replace(os.sep, ":"))
except ValueError:
pass

if not missing_jar_modules:
return classpath

# Phase 2: compile uncompiled dependency modules in one Gradle call
if uncompiled_modules:
self._compile_dependency_modules(build_root, env, uncompiled_modules, timeout)

# Phase 3: replace each missing JAR with class + resource directories
result_entries: list[str] = []
for i, entry in enumerate(entries):
if i not in missing_jar_modules:
result_entries.append(entry)
continue

module_dir = missing_jar_modules[i]
added = False

classes_dir = module_dir / "build" / "classes"
if classes_dir.exists():
for lang_dir in classes_dir.iterdir():
if lang_dir.is_dir():
main_dir = lang_dir / "main"
if main_dir.exists():
result_entries.append(str(main_dir))
added = True

resources_main = module_dir / "build" / "resources" / "main"
if resources_main.exists():
result_entries.append(str(resources_main))
added = True

if not added:
logger.warning("No class/resource directories found for missing JAR: %s", entry)

logger.debug(
"Replaced %d missing project dependency JARs with class/resource directories (compiled %d modules)",
len(missing_jar_modules),
len(uncompiled_modules),
)
return os.pathsep.join(result_entries)

def _compile_dependency_modules(
self, build_root: Path, env: dict[str, str], module_names: list[str], timeout: int
) -> None:
"""Compile project dependency modules that haven't been compiled yet.

This handles ``testRuntimeOnly`` project dependencies that ``testClasses``
does not compile. All modules are compiled in a single Gradle invocation.
"""
from codeflash.languages.java.test_runner import _run_cmd_kill_pg_on_timeout

gradle = self.find_executable(build_root)
if not gradle:
logger.warning("Gradle not found — cannot compile dependency modules")
return

tasks = [f":{module}:classes" for module in module_names]
cmd = [gradle, *tasks, "--no-daemon"]
cmd.extend(["--init-script", _get_skip_validation_init_script()])

logger.info("Compiling %d uncompiled project dependencies: %s", len(module_names), module_names)

try:
result = _run_cmd_kill_pg_on_timeout(cmd, cwd=build_root, env=env, timeout=timeout)
if result.returncode != 0:
logger.warning(
"Failed to compile dependency modules (exit %d): %s",
result.returncode,
result.stderr[-1000:] if result.stderr else "",
)
except Exception:
logger.exception("Exception compiling dependency modules")

def get_reports_dir(self, build_root: Path, test_module: str | None) -> Path:
build_dir = self.get_build_output_dir(build_root, test_module)
return build_dir / "test-results" / "test"
Expand Down
45 changes: 37 additions & 8 deletions codeflash/languages/java/test_runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -202,15 +202,43 @@ def _validate_test_filter(test_filter: str) -> str:
def _extract_modules_from_settings_gradle(content: str) -> list[str]:
"""Extract module names from settings.gradle(.kts) content.

Looks for include directives like:
include("module-a", "module-b") // Kotlin DSL
include 'module-a', 'module-b' // Groovy DSL
Module names may be prefixed with ':' which is stripped.
Collects every quoted string that looks like a Gradle module identifier
(word chars, hyphens, colons, dots). This is intentionally broad so it
works regardless of DSL style — ``include("a")``, ``listOf("a")``,
variable-based indirection, etc. False positives are harmless because
callers match module names against actual file paths.
"""
seen: set[str] = set()
modules: list[str] = []
for match in re.findall(r"""include\s*\(?[^)\n]*\)?""", content):
for name in re.findall(r"""['"]([^'"]+)['"]""", match):
modules.append(name.lstrip(":"))
for name in re.findall(r"""['"]([:\w][\w.:-]*)['"]""", content):
clean = name.lstrip(":")
if clean and clean not in seen:
seen.add(clean)
modules.append(clean)
return modules


def _scan_filesystem_for_modules(directory: Path) -> list[str]:
"""Detect Gradle/Maven sub-modules by scanning for build files on disk.

Checks up to two directory levels (covers nested modules like
``connect/runtime``). This is a reliable fallback when the settings
file uses complex DSL that text-based parsing cannot handle.
"""
modules: list[str] = []
build_files = ("build.gradle", "build.gradle.kts", "pom.xml")
for child in directory.iterdir():
if not child.is_dir() or child.name.startswith("."):
continue
if any((child / bf).exists() for bf in build_files):
modules.append(child.name)
# One level deeper for nested modules (connect/runtime)
for grandchild in child.iterdir():
if not grandchild.is_dir() or grandchild.name.startswith("."):
continue
if any((grandchild / bf).exists() for bf in build_files):
rel = grandchild.relative_to(directory)
modules.append(str(rel).replace(os.sep, ":"))
return modules


Expand All @@ -237,7 +265,8 @@ def _detect_modules(directory: Path) -> list[str]:
except Exception:
pass

return []
# Fallback: scan filesystem for directories with build files
return _scan_filesystem_for_modules(directory)


def _is_build_root(directory: Path) -> bool:
Expand Down
Loading
Loading