diff --git a/codeflash-benchmark/codeflash_benchmark/version.py b/codeflash-benchmark/codeflash_benchmark/version.py index a421c3690..a759bb9fd 100644 --- a/codeflash-benchmark/codeflash_benchmark/version.py +++ b/codeflash-benchmark/codeflash_benchmark/version.py @@ -1,2 +1,2 @@ # These version placeholders will be replaced by uv-dynamic-versioning during build. -__version__ = "0.20.5.post146.dev0+5ff38597" +__version__ = "0.20.5.post169.dev0+2dba3e38" diff --git a/codeflash/languages/base.py b/codeflash/languages/base.py index e699afd2b..dea5126f7 100644 --- a/codeflash/languages/base.py +++ b/codeflash/languages/base.py @@ -897,7 +897,7 @@ def instrument_existing_test( ... def instrument_source_for_line_profiler( - self, func_info: FunctionToOptimize, line_profiler_output_file: Path + self, func_info: FunctionToOptimize, line_profiler_output_file: Path, project_classpath: str | None = None ) -> bool: """Instrument source code before line profiling.""" ... diff --git a/codeflash/languages/java/function_optimizer.py b/codeflash/languages/java/function_optimizer.py index 5700a907a..08980032e 100644 --- a/codeflash/languages/java/function_optimizer.py +++ b/codeflash/languages/java/function_optimizer.py @@ -404,7 +404,9 @@ def line_profiler_step( line_profiler_output_path = get_run_tmp_file(Path("line_profiler_output.json")) success = self.language_support.instrument_source_for_line_profiler( - func_info=self.function_to_optimize, line_profiler_output_file=line_profiler_output_path + func_info=self.function_to_optimize, + line_profiler_output_file=line_profiler_output_path, + project_classpath=self._get_project_classpath(), ) if not success: return {"timings": {}, "unit": 0, "str_out": ""} diff --git a/codeflash/languages/java/gradle_strategy.py b/codeflash/languages/java/gradle_strategy.py index 7adb70dfa..c4174ffe4 100644 --- a/codeflash/languages/java/gradle_strategy.py +++ b/codeflash/languages/java/gradle_strategy.py @@ -9,7 +9,6 @@ import logging import os import re -import shutil import subprocess import tempfile import xml.etree.ElementTree as ET @@ -17,7 +16,7 @@ from typing import Any from codeflash.languages.java.build_tool_strategy import BuildToolStrategy, module_to_dir -from codeflash.languages.java.build_tools import BuildTool, JavaProjectInfo +from codeflash.languages.java.build_tools import CODEFLASH_RUNTIME_VERSION, BuildTool, JavaProjectInfo _RE_INCLUDE = re.compile(r"""include\s*\(?([^)\n]+)\)?""") @@ -104,22 +103,6 @@ def cp = configurations.findByName('testRuntimeClasspath') } """ -# Gradle init script that applies JaCoCo plugin for coverage collection. -# Uses projectsEvaluated to avoid triggering configuration of unrelated subprojects. -_JACOCO_INIT_SCRIPT = """\ -gradle.projectsEvaluated { - allprojects { - apply plugin: 'jacoco' - jacocoTestReport { - reports { - xml.required = true - html.required = false - } - } - } -} -""" - def find_gradle_build_file(project_root: Path) -> Path | None: kts = project_root / "build.gradle.kts" @@ -131,12 +114,12 @@ def find_gradle_build_file(project_root: Path) -> Path | None: return None -def _find_top_level_dependencies_block(build_file: Path, content: str) -> int | None: - """Find the insert position (before closing }) of the top-level dependencies block using tree-sitter. +def _find_top_level_block(build_file: Path, content: str, block_name: str) -> int | None: + """Find the insert position (before closing }) of a top-level block using tree-sitter. - Returns the byte offset of the closing brace, or None if no top-level dependencies block exists. - Only matches `dependencies { }` at the root level — ignores blocks nested inside - `buildscript`, `subprojects`, `allprojects`, etc. + Returns the byte offset of the closing brace, or None if no top-level block with the + given name exists. Only matches blocks at the root level — ignores blocks nested inside + ``buildscript``, ``subprojects``, ``allprojects``, etc. """ import tree_sitter as ts @@ -154,10 +137,10 @@ def _find_top_level_dependencies_block(build_file: Path, content: str) -> int | tree = parser.parse(source_bytes) - # Walk only direct children of root to find top-level `dependencies { }` + # Walk only direct children of root to find top-level ` { }` for child in tree.root_node.children: - # Groovy: expression_statement > method_invocation(identifier="dependencies", closure) - # Kotlin: call_expression(identifier="dependencies", annotated_lambda) + # Groovy: expression_statement > method_invocation(identifier, closure) + # Kotlin: call_expression(identifier, annotated_lambda) node = child if node.type == "expression_statement" and node.child_count > 0: node = node.children[0] @@ -177,7 +160,7 @@ def _find_top_level_dependencies_block(build_file: Path, content: str) -> int | continue name = source_bytes[name_node.start_byte : name_node.end_byte].decode("utf-8") - if name != "dependencies": + if name != block_name: continue # Find the closing brace of this block @@ -192,6 +175,11 @@ def _find_top_level_dependencies_block(build_file: Path, content: str) -> int | return None +def _find_top_level_dependencies_block(build_file: Path, content: str) -> int | None: + """Find the insert position (before closing }) of the top-level dependencies block.""" + return _find_top_level_block(build_file, content, "dependencies") + + def _is_multimodule_project(build_root: Path) -> bool: """Check if this is a multi-module Gradle project by looking for include directives in settings files.""" for settings_name in ("settings.gradle", "settings.gradle.kts"): @@ -206,8 +194,66 @@ def _is_multimodule_project(build_root: Path) -> bool: return False -def add_codeflash_dependency_multimodule(build_file: Path, runtime_jar_path: Path) -> bool: - """Add codeflash-runtime dependency wrapped in a subprojects block for multi-module projects. +_CODEFLASH_MAVEN_COORD = f"com.codeflash:codeflash-runtime:{CODEFLASH_RUNTIME_VERSION}" + + +def _update_existing_codeflash_dependency(build_file: Path, content: str) -> str | None: + """If the codeflash-runtime dependency exists but is outdated or uses the old files() format, update it. + + Returns the updated content, or None if no update was needed (already current). + """ + is_kts = build_file.name.endswith(".kts") + + if is_kts: + current_dep = f'testImplementation("{_CODEFLASH_MAVEN_COORD}")' + else: + current_dep = f"testImplementation '{_CODEFLASH_MAVEN_COORD}'" + + if current_dep in content: + return None + + # Replace the line containing "codeflash-runtime" with the current Maven Central coordinate. + # This handles both old versions (e.g. 1.0.0) and old files() format. + updated_lines: list[str] = [] + replaced = False + for line in content.splitlines(keepends=True): + if "codeflash-runtime" in line: + indent = len(line) - len(line.lstrip()) + spaces = " " * indent + if is_kts: + updated_lines.append(f'{spaces}testImplementation("{_CODEFLASH_MAVEN_COORD}") // codeflash-runtime\n') + else: + updated_lines.append(f"{spaces}testImplementation '{_CODEFLASH_MAVEN_COORD}' // codeflash-runtime\n") + replaced = True + else: + updated_lines.append(line) + + if replaced: + return "".join(updated_lines) + return None + + +def _ensure_maven_central_repo(build_file: Path, content: str) -> str: + """Ensure mavenCentral() is present in the top-level repositories block. Returns updated content. + + Uses tree-sitter to find the correct top-level ``repositories {}`` block, avoiding + false matches inside ``buildscript {}``, ``subprojects {}``, etc. + """ + if "mavenCentral()" in content: + return content + + # Use tree-sitter to find the top-level repositories block + insert_pos = _find_top_level_block(build_file, content, "repositories") + if insert_pos is not None: + return content[:insert_pos] + " mavenCentral()\n" + content[insert_pos:] + + # No top-level repositories block — append one + content += "\nrepositories {\n mavenCentral()\n}\n" + return content + + +def add_codeflash_dependency_multimodule(build_file: Path) -> bool: + """Add codeflash-runtime dependency from Maven Central in a subprojects block for multi-module projects. This avoids adding testImplementation to the root build file directly, which would fail if the root project doesn't apply the java plugin. @@ -219,18 +265,29 @@ def add_codeflash_dependency_multimodule(build_file: Path, runtime_jar_path: Pat content = build_file.read_text(encoding="utf-8") if "codeflash-runtime" in content: - logger.info("codeflash-runtime dependency already present in %s", build_file.name) + updated = _update_existing_codeflash_dependency(build_file, content) + if updated is not None: + build_file.write_text(updated, encoding="utf-8") + logger.info( + "Updated codeflash-runtime dependency in %s to version %s", + build_file.name, + CODEFLASH_RUNTIME_VERSION, + ) + else: + logger.info("codeflash-runtime dependency already up-to-date in %s", build_file.name) return True is_kts = build_file.name.endswith(".kts") - jar_str = str(runtime_jar_path).replace("\\", "/") if is_kts: block = ( f"\nsubprojects {{\n" f' plugins.withId("java") {{\n' + f" repositories {{\n" + f" mavenCentral()\n" + f" }}\n" f" dependencies {{\n" - f' testImplementation(files("{jar_str}")) // codeflash-runtime\n' + f' testImplementation("{_CODEFLASH_MAVEN_COORD}") // codeflash-runtime\n' f" }}\n" f" }}\n" f"}}\n" @@ -239,8 +296,11 @@ def add_codeflash_dependency_multimodule(build_file: Path, runtime_jar_path: Pat block = ( f"\nsubprojects {{\n" f" plugins.withId('java') {{\n" + f" repositories {{\n" + f" mavenCentral()\n" + f" }}\n" f" dependencies {{\n" - f" testImplementation files('{jar_str}') // codeflash-runtime\n" + f" testImplementation '{_CODEFLASH_MAVEN_COORD}' // codeflash-runtime\n" f" }}\n" f" }}\n" f"}}\n" @@ -256,7 +316,7 @@ def add_codeflash_dependency_multimodule(build_file: Path, runtime_jar_path: Pat return False -def add_codeflash_dependency(build_file: Path, runtime_jar_path: Path) -> bool: +def add_codeflash_dependency(build_file: Path) -> bool: if not build_file.exists(): return False @@ -264,16 +324,28 @@ def add_codeflash_dependency(build_file: Path, runtime_jar_path: Path) -> bool: content = build_file.read_text(encoding="utf-8") if "codeflash-runtime" in content: - logger.info("codeflash-runtime dependency already present in %s", build_file.name) + updated = _update_existing_codeflash_dependency(build_file, content) + if updated is not None: + # Also ensure mavenCentral() is present (old files() format won't have it) + updated = _ensure_maven_central_repo(build_file, updated) + build_file.write_text(updated, encoding="utf-8") + logger.info( + "Updated codeflash-runtime dependency in %s to version %s", + build_file.name, + CODEFLASH_RUNTIME_VERSION, + ) + else: + logger.info("codeflash-runtime dependency already up-to-date in %s", build_file.name) return True + content = _ensure_maven_central_repo(build_file, content) + is_kts = build_file.name.endswith(".kts") - jar_str = str(runtime_jar_path).replace("\\", "/") if is_kts: - dep_line = f' testImplementation(files("{jar_str}")) // codeflash-runtime\n' + dep_line = f' testImplementation("{_CODEFLASH_MAVEN_COORD}") // codeflash-runtime\n' else: - dep_line = f" testImplementation files('{jar_str}') // codeflash-runtime\n" + dep_line = f" testImplementation '{_CODEFLASH_MAVEN_COORD}' // codeflash-runtime\n" # Use tree-sitter to find the top-level dependencies block insert_pos = _find_top_level_dependencies_block(build_file, content) @@ -285,9 +357,13 @@ def add_codeflash_dependency(build_file: Path, runtime_jar_path: Path) -> bool: # No existing dependencies block — append one if is_kts: - content += f'\ndependencies {{\n testImplementation(files("{jar_str}")) // codeflash-runtime\n}}\n' + content += ( + f'\ndependencies {{\n testImplementation("{_CODEFLASH_MAVEN_COORD}") // codeflash-runtime\n}}\n' + ) else: - content += f"\ndependencies {{\n testImplementation files('{jar_str}') // codeflash-runtime\n}}\n" + content += ( + f"\ndependencies {{\n testImplementation '{_CODEFLASH_MAVEN_COORD}' // codeflash-runtime\n}}\n" + ) build_file.write_text(content, encoding="utf-8") logger.info("Added codeflash-runtime dependency to %s (new block)", build_file.name) return True @@ -421,34 +497,21 @@ def find_executable(self, build_root: Path) -> str | None: return self.find_wrapper_executable(build_root, ("gradlew", "gradlew.bat"), "gradle") def ensure_runtime(self, build_root: Path, test_module: str | None) -> bool: - runtime_jar = self.find_runtime_jar() - if runtime_jar is None: - logger.error("codeflash-runtime JAR not found. Generated tests will fail to compile.") - return False - if test_module: module_root = build_root / module_to_dir(test_module) else: module_root = build_root - libs_dir = module_root / "libs" - libs_dir.mkdir(parents=True, exist_ok=True) - dest_jar = libs_dir / "codeflash-runtime-1.0.1.jar" - - if not dest_jar.exists(): - logger.info("Copying codeflash-runtime JAR to %s", dest_jar) - shutil.copy2(runtime_jar, dest_jar) - build_file = find_gradle_build_file(module_root) if build_file is None: logger.warning("No build.gradle(.kts) found at %s, cannot add codeflash-runtime dependency", module_root) return False if not test_module and _is_multimodule_project(build_root): - if not add_codeflash_dependency_multimodule(build_file, dest_jar): + if not add_codeflash_dependency_multimodule(build_file): logger.error("Failed to add codeflash-runtime dependency to %s", build_file) return False - elif not add_codeflash_dependency(build_file, dest_jar): + elif not add_codeflash_dependency(build_file): logger.error("Failed to add codeflash-runtime dependency to %s", build_file) return False @@ -470,14 +533,14 @@ def install_multi_module_deps(self, build_root: Path, test_module: str | None, e logger.error("Gradle not found — cannot pre-install multi-module dependencies") return False - cmd = [gradle, f":{test_module}:classes", "-x", "test", "--build-cache", "--no-daemon"] + cmd = [gradle, f":{test_module}:testClasses", "-x", "test", "--build-cache", "--no-daemon"] cmd.extend(["--init-script", _get_skip_validation_init_script()]) logger.info("Pre-installing multi-module dependencies: %s (module: %s)", build_root, test_module) logger.debug("Running: %s", " ".join(cmd)) try: - result = _run_cmd_kill_pg_on_timeout(cmd, cwd=build_root, env=env, timeout=300) + result = _run_cmd_kill_pg_on_timeout(cmd, cwd=build_root, env=env, timeout=900) if result.returncode != 0: logger.error( "Failed to pre-install multi-module deps (exit %d).\nstdout: %s\nstderr: %s", @@ -658,7 +721,7 @@ def run_tests_via_build_tool( mode: str, test_module: str | None, javaagent_arg: str | None = None, - enable_coverage: bool = False, + enable_coverage: bool = False, # kept for interface compatibility; coverage now uses JAVA_TOOL_OPTIONS ) -> subprocess.CompletedProcess[str]: from codeflash.languages.java.test_runner import _build_test_filter, _run_cmd_kill_pg_on_timeout @@ -729,25 +792,12 @@ def run_tests_via_build_tool( cmd = [gradle, task, "--no-daemon", "--rerun", "--init-script", init_path] cmd.extend(["--init-script", _get_skip_validation_init_script()]) - # --continue ensures Gradle keeps going even if some tests fail. - # For coverage: needed so jacocoTestReport runs even after test failures - # (matches Maven's -Dmaven.test.failure.ignore=true). - # Note: multi-module --tests filtering is handled by - # filter.failOnNoMatchingTests = false in the init script above - # (matches Maven's -DfailIfNoTests=false). - if enable_coverage: - cmd.append("--continue") - for class_filter in test_filter.split(","): class_filter = class_filter.strip() if class_filter: cmd.extend(["--tests", class_filter]) logger.debug("Added --tests filters to Gradle command") - # Append jacocoTestReport AFTER --tests so Gradle doesn't try to apply --tests to it - if enable_coverage: - cmd.append("jacocoTestReport") - logger.debug("Running Gradle command: %s in %s", " ".join(cmd), build_root) result = _run_cmd_kill_pg_on_timeout(cmd, cwd=build_root, env=env, timeout=timeout) @@ -884,64 +934,105 @@ def run_tests_with_coverage( timeout: int, candidate_index: int, ) -> tuple[subprocess.CompletedProcess[str], Path, Path | None]: + from codeflash.languages.java.line_profiler import find_agent_jar from codeflash.languages.java.test_runner import _get_combined_junit_xml - coverage_xml_path = self.setup_coverage(build_root, test_module, build_root) + if test_module: + module_path = build_root / module_to_dir(test_module) + else: + module_path = build_root + # Locate the runtime JAR (contains shaded JaCoCo agent + CLI) + classpath = self.get_classpath(build_root, run_env, test_module) + runtime_jar = find_agent_jar(classpath=classpath) + if runtime_jar is None: + logger.warning("codeflash-runtime JAR not found, cannot collect coverage") + result = self.run_tests_via_build_tool( + build_root, test_paths, run_env, timeout=timeout, mode="behavior", test_module=test_module + ) + reports_dir = self.get_reports_dir(build_root, test_module) + result_xml_path = _get_combined_junit_xml(reports_dir, candidate_index) + return result, result_xml_path, None + + # Use the runtime JAR's built-in JaCoCo agent via AgentDispatcher + exec_path = module_path / "build" / "jacoco" / "test.exec" + exec_path.parent.mkdir(parents=True, exist_ok=True) + + jacoco_agent_arg = f"-javaagent:{runtime_jar}=destfile={exec_path}" + run_env = run_env.copy() + existing_opts = run_env.get("JAVA_TOOL_OPTIONS", "") + run_env["JAVA_TOOL_OPTIONS"] = f"{existing_opts} {jacoco_agent_arg}".strip() + + # Run tests WITHOUT enable_coverage (no jacocoTestReport task needed) result = self.run_tests_via_build_tool( - build_root, - test_paths, - run_env, - timeout=timeout, - mode="behavior", - enable_coverage=True, - test_module=test_module, + build_root, test_paths, run_env, timeout=timeout, mode="behavior", test_module=test_module ) reports_dir = self.get_reports_dir(build_root, test_module) result_xml_path = _get_combined_junit_xml(reports_dir, candidate_index) + # Convert .exec → .xml via the shaded JaCoCo CLI in the runtime JAR + coverage_xml_path = self._convert_jacoco_exec_to_xml(runtime_jar, exec_path, module_path) + return result, result_xml_path, coverage_xml_path - def setup_coverage(self, build_root: Path, test_module: str | None, project_root: Path) -> Path | None: - if test_module: - module_root = build_root / module_to_dir(test_module) - else: - module_root = project_root + def _convert_jacoco_exec_to_xml(self, runtime_jar: Path, exec_path: Path, module_path: Path) -> Path | None: + if not exec_path.exists(): + logger.warning("JaCoCo exec file not found: %s", exec_path) + return None - build_file = find_gradle_build_file(module_root) - if build_file is None: - logger.warning("No build.gradle(.kts) found at %s, cannot setup JaCoCo", module_root) + xml_path = exec_path.with_suffix(".xml") + + # Collect classfiles directories for the report + classfiles_dirs: list[str] = [] + for classes_dir in [ + module_path / "build" / "classes" / "java" / "main", + module_path / "build" / "classes" / "java" / "test", + ]: + if classes_dir.exists(): + classfiles_dirs.append(str(classes_dir)) + + if not classfiles_dirs: + logger.warning("No classfiles directories found under %s/build/classes", module_path) return None - content = build_file.read_text(encoding="utf-8") - if "jacoco" not in content.lower(): - logger.info("Adding JaCoCo plugin to %s for coverage collection", build_file.name) - is_kts = build_file.name.endswith(".kts") - if is_kts: - plugin_line = "plugins {\n jacoco\n}\n" - else: - plugin_line = "apply plugin: 'jacoco'\n" - - if "plugins {" in content or "plugins{" in content: - # Insert jacoco inside existing plugins block - plugins_idx = content.find("plugins") - brace_depth = 0 - for i in range(plugins_idx, len(content)): - if content[i] == "{": - brace_depth += 1 - elif content[i] == "}": - brace_depth -= 1 - if brace_depth == 0: - insert = " jacoco\n" if is_kts else " id 'jacoco'\n" - content = content[:i] + insert + content[i:] - break - else: - content = plugin_line + content + cmd = [ + "java", + "-cp", + str(runtime_jar), + "com.codeflash.shaded.org.jacoco.cli.internal.Main", + "report", + str(exec_path), + ] + for d in classfiles_dirs: + cmd.extend(["--classfiles", d]) + cmd.extend(["--xml", str(xml_path)]) - build_file.write_text(content, encoding="utf-8") + logger.debug("Converting JaCoCo exec to XML: %s", " ".join(cmd)) + try: + conv_result = subprocess.run(cmd, capture_output=True, text=True, timeout=30, check=False) + if conv_result.returncode != 0: + logger.warning( + "JaCoCo exec→XML conversion failed (exit %d): %s", conv_result.returncode, conv_result.stderr + ) + return None + except Exception: + logger.exception("JaCoCo exec→XML conversion error") + return None + + if xml_path.exists(): + logger.info("JaCoCo coverage XML generated: %s", xml_path) + return xml_path + + logger.warning("JaCoCo XML not created at %s", xml_path) + return None - return module_root / "build" / "reports" / "jacoco" / "test" / "jacocoTestReport.xml" + def setup_coverage(self, build_root: Path, test_module: str | None, project_root: Path) -> Path | None: + if test_module: + module_root = build_root / module_to_dir(test_module) + else: + module_root = project_root + return module_root / "build" / "jacoco" / "test.xml" def get_test_run_command(self, project_root: Path, test_classes: list[str] | None = None) -> list[str]: from codeflash.languages.java.test_runner import _validate_java_class_name diff --git a/codeflash/languages/java/line_profiler.py b/codeflash/languages/java/line_profiler.py index 854a8549d..706b543a8 100644 --- a/codeflash/languages/java/line_profiler.py +++ b/codeflash/languages/java/line_profiler.py @@ -13,6 +13,7 @@ import json import logging +import os import re from pathlib import Path from typing import TYPE_CHECKING, Any @@ -130,9 +131,9 @@ class name, then writes a config JSON that the agent uses to know which config_output_path.write_text(json.dumps(config, indent=2), encoding="utf-8") return config_output_path - def build_javaagent_arg(self, config_path: Path) -> str: + def build_javaagent_arg(self, config_path: Path, classpath: str | None = None) -> str: """Return the -javaagent JVM argument string.""" - agent_jar = find_agent_jar() + agent_jar = find_agent_jar(classpath=classpath) if agent_jar is None: msg = f"{AGENT_JAR_NAME} not found in resources or dev build directory" raise FileNotFoundError(msg) @@ -565,12 +566,20 @@ def find_method_for_line( return Path(file_path).name, line_num -def find_agent_jar() -> Path | None: +def find_agent_jar(classpath: str | None = None) -> Path | None: """Locate the profiler agent JAR file (now bundled in codeflash-runtime). - Checks local Maven repo, package resources, and development build directory. + Checks the resolved classpath (if provided), local Maven repo, package resources, + and development build directory. """ - # Check local Maven repository first (fastest) + # Check resolved classpath first (Gradle projects resolve here, not ~/.m2) + if classpath: + for entry in classpath.split(os.pathsep): + jar_path = Path(entry) + if "codeflash-runtime" in jar_path.name and jar_path.suffix == ".jar" and jar_path.exists(): + return jar_path + + # Check local Maven repository (Maven projects resolve here) m2_jar = ( Path.home() / ".m2" diff --git a/codeflash/languages/java/support.py b/codeflash/languages/java/support.py index ab3818348..7115d2225 100644 --- a/codeflash/languages/java/support.py +++ b/codeflash/languages/java/support.py @@ -590,7 +590,7 @@ def instrument_existing_test( ) def instrument_source_for_line_profiler( - self, func_info: FunctionToOptimize, line_profiler_output_file: Path + self, func_info: FunctionToOptimize, line_profiler_output_file: Path, project_classpath: str | None = None ) -> bool: """Prepare line profiling via the bytecode-instrumentation agent. @@ -602,6 +602,7 @@ def instrument_source_for_line_profiler( Args: func_info: Function to profile. line_profiler_output_file: Path where profiling results will be written by the agent. + project_classpath: Resolved classpath from the build tool, used to locate the agent JAR. Returns: True if preparation succeeded, False otherwise. @@ -619,7 +620,7 @@ def instrument_source_for_line_profiler( source=source, file_path=func_info.file_path, functions=[func_info], config_output_path=config_path ) - self.line_profiler_agent_arg = profiler.build_javaagent_arg(config_path) + self.line_profiler_agent_arg = profiler.build_javaagent_arg(config_path, classpath=project_classpath) self.line_profiler_warmup_iterations = profiler.warmup_iterations return True except Exception: diff --git a/codeflash/languages/java/test_runner.py b/codeflash/languages/java/test_runner.py index 74830d436..6a144b367 100644 --- a/codeflash/languages/java/test_runner.py +++ b/codeflash/languages/java/test_runner.py @@ -24,6 +24,12 @@ from codeflash.code_utils.code_utils import get_run_tmp_file from codeflash.languages.base import TestResult +_INCLUDE_PATTERN = re.compile(r"""(?:^|(?<=\s))include\s*\(?[^)]*\)?""", re.MULTILINE | re.DOTALL) + +_LISTOF_PATTERN = re.compile(r"""listOf\s*\(([^)]*)\)""", re.DOTALL) + +_QUOTED_PATTERN = re.compile(r"""['"]([^'"]+)['"]""") + _result_counter = itertools.count(1) @@ -205,12 +211,28 @@ def _extract_modules_from_settings_gradle(content: str) -> list[str]: Looks for include directives like: include("module-a", "module-b") // Kotlin DSL include 'module-a', 'module-b' // Groovy DSL + Also handles dynamic Kotlin DSL patterns like: + val allProjects = listOf("module-a", "module-b") + include(*(allProjects + ...).toTypedArray()) Module names may be prefixed with ':' which is stripped. """ modules: list[str] = [] - for match in re.findall(r"""include\s*\(?[^)\n]*\)?""", content): - for name in re.findall(r"""['"]([^'"]+)['"]""", match): + # Standard include(...) directives — word boundary avoids matching variable names + # like 'includedProjects'. Pattern allows multi-line includes (e.g., eureka's + # include 'module-a',\n 'module-b',\n 'module-c') + for match in _INCLUDE_PATTERN.findall(content): + for name in _QUOTED_PATTERN.findall(match): modules.append(name.lstrip(":")) + # Kotlin DSL: val ... = listOf("module-a", "module-b", ...) spanning multiple lines. + # Used when settings.gradle.kts builds the include list dynamically. + if not modules or not any("/" not in m and "." not in m for m in modules): + seen = set(modules) + for match in _LISTOF_PATTERN.findall(content): + for name in _QUOTED_PATTERN.findall(match): + stripped = name.lstrip(":") + if stripped not in seen: + modules.append(stripped) + seen.add(stripped) return modules @@ -269,6 +291,50 @@ def _match_module_from_rel_path(rel_path: Path, modules: list[str]) -> str | Non return None +def _read_config_module_root(project_root: Path) -> str | None: + """Read module-root from codeflash.toml or pyproject.toml.""" + for cfg_name in ("codeflash.toml", "pyproject.toml"): + cfg_path = project_root / cfg_name + if cfg_path.exists(): + try: + cfg_text = cfg_path.read_text(encoding="utf-8") + m = re.search(r'module-root\s*=\s*["\']([^"\']+)["\']', cfg_text) + if m: + return m.group(1).strip().strip("/") + except Exception: + pass + return None + + +def _infer_module_from_config(project_root: Path) -> str | None: + """Infer the target Gradle module from codeflash config in gradle.properties. + + Reads codeflash.moduleRoot or codeflash.testsRoot and extracts the first + path component as the module name. Verifies the module directory has a + build.gradle(.kts) file. + """ + props_file = project_root / "gradle.properties" + if not props_file.exists(): + return None + try: + content = props_file.read_text(encoding="utf-8") + except Exception: + return None + + for key in ("codeflash.moduleRoot", "codeflash.testsRoot"): + for line in content.splitlines(): + line = line.strip() + if line.startswith(key + "="): + value = line.split("=", 1)[1].strip() + # Extract first path component (e.g. "rewrite-core/src/main/java" → "rewrite-core") + candidate = Path(value).parts[0] if Path(value).parts else None + if candidate: + module_dir = project_root / candidate + if (module_dir / "build.gradle.kts").exists() or (module_dir / "build.gradle").exists(): + return candidate + return None + + def _find_multi_module_root(project_root: Path, test_paths: Any) -> tuple[Path, str | None]: """Find the multi-module parent root if tests are in a different module. @@ -287,10 +353,18 @@ def _find_multi_module_root(project_root: Path, test_paths: Any) -> tuple[Path, test_file_paths.append(test_file.benchmarking_file_path) elif hasattr(test_file, "instrumented_behavior_file_path") and test_file.instrumented_behavior_file_path: test_file_paths.append(test_file.instrumented_behavior_file_path) + elif hasattr(test_file, "original_file_path") and test_file.original_file_path: + test_file_paths.append(test_file.original_file_path) elif isinstance(test_paths, (list, tuple)): test_file_paths = [Path(p) if isinstance(p, str) else p for p in test_paths] if not test_file_paths: + # No test file paths available — try to infer the module from codeflash config + # in gradle.properties (e.g. codeflash.moduleRoot=rewrite-core/src/main/java). + module = _infer_module_from_config(project_root) + if module: + logger.info("Inferred module '%s' from codeflash config (no test file paths)", module) + return project_root, module return project_root, None test_outside_project = False @@ -320,7 +394,14 @@ def _find_multi_module_root(project_root: Path, test_paths: Any) -> tuple[Path, module_counts[matched] = module_counts.get(matched, 0) + 1 if module_counts: - best_module = max(module_counts, key=lambda m: module_counts[m]) + # On ties, prefer the module matching codeflash.toml module-root + config_module = _read_config_module_root(project_root) + max_count = max(module_counts.values()) + tied = [m for m, c in module_counts.items() if c == max_count] + if config_module and config_module in tied: + best_module = config_module + else: + best_module = max(module_counts, key=lambda m: module_counts[m]) logger.debug( "Detected multi-module project. Root: %s, Module votes: %s, Selected: %s", project_root, @@ -328,6 +409,31 @@ def _find_multi_module_root(project_root: Path, test_paths: Any) -> tuple[Path, best_module, ) return project_root, best_module + + # project_root has no sub-modules — check if it is itself a sub-module + # of a parent multi-module project (e.g. rewrite-core/ inside rewrite/). + parent = project_root.parent + while parent != parent.parent: + if _is_build_root(parent): + parent_modules = _detect_modules(parent) + if parent_modules: + try: + rel_path = project_root.relative_to(parent) + matched = _match_module_from_rel_path(rel_path, parent_modules) + if matched: + logger.debug("Detected project_root as sub-module. Root: %s, Module: %s", parent, matched) + return parent, matched + except ValueError: + pass + parent = parent.parent + + # Last resort: settings.gradle may use dynamic includes that _detect_modules + # can't parse. Fall back to codeflash config in gradle.properties. + module = _infer_module_from_config(project_root) + if module: + logger.info("Inferred module '%s' from codeflash config (dynamic settings.gradle)", module) + return project_root, module + return project_root, None current = project_root.parent @@ -420,7 +526,7 @@ def run_behavioral_tests( if enable_coverage: coverage_xml_path = strategy.setup_coverage(build_root, test_module, project_root) - min_timeout = 300 if enable_coverage else 60 + min_timeout = 1200 if enable_coverage else 60 effective_timeout = max(timeout or 300, min_timeout) if enable_coverage: diff --git a/codeflash/languages/java/tracer.py b/codeflash/languages/java/tracer.py index ab8f19514..50506797e 100644 --- a/codeflash/languages/java/tracer.py +++ b/codeflash/languages/java/tracer.py @@ -132,9 +132,9 @@ def build_jfr_env(self, jfr_file: Path) -> dict[str, str]: env["JAVA_TOOL_OPTIONS"] = f"{existing} {jfr_opts}".strip() return env - def build_agent_env(self, config_path: Path) -> dict[str, str]: + def build_agent_env(self, config_path: Path, classpath: str | None = None) -> dict[str, str]: env = os.environ.copy() - agent_jar = find_agent_jar() + agent_jar = find_agent_jar(classpath=classpath) if agent_jar is None: msg = "codeflash-runtime JAR not found, cannot run tracing agent" raise FileNotFoundError(msg) diff --git a/codeflash/version.py b/codeflash/version.py index 226fdf7ad..a759bb9fd 100644 --- a/codeflash/version.py +++ b/codeflash/version.py @@ -1,2 +1,2 @@ # These version placeholders will be replaced by uv-dynamic-versioning during build. -__version__ = "0.20.5" +__version__ = "0.20.5.post169.dev0+2dba3e38" diff --git a/tests/test_languages/test_java/test_build_tools.py b/tests/test_languages/test_java/test_build_tools.py index 10bb90fa9..36d110cb3 100644 --- a/tests/test_languages/test_java/test_build_tools.py +++ b/tests/test_languages/test_java/test_build_tools.py @@ -2,16 +2,22 @@ import os from pathlib import Path -from unittest.mock import patch from codeflash.languages.java.build_tools import ( + CODEFLASH_RUNTIME_VERSION, BuildTool, detect_build_tool, find_source_root, find_test_root, get_project_info, ) -from codeflash.languages.java.gradle_strategy import GradleStrategy +from codeflash.languages.java.gradle_strategy import ( + GradleStrategy, + _ensure_maven_central_repo, + _find_top_level_block, + _update_existing_codeflash_dependency, +) +from codeflash.languages.java.gradle_strategy import add_codeflash_dependency as gradle_add_codeflash_dependency from codeflash.languages.java.maven_strategy import MavenStrategy, add_codeflash_dependency from codeflash.languages.java.test_runner import _extract_modules_from_pom_content @@ -588,17 +594,14 @@ def test_adds_dependency_to_correct_module_build_file(self, tmp_path): project = self._make_multi_module_project(tmp_path) strategy = GradleStrategy() - # Provide a fake runtime JAR - fake_jar = tmp_path / "fake-runtime.jar" - fake_jar.write_bytes(b"PK\x03\x04") # minimal zip header - - with patch.object(strategy, "find_runtime_jar", return_value=fake_jar): - result = strategy.ensure_runtime(project, test_module="streams") + result = strategy.ensure_runtime(project, test_module="streams") assert result is True - # Dependency should be in streams/build.gradle.kts + # Dependency should be in streams/build.gradle.kts with Maven Central coordinate streams_build = (project / "streams" / "build.gradle.kts").read_text(encoding="utf-8") assert "codeflash-runtime" in streams_build + assert "com.codeflash:codeflash-runtime:" in streams_build + assert "mavenCentral()" in streams_build # And NOT in clients/build.gradle.kts or root build.gradle.kts clients_build = (project / "clients" / "build.gradle.kts").read_text(encoding="utf-8") assert "codeflash-runtime" not in clients_build @@ -610,15 +613,13 @@ def test_adds_dependency_to_root_when_no_module(self, tmp_path): project = self._make_multi_module_project(tmp_path) strategy = GradleStrategy() - fake_jar = tmp_path / "fake-runtime.jar" - fake_jar.write_bytes(b"PK\x03\x04") - - with patch.object(strategy, "find_runtime_jar", return_value=fake_jar): - result = strategy.ensure_runtime(project, test_module=None) + result = strategy.ensure_runtime(project, test_module=None) assert result is True root_build = (project / "build.gradle.kts").read_text(encoding="utf-8") assert "codeflash-runtime" in root_build + assert "com.codeflash:codeflash-runtime:" in root_build + assert "mavenCentral()" in root_build def test_adds_dependency_to_nested_module(self, tmp_path): """When test_module='connect:runtime', the dep goes to connect/runtime/build.gradle.kts.""" @@ -632,15 +633,203 @@ def test_adds_dependency_to_nested_module(self, tmp_path): ) strategy = GradleStrategy() - fake_jar = tmp_path / "fake-runtime.jar" - fake_jar.write_bytes(b"PK\x03\x04") - - with patch.object(strategy, "find_runtime_jar", return_value=fake_jar): - result = strategy.ensure_runtime(project, test_module="connect:runtime") + result = strategy.ensure_runtime(project, test_module="connect:runtime") assert result is True nested_build = (nested / "build.gradle.kts").read_text(encoding="utf-8") assert "codeflash-runtime" in nested_build + assert "com.codeflash:codeflash-runtime:" in nested_build + assert "mavenCentral()" in nested_build + + def test_does_not_copy_jar_to_libs(self, tmp_path): + """ensure_runtime should NOT copy JARs locally — Gradle resolves from Maven Central.""" + project = self._make_multi_module_project(tmp_path) + + strategy = GradleStrategy() + strategy.ensure_runtime(project, test_module="streams") + + libs_dir = project / "streams" / "libs" + assert not libs_dir.exists() + + +class TestEnsureMavenCentralRepo: + """Tests for _ensure_maven_central_repo with tree-sitter.""" + + def test_skips_when_maven_central_already_present(self, tmp_path): + build_file = tmp_path / "build.gradle" + content = "repositories {\n mavenCentral()\n}\n" + build_file.write_text(content, encoding="utf-8") + result = _ensure_maven_central_repo(build_file, content) + assert result == content + + def test_inserts_into_top_level_repositories(self, tmp_path): + build_file = tmp_path / "build.gradle" + content = "repositories {\n google()\n}\n" + build_file.write_text(content, encoding="utf-8") + result = _ensure_maven_central_repo(build_file, content) + assert "mavenCentral()" in result + assert "google()" in result + + def test_does_not_match_buildscript_repositories(self, tmp_path): + build_file = tmp_path / "build.gradle" + content = "buildscript {\n repositories {\n google()\n }\n}\n" + build_file.write_text(content, encoding="utf-8") + result = _ensure_maven_central_repo(build_file, content) + # Should NOT insert into buildscript repositories — should append a new top-level block + assert result.endswith("\nrepositories {\n mavenCentral()\n}\n") + # The buildscript block should be unchanged + assert "buildscript {\n repositories {\n google()\n }\n}" in result + + def test_appends_new_block_when_no_repositories(self, tmp_path): + build_file = tmp_path / "build.gradle" + content = "plugins {\n id 'java'\n}\n" + build_file.write_text(content, encoding="utf-8") + result = _ensure_maven_central_repo(build_file, content) + assert result.endswith("\nrepositories {\n mavenCentral()\n}\n") + + def test_works_with_kts(self, tmp_path): + build_file = tmp_path / "build.gradle.kts" + content = "repositories {\n google()\n}\n" + build_file.write_text(content, encoding="utf-8") + result = _ensure_maven_central_repo(build_file, content) + assert "mavenCentral()" in result + + +class TestFindTopLevelBlock: + """Tests for _find_top_level_block tree-sitter function.""" + + def test_finds_top_level_dependencies(self, tmp_path): + build_file = tmp_path / "build.gradle" + content = 'dependencies {\n testImplementation "junit:junit:4.13"\n}\n' + build_file.write_text(content, encoding="utf-8") + pos = _find_top_level_block(build_file, content, "dependencies") + assert pos is not None + assert content[pos] == "}" + + def test_ignores_nested_dependencies(self, tmp_path): + build_file = tmp_path / "build.gradle" + content = ( + "buildscript {\n dependencies {\n classpath 'com.android.tools.build:gradle:7.0'\n }\n}\n" + ) + build_file.write_text(content, encoding="utf-8") + pos = _find_top_level_block(build_file, content, "dependencies") + assert pos is None + + def test_finds_top_level_repositories(self, tmp_path): + build_file = tmp_path / "build.gradle" + content = "repositories {\n google()\n}\n" + build_file.write_text(content, encoding="utf-8") + pos = _find_top_level_block(build_file, content, "repositories") + assert pos is not None + + def test_ignores_buildscript_repositories(self, tmp_path): + build_file = tmp_path / "build.gradle" + content = "buildscript {\n repositories {\n google()\n }\n}\n" + build_file.write_text(content, encoding="utf-8") + pos = _find_top_level_block(build_file, content, "repositories") + assert pos is None + + def test_returns_none_when_block_absent(self, tmp_path): + build_file = tmp_path / "build.gradle" + content = "plugins {\n id 'java'\n}\n" + build_file.write_text(content, encoding="utf-8") + pos = _find_top_level_block(build_file, content, "repositories") + assert pos is None + + def test_works_with_kts(self, tmp_path): + build_file = tmp_path / "build.gradle.kts" + content = 'dependencies {\n testImplementation("junit:junit:4.13")\n}\n' + build_file.write_text(content, encoding="utf-8") + pos = _find_top_level_block(build_file, content, "dependencies") + assert pos is not None + + +class TestGradleVersionUpdate: + """Tests for version update logic in Gradle dependency management.""" + + def test_updates_old_version(self, tmp_path): + build_file = tmp_path / "build.gradle" + content = ( + "dependencies {\n testImplementation 'com.codeflash:codeflash-runtime:1.0.0' // codeflash-runtime\n}\n" + ) + build_file.write_text(content, encoding="utf-8") + result = _update_existing_codeflash_dependency(build_file, content) + assert result is not None + assert f"com.codeflash:codeflash-runtime:{CODEFLASH_RUNTIME_VERSION}" in result + assert "1.0.0" not in result + + def test_updates_old_files_format(self, tmp_path): + build_file = tmp_path / "build.gradle" + content = "dependencies {\n testImplementation files('/path/to/codeflash-runtime-1.0.1.jar')\n}\n" + build_file.write_text(content, encoding="utf-8") + result = _update_existing_codeflash_dependency(build_file, content) + assert result is not None + assert f"com.codeflash:codeflash-runtime:{CODEFLASH_RUNTIME_VERSION}" in result + assert "files(" not in result + + def test_returns_none_when_already_current(self, tmp_path): + build_file = tmp_path / "build.gradle" + content = f"dependencies {{\n testImplementation 'com.codeflash:codeflash-runtime:{CODEFLASH_RUNTIME_VERSION}' // codeflash-runtime\n}}\n" + build_file.write_text(content, encoding="utf-8") + result = _update_existing_codeflash_dependency(build_file, content) + assert result is None + + def test_returns_none_when_already_current_kts(self, tmp_path): + build_file = tmp_path / "build.gradle.kts" + content = f'dependencies {{\n testImplementation("com.codeflash:codeflash-runtime:{CODEFLASH_RUNTIME_VERSION}")\n}}\n' + build_file.write_text(content, encoding="utf-8") + result = _update_existing_codeflash_dependency(build_file, content) + assert result is None + + def test_updates_old_version_kts(self, tmp_path): + build_file = tmp_path / "build.gradle.kts" + content = ( + 'dependencies {\n testImplementation("com.codeflash:codeflash-runtime:1.0.0") // codeflash-runtime\n}\n' + ) + build_file.write_text(content, encoding="utf-8") + result = _update_existing_codeflash_dependency(build_file, content) + assert result is not None + assert f"com.codeflash:codeflash-runtime:{CODEFLASH_RUNTIME_VERSION}" in result + + def test_add_codeflash_dependency_updates_old_version(self, tmp_path): + """Full integration: add_codeflash_dependency updates old version instead of skipping.""" + build_file = tmp_path / "build.gradle" + build_file.write_text( + "repositories {\n mavenCentral()\n}\n\n" + "dependencies {\n testImplementation 'com.codeflash:codeflash-runtime:1.0.0'\n}\n", + encoding="utf-8", + ) + result = gradle_add_codeflash_dependency(build_file) + assert result is True + content = build_file.read_text(encoding="utf-8") + assert f"com.codeflash:codeflash-runtime:{CODEFLASH_RUNTIME_VERSION}" in content + assert "1.0.0" not in content + + def test_add_codeflash_dependency_replaces_files_format(self, tmp_path): + """Full integration: add_codeflash_dependency replaces old files() with Maven Central coord.""" + build_file = tmp_path / "build.gradle" + build_file.write_text( + "dependencies {\n testImplementation files('/home/user/libs/codeflash-runtime-1.0.1.jar')\n}\n", + encoding="utf-8", + ) + result = gradle_add_codeflash_dependency(build_file) + assert result is True + content = build_file.read_text(encoding="utf-8") + assert f"com.codeflash:codeflash-runtime:{CODEFLASH_RUNTIME_VERSION}" in content + assert "files(" not in content + # Should also add mavenCentral() since the old format didn't need it + assert "mavenCentral()" in content + + def test_add_codeflash_dependency_preserves_indent(self, tmp_path): + build_file = tmp_path / "build.gradle" + build_file.write_text( + "dependencies {\n testImplementation 'com.codeflash:codeflash-runtime:1.0.0'\n}\n", encoding="utf-8" + ) + result = gradle_add_codeflash_dependency(build_file) + assert result is True + content = build_file.read_text(encoding="utf-8") + # Should preserve the 8-space indent + assert f" testImplementation 'com.codeflash:codeflash-runtime:{CODEFLASH_RUNTIME_VERSION}'" in content class TestValidationSkipFlags: diff --git a/tests/test_languages/test_java/test_java_test_paths.py b/tests/test_languages/test_java/test_java_test_paths.py index 3a6ff95db..4c4e7fa08 100644 --- a/tests/test_languages/test_java/test_java_test_paths.py +++ b/tests/test_languages/test_java/test_java_test_paths.py @@ -507,6 +507,25 @@ def test_leading_colon_stripped(self): assert "streams" in modules assert "clients" in modules + def test_multi_line_groovy_include(self): + content = """rootProject.name='eureka' +include 'eureka-client', + 'eureka-client-jersey2', + 'eureka-server', + 'eureka-core', + 'eureka-resources'""" + modules = _extract_modules_from_settings_gradle(content) + assert modules == ["eureka-client", "eureka-client-jersey2", "eureka-server", "eureka-core", "eureka-resources"] + + def test_multi_line_kotlin_dsl_include(self): + content = """include( + "module-a", + "module-b", + "module-c" +)""" + modules = _extract_modules_from_settings_gradle(content) + assert modules == ["module-a", "module-b", "module-c"] + class TestFindMultiModuleRoot: """Tests for _find_multi_module_root with Gradle multi-module projects.""" @@ -631,3 +650,32 @@ def test_project_root_is_submodule_test_outside(self, tmp_path): assert build_root == tmp_path assert test_module == "streams" + + def test_submodule_as_project_root_with_tests_inside(self, tmp_path): + """When project_root is a sub-module (e.g. rewrite-core/) and generated tests + are inside it, should walk up to find the real root and detect the module.""" + self._make_kafka_like_project(tmp_path) + submodule_root = tmp_path / "clients" + test_file = submodule_root / "src" / "test" / "java" / "com" / "ClientsTest.java" + test_file.parent.mkdir(parents=True, exist_ok=True) + test_file.touch() + + test_paths = self._make_test_paths_mock([test_file]) + build_root, test_module = _find_multi_module_root(submodule_root, test_paths) + + assert build_root == tmp_path + assert test_module == "clients" + + def test_submodule_as_project_root_nested_module(self, tmp_path): + """When project_root is a nested sub-module (connect/runtime), should detect it.""" + self._make_kafka_like_project(tmp_path) + submodule_root = tmp_path / "connect" / "runtime" + test_file = submodule_root / "src" / "test" / "java" / "com" / "RuntimeTest.java" + test_file.parent.mkdir(parents=True, exist_ok=True) + test_file.touch() + + test_paths = self._make_test_paths_mock([test_file]) + build_root, test_module = _find_multi_module_root(submodule_root, test_paths) + + assert build_root == tmp_path + assert test_module == "connect:runtime"