From 4b01df0e9672571c0b10106e3d1405b0b1d374f5 Mon Sep 17 00:00:00 2001 From: Christoph Hegemann Date: Wed, 4 Nov 2026 14:01:02 +0100 Subject: [PATCH 1/5] fix/gradle: don't skip the Kotlin config export for cached compiles register a scipKotlinConfigs task instead and finalize Kotlin compiles with it. As it doesn't declare outputs Gradle runs it every time and never caches it. Kotlin compilation itself stays cacheable. --- .../scip_java/gradle/ScipGradlePlugin.java | 151 ++++++++++-------- .../src/test/kotlin/tests/BuildToolHarness.kt | 31 ++-- .../test/kotlin/tests/GradleBuildToolTest.kt | 22 +++ .../gradle/kotlin-build-cache/build.gradle | 9 ++ .../kotlin-build-cache/gradle.properties | 1 + .../gradle/kotlin-build-cache/settings.gradle | 7 + .../src/main/java/foo/JExample.java | 2 + .../src/main/kotlin/foo/Example.kt | 2 + .../src/test/java/foo/JExampleSuite.java | 2 + .../src/test/kotlin/foo/ExampleSuite.kt | 2 + 10 files changed, 153 insertions(+), 76 deletions(-) create mode 100644 scip-java/src/test/resources/fixtures/gradle/kotlin-build-cache/build.gradle create mode 100644 scip-java/src/test/resources/fixtures/gradle/kotlin-build-cache/gradle.properties create mode 100644 scip-java/src/test/resources/fixtures/gradle/kotlin-build-cache/settings.gradle create mode 100644 scip-java/src/test/resources/fixtures/gradle/kotlin-build-cache/src/main/java/foo/JExample.java create mode 100644 scip-java/src/test/resources/fixtures/gradle/kotlin-build-cache/src/main/kotlin/foo/Example.kt create mode 100644 scip-java/src/test/resources/fixtures/gradle/kotlin-build-cache/src/test/java/foo/JExampleSuite.java create mode 100644 scip-java/src/test/resources/fixtures/gradle/kotlin-build-cache/src/test/kotlin/foo/ExampleSuite.kt diff --git a/scip-gradle-plugin/src/main/java/org/scip_code/scip_java/gradle/ScipGradlePlugin.java b/scip-gradle-plugin/src/main/java/org/scip_code/scip_java/gradle/ScipGradlePlugin.java index 7bb2c45ab..e4c9845b9 100644 --- a/scip-gradle-plugin/src/main/java/org/scip_code/scip_java/gradle/ScipGradlePlugin.java +++ b/scip-gradle-plugin/src/main/java/org/scip_code/scip_java/gradle/ScipGradlePlugin.java @@ -20,6 +20,8 @@ import org.gradle.api.tasks.Input; import org.gradle.api.tasks.SourceSet; import org.gradle.api.tasks.SourceSetContainer; +import org.gradle.api.tasks.TaskProvider; +import org.gradle.api.tasks.TaskState; import org.gradle.api.tasks.compile.CompileOptions; import org.gradle.api.tasks.compile.JavaCompile; import org.gradle.process.CommandLineArgumentProvider; @@ -137,11 +139,43 @@ private void configureProject(Project project) { } } + // Kotlin sources are not indexed inside kotlinc: after the Kotlin compile + // tasks, their sources and classpath are dumped for the scip-java CLI to + // index after the build, decoupling indexing from the build's Kotlin + // compiler version. The dump is a finalizer rather than an action of the + // compile tasks, because Gradle skips their actions when they are up to + // date or restored from the build cache, while the CLI deletes the + // targetroot before every build. The finalizer declares no outputs, so + // Gradle runs it every time. + List kotlinCompileTasks = new ArrayList<>(); + TaskProvider kotlinConfigs = + project + .getTasks() + .register( + "scipKotlinConfigs", + export -> + export.doLast( + ignored -> { + for (Task task : kotlinCompileTasks) { + // Only tasks of this build that succeeded; up-to-date + // and cached tasks count as executed. + TaskState state = task.getState(); + if (state.getExecuted() && state.getFailure() == null) { + writeKotlinConfig(task, sourceRoot, targetRoot); + } + } + })); // Not limited to the plugin checks above: AGP 9's built-in Kotlin adds // Kotlin compile tasks to Android modules without applying a Kotlin plugin. project .getTasks() - .configureEach(task -> configureKotlinCompileTask(task, sourceRoot, targetRoot)); + .configureEach( + task -> { + if (task.getClass().getSimpleName().contains("KotlinCompile")) { + kotlinCompileTasks.add(task); + task.finalizedBy(kotlinConfigs); + } + }); project.getTasks().create("scipCompileAll").dependsOn(triggers); @@ -237,71 +271,62 @@ private static Set addJavacPlugin(Project project, Object javacPluginDep } /** - * Kotlin sources are not indexed inside kotlinc: after each Kotlin compile task runs, its sources - * and classpath are dumped to {@code /kotlin-configs/.txt} for the scip-java - * CLI to index after the build, decoupling indexing from the build's Kotlin compiler version. + * Dumps the sources and classpath of a Kotlin compile task to {@code kotlin-configs/.txt}. */ - private static void configureKotlinCompileTask(Task task, String sourceRoot, String targetRoot) { - if (!task.getClass().getSimpleName().contains("KotlinCompile")) { - return; - } + private static void writeKotlinConfig(Task task, String sourceRoot, String targetRoot) { + try { + List lines = new ArrayList<>(); + lines.add("sourceroot " + sourceRoot); - task.doLast( - ignored -> { - try { - List lines = new ArrayList<>(); - lines.add("sourceroot " + sourceRoot); - - // Referring to KotlinCompile directly triggers NoClassDefFoundError - - // the plugin classpath is murky and we deliberately don't bundle the - // Kotlin Gradle plugin. So we commit the sins of reflection. - FileCollection libraries; - try { - libraries = (FileCollection) task.getClass().getMethod("getLibraries").invoke(task); - } catch (ReflectiveOperationException exc) { - libraries = (FileCollection) task.getClass().getMethod("getClasspath").invoke(task); - } - for (File entry : libraries.getFiles()) { - lines.add("classpath " + entry.getAbsolutePath()); - } - - int sources = 0; - for (File file : task.getInputs().getSourceFiles().getFiles()) { - if (file.getName().endsWith(".kt") || file.getName().endsWith(".kts")) { - lines.add("source " + file.getAbsolutePath()); - sources++; - } - } - // Java sources of the same module are listed too, so Kotlin - // references to them resolve. The indexer only emits Kotlin files. - try { - FileCollection javaSources = - (FileCollection) task.getClass().getMethod("getJavaSources").invoke(task); - for (File file : javaSources.getFiles()) { - if (file.getName().endsWith(".java")) { - lines.add("source " + file.getAbsolutePath()); - } - } - } catch (ReflectiveOperationException exc) { - // Kotlin Gradle Plugin without javaSources (pre 1.7). - } - if (sources == 0) { - return; - } - - Path configDir = Paths.get(targetRoot, "kotlin-configs"); - Files.createDirectories(configDir); - String fileName = task.getPath().replaceAll("[^A-Za-z0-9]", "-") + ".txt"; - Files.write(configDir.resolve(fileName), lines); - } catch (Exception exc) { - throw new RuntimeException( - "scip-java: failed to extract Kotlin compile configuration for task '" - + task.getName() - + "': " - + exc, - exc); + // Referring to KotlinCompile directly triggers NoClassDefFoundError - + // the plugin classpath is murky and we deliberately don't bundle the + // Kotlin Gradle plugin. So we commit the sins of reflection. + FileCollection libraries; + try { + libraries = (FileCollection) task.getClass().getMethod("getLibraries").invoke(task); + } catch (ReflectiveOperationException exc) { + libraries = (FileCollection) task.getClass().getMethod("getClasspath").invoke(task); + } + for (File entry : libraries.getFiles()) { + lines.add("classpath " + entry.getAbsolutePath()); + } + + int sources = 0; + for (File file : task.getInputs().getSourceFiles().getFiles()) { + if (file.getName().endsWith(".kt") || file.getName().endsWith(".kts")) { + lines.add("source " + file.getAbsolutePath()); + sources++; + } + } + // Java sources of the same module are listed too, so Kotlin + // references to them resolve. The indexer only emits Kotlin files. + try { + FileCollection javaSources = + (FileCollection) task.getClass().getMethod("getJavaSources").invoke(task); + for (File file : javaSources.getFiles()) { + if (file.getName().endsWith(".java")) { + lines.add("source " + file.getAbsolutePath()); } - }); + } + } catch (ReflectiveOperationException exc) { + // Kotlin Gradle Plugin without javaSources (pre 1.7). + } + if (sources == 0) { + return; + } + + Path configDir = Paths.get(targetRoot, "kotlin-configs"); + Files.createDirectories(configDir); + String fileName = task.getPath().replaceAll("[^A-Za-z0-9]", "-") + ".txt"; + Files.write(configDir.resolve(fileName), lines); + } catch (Exception exc) { + throw new RuntimeException( + "scip-java: failed to extract Kotlin compile configuration for task '" + + task.getName() + + "': " + + exc, + exc); + } } private static Object requiredExtra(Map extraProperties, String name) { diff --git a/scip-java/src/test/kotlin/tests/BuildToolHarness.kt b/scip-java/src/test/kotlin/tests/BuildToolHarness.kt index 2ea39aa09..e1692b14b 100644 --- a/scip-java/src/test/kotlin/tests/BuildToolHarness.kt +++ b/scip-java/src/test/kotlin/tests/BuildToolHarness.kt @@ -113,6 +113,7 @@ abstract class BuildToolHarness { targetRoot: String? = null, maxJdk: Int? = null, substitutions: Map = emptyMap(), + runs: Int = 1, ): DynamicTest = dynamicTest(name) { // Some build tools cap the JDK they support; skip rather than fail @@ -143,21 +144,25 @@ abstract class BuildToolHarness { targetroot.toString(), ) + extraArguments - val (exit, output) = runScipJava(workingDirectory, arguments) + // Later runs reuse the checkout, so they cover state left by + // earlier builds (up-to-date tasks, build cache entries). + for (run in 1..runs) { + val (exit, output) = runScipJava(workingDirectory, arguments) - if (expectedError != null) { - assertNotEquals(0, exit, "expected failure, output:\n$output") - expectedError(output) - } else { - assertEquals(0, exit, "expected success, output:\n$output") - } + if (expectedError != null) { + assertNotEquals(0, exit, "expected failure, output:\n$output") + expectedError(output) + } else { + assertEquals(0, exit, "expected success, output:\n$output") + } - val shards = listScipShards(targetroot) - if (shards.size != expectedScipFiles) { - fail( - "Expected $expectedScipFiles SCIP shard(s), got ${shards.size}: " + - "$shards\noutput:\n$output" - ) + val shards = listScipShards(targetroot) + if (shards.size != expectedScipFiles) { + fail( + "Run $run: expected $expectedScipFiles SCIP shard(s), got ${shards.size}: " + + "$shards\noutput:\n$output" + ) + } } if (expectedPackages.isNotEmpty()) { diff --git a/scip-java/src/test/kotlin/tests/GradleBuildToolTest.kt b/scip-java/src/test/kotlin/tests/GradleBuildToolTest.kt index 937095327..ff41b826e 100644 --- a/scip-java/src/test/kotlin/tests/GradleBuildToolTest.kt +++ b/scip-java/src/test/kotlin/tests/GradleBuildToolTest.kt @@ -20,6 +20,7 @@ class GradleBuildToolTest : BuildToolHarness() { expectedPackages: String = "", extraArguments: List = emptyList(), substitutions: Map = emptyMap(), + runs: Int = 1, ): DynamicTest = checkBuild( "$name-gradle-$GRADLE_VERSION", @@ -28,6 +29,7 @@ class GradleBuildToolTest : BuildToolHarness() { expectedPackages = expectedPackages, extraArguments = extraArguments, substitutions = substitutions, + runs = runs, initCommand = { workingDirectory -> // Gradle needs a build script present to generate the wrapper; // the real one is laid down afterwards from the fixture. @@ -162,6 +164,26 @@ class GradleBuildToolTest : BuildToolHarness() { ) ) add(checkGradleBuild("kotlin2", "gradle/kotlin2", expectedScipFiles = 4)) + // Regression tests: Kotlin sources must be indexed again when the Kotlin + // compile tasks are restored from the build cache, or are up to date + // because the build command doesn't clean. + add( + checkGradleBuild( + "kotlin-build-cache", + "gradle/kotlin-build-cache", + expectedScipFiles = 4, + runs = 2, + ) + ) + add( + checkGradleBuild( + "kotlin-up-to-date", + "gradle/kotlin2", + expectedScipFiles = 2, + extraArguments = listOf("--", "compileKotlin", "compileJava"), + runs = 2, + ) + ) add( checkGradleBuild( "implementation-deps", diff --git a/scip-java/src/test/resources/fixtures/gradle/kotlin-build-cache/build.gradle b/scip-java/src/test/resources/fixtures/gradle/kotlin-build-cache/build.gradle new file mode 100644 index 000000000..b559907c9 --- /dev/null +++ b/scip-java/src/test/resources/fixtures/gradle/kotlin-build-cache/build.gradle @@ -0,0 +1,9 @@ +plugins { + id 'org.jetbrains.kotlin.jvm' version '2.4.0' +} +kotlin { + jvmToolchain(17) +} +repositories { + mavenCentral() +} diff --git a/scip-java/src/test/resources/fixtures/gradle/kotlin-build-cache/gradle.properties b/scip-java/src/test/resources/fixtures/gradle/kotlin-build-cache/gradle.properties new file mode 100644 index 000000000..160890028 --- /dev/null +++ b/scip-java/src/test/resources/fixtures/gradle/kotlin-build-cache/gradle.properties @@ -0,0 +1 @@ +org.gradle.caching=true diff --git a/scip-java/src/test/resources/fixtures/gradle/kotlin-build-cache/settings.gradle b/scip-java/src/test/resources/fixtures/gradle/kotlin-build-cache/settings.gradle new file mode 100644 index 000000000..067e7d885 --- /dev/null +++ b/scip-java/src/test/resources/fixtures/gradle/kotlin-build-cache/settings.gradle @@ -0,0 +1,7 @@ +// A build cache inside the checkout, so every test starts with an empty +// cache and leaves the user's Gradle home untouched. +buildCache { + local { + directory = file('build-cache') + } +} diff --git a/scip-java/src/test/resources/fixtures/gradle/kotlin-build-cache/src/main/java/foo/JExample.java b/scip-java/src/test/resources/fixtures/gradle/kotlin-build-cache/src/main/java/foo/JExample.java new file mode 100644 index 000000000..b46e167d9 --- /dev/null +++ b/scip-java/src/test/resources/fixtures/gradle/kotlin-build-cache/src/main/java/foo/JExample.java @@ -0,0 +1,2 @@ +package foo; +public class JExample {} diff --git a/scip-java/src/test/resources/fixtures/gradle/kotlin-build-cache/src/main/kotlin/foo/Example.kt b/scip-java/src/test/resources/fixtures/gradle/kotlin-build-cache/src/main/kotlin/foo/Example.kt new file mode 100644 index 000000000..26f3d6294 --- /dev/null +++ b/scip-java/src/test/resources/fixtures/gradle/kotlin-build-cache/src/main/kotlin/foo/Example.kt @@ -0,0 +1,2 @@ +package foo +object Example {} diff --git a/scip-java/src/test/resources/fixtures/gradle/kotlin-build-cache/src/test/java/foo/JExampleSuite.java b/scip-java/src/test/resources/fixtures/gradle/kotlin-build-cache/src/test/java/foo/JExampleSuite.java new file mode 100644 index 000000000..cc39874dd --- /dev/null +++ b/scip-java/src/test/resources/fixtures/gradle/kotlin-build-cache/src/test/java/foo/JExampleSuite.java @@ -0,0 +1,2 @@ +package foo; +public class JExampleSuite {} diff --git a/scip-java/src/test/resources/fixtures/gradle/kotlin-build-cache/src/test/kotlin/foo/ExampleSuite.kt b/scip-java/src/test/resources/fixtures/gradle/kotlin-build-cache/src/test/kotlin/foo/ExampleSuite.kt new file mode 100644 index 000000000..67303e2ee --- /dev/null +++ b/scip-java/src/test/resources/fixtures/gradle/kotlin-build-cache/src/test/kotlin/foo/ExampleSuite.kt @@ -0,0 +1,2 @@ +package foo +class ExampleSuite {} From e976e405370df8c764c7cca182de50fc47aa4db2 Mon Sep 17 00:00:00 2001 From: Christoph Hegemann Date: Wed, 4 Nov 2026 14:30:09 +0100 Subject: [PATCH 2/5] fix/gradle: fix configuration cache issues --- .../scip_java/gradle/ScipGradlePlugin.java | 133 +++++++++++------- .../test/kotlin/tests/GradleBuildToolTest.kt | 7 +- 2 files changed, 86 insertions(+), 54 deletions(-) diff --git a/scip-gradle-plugin/src/main/java/org/scip_code/scip_java/gradle/ScipGradlePlugin.java b/scip-gradle-plugin/src/main/java/org/scip_code/scip_java/gradle/ScipGradlePlugin.java index e4c9845b9..1421ba55c 100644 --- a/scip-gradle-plugin/src/main/java/org/scip_code/scip_java/gradle/ScipGradlePlugin.java +++ b/scip-gradle-plugin/src/main/java/org/scip_code/scip_java/gradle/ScipGradlePlugin.java @@ -5,6 +5,7 @@ import java.nio.file.Path; import java.nio.file.Paths; import java.util.ArrayList; +import java.util.HashMap; import java.util.HashSet; import java.util.List; import java.util.Map; @@ -20,8 +21,6 @@ import org.gradle.api.tasks.Input; import org.gradle.api.tasks.SourceSet; import org.gradle.api.tasks.SourceSetContainer; -import org.gradle.api.tasks.TaskProvider; -import org.gradle.api.tasks.TaskState; import org.gradle.api.tasks.compile.CompileOptions; import org.gradle.api.tasks.compile.JavaCompile; import org.gradle.process.CommandLineArgumentProvider; @@ -139,32 +138,62 @@ private void configureProject(Project project) { } } - // Kotlin sources are not indexed inside kotlinc: after the Kotlin compile - // tasks, their sources and classpath are dumped for the scip-java CLI to - // index after the build, decoupling indexing from the build's Kotlin - // compiler version. The dump is a finalizer rather than an action of the - // compile tasks, because Gradle skips their actions when they are up to + // Kotlin sources are not indexed inside kotlinc: after each Kotlin compile + // task, its sources and classpath are dumped for the scip-java CLI to index + // after the build, decoupling indexing from the build's Kotlin compiler + // version. The dump is a finalizer rather than an action of the compile + // task, because Gradle skips the compile task's actions when it is up to // date or restored from the build cache, while the CLI deletes the // targetroot before every build. The finalizer declares no outputs, so - // Gradle runs it every time. - List kotlinCompileTasks = new ArrayList<>(); - TaskProvider kotlinConfigs = - project - .getTasks() - .register( - "scipKotlinConfigs", - export -> - export.doLast( - ignored -> { - for (Task task : kotlinCompileTasks) { - // Only tasks of this build that succeeded; up-to-date - // and cached tasks count as executed. - TaskState state = task.getState(); - if (state.getExecuted() && state.getFailure() == null) { - writeKotlinConfig(task, sourceRoot, targetRoot); - } - } - })); + // Gradle runs it every time. A rule creates it on demand, because tasks + // can't be registered from configureEach, and it captures only file + // collections rather than the compile task, so it works with the + // configuration cache. + Map kotlinConfigTasks = new HashMap<>(); + project + .getTasks() + .addRule( + "Pattern: scipKotlinConfig: dumps a Kotlin compile task's configuration", + name -> { + String compileName = kotlinConfigTasks.get(name); + if (compileName == null) { + return; + } + Task compile = project.getTasks().getByName(compileName); + String compilePath = compile.getPath(); + // Referring to KotlinCompile directly triggers NoClassDefFoundError - + // the plugin classpath is murky and we deliberately don't bundle the + // Kotlin Gradle plugin. So we commit the sins of reflection. + FileCollection libraries = fileCollection(compile, "getLibraries"); + if (libraries == null) { + libraries = fileCollection(compile, "getClasspath"); + } + if (libraries == null) { + throw new IllegalStateException( + "scip-java: failed to extract Kotlin compile configuration for task '" + + compileName + + "': no libraries or classpath"); + } + FileCollection classpath = libraries; + FileCollection sources = compile.getInputs().getSourceFiles(); + // Kotlin Gradle Plugin without javaSources (pre 1.7) has none. + FileCollection javaSources = fileCollection(compile, "getJavaSources"); + FileCollection allJavaSources = javaSources != null ? javaSources : project.files(); + project + .getTasks() + .create( + name, + export -> + export.doLast( + ignored -> + writeKotlinConfig( + compilePath, + classpath, + sources, + allJavaSources, + sourceRoot, + targetRoot))); + }); // Not limited to the plugin checks above: AGP 9's built-in Kotlin adds // Kotlin compile tasks to Android modules without applying a Kotlin plugin. project @@ -172,8 +201,12 @@ private void configureProject(Project project) { .configureEach( task -> { if (task.getClass().getSimpleName().contains("KotlinCompile")) { - kotlinCompileTasks.add(task); - task.finalizedBy(kotlinConfigs); + String name = + "scipKotlinConfig" + + Character.toUpperCase(task.getName().charAt(0)) + + task.getName().substring(1); + kotlinConfigTasks.put(name, task.getName()); + task.finalizedBy(name); } }); @@ -270,29 +303,33 @@ private static Set addJavacPlugin(Project project, Object javacPluginDep return pluginTasks; } + private static FileCollection fileCollection(Task task, String getter) { + try { + return (FileCollection) task.getClass().getMethod(getter).invoke(task); + } catch (ReflectiveOperationException exc) { + return null; + } + } + /** * Dumps the sources and classpath of a Kotlin compile task to {@code kotlin-configs/.txt}. */ - private static void writeKotlinConfig(Task task, String sourceRoot, String targetRoot) { + private static void writeKotlinConfig( + String taskPath, + FileCollection libraries, + FileCollection kotlinSources, + FileCollection javaSources, + String sourceRoot, + String targetRoot) { try { List lines = new ArrayList<>(); lines.add("sourceroot " + sourceRoot); - - // Referring to KotlinCompile directly triggers NoClassDefFoundError - - // the plugin classpath is murky and we deliberately don't bundle the - // Kotlin Gradle plugin. So we commit the sins of reflection. - FileCollection libraries; - try { - libraries = (FileCollection) task.getClass().getMethod("getLibraries").invoke(task); - } catch (ReflectiveOperationException exc) { - libraries = (FileCollection) task.getClass().getMethod("getClasspath").invoke(task); - } for (File entry : libraries.getFiles()) { lines.add("classpath " + entry.getAbsolutePath()); } int sources = 0; - for (File file : task.getInputs().getSourceFiles().getFiles()) { + for (File file : kotlinSources.getFiles()) { if (file.getName().endsWith(".kt") || file.getName().endsWith(".kts")) { lines.add("source " + file.getAbsolutePath()); sources++; @@ -300,16 +337,10 @@ private static void writeKotlinConfig(Task task, String sourceRoot, String targe } // Java sources of the same module are listed too, so Kotlin // references to them resolve. The indexer only emits Kotlin files. - try { - FileCollection javaSources = - (FileCollection) task.getClass().getMethod("getJavaSources").invoke(task); - for (File file : javaSources.getFiles()) { - if (file.getName().endsWith(".java")) { - lines.add("source " + file.getAbsolutePath()); - } + for (File file : javaSources.getFiles()) { + if (file.getName().endsWith(".java")) { + lines.add("source " + file.getAbsolutePath()); } - } catch (ReflectiveOperationException exc) { - // Kotlin Gradle Plugin without javaSources (pre 1.7). } if (sources == 0) { return; @@ -317,12 +348,12 @@ private static void writeKotlinConfig(Task task, String sourceRoot, String targe Path configDir = Paths.get(targetRoot, "kotlin-configs"); Files.createDirectories(configDir); - String fileName = task.getPath().replaceAll("[^A-Za-z0-9]", "-") + ".txt"; + String fileName = taskPath.replaceAll("[^A-Za-z0-9]", "-") + ".txt"; Files.write(configDir.resolve(fileName), lines); } catch (Exception exc) { throw new RuntimeException( "scip-java: failed to extract Kotlin compile configuration for task '" - + task.getName() + + taskPath + "': " + exc, exc); diff --git a/scip-java/src/test/kotlin/tests/GradleBuildToolTest.kt b/scip-java/src/test/kotlin/tests/GradleBuildToolTest.kt index ff41b826e..9467efa31 100644 --- a/scip-java/src/test/kotlin/tests/GradleBuildToolTest.kt +++ b/scip-java/src/test/kotlin/tests/GradleBuildToolTest.kt @@ -166,7 +166,8 @@ class GradleBuildToolTest : BuildToolHarness() { add(checkGradleBuild("kotlin2", "gradle/kotlin2", expectedScipFiles = 4)) // Regression tests: Kotlin sources must be indexed again when the Kotlin // compile tasks are restored from the build cache, or are up to date - // because the build command doesn't clean. + // because the build command doesn't clean. The second run of the latter + // also reuses the configuration cache entry of the first. add( checkGradleBuild( "kotlin-build-cache", @@ -179,8 +180,8 @@ class GradleBuildToolTest : BuildToolHarness() { checkGradleBuild( "kotlin-up-to-date", "gradle/kotlin2", - expectedScipFiles = 2, - extraArguments = listOf("--", "compileKotlin", "compileJava"), + expectedScipFiles = 1, + extraArguments = listOf("--", "--configuration-cache", "compileKotlin"), runs = 2, ) ) From 33154be85ef1c765c102c12b7f5cae6b2a83e52c Mon Sep 17 00:00:00 2001 From: Christoph Hegemann Date: Wed, 4 Nov 2026 14:46:21 +0100 Subject: [PATCH 3/5] test/gradle: reproduce missing Java shards on configuration cache reuse When a run reuses the configuration cache entry of an earlier run, the stored -randomtimestamp leaves compileJava up to date and no Java shards are written. This test fails until that is fixed. Amp-Thread-ID: https://ampcode.com/threads/T-01a1a699-5570-748e-af9d-6031d2964026 Co-authored-by: Amp --- .../src/test/kotlin/tests/GradleBuildToolTest.kt | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/scip-java/src/test/kotlin/tests/GradleBuildToolTest.kt b/scip-java/src/test/kotlin/tests/GradleBuildToolTest.kt index 9467efa31..0ae7654cf 100644 --- a/scip-java/src/test/kotlin/tests/GradleBuildToolTest.kt +++ b/scip-java/src/test/kotlin/tests/GradleBuildToolTest.kt @@ -163,6 +163,18 @@ class GradleBuildToolTest : BuildToolHarness() { extraArguments = listOf("--", "compileJava"), ) ) + // Regression test: Java sources must be indexed again when the second run + // reuses the configuration cache entry of the first, which leaves + // compileJava up to date. + add( + checkGradleBuild( + "java-configuration-cache", + "gradle/build-command", + expectedScipFiles = 1, + extraArguments = listOf("--", "--configuration-cache", "compileJava"), + runs = 2, + ) + ) add(checkGradleBuild("kotlin2", "gradle/kotlin2", expectedScipFiles = 4)) // Regression tests: Kotlin sources must be indexed again when the Kotlin // compile tasks are restored from the build cache, or are up to date From 34064ceb9b57d0054a7a33f71b9692d68f7345f6 Mon Sep 17 00:00:00 2001 From: Christoph Hegemann Date: Wed, 4 Nov 2026 14:55:16 +0100 Subject: [PATCH 4/5] fix/gradle: disable the configuration cache for indexing builds The Gradle plugin isn't compatible with the configuration cache: scipPrintDependencies uses the project at execution time, and a reused entry keeps compileJava's random timestamp, leaving it up to date. The CLI now passes --no-configuration-cache after the build command, so it also overrides gradle.properties and a --configuration-cache in a custom build command. --- .../scip_java/buildtools/GradleBuildTool.kt | 4 ++++ .../src/test/kotlin/tests/GradleBuildToolTest.kt | 14 +++++++++++--- .../gradle/configuration-cache/build.gradle | 1 + .../gradle/configuration-cache/gradle.properties | 1 + .../configuration-cache/src/main/java/Example.java | 1 + 5 files changed, 18 insertions(+), 3 deletions(-) create mode 100644 scip-java/src/test/resources/fixtures/gradle/configuration-cache/build.gradle create mode 100644 scip-java/src/test/resources/fixtures/gradle/configuration-cache/gradle.properties create mode 100644 scip-java/src/test/resources/fixtures/gradle/configuration-cache/src/main/java/Example.java diff --git a/scip-java/src/main/kotlin/org/scip_code/scip_java/buildtools/GradleBuildTool.kt b/scip-java/src/main/kotlin/org/scip_code/scip_java/buildtools/GradleBuildTool.kt index 511d30834..c95ab9563 100644 --- a/scip-java/src/main/kotlin/org/scip_code/scip_java/buildtools/GradleBuildTool.kt +++ b/scip-java/src/main/kotlin/org/scip_code/scip_java/buildtools/GradleBuildTool.kt @@ -94,6 +94,10 @@ This means our SCIP compiler plugin was not attached to one or more JavaCompile cmd += script cmd += "-Dscip.targetroot=${targetroot()}" cmd += index.finalBuildCommand(listOf("clean", "scipPrintDependencies", "scipCompileAll")) + // The Gradle plugin isn't compatible with the configuration cache, which + // a build can enable in gradle.properties. Last, because the last of + // --configuration-cache and --no-configuration-cache wins. + cmd += "--no-configuration-cache" targetroot().toFile().deleteRecursively() val result = index.app.runProcess(cmd, env = mapOf("TERM" to "dumb")) diff --git a/scip-java/src/test/kotlin/tests/GradleBuildToolTest.kt b/scip-java/src/test/kotlin/tests/GradleBuildToolTest.kt index 0ae7654cf..efad3ff68 100644 --- a/scip-java/src/test/kotlin/tests/GradleBuildToolTest.kt +++ b/scip-java/src/test/kotlin/tests/GradleBuildToolTest.kt @@ -163,9 +163,17 @@ class GradleBuildToolTest : BuildToolHarness() { extraArguments = listOf("--", "compileJava"), ) ) - // Regression test: Java sources must be indexed again when the second run - // reuses the configuration cache entry of the first, which leaves - // compileJava up to date. + // Regression tests: the configuration cache is disabled, both when the + // build enables it and when the build command asks for it. Otherwise + // scipPrintDependencies fails, and a second run reuses the configuration + // cache entry of the first, which leaves compileJava up to date. + add( + checkGradleBuild( + "configuration-cache", + "gradle/configuration-cache", + expectedScipFiles = 1, + ) + ) add( checkGradleBuild( "java-configuration-cache", diff --git a/scip-java/src/test/resources/fixtures/gradle/configuration-cache/build.gradle b/scip-java/src/test/resources/fixtures/gradle/configuration-cache/build.gradle new file mode 100644 index 000000000..bbfeb03c2 --- /dev/null +++ b/scip-java/src/test/resources/fixtures/gradle/configuration-cache/build.gradle @@ -0,0 +1 @@ +apply plugin: 'java' diff --git a/scip-java/src/test/resources/fixtures/gradle/configuration-cache/gradle.properties b/scip-java/src/test/resources/fixtures/gradle/configuration-cache/gradle.properties new file mode 100644 index 000000000..5ad69748c --- /dev/null +++ b/scip-java/src/test/resources/fixtures/gradle/configuration-cache/gradle.properties @@ -0,0 +1 @@ +org.gradle.configuration-cache=true diff --git a/scip-java/src/test/resources/fixtures/gradle/configuration-cache/src/main/java/Example.java b/scip-java/src/test/resources/fixtures/gradle/configuration-cache/src/main/java/Example.java new file mode 100644 index 000000000..0f5fefd30 --- /dev/null +++ b/scip-java/src/test/resources/fixtures/gradle/configuration-cache/src/main/java/Example.java @@ -0,0 +1 @@ +public class Example {} From 802f52fa3c4fa3f340f5dcd554dbe9e4738545f0 Mon Sep 17 00:00:00 2001 From: Christoph Hegemann Date: Wed, 4 Nov 2026 14:55:17 +0100 Subject: [PATCH 5/5] Revert "fix/gradle: fix configuration cache issues" This reverts commit e976e405370df8c764c7cca182de50fc47aa4db2. Not needed now that the CLI disables the configuration cache. --- .../scip_java/gradle/ScipGradlePlugin.java | 133 +++++++----------- .../test/kotlin/tests/GradleBuildToolTest.kt | 7 +- 2 files changed, 54 insertions(+), 86 deletions(-) diff --git a/scip-gradle-plugin/src/main/java/org/scip_code/scip_java/gradle/ScipGradlePlugin.java b/scip-gradle-plugin/src/main/java/org/scip_code/scip_java/gradle/ScipGradlePlugin.java index 1421ba55c..e4c9845b9 100644 --- a/scip-gradle-plugin/src/main/java/org/scip_code/scip_java/gradle/ScipGradlePlugin.java +++ b/scip-gradle-plugin/src/main/java/org/scip_code/scip_java/gradle/ScipGradlePlugin.java @@ -5,7 +5,6 @@ import java.nio.file.Path; import java.nio.file.Paths; import java.util.ArrayList; -import java.util.HashMap; import java.util.HashSet; import java.util.List; import java.util.Map; @@ -21,6 +20,8 @@ import org.gradle.api.tasks.Input; import org.gradle.api.tasks.SourceSet; import org.gradle.api.tasks.SourceSetContainer; +import org.gradle.api.tasks.TaskProvider; +import org.gradle.api.tasks.TaskState; import org.gradle.api.tasks.compile.CompileOptions; import org.gradle.api.tasks.compile.JavaCompile; import org.gradle.process.CommandLineArgumentProvider; @@ -138,62 +139,32 @@ private void configureProject(Project project) { } } - // Kotlin sources are not indexed inside kotlinc: after each Kotlin compile - // task, its sources and classpath are dumped for the scip-java CLI to index - // after the build, decoupling indexing from the build's Kotlin compiler - // version. The dump is a finalizer rather than an action of the compile - // task, because Gradle skips the compile task's actions when it is up to + // Kotlin sources are not indexed inside kotlinc: after the Kotlin compile + // tasks, their sources and classpath are dumped for the scip-java CLI to + // index after the build, decoupling indexing from the build's Kotlin + // compiler version. The dump is a finalizer rather than an action of the + // compile tasks, because Gradle skips their actions when they are up to // date or restored from the build cache, while the CLI deletes the // targetroot before every build. The finalizer declares no outputs, so - // Gradle runs it every time. A rule creates it on demand, because tasks - // can't be registered from configureEach, and it captures only file - // collections rather than the compile task, so it works with the - // configuration cache. - Map kotlinConfigTasks = new HashMap<>(); - project - .getTasks() - .addRule( - "Pattern: scipKotlinConfig: dumps a Kotlin compile task's configuration", - name -> { - String compileName = kotlinConfigTasks.get(name); - if (compileName == null) { - return; - } - Task compile = project.getTasks().getByName(compileName); - String compilePath = compile.getPath(); - // Referring to KotlinCompile directly triggers NoClassDefFoundError - - // the plugin classpath is murky and we deliberately don't bundle the - // Kotlin Gradle plugin. So we commit the sins of reflection. - FileCollection libraries = fileCollection(compile, "getLibraries"); - if (libraries == null) { - libraries = fileCollection(compile, "getClasspath"); - } - if (libraries == null) { - throw new IllegalStateException( - "scip-java: failed to extract Kotlin compile configuration for task '" - + compileName - + "': no libraries or classpath"); - } - FileCollection classpath = libraries; - FileCollection sources = compile.getInputs().getSourceFiles(); - // Kotlin Gradle Plugin without javaSources (pre 1.7) has none. - FileCollection javaSources = fileCollection(compile, "getJavaSources"); - FileCollection allJavaSources = javaSources != null ? javaSources : project.files(); - project - .getTasks() - .create( - name, - export -> - export.doLast( - ignored -> - writeKotlinConfig( - compilePath, - classpath, - sources, - allJavaSources, - sourceRoot, - targetRoot))); - }); + // Gradle runs it every time. + List kotlinCompileTasks = new ArrayList<>(); + TaskProvider kotlinConfigs = + project + .getTasks() + .register( + "scipKotlinConfigs", + export -> + export.doLast( + ignored -> { + for (Task task : kotlinCompileTasks) { + // Only tasks of this build that succeeded; up-to-date + // and cached tasks count as executed. + TaskState state = task.getState(); + if (state.getExecuted() && state.getFailure() == null) { + writeKotlinConfig(task, sourceRoot, targetRoot); + } + } + })); // Not limited to the plugin checks above: AGP 9's built-in Kotlin adds // Kotlin compile tasks to Android modules without applying a Kotlin plugin. project @@ -201,12 +172,8 @@ private void configureProject(Project project) { .configureEach( task -> { if (task.getClass().getSimpleName().contains("KotlinCompile")) { - String name = - "scipKotlinConfig" - + Character.toUpperCase(task.getName().charAt(0)) - + task.getName().substring(1); - kotlinConfigTasks.put(name, task.getName()); - task.finalizedBy(name); + kotlinCompileTasks.add(task); + task.finalizedBy(kotlinConfigs); } }); @@ -303,33 +270,29 @@ private static Set addJavacPlugin(Project project, Object javacPluginDep return pluginTasks; } - private static FileCollection fileCollection(Task task, String getter) { - try { - return (FileCollection) task.getClass().getMethod(getter).invoke(task); - } catch (ReflectiveOperationException exc) { - return null; - } - } - /** * Dumps the sources and classpath of a Kotlin compile task to {@code kotlin-configs/.txt}. */ - private static void writeKotlinConfig( - String taskPath, - FileCollection libraries, - FileCollection kotlinSources, - FileCollection javaSources, - String sourceRoot, - String targetRoot) { + private static void writeKotlinConfig(Task task, String sourceRoot, String targetRoot) { try { List lines = new ArrayList<>(); lines.add("sourceroot " + sourceRoot); + + // Referring to KotlinCompile directly triggers NoClassDefFoundError - + // the plugin classpath is murky and we deliberately don't bundle the + // Kotlin Gradle plugin. So we commit the sins of reflection. + FileCollection libraries; + try { + libraries = (FileCollection) task.getClass().getMethod("getLibraries").invoke(task); + } catch (ReflectiveOperationException exc) { + libraries = (FileCollection) task.getClass().getMethod("getClasspath").invoke(task); + } for (File entry : libraries.getFiles()) { lines.add("classpath " + entry.getAbsolutePath()); } int sources = 0; - for (File file : kotlinSources.getFiles()) { + for (File file : task.getInputs().getSourceFiles().getFiles()) { if (file.getName().endsWith(".kt") || file.getName().endsWith(".kts")) { lines.add("source " + file.getAbsolutePath()); sources++; @@ -337,10 +300,16 @@ private static void writeKotlinConfig( } // Java sources of the same module are listed too, so Kotlin // references to them resolve. The indexer only emits Kotlin files. - for (File file : javaSources.getFiles()) { - if (file.getName().endsWith(".java")) { - lines.add("source " + file.getAbsolutePath()); + try { + FileCollection javaSources = + (FileCollection) task.getClass().getMethod("getJavaSources").invoke(task); + for (File file : javaSources.getFiles()) { + if (file.getName().endsWith(".java")) { + lines.add("source " + file.getAbsolutePath()); + } } + } catch (ReflectiveOperationException exc) { + // Kotlin Gradle Plugin without javaSources (pre 1.7). } if (sources == 0) { return; @@ -348,12 +317,12 @@ private static void writeKotlinConfig( Path configDir = Paths.get(targetRoot, "kotlin-configs"); Files.createDirectories(configDir); - String fileName = taskPath.replaceAll("[^A-Za-z0-9]", "-") + ".txt"; + String fileName = task.getPath().replaceAll("[^A-Za-z0-9]", "-") + ".txt"; Files.write(configDir.resolve(fileName), lines); } catch (Exception exc) { throw new RuntimeException( "scip-java: failed to extract Kotlin compile configuration for task '" - + taskPath + + task.getName() + "': " + exc, exc); diff --git a/scip-java/src/test/kotlin/tests/GradleBuildToolTest.kt b/scip-java/src/test/kotlin/tests/GradleBuildToolTest.kt index efad3ff68..cdf0f6bbf 100644 --- a/scip-java/src/test/kotlin/tests/GradleBuildToolTest.kt +++ b/scip-java/src/test/kotlin/tests/GradleBuildToolTest.kt @@ -186,8 +186,7 @@ class GradleBuildToolTest : BuildToolHarness() { add(checkGradleBuild("kotlin2", "gradle/kotlin2", expectedScipFiles = 4)) // Regression tests: Kotlin sources must be indexed again when the Kotlin // compile tasks are restored from the build cache, or are up to date - // because the build command doesn't clean. The second run of the latter - // also reuses the configuration cache entry of the first. + // because the build command doesn't clean. add( checkGradleBuild( "kotlin-build-cache", @@ -200,8 +199,8 @@ class GradleBuildToolTest : BuildToolHarness() { checkGradleBuild( "kotlin-up-to-date", "gradle/kotlin2", - expectedScipFiles = 1, - extraArguments = listOf("--", "--configuration-cache", "compileKotlin"), + expectedScipFiles = 2, + extraArguments = listOf("--", "compileKotlin", "compileJava"), runs = 2, ) )