From fe2faba9878f36a9a5d762804930a1f1179cb1f7 Mon Sep 17 00:00:00 2001 From: Brice Dutheil Date: Tue, 21 Jul 2026 14:52:25 +0200 Subject: [PATCH 1/8] fix: restore project rule set substitution Use named project configurations so existing configurations.all rules can continue substituting a Maven rule set with a local project. Keep root buildscript resolution detached for Gradle 9 compatibility. This is a transitional compatibility fix. A follow-up adds an explicit project dependency API because named configurations are not suitable for every resolution scope. --- .../gradle/spotless/GradleProvisioner.java | 27 ++++++++++----- .../gradle/spotless/KotlinExtensionTest.java | 34 ++++++++++++++++++- 2 files changed, 51 insertions(+), 10 deletions(-) diff --git a/plugin-gradle/src/main/java/com/diffplug/gradle/spotless/GradleProvisioner.java b/plugin-gradle/src/main/java/com/diffplug/gradle/spotless/GradleProvisioner.java index 52196faf83..0a5ec0aee2 100644 --- a/plugin-gradle/src/main/java/com/diffplug/gradle/spotless/GradleProvisioner.java +++ b/plugin-gradle/src/main/java/com/diffplug/gradle/spotless/GradleProvisioner.java @@ -111,25 +111,34 @@ public Set provisionWithTransitives(boolean withTransitives, Collection { try { Request request = new Request(withTransitives, mavenCoords); - Dependency[] deps = mavenCoords.stream() - .map(dependencies::create) - .toArray(Dependency[]::new); - // Detached configurations avoid mutating the target configuration container, which Gradle 9 forbids - // for the root buildscript container during task execution. See https://github.com/diffplug/spotless/issues/2599. - Configuration config = configurations.detachedConfiguration(deps); + Configuration config; + if (detached) { + Dependency[] deps = mavenCoords.stream() + .map(dependencies::create) + .toArray(Dependency[]::new); + // Detached configurations avoid mutating the root buildscript configuration container, which Gradle 9 + // forbids during task execution. See https://github.com/diffplug/spotless/issues/2599. + config = configurations.detachedConfiguration(deps); + } else { + // Named project configurations participate in configurations.all, including dependency substitutions. + config = configurations.create("spotless" + request.hashCode()); + mavenCoords.stream() + .map(dependencies::create) + .forEach(config.getDependencies()::add); + } config.setDescription("Spotless internal dependency resolution for " + request); config.setTransitive(withTransitives); config.setCanBeConsumed(false); diff --git a/plugin-gradle/src/test/java/com/diffplug/gradle/spotless/KotlinExtensionTest.java b/plugin-gradle/src/test/java/com/diffplug/gradle/spotless/KotlinExtensionTest.java index f986d83717..a48bb685e1 100644 --- a/plugin-gradle/src/test/java/com/diffplug/gradle/spotless/KotlinExtensionTest.java +++ b/plugin-gradle/src/test/java/com/diffplug/gradle/spotless/KotlinExtensionTest.java @@ -1,5 +1,5 @@ /* - * Copyright 2016-2025 DiffPlug + * Copyright 2016-2026 DiffPlug * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -191,6 +191,38 @@ void withCustomRuleSetApply() throws IOException { assertThat(buildOutput).contains("Composable functions that return Unit should start with an uppercase letter."); } + @Test + void issue1901CustomRuleSetSupportsProjectDependencySubstitution() throws IOException { + setFile("settings.gradle.kts").toContent("include(\"ktlint-rules\")"); + setFile("ktlint-rules/build.gradle.kts").toContent("plugins { java }"); + setFile("build.gradle.kts").toContent(""" + plugins { + id("com.diffplug.spotless") + } + repositories { mavenCentral() } + configurations.all { + resolutionStrategy.dependencySubstitution { + substitute(module("my:ktlint-rules")).using(project(":ktlint-rules")) + } + } + tasks.withType(com.diffplug.gradle.spotless.SpotlessTask::class) { + dependsOn(":ktlint-rules:jar") + } + spotless { + kotlin { + target("src/**/*.kt") + ktlint("1.0.1").customRuleSets(listOf("my:ktlint-rules:+")) + } + } + """); + setFile("src/main/kotlin/Main.kt").toContent("fun main() {}\n"); + + gradleRunner() + .withGradleVersion("9.5.1") + .withArguments("spotlessCheck", "--stacktrace") + .build(); + } + @Test void testWithHeader() throws IOException { setFile("build.gradle").toLines( From e3f57b4966852df9e3cad655ed83e4493b5078f4 Mon Sep 17 00:00:00 2001 From: Brice Dutheil Date: Tue, 21 Jul 2026 18:22:07 +0200 Subject: [PATCH 2/8] feat: support ktlint rule set project dependencies Ktlint custom rule sets could previously rely on dependency substitution to replace a module with a local project. Detached configurations introduced for Gradle 9 buildscript predeclaration do not inherit those container-wide rules. Accept project dependencies directly and register their resolved JARs as Spotless task inputs. Gradle can infer producer tasks while preserving incremental and configuration-cache behavior, including transitive and predeclared project rules. --- .../java/com/diffplug/spotless/JarState.java | 11 ++- .../diffplug/spotless/kotlin/KtLintStep.java | 48 ++++++++++- .../spotless/kotlin/PromisedClasspath.java | 77 +++++++++++++++++ .../gradle/spotless/BaseKotlinExtension.java | 53 +++++++++++- .../gradle/spotless/FormatExtension.java | 12 +++ .../gradle/spotless/GradleProvisioner.java | 70 ++++++++------- .../gradle/spotless/SpotlessTask.java | 7 +- .../gradle/spotless/KotlinExtensionTest.java | 79 ++++++++++++++--- .../SpotlessPredeclareIntegrationTest.java | 86 +++++++++++++++++++ 9 files changed, 387 insertions(+), 56 deletions(-) create mode 100644 lib/src/main/java/com/diffplug/spotless/kotlin/PromisedClasspath.java diff --git a/lib/src/main/java/com/diffplug/spotless/JarState.java b/lib/src/main/java/com/diffplug/spotless/JarState.java index bcf55208b2..f714d09168 100644 --- a/lib/src/main/java/com/diffplug/spotless/JarState.java +++ b/lib/src/main/java/com/diffplug/spotless/JarState.java @@ -1,5 +1,5 @@ /* - * Copyright 2016-2025 DiffPlug + * Copyright 2016-2026 DiffPlug * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -24,8 +24,10 @@ import java.io.Serializable; import java.net.URI; import java.net.URL; +import java.util.ArrayList; import java.util.Collection; import java.util.Collections; +import java.util.List; import java.util.NoSuchElementException; import java.util.Objects; import java.util.Set; @@ -143,6 +145,13 @@ public static JarState preserveOrder(Collection jars) throws IOException { return new JarState(fileSignature); } + /** Returns a new state whose classpath also contains the given JARs. */ + public JarState withAdditionalJars(Iterable additionalJars) throws IOException { + List jars = new ArrayList<>(fileSignature.files()); + additionalJars.forEach(jars::add); + return new JarState(FileSignature.signAsList(jars)); + } + URL[] jarUrls() { return fileSignature.files().stream().map(File::toURI).map(ThrowingEx.wrap(URI::toURL)).toArray(URL[]::new); } diff --git a/lib/src/main/java/com/diffplug/spotless/kotlin/KtLintStep.java b/lib/src/main/java/com/diffplug/spotless/kotlin/KtLintStep.java index 1da50be59e..5702e0ac12 100644 --- a/lib/src/main/java/com/diffplug/spotless/kotlin/KtLintStep.java +++ b/lib/src/main/java/com/diffplug/spotless/kotlin/KtLintStep.java @@ -1,5 +1,5 @@ /* - * Copyright 2016-2025 DiffPlug + * Copyright 2016-2026 DiffPlug * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -15,6 +15,7 @@ */ package com.diffplug.spotless.kotlin; +import java.io.File; import java.io.Serial; import java.io.Serializable; import java.lang.reflect.Constructor; @@ -33,6 +34,9 @@ import com.diffplug.spotless.FormatterStep; import com.diffplug.spotless.JarState; import com.diffplug.spotless.Provisioner; +import com.diffplug.spotless.ThrowingEx; + +import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; /** Wraps up ktlint as a FormatterStep. */ public final class KtLintStep implements Serializable { @@ -44,40 +48,64 @@ public final class KtLintStep implements Serializable { private static final String MAVEN_COORDINATE_1_DOT = "com.pinterest.ktlint:ktlint-cli:"; private final JarState.Promised jarState; + @Nullable private final PromisedClasspath additionalClasspath; @Nullable private final FileSignature.Promised config; private final Map editorConfigOverride; private final String version; private KtLintStep(String version, JarState.Promised jarState, + @Nullable PromisedClasspath additionalClasspath, @Nullable FileSignature config, Map editorConfigOverride) { this.version = version; this.jarState = jarState; + this.additionalClasspath = additionalClasspath; this.config = config != null ? config.asPromise() : null; this.editorConfigOverride = editorConfigOverride; } + /** Creates a ktlint step using the default version and configuration. */ public static FormatterStep create(Provisioner provisioner) { return create(defaultVersion(), provisioner); } + /** Creates a ktlint step using the specified version and default configuration. */ public static FormatterStep create(String version, Provisioner provisioner) { return create(version, provisioner, null, Collections.emptyMap(), Collections.emptyList()); } + /** Creates a ktlint step with editor configuration and custom rule sets resolved from Maven coordinates. */ public static FormatterStep create(String version, Provisioner provisioner, @Nullable FileSignature editorConfig, Map editorConfigOverride, List customRuleSets) { + return create(version, provisioner, editorConfig, editorConfigOverride, customRuleSets, null); + } + + /** + * Creates a ktlint step with generated JARs whose contents are modeled separately by the calling build system. + * The supplier may return paths which do not exist until immediately before formatter execution. + */ + public static FormatterStep create(String version, + Provisioner provisioner, + @Nullable FileSignature editorConfig, + Map editorConfigOverride, + List customRuleSets, + @Nullable ThrowingEx.Supplier> additionalClasspath) { Objects.requireNonNull(version, "version"); Objects.requireNonNull(provisioner, "provisioner"); String ktlintCoordinate = (version.startsWith("0.") ? MAVEN_COORDINATE_0_DOT : MAVEN_COORDINATE_1_DOT) + version; Set mavenCoordinates = new HashSet<>(customRuleSets); mavenCoordinates.add(ktlintCoordinate); return FormatterStep.create(NAME, - new KtLintStep(version, JarState.promise(() -> JarState.from(mavenCoordinates, provisioner)), editorConfig, editorConfigOverride), + new KtLintStep( + version, + JarState.promise(() -> JarState.from(mavenCoordinates, provisioner)), + additionalClasspath == null ? null : new PromisedClasspath(additionalClasspath), + editorConfig, + editorConfigOverride), KtLintStep::equalityState, State::createFormat); } @@ -87,7 +115,12 @@ public static String defaultVersion() { } private State equalityState() { - return new State(version, jarState.get(), config != null ? config.get() : null, editorConfigOverride); + return new State( + version, + jarState.get(), + additionalClasspath == null ? List.of() : additionalClasspath.get(), + config != null ? config.get() : null, + editorConfigOverride); } private static final class State implements Serializable { @@ -95,22 +128,29 @@ private static final class State implements Serializable { private static final long serialVersionUID = 1L; /** The jar that contains the formatter. */ private final JarState jarState; + @SuppressFBWarnings(value = "SE_TRANSIENT_FIELD_NOT_RESTORED", justification = "Project classpath contents are separate Gradle task inputs and must not enter formatter equality") + private final transient List additionalClasspath; private final TreeMap editorConfigOverride; private final String version; @Nullable private final FileSignature editorConfigPath; State(String version, JarState jarState, + List additionalClasspath, @Nullable FileSignature editorConfigPath, Map editorConfigOverride) { this.version = version; this.jarState = jarState; + this.additionalClasspath = List.copyOf(additionalClasspath); this.editorConfigOverride = new TreeMap<>(editorConfigOverride); this.editorConfigPath = editorConfigPath; } FormatterFunc createFormat() throws Exception { - final ClassLoader classLoader = jarState.getClassLoader(); + JarState runtimeJarState = additionalClasspath == null || additionalClasspath.isEmpty() + ? jarState + : jarState.withAdditionalJars(additionalClasspath); + final ClassLoader classLoader = runtimeJarState.getClassLoader(); Class formatterFunc = classLoader.loadClass("com.diffplug.spotless.glue.ktlint.KtlintFormatterFunc"); Constructor constructor = formatterFunc.getConstructor( String.class, FileSignature.class, Map.class); diff --git a/lib/src/main/java/com/diffplug/spotless/kotlin/PromisedClasspath.java b/lib/src/main/java/com/diffplug/spotless/kotlin/PromisedClasspath.java new file mode 100644 index 0000000000..159a9c0f52 --- /dev/null +++ b/lib/src/main/java/com/diffplug/spotless/kotlin/PromisedClasspath.java @@ -0,0 +1,77 @@ +/* + * Copyright 2026 DiffPlug + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.diffplug.spotless.kotlin; + +import java.io.File; +import java.io.IOException; +import java.io.ObjectOutputStream; +import java.io.Serial; +import java.io.Serializable; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +import javax.annotation.Nullable; + +import com.diffplug.spotless.FileSignature; +import com.diffplug.spotless.JarState; +import com.diffplug.spotless.ThrowingEx; + +import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; + +/** + * Configuration-cache-safe promise for a generated classpath. + *

+ * {@link JarState.Promised} and {@link FileSignature.Promised} represent signed file contents when + * materialized. That is too early for project artifacts, which might not exist while Gradle serializes + * formatter state. This promise serializes only the resolved file paths; Gradle tracks their contents as + * a separate {@code @Classpath} task input, and {@link KtLintStep.State#createFormat()} signs them after the producer + * tasks have run. + *

+ * Resolution is synchronized because Gradle can serialize the roundtrip and equality views concurrently. + */ +final class PromisedClasspath implements Serializable { + @Serial + private static final long serialVersionUID = 1L; + @SuppressFBWarnings(value = "SE_TRANSIENT_FIELD_NOT_RESTORED", justification = "Serialized file paths replace the supplier after a configuration-cache roundtrip") + private final transient ThrowingEx.Supplier> supplier; + @Nullable private volatile List files; + + PromisedClasspath(ThrowingEx.Supplier> supplier) { + this.supplier = supplier; + } + + List get() { + List result = files; + if (result == null) { + synchronized (this) { + result = files; + if (result == null) { + ThrowingEx.Supplier> availableSupplier = Objects.requireNonNull(supplier, "supplier"); + List suppliedFiles = new ArrayList<>(); + ThrowingEx.get(availableSupplier).forEach(suppliedFiles::add); + files = result = List.copyOf(suppliedFiles); + } + } + } + return result; + } + + private void writeObject(ObjectOutputStream out) throws IOException { + get(); + out.defaultWriteObject(); + } +} diff --git a/plugin-gradle/src/main/java/com/diffplug/gradle/spotless/BaseKotlinExtension.java b/plugin-gradle/src/main/java/com/diffplug/gradle/spotless/BaseKotlinExtension.java index c3dc731215..4c11e6197a 100644 --- a/plugin-gradle/src/main/java/com/diffplug/gradle/spotless/BaseKotlinExtension.java +++ b/plugin-gradle/src/main/java/com/diffplug/gradle/spotless/BaseKotlinExtension.java @@ -17,6 +17,8 @@ import java.io.File; import java.io.IOException; +import java.util.ArrayList; +import java.util.Collection; import java.util.Collections; import java.util.List; import java.util.Map; @@ -25,6 +27,9 @@ import javax.annotation.Nullable; +import org.gradle.api.Project; +import org.gradle.api.artifacts.Configuration; + import com.diffplug.common.collect.ImmutableList; import com.diffplug.common.collect.ImmutableSortedMap; import com.diffplug.spotless.FileSignature; @@ -179,6 +184,7 @@ public final class KtlintConfig { private FileSignature editorConfigPath; private Map editorConfigOverride; private List customRuleSets; + private Configuration customRuleSetProjectClasspath; private KtlintConfig( String version, @@ -216,19 +222,60 @@ public KtlintConfig editorConfigOverride(Map editorConfigOverrid return this; } + /** Uses custom rule sets published at the given Maven coordinates. */ public KtlintConfig customRuleSets(List customRuleSets) { - this.customRuleSets = ImmutableList.copyOf(customRuleSets); - replaceStep(createStep()); + setCustomRuleSets(customRuleSets, List.of()); + return this; + } + + /** + * Uses custom rule sets from Maven coordinates and/or local Gradle projects. + * Each dependency must be either a {@link String} Maven coordinate or a {@link Project} + * from this build. + */ + public KtlintConfig customRuleSets(Object... customRuleSets) { + Objects.requireNonNull(customRuleSets, "customRuleSets"); + List mavenCoordinates = new ArrayList<>(); + List projectPaths = new ArrayList<>(); + for (Object customRuleSet : customRuleSets) { + Objects.requireNonNull(customRuleSet, "customRuleSets must not contain null"); + if (customRuleSet instanceof String mavenCoordinate) { + mavenCoordinates.add(mavenCoordinate); + } else if (customRuleSet instanceof Project project) { + if (project.getGradle() != getProject().getGradle()) { + throw new IllegalArgumentException("Custom ktlint rule-set projects must belong to the same Gradle build."); + } + projectPaths.add(project.getPath()); + } else { + throw new IllegalArgumentException("Custom ktlint rule-set dependencies must be Maven coordinate strings or Gradle projects, but found " + customRuleSet.getClass().getName() + "."); + } + } + setCustomRuleSets(mavenCoordinates, projectPaths); return this; } + private void setCustomRuleSets(Collection mavenCoordinates, Collection projectPaths) { + this.customRuleSets = ImmutableList.copyOf(mavenCoordinates); + if (customRuleSetProjectClasspath != null) { + removeFormatterClasspath(customRuleSetProjectClasspath); + } + if (projectPaths.isEmpty()) { + customRuleSetProjectClasspath = null; + } else { + customRuleSetProjectClasspath = GradleProvisioner.projectDependencies(getProject(), projectPaths); + addFormatterClasspath(customRuleSetProjectClasspath); + } + replaceStep(createStep()); + } + private FormatterStep createStep() { return KtLintStep.create( version, provisioner(), editorConfigPath, editorConfigOverride, - customRuleSets); + customRuleSets, + customRuleSetProjectClasspath == null ? null : customRuleSetProjectClasspath::resolve); } } } diff --git a/plugin-gradle/src/main/java/com/diffplug/gradle/spotless/FormatExtension.java b/plugin-gradle/src/main/java/com/diffplug/gradle/spotless/FormatExtension.java index 48f3e50eb9..6fcabd2920 100644 --- a/plugin-gradle/src/main/java/com/diffplug/gradle/spotless/FormatExtension.java +++ b/plugin-gradle/src/main/java/com/diffplug/gradle/spotless/FormatExtension.java @@ -358,6 +358,15 @@ private static void relativizeIfSubdir(List relativePaths, File root, Fi /** The steps that need to be added. */ protected final List steps = new ArrayList<>(); + final List additionalFormatterClasspath = new ArrayList<>(); + + protected final void addFormatterClasspath(FileCollection classpath) { + additionalFormatterClasspath.add(requireNonNull(classpath)); + } + + protected final void removeFormatterClasspath(FileCollection classpath) { + additionalFormatterClasspath.remove(classpath); + } /** Adds a new step. */ public void addStep(FormatterStep newStep) { @@ -403,6 +412,7 @@ protected void replaceStep(FormatterStep replacementStep) { /** Clears all of the existing steps. */ public void clearSteps() { steps.clear(); + additionalFormatterClasspath.clear(); } /** @@ -1054,6 +1064,7 @@ private void withinBlocksHelper(FenceStep fence, Cla // create a step which applies all of those steps as sub-steps FormatterStep step = fence.applyWithin(formatExtension.steps); addStep(step); + additionalFormatterClasspath.addAll(formatExtension.additionalFormatterClasspath); } /** @@ -1086,6 +1097,7 @@ public void toggleOffOnDisable() { /** Sets up a format task according to the values in this extension. */ protected void setupTask(SpotlessTask task) { + task.getAdditionalFormatterClasspath().from(additionalFormatterClasspath); task.setEncoding(getEncoding().name()); task.setLintSuppressions(lintSuppressions); FileCollection totalTarget = targetExclude == null ? target : target.minus(targetExclude); diff --git a/plugin-gradle/src/main/java/com/diffplug/gradle/spotless/GradleProvisioner.java b/plugin-gradle/src/main/java/com/diffplug/gradle/spotless/GradleProvisioner.java index 0a5ec0aee2..caa7702f60 100644 --- a/plugin-gradle/src/main/java/com/diffplug/gradle/spotless/GradleProvisioner.java +++ b/plugin-gradle/src/main/java/com/diffplug/gradle/spotless/GradleProvisioner.java @@ -53,16 +53,16 @@ enum Policy { public DedupingProvisioner dedupingProvisioner(Project project) { return switch (this) { - case ROOT_PROJECT -> new DedupingProvisioner(forProject(project)); - case ROOT_BUILDSCRIPT -> new DedupingProvisioner(forRootProjectBuildscript(project)); - default -> throw Unhandled.enumException(this); + case ROOT_PROJECT -> new DedupingProvisioner(forProject(project)); + case ROOT_BUILDSCRIPT -> new DedupingProvisioner(forRootProjectBuildscript(project)); + default -> throw Unhandled.enumException(this); }; } public DedupingP2Provisioner dedupingP2Provisioner(Project project) { return switch (this) { - case ROOT_PROJECT, ROOT_BUILDSCRIPT -> new DedupingP2Provisioner(P2Provisioner.createDefault(), defaultP2CacheDirectory(project)); - default -> throw Unhandled.enumException(this); + case ROOT_PROJECT, ROOT_BUILDSCRIPT -> new DedupingP2Provisioner(P2Provisioner.createDefault(), defaultP2CacheDirectory(project)); + default -> throw Unhandled.enumException(this); }; } } @@ -111,44 +111,24 @@ public Set provisionWithTransitives(boolean withTransitives, Collection { try { Request request = new Request(withTransitives, mavenCoords); - Configuration config; - if (detached) { - Dependency[] deps = mavenCoords.stream() - .map(dependencies::create) - .toArray(Dependency[]::new); - // Detached configurations avoid mutating the root buildscript configuration container, which Gradle 9 - // forbids during task execution. See https://github.com/diffplug/spotless/issues/2599. - config = configurations.detachedConfiguration(deps); - } else { - // Named project configurations participate in configurations.all, including dependency substitutions. - config = configurations.create("spotless" + request.hashCode()); - mavenCoords.stream() - .map(dependencies::create) - .forEach(config.getDependencies()::add); - } - config.setDescription("Spotless internal dependency resolution for " + request); - config.setTransitive(withTransitives); - config.setCanBeConsumed(false); - config.setVisible(false); - config.attributes(attr -> { - attr.attribute(Category.CATEGORY_ATTRIBUTE, project.getObjects().named(Category.class, Category.LIBRARY)); - attr.attribute(Bundling.BUNDLING_ATTRIBUTE, project.getObjects().named(Bundling.class, Bundling.EXTERNAL)); - // Add this attribute for resolving Guava dependency, see https://github.com/google/guava/issues/6801. - attr.attribute(TargetJvmEnvironment.TARGET_JVM_ENVIRONMENT_ATTRIBUTE, project.getObjects().named(TargetJvmEnvironment.class, TargetJvmEnvironment.STANDARD_JVM)); - }); + Dependency[] deps = mavenCoords.stream() + .map(dependencies::create) + .toArray(Dependency[]::new); + Configuration config = configurations.detachedConfiguration(deps); + configure(project, config, withTransitives, "Spotless internal dependency resolution for " + request); return config.resolve(); } catch (Exception e) { String projName = project.getPath().substring(1).replace(':', '/'); @@ -163,6 +143,30 @@ private static Provisioner forConfigurationContainer(Project project, Configurat }; } + static Configuration projectDependencies(Project project, Collection projectPaths) { + Dependency[] dependencies = projectPaths.stream() + .map(projectPath -> project.getDependencies().project(Map.of("path", projectPath))) + .toArray(Dependency[]::new); + Configuration config = project.getConfigurations().detachedConfiguration(dependencies); + configure(project, config, true, "Spotless internal project dependency resolution for " + projectPaths); + return config; + } + + private static void configure(Project project, Configuration config, boolean withTransitives, String description) { + // Detached configurations avoid mutating configuration containers during task execution, which Gradle 9 + // forbids for buildscript configurations. See https://github.com/diffplug/spotless/issues/2599. + config.setDescription(description); + config.setTransitive(withTransitives); + config.setCanBeConsumed(false); + config.setVisible(false); + config.attributes(attr -> { + attr.attribute(Category.CATEGORY_ATTRIBUTE, project.getObjects().named(Category.class, Category.LIBRARY)); + attr.attribute(Bundling.BUNDLING_ATTRIBUTE, project.getObjects().named(Bundling.class, Bundling.EXTERNAL)); + // Add this attribute for resolving Guava dependency, see https://github.com/google/guava/issues/6801. + attr.attribute(TargetJvmEnvironment.TARGET_JVM_ENVIRONMENT_ATTRIBUTE, project.getObjects().named(TargetJvmEnvironment.class, TargetJvmEnvironment.STANDARD_JVM)); + }); + } + private static final Logger LOGGER = LoggerFactory.getLogger(GradleProvisioner.class); static File defaultP2CacheDirectory(Project project) { diff --git a/plugin-gradle/src/main/java/com/diffplug/gradle/spotless/SpotlessTask.java b/plugin-gradle/src/main/java/com/diffplug/gradle/spotless/SpotlessTask.java index 713c9a2e86..73d924ed58 100644 --- a/plugin-gradle/src/main/java/com/diffplug/gradle/spotless/SpotlessTask.java +++ b/plugin-gradle/src/main/java/com/diffplug/gradle/spotless/SpotlessTask.java @@ -1,5 +1,5 @@ /* - * Copyright 2020-2025 DiffPlug + * Copyright 2020-2026 DiffPlug * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -24,10 +24,12 @@ import org.eclipse.jgit.lib.ObjectId; import org.gradle.api.DefaultTask; +import org.gradle.api.file.ConfigurableFileCollection; import org.gradle.api.file.DirectoryProperty; import org.gradle.api.file.FileCollection; import org.gradle.api.provider.Property; import org.gradle.api.provider.Provider; +import org.gradle.api.tasks.Classpath; import org.gradle.api.tasks.Input; import org.gradle.api.tasks.InputFiles; import org.gradle.api.tasks.Internal; @@ -152,6 +154,9 @@ public void setTarget(Iterable target) { } } + @Classpath + public abstract ConfigurableFileCollection getAdditionalFormatterClasspath(); + protected File cleanDirectory = new File(getProject().getLayout().getBuildDirectory().getAsFile().get(), "spotless-clean/" + getName()); diff --git a/plugin-gradle/src/test/java/com/diffplug/gradle/spotless/KotlinExtensionTest.java b/plugin-gradle/src/test/java/com/diffplug/gradle/spotless/KotlinExtensionTest.java index a48bb685e1..f1d9dfa96d 100644 --- a/plugin-gradle/src/test/java/com/diffplug/gradle/spotless/KotlinExtensionTest.java +++ b/plugin-gradle/src/test/java/com/diffplug/gradle/spotless/KotlinExtensionTest.java @@ -20,6 +20,8 @@ import java.io.File; import java.io.IOException; +import org.gradle.testkit.runner.BuildResult; +import org.gradle.testkit.runner.TaskOutcome; import org.junit.jupiter.api.Test; class KotlinExtensionTest extends GradleIntegrationHarness { @@ -192,35 +194,84 @@ void withCustomRuleSetApply() throws IOException { } @Test - void issue1901CustomRuleSetSupportsProjectDependencySubstitution() throws IOException { - setFile("settings.gradle.kts").toContent("include(\"ktlint-rules\")"); - setFile("ktlint-rules/build.gradle.kts").toContent("plugins { java }"); + void issue1901CustomRuleSetSupportsProjectDependency() throws IOException { + setFile("settings.gradle.kts").toContent("include(\"ktlint-rules\", \"rule-support\")"); + setFile("rule-support/build.gradle.kts").toContent("plugins { java }"); + setFile("rule-support/src/main/java/support/Marker.java").toContent("package support; public final class Marker { public static int version() { return 1; } }\n"); + setFile("ktlint-rules/build.gradle.kts").toContent(""" + plugins { java } + repositories { mavenCentral() } + dependencies { + implementation(project(":rule-support")) + compileOnly("com.pinterest.ktlint:ktlint-cli-ruleset-core:1.0.1") + compileOnly("com.pinterest.ktlint:ktlint-rule-engine-core:1.0.1") + } + """); + setFile("ktlint-rules/src/main/java/rules/LocalRuleSetProvider.java").toContent(""" + package rules; + + import java.nio.file.Files; + import java.nio.file.Path; + import java.util.Collections; + import java.util.Set; + + import com.pinterest.ktlint.cli.ruleset.core.api.RuleSetProviderV3; + import com.pinterest.ktlint.rule.engine.core.api.RuleProvider; + import com.pinterest.ktlint.rule.engine.core.api.RuleSetId; + import support.Marker; + + public final class LocalRuleSetProvider extends RuleSetProviderV3 { + public LocalRuleSetProvider() { + super(new RuleSetId("local-project")); + try { + Files.writeString(Path.of(System.getProperty("spotless.test.rule.version")), Integer.toString(Marker.version())); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + @Override + public Set getRuleProviders() { + return Collections.emptySet(); + } + } + """); + setFile("ktlint-rules/src/main/resources/META-INF/services/com.pinterest.ktlint.cli.ruleset.core.api.RuleSetProviderV3") + .toContent("rules.LocalRuleSetProvider\n"); setFile("build.gradle.kts").toContent(""" plugins { id("com.diffplug.spotless") } repositories { mavenCentral() } - configurations.all { - resolutionStrategy.dependencySubstitution { - substitute(module("my:ktlint-rules")).using(project(":ktlint-rules")) - } - } - tasks.withType(com.diffplug.gradle.spotless.SpotlessTask::class) { - dependsOn(":ktlint-rules:jar") - } spotless { kotlin { target("src/**/*.kt") - ktlint("1.0.1").customRuleSets(listOf("my:ktlint-rules:+")) + ktlint("1.0.1").customRuleSets(project(":ktlint-rules")) } } """); setFile("src/main/kotlin/Main.kt").toContent("fun main() {}\n"); + String ruleVersionProperty = "-Dspotless.test.rule.version=" + newFile("rule-version.txt").getAbsolutePath(); + + BuildResult firstRun = gradleRunner() + .withGradleVersion("9.5.1") + .withArguments("spotlessCheck", "--configuration-cache", "--stacktrace", ruleVersionProperty) + .build(); + assertThat(firstRun.getOutput()).contains("Configuration cache entry stored."); + assertThat(firstRun.task(":ktlint-rules:jar")).isNotNull(); + assertThat(firstRun.task(":rule-support:jar")).isNotNull(); + assertThat(firstRun.task(":spotlessKotlin")).isNotNull(); + assertFile("rule-version.txt").hasContent("1"); - gradleRunner() + setFile("rule-support/src/main/java/support/Marker.java").toContent("package support; public final class Marker { public static int version() { return 2; } }\n"); + BuildResult secondRun = gradleRunner() .withGradleVersion("9.5.1") - .withArguments("spotlessCheck", "--stacktrace") + .withArguments("spotlessCheck", "--configuration-cache", "--stacktrace", ruleVersionProperty) .build(); + assertThat(secondRun.getOutput()).contains("Reusing configuration cache."); + assertThat(secondRun.task(":rule-support:jar").getOutcome()).isNotEqualTo(TaskOutcome.UP_TO_DATE); + assertThat(secondRun.task(":spotlessKotlin").getOutcome()).isNotEqualTo(TaskOutcome.UP_TO_DATE); + assertFile("rule-version.txt").hasContent("2"); } @Test diff --git a/plugin-gradle/src/test/java/com/diffplug/gradle/spotless/SpotlessPredeclareIntegrationTest.java b/plugin-gradle/src/test/java/com/diffplug/gradle/spotless/SpotlessPredeclareIntegrationTest.java index 796c8896ec..fae6ab5a2c 100644 --- a/plugin-gradle/src/test/java/com/diffplug/gradle/spotless/SpotlessPredeclareIntegrationTest.java +++ b/plugin-gradle/src/test/java/com/diffplug/gradle/spotless/SpotlessPredeclareIntegrationTest.java @@ -20,6 +20,7 @@ import java.io.IOException; import org.gradle.testkit.runner.BuildResult; +import org.gradle.testkit.runner.TaskOutcome; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; @@ -510,6 +511,91 @@ target file('test.java') @Nested class Gradle9Compatibility { + @Test + void gradle951CanPredeclareKtlintRuleSetProjectFromBuildscript() throws IOException { + setFile("settings.gradle.kts").toContent("include(\"ktlint-rules\")"); + setFile("ktlint-rules/build.gradle.kts").toContent("plugins { java }"); + setFile("build.gradle.kts").toContent(""" + buildscript { + repositories { mavenCentral() } + } + plugins { + id("com.diffplug.spotless") + } + spotless { + predeclareDepsFromBuildscript() + } + spotlessPredeclare { + kotlin { + ktlint("1.0.1").customRuleSets(project(":ktlint-rules")) + } + } + spotless { + kotlin { + target("src/**/*.kt") + ktlint("1.0.1").customRuleSets(project(":ktlint-rules")) + } + } + """); + setFile("src/main/kotlin/Main.kt").toContent("fun main() {}\n"); + + BuildResult result = gradleRunner() + .withGradleVersion("9.5.1") + .withArguments("spotlessCheck", "--configuration-cache", "--stacktrace") + .build(); + + assertThat(result.getOutput()).contains("Configuration cache entry stored."); + assertThat(result.task(":ktlint-rules:jar")).isNotNull(); + assertThat(result.task(":ktlint-rules:jar").getOutcome()).isEqualTo(TaskOutcome.SUCCESS); + } + + @Test + void predeclaredKtlintProjectIsNotBuiltForUnrelatedFormat() throws IOException { + setFile("settings.gradle.kts").toContent("include(\"ktlint-rules\")"); + setFile("ktlint-rules/build.gradle.kts").toContent("plugins { java }"); + setFile("build.gradle.kts").toContent(""" + buildscript { + repositories { mavenCentral() } + } + plugins { + id("com.diffplug.spotless") + } + spotless { + predeclareDepsFromBuildscript() + } + spotlessPredeclare { + kotlin { + ktlint("1.0.1").customRuleSets(project(":ktlint-rules")) + } + } + spotless { + java { + target("src/**/*.java") + trimTrailingWhitespace() + } + } + """); + setFile("src/main/java/Main.java").toContent("class Main {}\n"); + + BuildResult first = gradleRunner() + .withGradleVersion("9.5.1") + .withArguments("spotlessJavaCheck", "--configuration-cache", "--stacktrace") + .build(); + + assertThat(first.getOutput()).contains("Configuration cache entry stored."); + assertThat(first.task(":spotlessInternalRegisterDependencies")).isNotNull(); + assertThat(first.task(":ktlint-rules:jar")).isNull(); + + BuildResult second = gradleRunner() + .withGradleVersion("9.5.1") + .withArguments("spotlessJavaCheck", "--configuration-cache", "--stacktrace") + .build(); + + assertThat(second.getOutput()).contains("Reusing configuration cache."); + assertThat(second.task(":spotlessInternalRegisterDependencies")).isNotNull(); + assertThat(second.task(":ktlint-rules:jar")).isNull(); + } + @Test void issue2599_Gradle951_CanUsePredeclareDepsFromBuildscript() throws IOException { setFile("build.gradle.kts").toContent(""" From fce86305efe5c82b9668cf8361527a132851459a Mon Sep 17 00:00:00 2001 From: Brice Dutheil Date: Wed, 22 Jul 2026 14:10:31 +0200 Subject: [PATCH 3/8] fix: resolve ktlint project rule sets in one graph Resolve ktlint, Maven rule sets, and local project rule sets through one detached configuration. Gradle can mediate shared transitives and infer producer tasks. Keep the ktlint version selected by the DSL strict. Model generated project JARs as additional `@Classpath` inputs and defer their signatures until execution. This preserves configuration-cache reuse and predeclare semantics while rejecting projects that request another version of the selected ktlint module. --- .../java/com/diffplug/spotless/JarState.java | 1 + .../diffplug/spotless/kotlin/KtLintStep.java | 10 +- .../spotless/kotlin/PromisedClasspath.java | 14 +- .../gradle/spotless/BaseKotlinExtension.java | 32 ++- .../gradle/spotless/FormatExtension.java | 28 +- .../gradle/spotless/GradleProvisioner.java | 267 +++++++++++++++--- .../gradle/spotless/SpotlessTaskService.java | 26 +- .../gradle/spotless/KotlinExtensionTest.java | 205 ++++++++++++++ .../SpotlessPredeclareIntegrationTest.java | 54 +++- 9 files changed, 577 insertions(+), 60 deletions(-) diff --git a/lib/src/main/java/com/diffplug/spotless/JarState.java b/lib/src/main/java/com/diffplug/spotless/JarState.java index f714d09168..e69b586e99 100644 --- a/lib/src/main/java/com/diffplug/spotless/JarState.java +++ b/lib/src/main/java/com/diffplug/spotless/JarState.java @@ -149,6 +149,7 @@ public static JarState preserveOrder(Collection jars) throws IOException { public JarState withAdditionalJars(Iterable additionalJars) throws IOException { List jars = new ArrayList<>(fileSignature.files()); additionalJars.forEach(jars::add); + // Local project tasks that produce the requested artifacts have completed and files exist to be signed return new JarState(FileSignature.signAsList(jars)); } diff --git a/lib/src/main/java/com/diffplug/spotless/kotlin/KtLintStep.java b/lib/src/main/java/com/diffplug/spotless/kotlin/KtLintStep.java index 5702e0ac12..f623545be1 100644 --- a/lib/src/main/java/com/diffplug/spotless/kotlin/KtLintStep.java +++ b/lib/src/main/java/com/diffplug/spotless/kotlin/KtLintStep.java @@ -96,9 +96,8 @@ public static FormatterStep create(String version, @Nullable ThrowingEx.Supplier> additionalClasspath) { Objects.requireNonNull(version, "version"); Objects.requireNonNull(provisioner, "provisioner"); - String ktlintCoordinate = (version.startsWith("0.") ? MAVEN_COORDINATE_0_DOT : MAVEN_COORDINATE_1_DOT) + version; Set mavenCoordinates = new HashSet<>(customRuleSets); - mavenCoordinates.add(ktlintCoordinate); + mavenCoordinates.add(mavenCoordinate(version)); return FormatterStep.create(NAME, new KtLintStep( version, @@ -114,6 +113,12 @@ public static String defaultVersion() { return DEFAULT_VERSION; } + /** Returns the Maven coordinate used for the specified ktlint version. */ + public static String mavenCoordinate(String version) { + Objects.requireNonNull(version, "version"); + return (version.startsWith("0.") ? MAVEN_COORDINATE_0_DOT : MAVEN_COORDINATE_1_DOT) + version; + } + private State equalityState() { return new State( version, @@ -150,6 +155,7 @@ FormatterFunc createFormat() throws Exception { JarState runtimeJarState = additionalClasspath == null || additionalClasspath.isEmpty() ? jarState : jarState.withAdditionalJars(additionalClasspath); + // At this time, it is possible to sign the generated JAR contents. final ClassLoader classLoader = runtimeJarState.getClassLoader(); Class formatterFunc = classLoader.loadClass("com.diffplug.spotless.glue.ktlint.KtlintFormatterFunc"); Constructor constructor = formatterFunc.getConstructor( diff --git a/lib/src/main/java/com/diffplug/spotless/kotlin/PromisedClasspath.java b/lib/src/main/java/com/diffplug/spotless/kotlin/PromisedClasspath.java index 159a9c0f52..0a156ac264 100644 --- a/lib/src/main/java/com/diffplug/spotless/kotlin/PromisedClasspath.java +++ b/lib/src/main/java/com/diffplug/spotless/kotlin/PromisedClasspath.java @@ -38,8 +38,8 @@ * {@link JarState.Promised} and {@link FileSignature.Promised} represent signed file contents when * materialized. That is too early for project artifacts, which might not exist while Gradle serializes * formatter state. This promise serializes only the resolved file paths; Gradle tracks their contents as - * a separate {@code @Classpath} task input, and {@link KtLintStep.State#createFormat()} signs them after the producer - * tasks have run. + * a separate {@code @Classpath} task input, and {@link KtLintStep.State#createFormat()} signs them after + * the producer tasks have run. *

* Resolution is synchronized because Gradle can serialize the roundtrip and equality views concurrently. */ @@ -54,6 +54,16 @@ final class PromisedClasspath implements Serializable { this.supplier = supplier; } + /** + * Returns the promised classpath paths, materializing the supplier at most once. + *

+ * Materialization resolves only paths. The referenced files may not exist yet because their Gradle producer tasks + * can run after formatter-state serialization. File contents are signed later by + * {@link KtLintStep.State#createFormat()}. + *

+ * After a serialization roundtrip, the serialized path list is reused and the transient supplier is no longer + * required. + */ List get() { List result = files; if (result == null) { diff --git a/plugin-gradle/src/main/java/com/diffplug/gradle/spotless/BaseKotlinExtension.java b/plugin-gradle/src/main/java/com/diffplug/gradle/spotless/BaseKotlinExtension.java index 4c11e6197a..09a40f8110 100644 --- a/plugin-gradle/src/main/java/com/diffplug/gradle/spotless/BaseKotlinExtension.java +++ b/plugin-gradle/src/main/java/com/diffplug/gradle/spotless/BaseKotlinExtension.java @@ -28,7 +28,6 @@ import javax.annotation.Nullable; import org.gradle.api.Project; -import org.gradle.api.artifacts.Configuration; import com.diffplug.common.collect.ImmutableList; import com.diffplug.common.collect.ImmutableSortedMap; @@ -184,7 +183,7 @@ public final class KtlintConfig { private FileSignature editorConfigPath; private Map editorConfigOverride; private List customRuleSets; - private Configuration customRuleSetProjectClasspath; + private GradleProvisioner.DependencyClasspath customRuleSetProjectClasspath; private KtlintConfig( String version, @@ -245,8 +244,10 @@ public KtlintConfig customRuleSets(Object... customRuleSets) { if (project.getGradle() != getProject().getGradle()) { throw new IllegalArgumentException("Custom ktlint rule-set projects must belong to the same Gradle build."); } + // Store the stable project path, not the Project object projectPaths.add(project.getPath()); } else { + // FileCollection and arbitrary Dependency objects are not supported in this version throw new IllegalArgumentException("Custom ktlint rule-set dependencies must be Maven coordinate strings or Gradle projects, but found " + customRuleSet.getClass().getName() + "."); } } @@ -256,26 +257,43 @@ public KtlintConfig customRuleSets(Object... customRuleSets) { private void setCustomRuleSets(Collection mavenCoordinates, Collection projectPaths) { this.customRuleSets = ImmutableList.copyOf(mavenCoordinates); + // Remove the previous Gradle task input when customRuleSets() is called more than once. if (customRuleSetProjectClasspath != null) { - removeFormatterClasspath(customRuleSetProjectClasspath); + removeFormatterClasspath(customRuleSetProjectClasspath.projectArtifacts); } if (projectPaths.isEmpty()) { + // Preserve the existing, simpler Maven-only path. customRuleSetProjectClasspath = null; } else { - customRuleSetProjectClasspath = GradleProvisioner.projectDependencies(getProject(), projectPaths); - addFormatterClasspath(customRuleSetProjectClasspath); + List allMavenCoordinates = new ArrayList<>(mavenCoordinates); + // ktlint, published rule sets, and local rule projects must participate in the same resolution graph. + String ktlintCoordinate = KtLintStep.mavenCoordinate(version); + allMavenCoordinates.add(ktlintCoordinate); + + customRuleSetProjectClasspath = dependencyClasspath(allMavenCoordinates, projectPaths, ktlintCoordinate); + + // Give Gradle ownership of generated project artifacts + addFormatterClasspath(customRuleSetProjectClasspath.projectArtifacts); } replaceStep(createStep()); } private FormatterStep createStep() { + if (customRuleSetProjectClasspath != null) { + return KtLintStep.create( + version, + customRuleSetProjectClasspath.externalProvisioner, + editorConfigPath, + editorConfigOverride, + customRuleSets, + customRuleSetProjectClasspath.projectArtifacts::getFiles); + } return KtLintStep.create( version, provisioner(), editorConfigPath, editorConfigOverride, - customRuleSets, - customRuleSetProjectClasspath == null ? null : customRuleSetProjectClasspath::resolve); + customRuleSets); } } } diff --git a/plugin-gradle/src/main/java/com/diffplug/gradle/spotless/FormatExtension.java b/plugin-gradle/src/main/java/com/diffplug/gradle/spotless/FormatExtension.java index 6fcabd2920..ac386ba327 100644 --- a/plugin-gradle/src/main/java/com/diffplug/gradle/spotless/FormatExtension.java +++ b/plugin-gradle/src/main/java/com/diffplug/gradle/spotless/FormatExtension.java @@ -98,6 +98,14 @@ protected final Provisioner provisioner() { return spotless.getSpotlessTaskService().get().provisionerFor(spotless); } + final GradleProvisioner.DependencyClasspath dependencyClasspath( + Collection mavenCoordinates, + Collection projectPaths, + String strictlyEnforcedCoordinate) { + return spotless.getSpotlessTaskService().get().dependencyClasspathFor( + spotless, mavenCoordinates, projectPaths, strictlyEnforcedCoordinate); + } + protected final P2Provisioner p2Provisioner() { return spotless.getSpotlessTaskService().get().p2ProvisionerFor(spotless); } @@ -358,14 +366,22 @@ private static void relativizeIfSubdir(List relativePaths, File root, Fi /** The steps that need to be added. */ protected final List steps = new ArrayList<>(); - final List additionalFormatterClasspath = new ArrayList<>(); + + /** + * Individual formatter-owned classpath sources. + * + * FileCollection sources remain separate so a formatter can replace or remove its own classpath + * without disturbing sources registered by other steps. Gradle flattens them into the task's + * {@link org.gradle.api.file.ConfigurableFileCollection}. + */ + final List additionalFormatterClasspaths = new ArrayList<>(); protected final void addFormatterClasspath(FileCollection classpath) { - additionalFormatterClasspath.add(requireNonNull(classpath)); + additionalFormatterClasspaths.add(requireNonNull(classpath)); } protected final void removeFormatterClasspath(FileCollection classpath) { - additionalFormatterClasspath.remove(classpath); + additionalFormatterClasspaths.remove(classpath); } /** Adds a new step. */ @@ -412,7 +428,7 @@ protected void replaceStep(FormatterStep replacementStep) { /** Clears all of the existing steps. */ public void clearSteps() { steps.clear(); - additionalFormatterClasspath.clear(); + additionalFormatterClasspaths.clear(); } /** @@ -1064,7 +1080,7 @@ private void withinBlocksHelper(FenceStep fence, Cla // create a step which applies all of those steps as sub-steps FormatterStep step = fence.applyWithin(formatExtension.steps); addStep(step); - additionalFormatterClasspath.addAll(formatExtension.additionalFormatterClasspath); + additionalFormatterClasspaths.addAll(formatExtension.additionalFormatterClasspaths); } /** @@ -1097,7 +1113,7 @@ public void toggleOffOnDisable() { /** Sets up a format task according to the values in this extension. */ protected void setupTask(SpotlessTask task) { - task.getAdditionalFormatterClasspath().from(additionalFormatterClasspath); + task.getAdditionalFormatterClasspath().from(additionalFormatterClasspaths); task.setEncoding(getEncoding().name()); task.setLintSuppressions(lintSuppressions); FileCollection totalTarget = targetExclude == null ? target : target.minus(targetExclude); diff --git a/plugin-gradle/src/main/java/com/diffplug/gradle/spotless/GradleProvisioner.java b/plugin-gradle/src/main/java/com/diffplug/gradle/spotless/GradleProvisioner.java index caa7702f60..c0bb6abd40 100644 --- a/plugin-gradle/src/main/java/com/diffplug/gradle/spotless/GradleProvisioner.java +++ b/plugin-gradle/src/main/java/com/diffplug/gradle/spotless/GradleProvisioner.java @@ -17,10 +17,13 @@ import java.io.File; import java.io.IOException; +import java.util.ArrayList; import java.util.Collection; import java.util.HashMap; +import java.util.HashSet; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.Set; import javax.annotation.Nullable; @@ -30,11 +33,17 @@ import org.gradle.api.artifacts.Configuration; import org.gradle.api.artifacts.ConfigurationContainer; import org.gradle.api.artifacts.Dependency; +import org.gradle.api.artifacts.ExternalDependency; +import org.gradle.api.artifacts.component.ModuleComponentSelector; +import org.gradle.api.artifacts.component.ProjectComponentIdentifier; import org.gradle.api.artifacts.dsl.DependencyHandler; +import org.gradle.api.artifacts.result.DependencyResult; import org.gradle.api.attributes.Bundling; import org.gradle.api.attributes.Category; import org.gradle.api.attributes.java.TargetJvmEnvironment; +import org.gradle.api.file.FileCollection; import org.gradle.api.initialization.dsl.ScriptHandler; +import org.gradle.api.provider.Provider; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -70,11 +79,58 @@ public DedupingP2Provisioner dedupingP2Provisioner(Project project) { static class DedupingProvisioner implements Provisioner { private final Provisioner provisioner; private final Map> cache = new HashMap<>(); + private final Map dependencyClasspathCache = new HashMap<>(); DedupingProvisioner(Provisioner provisioner) { this.provisioner = provisioner; } + DependencyClasspath dependencyClasspath( + Collection mavenCoordinates, + Collection projectPaths, + String strictlyEnforcedCoordinate) { + ConfigurationProvisioner configurationProvisioner = configurationProvisioner(); + DependencyClasspathRequest request = new DependencyClasspathRequest(mavenCoordinates, projectPaths, strictlyEnforcedCoordinate); + synchronized (dependencyClasspathCache) { + // Do not use a concurrent map here: different keys could create detached configurations concurrently, + // while calls into Gradle's mutable project model must remain serialized. + return dependencyClasspathCache.computeIfAbsent( + request, + unused -> configurationProvisioner.dependencyClasspath( + request.mavenCoordinates, request.projectPaths, request.strictlyEnforcedCoordinate)); + } + } + + DependencyClasspath cachedOnlyDependencyClasspath( + Collection mavenCoordinates, + Collection projectPaths, + String strictlyEnforcedCoordinate) { + ConfigurationProvisioner configurationProvisioner = configurationProvisioner(); + DependencyClasspathRequest request = new DependencyClasspathRequest(mavenCoordinates, projectPaths, strictlyEnforcedCoordinate); + Provider cached = configurationProvisioner.project.getProviders().provider(() -> { + synchronized (dependencyClasspathCache) { + DependencyClasspath result = dependencyClasspathCache.get(request); + if (result != null) { + return result; + } + } + throw new GradleException("Add a step with " + request.mavenCoordinates + " and projects " + request.projectPaths + + " into the `spotlessPredeclare` block in the root project."); + }); + Provisioner externalProvisioner = (withTransitives, requestedCoordinates) -> cached.get().externalProvisioner + .provisionWithTransitives(withTransitives, requestedCoordinates); + FileCollection projectArtifacts = configurationProvisioner.project.getObjects().fileCollection() + .from(cached.map(classpath -> classpath.projectArtifacts)); + return new DependencyClasspath(externalProvisioner, projectArtifacts); + } + + private ConfigurationProvisioner configurationProvisioner() { + if (provisioner instanceof ConfigurationProvisioner configurationProvisioner) { + return configurationProvisioner; + } + throw new IllegalStateException("Project dependencies require a Gradle configuration-backed provisioner."); + } + @Override public Set provisionWithTransitives(boolean withTransitives, Collection mavenCoordinates) { Request req = new Request(withTransitives, mavenCoordinates); @@ -111,60 +167,197 @@ public Set provisionWithTransitives(boolean withTransitives, Collection { + /** + * A provisioner bound to a specific Gradle dependency-resolution scope. + *

+ * Retaining the {@link ConfigurationContainer} and {@link DependencyHandler} is necessary when a formatter uses + * both Maven and project dependencies. It lets both dependency types participate in one detached configuration, + * so Gradle resolves version conflicts before the selected artifacts are separated into external and project + * classpaths. The binding also preserves whether dependencies must resolve from the consuming project's or the + * root buildscript's repositories, when using {@code predeclareDepsFromBuildscript()}. + */ + private static final class ConfigurationProvisioner implements Provisioner { + private final Project project; + private final ConfigurationContainer configurations; + private final DependencyHandler dependencies; + + private ConfigurationProvisioner(Project project, ConfigurationContainer configurations, DependencyHandler dependencies) { + this.project = project; + this.configurations = configurations; + this.dependencies = dependencies; + } + + @Override + public Set provisionWithTransitives(boolean withTransitives, Collection mavenCoords) { try { Request request = new Request(withTransitives, mavenCoords); - Dependency[] deps = mavenCoords.stream() - .map(dependencies::create) - .toArray(Dependency[]::new); - Configuration config = configurations.detachedConfiguration(deps); - configure(project, config, withTransitives, "Spotless internal dependency resolution for " + request); + Configuration config = configuration( + mavenCoords, List.of(), withTransitives, "Spotless internal dependency resolution for " + request, null); return config.resolve(); } catch (Exception e) { - String projName = project.getPath().substring(1).replace(':', '/'); - if (!projName.isEmpty()) { - projName = projName + "/"; + throw repositoryException(mavenCoords, e); + } + } + + DependencyClasspath dependencyClasspath( + Collection mavenCoordinates, + Collection projectPaths, + String strictlyEnforcedCoordinate) { + StrictVersion strictVersion = strictVersion(strictlyEnforcedCoordinate); + // Create every dependency before creating the configuration. + // This produces one conflict-resolution graph. + Configuration config = configuration( + mavenCoordinates, + projectPaths, + true, + "Spotless internal dependency resolution for " + mavenCoordinates + " and projects " + projectPaths, + strictVersion); + // This view contains ktlint and external libraries. + FileCollection externalArtifacts = config.getIncoming() + .artifactView(view -> view.componentFilter(identifier -> !(identifier instanceof ProjectComponentIdentifier))) + .getFiles(); + // This view contains local project artifacts. + FileCollection projectArtifacts = config.getIncoming() + .artifactView(view -> view.componentFilter(ProjectComponentIdentifier.class::isInstance)) + .getFiles(); + Set expectedCoordinates = Set.copyOf(mavenCoordinates); + Provisioner externalProvisioner = new DedupingProvisioner((withTransitives, requestedCoordinates) -> { + if (!withTransitives || !expectedCoordinates.equals(new HashSet<>(requestedCoordinates))) { + throw new IllegalArgumentException("Unexpected dependency request for unified ktlint classpath: " + requestedCoordinates); } - throw new GradleException(String.format( - "You need to add a repository containing the '%s' artifact in '%sbuild.gradle'.%n" - + "E.g.: 'repositories { mavenCentral() }'", - mavenCoords, projName), e); + strictVersion.rejectConflicts(config.getIncoming().getResolutionResult().getAllDependencies()); + return externalArtifacts.getFiles(); + }); + return new DependencyClasspath(externalProvisioner, projectArtifacts); + } + + private Configuration configuration( + Collection mavenCoordinates, + Collection projectPaths, + boolean withTransitives, + String description, + @Nullable StrictVersion strictVersion) { + List requestedDependencies = new ArrayList<>(mavenCoordinates.size() + projectPaths.size()); + boolean strictDependencyFound = strictVersion == null; + for (String coordinate : mavenCoordinates) { + Dependency dependency = dependencies.create(coordinate); + if (strictVersion != null && strictVersion.coordinate.equals(coordinate)) { + strictVersion.enforce(dependency); + strictDependencyFound = true; + } + requestedDependencies.add(dependency); } - }; + if (!strictDependencyFound) { + throw new IllegalArgumentException("Strictly enforced dependency is not part of the request: " + strictVersion.coordinate); + } + projectPaths.stream() + .map(projectPath -> project.getDependencies().project(Map.of("path", projectPath))) + .forEach(requestedDependencies::add); + Configuration config = configurations.detachedConfiguration(requestedDependencies.toArray(Dependency[]::new)); + configure(config, withTransitives, description); + return config; + } + + private StrictVersion strictVersion(String coordinate) { + Dependency dependency = dependencies.create(coordinate); + if (!(dependency instanceof ExternalDependency externalDependency)) { + throw new IllegalArgumentException("Cannot strictly enforce non-module dependency " + coordinate); + } + return new StrictVersion( + Objects.requireNonNull(externalDependency.getGroup(), "group"), + Objects.requireNonNull(externalDependency.getName(), "name"), + Objects.requireNonNull(externalDependency.getVersion(), "version"), + coordinate); + } + + private void configure(Configuration config, boolean withTransitives, String description) { + // Detached configurations avoid mutating configuration containers during task execution, which Gradle 9 + // forbids for buildscript configurations. See https://github.com/diffplug/spotless/issues/2599. + config.setDescription(description); + config.setTransitive(withTransitives); + config.setCanBeConsumed(false); + config.setVisible(false); + config.attributes(attr -> { + attr.attribute(Category.CATEGORY_ATTRIBUTE, project.getObjects().named(Category.class, Category.LIBRARY)); + attr.attribute(Bundling.BUNDLING_ATTRIBUTE, project.getObjects().named(Bundling.class, Bundling.EXTERNAL)); + // Add this attribute for resolving Guava dependency, see https://github.com/google/guava/issues/6801. + attr.attribute(TargetJvmEnvironment.TARGET_JVM_ENVIRONMENT_ATTRIBUTE, project.getObjects().named(TargetJvmEnvironment.class, TargetJvmEnvironment.STANDARD_JVM)); + }); + } + + private GradleException repositoryException(Collection mavenCoordinates, Exception cause) { + String projName = project.getPath().substring(1).replace(':', '/'); + if (!projName.isEmpty()) { + projName = projName + "/"; + } + return new GradleException(String.format( + "You need to add a repository containing the '%s' artifact in '%sbuild.gradle'.%n" + + "E.g.: 'repositories { mavenCentral() }'", + mavenCoordinates, projName), cause); + } } - static Configuration projectDependencies(Project project, Collection projectPaths) { - Dependency[] dependencies = projectPaths.stream() - .map(projectPath -> project.getDependencies().project(Map.of("path", projectPath))) - .toArray(Dependency[]::new); - Configuration config = project.getConfigurations().detachedConfiguration(dependencies); - configure(project, config, true, "Spotless internal project dependency resolution for " + projectPaths); - return config; + static final class DependencyClasspath { + final Provisioner externalProvisioner; + final FileCollection projectArtifacts; + + private DependencyClasspath(Provisioner externalProvisioner, FileCollection projectArtifacts) { + this.externalProvisioner = externalProvisioner; + this.projectArtifacts = projectArtifacts; + } } - private static void configure(Project project, Configuration config, boolean withTransitives, String description) { - // Detached configurations avoid mutating configuration containers during task execution, which Gradle 9 - // forbids for buildscript configurations. See https://github.com/diffplug/spotless/issues/2599. - config.setDescription(description); - config.setTransitive(withTransitives); - config.setCanBeConsumed(false); - config.setVisible(false); - config.attributes(attr -> { - attr.attribute(Category.CATEGORY_ATTRIBUTE, project.getObjects().named(Category.class, Category.LIBRARY)); - attr.attribute(Bundling.BUNDLING_ATTRIBUTE, project.getObjects().named(Bundling.class, Bundling.EXTERNAL)); - // Add this attribute for resolving Guava dependency, see https://github.com/google/guava/issues/6801. - attr.attribute(TargetJvmEnvironment.TARGET_JVM_ENVIRONMENT_ATTRIBUTE, project.getObjects().named(TargetJvmEnvironment.class, TargetJvmEnvironment.STANDARD_JVM)); - }); + private record DependencyClasspathRequest( + ImmutableList mavenCoordinates, + ImmutableList projectPaths, + String strictlyEnforcedCoordinate) { + private DependencyClasspathRequest( + Collection mavenCoordinates, + Collection projectPaths, + String strictlyEnforcedCoordinate) { + this(ImmutableList.copyOf(mavenCoordinates), ImmutableList.copyOf(projectPaths), strictlyEnforcedCoordinate); + } + } + + private record StrictVersion(String group, String name, String version, String coordinate) { + private void enforce(Dependency dependency) { + if (!(dependency instanceof ExternalDependency externalDependency) + || !group.equals(externalDependency.getGroup()) + || !name.equals(externalDependency.getName()) + || !version.equals(externalDependency.getVersion())) { + throw new IllegalArgumentException("Cannot strictly enforce " + coordinate + " on " + dependency); + } + externalDependency.version(constraint -> constraint.strictly(version)); + } + + /** + * Rejects any dependencies that conflict with the strictly enforced version. + * + *

Current limitation: ktlint 0.x and 1.x use different modules. Which is not currently handled, + * i.e. it won't be detected / treated as a version conflict.

+ * + * @param dependencies + */ + private void rejectConflicts(Collection dependencies) { + for (DependencyResult dependency : dependencies) { + if (dependency.getRequested() instanceof ModuleComponentSelector requested + && group.equals(requested.getGroup()) + && name.equals(requested.getModule()) + && !version.equals(requested.getVersion())) { + throw new GradleException("The dependency graph requests '" + requested + "', but Spotless is configured to use '" + + coordinate + "'. Remove the conflicting ktlint dependency or align it with the version requested by the Spotless DSL."); + } + } + } } private static final Logger LOGGER = LoggerFactory.getLogger(GradleProvisioner.class); diff --git a/plugin-gradle/src/main/java/com/diffplug/gradle/spotless/SpotlessTaskService.java b/plugin-gradle/src/main/java/com/diffplug/gradle/spotless/SpotlessTaskService.java index 90f282d873..6d67d98259 100644 --- a/plugin-gradle/src/main/java/com/diffplug/gradle/spotless/SpotlessTaskService.java +++ b/plugin-gradle/src/main/java/com/diffplug/gradle/spotless/SpotlessTaskService.java @@ -16,6 +16,7 @@ package com.diffplug.gradle.spotless; import java.io.File; +import java.util.Collection; import java.util.Collections; import java.util.HashMap; import java.util.LinkedHashMap; @@ -58,7 +59,7 @@ public abstract class SpotlessTaskService implements BuildService, AutoCloseable, OperationCompletionListener { private final Map apply = Collections.synchronizedMap(new HashMap<>()); private final Map source = Collections.synchronizedMap(new HashMap<>()); - private final Map provisioner = Collections.synchronizedMap(new HashMap<>()); + private final Map provisioner = Collections.synchronizedMap(new HashMap<>()); private final Map p2Provisioner = Collections.synchronizedMap(new HashMap<>()); @Nullable GradleProvisioner.DedupingProvisioner predeclaredProvisioner; @@ -77,6 +78,29 @@ Provisioner provisionerFor(SpotlessExtension spotless) { } } + GradleProvisioner.DependencyClasspath dependencyClasspathFor( + SpotlessExtension spotless, + Collection mavenCoordinates, + Collection projectPaths, + String strictlyEnforcedCoordinate) { + if (spotless instanceof SpotlessExtensionPredeclare) { + // spotlessPredeclare owns creation of the configuration. + // The resulting classpath is cached under the exact request. + return predeclaredProvisioner.dependencyClasspath(mavenCoordinates, projectPaths, strictlyEnforcedCoordinate); + } else if (predeclaredProvisioner != null) { + // A concrete formatter may only reuse what spotlessPredeclare created. + // It must not silently resolve an omitted dependency itself. + return predeclaredProvisioner.cachedOnlyDependencyClasspath(mavenCoordinates, projectPaths, strictlyEnforcedCoordinate); + } else { + // Without predeclaration, resolve using the consuming project's + // configurations, dependencies, and repositories. + GradleProvisioner.DedupingProvisioner selectedProvisioner = provisioner.computeIfAbsent( + spotless.project.getPath(), + unused -> new GradleProvisioner.DedupingProvisioner(GradleProvisioner.forProject(spotless.project))); + return selectedProvisioner.dependencyClasspath(mavenCoordinates, projectPaths, strictlyEnforcedCoordinate); + } + } + P2Provisioner p2ProvisionerFor(SpotlessExtension spotless) { if (spotless instanceof SpotlessExtensionPredeclare) { return predeclaredP2Provisioner; diff --git a/plugin-gradle/src/test/java/com/diffplug/gradle/spotless/KotlinExtensionTest.java b/plugin-gradle/src/test/java/com/diffplug/gradle/spotless/KotlinExtensionTest.java index f1d9dfa96d..aebfe4fcbf 100644 --- a/plugin-gradle/src/test/java/com/diffplug/gradle/spotless/KotlinExtensionTest.java +++ b/plugin-gradle/src/test/java/com/diffplug/gradle/spotless/KotlinExtensionTest.java @@ -23,6 +23,8 @@ import org.gradle.testkit.runner.BuildResult; import org.gradle.testkit.runner.TaskOutcome; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; class KotlinExtensionTest extends GradleIntegrationHarness { private static final String HEADER = "// License Header"; @@ -274,6 +276,209 @@ public Set getRuleProviders() { assertFile("rule-version.txt").hasContent("2"); } + @Test + void ktlintProjectDependenciesShareConflictResolutionWithMavenRuleSets() throws IOException { + // The Maven rule graph requests support v1, while the project rule graph requests v2 and calls a v2-only method. + setFile("settings.gradle.kts").toContent("include(\"support-v1\", \"support-v2\", \"conflict-anchor\", \"ktlint-rules\")"); + setFile("support-v1/build.gradle").toContent(publishedSupportBuild("1")); + setFile("support-v2/build.gradle").toContent(publishedSupportBuild("2")); + setFile("support-v1/src/main/java/support/Api.java").toContent(""" + package support; + public final class Api { + public static String existing() { return "v1"; } + } + """); + setFile("support-v2/src/main/java/support/Api.java").toContent(""" + package support; + public final class Api { + public static String existing() { return "v2"; } + public static String addedInV2() { return "v2"; } + } + """); + setFile("conflict-anchor/build.gradle").toContent(""" + plugins { + id 'java-library' + id 'maven-publish' + } + group = 'test' + version = '1' + dependencies { api 'test:support:1' } + publishing { + publications { + mavenJava(MavenPublication) { + from components.java + artifactId = 'conflict-anchor' + } + } + repositories { + maven { url = rootProject.layout.projectDirectory.dir('repo') } + } + } + """); + setFile("ktlint-rules/build.gradle").toContent(""" + plugins { id 'java-library' } + dependencies { + implementation 'test:support:2' + compileOnly 'com.pinterest.ktlint:ktlint-cli-ruleset-core:1.0.1' + compileOnly 'com.pinterest.ktlint:ktlint-rule-engine-core:1.0.1' + } + """); + setFile("ktlint-rules/src/main/java/rules/LocalRuleSetProvider.java").toContent(""" + package rules; + + import java.nio.file.Files; + import java.nio.file.Path; + import java.util.Collections; + import java.util.Set; + + import com.pinterest.ktlint.cli.ruleset.core.api.RuleSetProviderV3; + import com.pinterest.ktlint.rule.engine.core.api.RuleProvider; + import com.pinterest.ktlint.rule.engine.core.api.RuleSetId; + import support.Api; + + public final class LocalRuleSetProvider extends RuleSetProviderV3 { + public LocalRuleSetProvider() { + super(new RuleSetId("local-project")); + try { + Files.writeString( + Path.of(System.getProperty("spotless.test.rule.version")), + Api.addedInV2()); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + @Override + public Set getRuleProviders() { + return Collections.emptySet(); + } + } + """); + setFile("ktlint-rules/src/main/resources/META-INF/services/com.pinterest.ktlint.cli.ruleset.core.api.RuleSetProviderV3") + .toContent("rules.LocalRuleSetProvider\n"); + setFile("build.gradle.kts").toContent(""" + plugins { + id("com.diffplug.spotless") + } + allprojects { + repositories { + maven { url = uri(rootProject.layout.projectDirectory.dir("repo")) } + mavenCentral() + } + } + spotless { + kotlin { + target("src/**/*.kt") + ktlint("1.0.1").customRuleSets( + "test:conflict-anchor:1", + project(":ktlint-rules")) + } + } + """); + setFile("src/main/kotlin/Main.kt").toContent("fun main() {}\n"); + + gradleRunner() + .withGradleVersion("9.5.1") + .withArguments( + ":support-v1:publishMavenJavaPublicationToMavenRepository", + ":support-v2:publishMavenJavaPublicationToMavenRepository", + ":conflict-anchor:publishMavenJavaPublicationToMavenRepository") + .build(); + + String ruleVersionProperty = "-Dspotless.test.rule.version=" + newFile("rule-version.txt").getAbsolutePath(); + BuildResult result = gradleRunner() + .withGradleVersion("9.5.1") + .withArguments("spotlessCheck", "--configuration-cache", "--stacktrace", ruleVersionProperty) + .build(); + + assertThat(result.getOutput()).contains("Configuration cache entry stored."); + assertFile("rule-version.txt").hasContent("v2"); + } + + @ParameterizedTest(name = "rejects conflicting ktlint {0}") + @ValueSource(strings = {"1.0.0", "1.1.0"}) + void ktlintProjectDependencyCannotOverrideRequestedVersion(String conflictingVersion) throws IOException { + setFile("settings.gradle.kts").toContent("include(\"ktlint-rules\")"); + setFile("ktlint-rules/build.gradle.kts").toContent(""" + plugins { java } + dependencies { + runtimeOnly("com.pinterest.ktlint:ktlint-cli:%s") + } + """.formatted(conflictingVersion)); + setFile("build.gradle.kts").toContent(""" + plugins { + id("com.diffplug.spotless") + } + repositories { mavenCentral() } + spotless { + kotlin { + target("src/**/*.kt") + ktlint("1.0.1").customRuleSets(project(":ktlint-rules")) + } + } + """); + setFile("src/main/kotlin/Main.kt").toContent("fun main() {}\n"); + + BuildResult result = gradleRunner() + .withGradleVersion("9.5.1") + .withArguments("spotlessCheck", "--stacktrace") + .buildAndFail(); + + assertThat(result.getOutput()).contains( + "The dependency graph requests 'com.pinterest.ktlint:ktlint-cli:" + conflictingVersion + "'", + "Spotless is configured to use 'com.pinterest.ktlint:ktlint-cli:1.0.1'"); + } + + @Test + void ktlintProjectDependencyPreservesGradleVariantFailure() throws IOException { + setFile("settings.gradle.kts").toContent("include(\"ktlint-rules\")"); + setFile("ktlint-rules/build.gradle.kts").toContent(""); + setFile("build.gradle.kts").toContent(""" + plugins { + id("com.diffplug.spotless") + } + repositories { mavenCentral() } + spotless { + kotlin { + target("src/**/*.kt") + ktlint("1.0.1").customRuleSets(project(":ktlint-rules")) + } + } + """); + setFile("src/main/kotlin/Main.kt").toContent("fun main() {}\n"); + + BuildResult result = gradleRunner() + .withGradleVersion("9.5.1") + .withArguments("spotlessCheck", "--stacktrace") + .buildAndFail(); + + assertThat(result.getOutput()) + .contains("No matching variant of project :ktlint-rules was found") + .doesNotContain("You need to add a repository containing"); + } + + private static String publishedSupportBuild(String version) { + return """ + plugins { + id 'java-library' + id 'maven-publish' + } + group = 'test' + version = '%s' + publishing { + publications { + mavenJava(MavenPublication) { + from components.java + artifactId = 'support' + } + } + repositories { + maven { url = rootProject.layout.projectDirectory.dir('repo') } + } + } + """.formatted(version); + } + @Test void testWithHeader() throws IOException { setFile("build.gradle").toLines( diff --git a/plugin-gradle/src/test/java/com/diffplug/gradle/spotless/SpotlessPredeclareIntegrationTest.java b/plugin-gradle/src/test/java/com/diffplug/gradle/spotless/SpotlessPredeclareIntegrationTest.java index fae6ab5a2c..1dec1655b4 100644 --- a/plugin-gradle/src/test/java/com/diffplug/gradle/spotless/SpotlessPredeclareIntegrationTest.java +++ b/plugin-gradle/src/test/java/com/diffplug/gradle/spotless/SpotlessPredeclareIntegrationTest.java @@ -514,7 +514,18 @@ class Gradle9Compatibility { @Test void gradle951CanPredeclareKtlintRuleSetProjectFromBuildscript() throws IOException { setFile("settings.gradle.kts").toContent("include(\"ktlint-rules\")"); - setFile("ktlint-rules/build.gradle.kts").toContent("plugins { java }"); + setFile("ktlint-rules/build.gradle.kts").toContent(""" + plugins { java } + repositories { mavenCentral() } + dependencies { + compileOnly("com.pinterest.ktlint:ktlint-cli-ruleset-core:1.0.1") + compileOnly("com.pinterest.ktlint:ktlint-rule-engine-core:1.0.1") + } + """); + setFile("ktlint-rules/src/main/java/rules/LocalRuleSetProvider.java") + .toContent(localRuleSetProvider("1")); + setFile("ktlint-rules/src/main/resources/META-INF/services/com.pinterest.ktlint.cli.ruleset.core.api.RuleSetProviderV3") + .toContent("rules.LocalRuleSetProvider\n"); setFile("build.gradle.kts").toContent(""" buildscript { repositories { mavenCentral() } @@ -524,15 +535,13 @@ void gradle951CanPredeclareKtlintRuleSetProjectFromBuildscript() throws IOExcept } spotless { predeclareDepsFromBuildscript() - } - spotlessPredeclare { kotlin { + target("src/**/*.kt") ktlint("1.0.1").customRuleSets(project(":ktlint-rules")) } } - spotless { + spotlessPredeclare { kotlin { - target("src/**/*.kt") ktlint("1.0.1").customRuleSets(project(":ktlint-rules")) } } @@ -549,6 +558,41 @@ void gradle951CanPredeclareKtlintRuleSetProjectFromBuildscript() throws IOExcept assertThat(result.task(":ktlint-rules:jar").getOutcome()).isEqualTo(TaskOutcome.SUCCESS); } + @Test + void mixedKtlintProjectDependenciesMustBePredeclared() throws IOException { + setFile("settings.gradle.kts").toContent("include(\"ktlint-rules\")"); + setFile("ktlint-rules/build.gradle.kts").toContent("plugins { java }"); + setFile("build.gradle.kts").toContent(""" + buildscript { + repositories { mavenCentral() } + } + plugins { + id("com.diffplug.spotless") + } + spotless { + predeclareDepsFromBuildscript() + kotlin { + target("src/**/*.kt") + ktlint("1.0.1").customRuleSets(project(":ktlint-rules")) + } + } + spotlessPredeclare { + kotlin { + ktlint("1.0.1") + } + } + """); + setFile("src/main/kotlin/Main.kt").toContent("fun main() {}\n"); + + BuildResult result = gradleRunner() + .withGradleVersion("9.5.1") + .withArguments("spotlessCheck", "--configuration-cache", "--stacktrace") + .buildAndFail(); + + assertThat(result.getOutput()) + .contains("Add a step with", "projects [:ktlint-rules]", "spotlessPredeclare"); + } + @Test void predeclaredKtlintProjectIsNotBuiltForUnrelatedFormat() throws IOException { setFile("settings.gradle.kts").toContent("include(\"ktlint-rules\")"); From 3dd85998c715d176990b5ca528b6393c2df5da12 Mon Sep 17 00:00:00 2001 From: Brice Dutheil Date: Wed, 22 Jul 2026 14:47:42 +0200 Subject: [PATCH 4/8] test: cover predeclared ktlint cache reuse Change and reload a real local rule provider after restoring the predeclared classpath from configuration cache. This proves Gradle rebuilds the producer JAR and reruns Spotless with the new implementation. --- .../SpotlessPredeclareIntegrationTest.java | 57 +++++++++++++++++-- 1 file changed, 52 insertions(+), 5 deletions(-) diff --git a/plugin-gradle/src/test/java/com/diffplug/gradle/spotless/SpotlessPredeclareIntegrationTest.java b/plugin-gradle/src/test/java/com/diffplug/gradle/spotless/SpotlessPredeclareIntegrationTest.java index 1dec1655b4..c613633148 100644 --- a/plugin-gradle/src/test/java/com/diffplug/gradle/spotless/SpotlessPredeclareIntegrationTest.java +++ b/plugin-gradle/src/test/java/com/diffplug/gradle/spotless/SpotlessPredeclareIntegrationTest.java @@ -547,15 +547,29 @@ void gradle951CanPredeclareKtlintRuleSetProjectFromBuildscript() throws IOExcept } """); setFile("src/main/kotlin/Main.kt").toContent("fun main() {}\n"); + String ruleVersionProperty = "-Dspotless.test.rule.version=" + newFile("rule-version.txt").getAbsolutePath(); - BuildResult result = gradleRunner() + BuildResult firstRun = gradleRunner() .withGradleVersion("9.5.1") - .withArguments("spotlessCheck", "--configuration-cache", "--stacktrace") + .withArguments("spotlessCheck", "--configuration-cache", "--stacktrace", ruleVersionProperty) .build(); - assertThat(result.getOutput()).contains("Configuration cache entry stored."); - assertThat(result.task(":ktlint-rules:jar")).isNotNull(); - assertThat(result.task(":ktlint-rules:jar").getOutcome()).isEqualTo(TaskOutcome.SUCCESS); + assertThat(firstRun.getOutput()).contains("Configuration cache entry stored."); + assertThat(firstRun.task(":ktlint-rules:jar")).isNotNull(); + assertThat(firstRun.task(":ktlint-rules:jar").getOutcome()).isEqualTo(TaskOutcome.SUCCESS); + assertFile("rule-version.txt").hasContent("1"); + + setFile("ktlint-rules/src/main/java/rules/LocalRuleSetProvider.java") + .toContent(localRuleSetProvider("2")); + BuildResult secondRun = gradleRunner() + .withGradleVersion("9.5.1") + .withArguments("spotlessCheck", "--configuration-cache", "--stacktrace", ruleVersionProperty) + .build(); + + assertThat(secondRun.getOutput()).contains("Reusing configuration cache."); + assertThat(secondRun.task(":ktlint-rules:jar").getOutcome()).isNotEqualTo(TaskOutcome.UP_TO_DATE); + assertThat(secondRun.task(":spotlessKotlin").getOutcome()).isNotEqualTo(TaskOutcome.UP_TO_DATE); + assertFile("rule-version.txt").hasContent("2"); } @Test @@ -799,4 +813,37 @@ void emptyPredeclareBlockIsValid() throws IOException { assertThat(result.getOutput()).contains("BUILD SUCCESSFUL"); } } + + private static String localRuleSetProvider(String version) { + return """ + package rules; + + import java.nio.file.Files; + import java.nio.file.Path; + import java.util.Collections; + import java.util.Set; + + import com.pinterest.ktlint.cli.ruleset.core.api.RuleSetProviderV3; + import com.pinterest.ktlint.rule.engine.core.api.RuleProvider; + import com.pinterest.ktlint.rule.engine.core.api.RuleSetId; + + public final class LocalRuleSetProvider extends RuleSetProviderV3 { + public LocalRuleSetProvider() { + super(new RuleSetId("local-project")); + try { + Files.writeString( + Path.of(System.getProperty("spotless.test.rule.version")), + "%s"); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + @Override + public Set getRuleProviders() { + return Collections.emptySet(); + } + } + """.formatted(version); + } } From 2bc4e8d834f209ec8f0ed9554b81f269509870b6 Mon Sep 17 00:00:00 2001 From: Brice Dutheil Date: Wed, 22 Jul 2026 15:09:54 +0200 Subject: [PATCH 5/8] chore: Fill in the changes --- CHANGES.md | 3 +++ plugin-gradle/CHANGES.md | 3 +++ 2 files changed, 6 insertions(+) diff --git a/CHANGES.md b/CHANGES.md index 5b0e8c042a..d3287f4a01 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -11,6 +11,9 @@ We adhere to the [keepachangelog](https://keepachangelog.com/en/1.0.0/) format ( ## [Unreleased] +### Added +- `KtLintStep` now supports additional generated rule-set JARs whose classpath is supplied at execution time. ([#2999](https://github.com/diffplug/spotless/pull/2999), fixes [#1901](https://github.com/diffplug/spotless/issues/1901)) + ## [4.8.0] - 2026-06-29 ### Added - Add support for custom string format for license header copyright year via `yearStringFormat()`. ([#2965](https://github.com/diffplug/spotless/pull/2965)) diff --git a/plugin-gradle/CHANGES.md b/plugin-gradle/CHANGES.md index 9665b4068c..0df868b2bc 100644 --- a/plugin-gradle/CHANGES.md +++ b/plugin-gradle/CHANGES.md @@ -4,6 +4,9 @@ We adhere to the [keepachangelog](https://keepachangelog.com/en/1.0.0/) format ( ## [Unreleased] +### Added +- `ktlint().customRuleSets(...)` now accepts local Gradle projects, allowing rule sets from the same build without dependency substitution. ([#2999](https://github.com/diffplug/spotless/pull/2999), fixes [#1901](https://github.com/diffplug/spotless/issues/1901)) + ## [8.8.0] - 2026-06-29 ### Added - Add support for custom string format for license header copyright year via `yearStringFormat()`. ([#2965](https://github.com/diffplug/spotless/pull/2965)) From 0e5aa82403f3289733a0dd8bb356a35a0bf6a6c5 Mon Sep 17 00:00:00 2001 From: Brice Dutheil Date: Wed, 22 Jul 2026 18:57:10 +0200 Subject: [PATCH 6/8] fix: tests on Windows Oracle explicitly documents that open URLClassLoader JARs cannot be replaced on Windows until the loader is closed. Technotes on URLClassLoader : > The application can then replace the JAR file, and create a new > URLClassLoader instance to load from the same location, but this time > using the new implementation of the classes/resources. However, since > it can't be predicted exactly when finalization and garbage collection > will occur, problems are caused for applications which need to be able > to do this in a predictable and timely fashion. > It is a problem on Windows, because open files cannot be deleted or > replaced. https://docs.oracle.com/javase/8/docs/technotes/guides/net/ClassLoader.html --- .../gradle/spotless/SpotlessTaskService.java | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/plugin-gradle/src/main/java/com/diffplug/gradle/spotless/SpotlessTaskService.java b/plugin-gradle/src/main/java/com/diffplug/gradle/spotless/SpotlessTaskService.java index 6d67d98259..082bf2cefd 100644 --- a/plugin-gradle/src/main/java/com/diffplug/gradle/spotless/SpotlessTaskService.java +++ b/plugin-gradle/src/main/java/com/diffplug/gradle/spotless/SpotlessTaskService.java @@ -23,6 +23,7 @@ import java.util.List; import java.util.Map; import java.util.TreeMap; +import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; import javax.annotation.Nullable; @@ -48,6 +49,7 @@ import com.diffplug.common.base.Unhandled; import com.diffplug.spotless.Lint; import com.diffplug.spotless.Provisioner; +import com.diffplug.spotless.SpotlessCache; import com.diffplug.spotless.extra.P2Provisioner; /** @@ -61,6 +63,7 @@ public abstract class SpotlessTaskService implements BuildService source = Collections.synchronizedMap(new HashMap<>()); private final Map provisioner = Collections.synchronizedMap(new HashMap<>()); private final Map p2Provisioner = Collections.synchronizedMap(new HashMap<>()); + private final AtomicBoolean hasProjectDependencies = new AtomicBoolean(); @Nullable GradleProvisioner.DedupingProvisioner predeclaredProvisioner; @Nullable GradleProvisioner.DedupingP2Provisioner predeclaredP2Provisioner; @@ -83,6 +86,9 @@ GradleProvisioner.DependencyClasspath dependencyClasspathFor( Collection mavenCoordinates, Collection projectPaths, String strictlyEnforcedCoordinate) { + if (!projectPaths.isEmpty()) { + hasProjectDependencies.set(true); + } if (spotless instanceof SpotlessExtensionPredeclare) { // spotlessPredeclare owns creation of the configuration. // The resulting classpath is cached under the exact request. @@ -136,7 +142,15 @@ public void onFinish(FinishEvent var1) { @Override public void close() throws Exception { - ratchet.close(); + try { + ratchet.close(); + } finally { + if (hasProjectDependencies.get()) { + // Project artifacts can live in directories which are deleted immediately after the build. + // Release the URLClassLoader file handles first, which is required on Windows. + SpotlessCache.clearOnce(null); + } + } } // From acb700553b534b0ef2ad9e4bc2e8ab2431b0aaa7 Mon Sep 17 00:00:00 2001 From: Brice Dutheil Date: Thu, 23 Jul 2026 17:04:05 +0200 Subject: [PATCH 7/8] fix: Try fix Windows build with using a build service param --- .../gradle/spotless/SpotlessTaskService.java | 15 +++++++++------ .../gradle/spotless/DiffMessageFormatterTest.java | 5 ++--- .../diffplug/gradle/spotless/FormatTaskTest.java | 5 ++--- .../gradle/spotless/PaddedCellTaskTest.java | 5 ++--- 4 files changed, 15 insertions(+), 15 deletions(-) diff --git a/plugin-gradle/src/main/java/com/diffplug/gradle/spotless/SpotlessTaskService.java b/plugin-gradle/src/main/java/com/diffplug/gradle/spotless/SpotlessTaskService.java index 082bf2cefd..b0df2999c5 100644 --- a/plugin-gradle/src/main/java/com/diffplug/gradle/spotless/SpotlessTaskService.java +++ b/plugin-gradle/src/main/java/com/diffplug/gradle/spotless/SpotlessTaskService.java @@ -23,7 +23,6 @@ import java.util.List; import java.util.Map; import java.util.TreeMap; -import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; import javax.annotation.Nullable; @@ -58,12 +57,15 @@ * duplicated work (e.g. no need for check to run if * apply already did). */ -public abstract class SpotlessTaskService implements BuildService, AutoCloseable, OperationCompletionListener { +public abstract class SpotlessTaskService implements BuildService, AutoCloseable, OperationCompletionListener { + public interface Parameters extends BuildServiceParameters { + Property getHasProjectDependencies(); + } + private final Map apply = Collections.synchronizedMap(new HashMap<>()); private final Map source = Collections.synchronizedMap(new HashMap<>()); private final Map provisioner = Collections.synchronizedMap(new HashMap<>()); private final Map p2Provisioner = Collections.synchronizedMap(new HashMap<>()); - private final AtomicBoolean hasProjectDependencies = new AtomicBoolean(); @Nullable GradleProvisioner.DedupingProvisioner predeclaredProvisioner; @Nullable GradleProvisioner.DedupingP2Provisioner predeclaredP2Provisioner; @@ -87,7 +89,7 @@ GradleProvisioner.DependencyClasspath dependencyClasspathFor( Collection projectPaths, String strictlyEnforcedCoordinate) { if (!projectPaths.isEmpty()) { - hasProjectDependencies.set(true); + getParameters().getHasProjectDependencies().set(true); } if (spotless instanceof SpotlessExtensionPredeclare) { // spotlessPredeclare owns creation of the configuration. @@ -145,7 +147,7 @@ public void close() throws Exception { try { ratchet.close(); } finally { - if (hasProjectDependencies.get()) { + if (getParameters().getHasProjectDependencies().getOrElse(false)) { // Project artifacts can live in directories which are deleted immediately after the build. // Release the URLClassLoader file handles first, which is required on Windows. SpotlessCache.clearOnce(null); @@ -174,7 +176,8 @@ public void hookSubprojectTask(Project project, SpotlessTask task) { public static Provider registerIfAbsent(Project project, String suffix) { return project.getGradle().getSharedServices() - .registerIfAbsent("SpotlessTaskService" + suffix, SpotlessTaskService.class, spec -> {}); + .registerIfAbsent("SpotlessTaskService" + suffix, SpotlessTaskService.class, + spec -> spec.getParameters().getHasProjectDependencies().convention(false)); } abstract static class ClientTask extends DefaultTask { diff --git a/plugin-gradle/src/test/java/com/diffplug/gradle/spotless/DiffMessageFormatterTest.java b/plugin-gradle/src/test/java/com/diffplug/gradle/spotless/DiffMessageFormatterTest.java index 7f8459b02a..7178eb668f 100644 --- a/plugin-gradle/src/test/java/com/diffplug/gradle/spotless/DiffMessageFormatterTest.java +++ b/plugin-gradle/src/test/java/com/diffplug/gradle/spotless/DiffMessageFormatterTest.java @@ -1,5 +1,5 @@ /* - * Copyright 2016-2025 DiffPlug + * Copyright 2016-2026 DiffPlug * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -25,7 +25,6 @@ import org.assertj.core.api.Assertions; import org.gradle.api.Project; import org.gradle.api.provider.Provider; -import org.gradle.api.services.BuildServiceParameters; import org.gradle.api.tasks.TaskProvider; import org.junit.jupiter.api.Test; @@ -43,7 +42,7 @@ private class Bundle { Project project = TestProvisioner.gradleProject(rootFolder()); Provider taskService = GradleIntegrationHarness.providerOf(new SpotlessTaskService() { @Override - public BuildServiceParameters.None getParameters() { + public Parameters getParameters() { return null; } }); diff --git a/plugin-gradle/src/test/java/com/diffplug/gradle/spotless/FormatTaskTest.java b/plugin-gradle/src/test/java/com/diffplug/gradle/spotless/FormatTaskTest.java index 6358b298fd..3a56ca353e 100644 --- a/plugin-gradle/src/test/java/com/diffplug/gradle/spotless/FormatTaskTest.java +++ b/plugin-gradle/src/test/java/com/diffplug/gradle/spotless/FormatTaskTest.java @@ -1,5 +1,5 @@ /* - * Copyright 2016-2025 DiffPlug + * Copyright 2016-2026 DiffPlug * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -20,7 +20,6 @@ import java.util.Set; import org.gradle.api.Project; -import org.gradle.api.services.BuildServiceParameters; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -39,7 +38,7 @@ void createTask() { spotlessTask.setLineEndingsPolicy(project.provider(LineEnding.UNIX::createPolicy)); spotlessTask.init(GradleIntegrationHarness.providerOf(new SpotlessTaskService() { @Override - public BuildServiceParameters.None getParameters() { + public Parameters getParameters() { return null; } })); diff --git a/plugin-gradle/src/test/java/com/diffplug/gradle/spotless/PaddedCellTaskTest.java b/plugin-gradle/src/test/java/com/diffplug/gradle/spotless/PaddedCellTaskTest.java index 0b13dd6475..b30680fd35 100644 --- a/plugin-gradle/src/test/java/com/diffplug/gradle/spotless/PaddedCellTaskTest.java +++ b/plugin-gradle/src/test/java/com/diffplug/gradle/spotless/PaddedCellTaskTest.java @@ -1,5 +1,5 @@ /* - * Copyright 2016-2025 DiffPlug + * Copyright 2016-2026 DiffPlug * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -25,7 +25,6 @@ import org.gradle.api.Project; import org.gradle.api.provider.Provider; -import org.gradle.api.services.BuildServiceParameters; import org.gradle.api.tasks.TaskProvider; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; @@ -45,7 +44,7 @@ private class Bundle { Project project = TestProvisioner.gradleProject(rootFolder()); Provider taskService = GradleIntegrationHarness.providerOf(new SpotlessTaskService() { @Override - public BuildServiceParameters.None getParameters() { + public Parameters getParameters() { return null; } }); From 7f8e9f34c493f18b813800bd12e4ea23625f3f24 Mon Sep 17 00:00:00 2001 From: Brice Dutheil Date: Thu, 23 Jul 2026 17:08:17 +0200 Subject: [PATCH 8/8] fix: close formatter classloaders after each build --- .../gradle/spotless/SpotlessTaskService.java | 20 ++++--------------- .../spotless/DiffMessageFormatterTest.java | 5 +++-- .../gradle/spotless/FormatTaskTest.java | 5 +++-- .../gradle/spotless/PaddedCellTaskTest.java | 5 +++-- 4 files changed, 13 insertions(+), 22 deletions(-) diff --git a/plugin-gradle/src/main/java/com/diffplug/gradle/spotless/SpotlessTaskService.java b/plugin-gradle/src/main/java/com/diffplug/gradle/spotless/SpotlessTaskService.java index b0df2999c5..620c0b6f97 100644 --- a/plugin-gradle/src/main/java/com/diffplug/gradle/spotless/SpotlessTaskService.java +++ b/plugin-gradle/src/main/java/com/diffplug/gradle/spotless/SpotlessTaskService.java @@ -57,11 +57,7 @@ * duplicated work (e.g. no need for check to run if * apply already did). */ -public abstract class SpotlessTaskService implements BuildService, AutoCloseable, OperationCompletionListener { - public interface Parameters extends BuildServiceParameters { - Property getHasProjectDependencies(); - } - +public abstract class SpotlessTaskService implements BuildService, AutoCloseable, OperationCompletionListener { private final Map apply = Collections.synchronizedMap(new HashMap<>()); private final Map source = Collections.synchronizedMap(new HashMap<>()); private final Map provisioner = Collections.synchronizedMap(new HashMap<>()); @@ -88,9 +84,6 @@ GradleProvisioner.DependencyClasspath dependencyClasspathFor( Collection mavenCoordinates, Collection projectPaths, String strictlyEnforcedCoordinate) { - if (!projectPaths.isEmpty()) { - getParameters().getHasProjectDependencies().set(true); - } if (spotless instanceof SpotlessExtensionPredeclare) { // spotlessPredeclare owns creation of the configuration. // The resulting classpath is cached under the exact request. @@ -143,15 +136,11 @@ public void onFinish(FinishEvent var1) { } @Override - public void close() throws Exception { + public void close() { try { ratchet.close(); } finally { - if (getParameters().getHasProjectDependencies().getOrElse(false)) { - // Project artifacts can live in directories which are deleted immediately after the build. - // Release the URLClassLoader file handles first, which is required on Windows. - SpotlessCache.clearOnce(null); - } + SpotlessCache.clearOnce(null); } } // @@ -176,8 +165,7 @@ public void hookSubprojectTask(Project project, SpotlessTask task) { public static Provider registerIfAbsent(Project project, String suffix) { return project.getGradle().getSharedServices() - .registerIfAbsent("SpotlessTaskService" + suffix, SpotlessTaskService.class, - spec -> spec.getParameters().getHasProjectDependencies().convention(false)); + .registerIfAbsent("SpotlessTaskService" + suffix, SpotlessTaskService.class, spec -> {}); } abstract static class ClientTask extends DefaultTask { diff --git a/plugin-gradle/src/test/java/com/diffplug/gradle/spotless/DiffMessageFormatterTest.java b/plugin-gradle/src/test/java/com/diffplug/gradle/spotless/DiffMessageFormatterTest.java index 7178eb668f..7f8459b02a 100644 --- a/plugin-gradle/src/test/java/com/diffplug/gradle/spotless/DiffMessageFormatterTest.java +++ b/plugin-gradle/src/test/java/com/diffplug/gradle/spotless/DiffMessageFormatterTest.java @@ -1,5 +1,5 @@ /* - * Copyright 2016-2026 DiffPlug + * Copyright 2016-2025 DiffPlug * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -25,6 +25,7 @@ import org.assertj.core.api.Assertions; import org.gradle.api.Project; import org.gradle.api.provider.Provider; +import org.gradle.api.services.BuildServiceParameters; import org.gradle.api.tasks.TaskProvider; import org.junit.jupiter.api.Test; @@ -42,7 +43,7 @@ private class Bundle { Project project = TestProvisioner.gradleProject(rootFolder()); Provider taskService = GradleIntegrationHarness.providerOf(new SpotlessTaskService() { @Override - public Parameters getParameters() { + public BuildServiceParameters.None getParameters() { return null; } }); diff --git a/plugin-gradle/src/test/java/com/diffplug/gradle/spotless/FormatTaskTest.java b/plugin-gradle/src/test/java/com/diffplug/gradle/spotless/FormatTaskTest.java index 3a56ca353e..6358b298fd 100644 --- a/plugin-gradle/src/test/java/com/diffplug/gradle/spotless/FormatTaskTest.java +++ b/plugin-gradle/src/test/java/com/diffplug/gradle/spotless/FormatTaskTest.java @@ -1,5 +1,5 @@ /* - * Copyright 2016-2026 DiffPlug + * Copyright 2016-2025 DiffPlug * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -20,6 +20,7 @@ import java.util.Set; import org.gradle.api.Project; +import org.gradle.api.services.BuildServiceParameters; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -38,7 +39,7 @@ void createTask() { spotlessTask.setLineEndingsPolicy(project.provider(LineEnding.UNIX::createPolicy)); spotlessTask.init(GradleIntegrationHarness.providerOf(new SpotlessTaskService() { @Override - public Parameters getParameters() { + public BuildServiceParameters.None getParameters() { return null; } })); diff --git a/plugin-gradle/src/test/java/com/diffplug/gradle/spotless/PaddedCellTaskTest.java b/plugin-gradle/src/test/java/com/diffplug/gradle/spotless/PaddedCellTaskTest.java index b30680fd35..0b13dd6475 100644 --- a/plugin-gradle/src/test/java/com/diffplug/gradle/spotless/PaddedCellTaskTest.java +++ b/plugin-gradle/src/test/java/com/diffplug/gradle/spotless/PaddedCellTaskTest.java @@ -1,5 +1,5 @@ /* - * Copyright 2016-2026 DiffPlug + * Copyright 2016-2025 DiffPlug * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -25,6 +25,7 @@ import org.gradle.api.Project; import org.gradle.api.provider.Provider; +import org.gradle.api.services.BuildServiceParameters; import org.gradle.api.tasks.TaskProvider; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; @@ -44,7 +45,7 @@ private class Bundle { Project project = TestProvisioner.gradleProject(rootFolder()); Provider taskService = GradleIntegrationHarness.providerOf(new SpotlessTaskService() { @Override - public Parameters getParameters() { + public BuildServiceParameters.None getParameters() { return null; } });