diff --git a/src/functionalTest/groovy/com/autonomousapps/AdviceHelper.groovy b/src/functionalTest/groovy/com/autonomousapps/AdviceHelper.groovy index 0134ee3d9..0624bf008 100644 --- a/src/functionalTest/groovy/com/autonomousapps/AdviceHelper.groovy +++ b/src/functionalTest/groovy/com/autonomousapps/AdviceHelper.groovy @@ -31,6 +31,10 @@ final class AdviceHelper { return STRATEGY.actualComprehensiveAdviceForProject(gradleProject, projectName) } + static FlatCoordinates flatCoordinates(String identifier) { + return new FlatCoordinates(identifier) + } + static ModuleCoordinates moduleCoordinates(com.autonomousapps.kit.gradle.Dependency dep) { return moduleCoordinates(dep.identifier, dep.version) } diff --git a/src/functionalTest/groovy/com/autonomousapps/jvm/BuildLogicVersionCatalogSpec.groovy b/src/functionalTest/groovy/com/autonomousapps/jvm/BuildLogicVersionCatalogSpec.groovy new file mode 100644 index 000000000..c182f3438 --- /dev/null +++ b/src/functionalTest/groovy/com/autonomousapps/jvm/BuildLogicVersionCatalogSpec.groovy @@ -0,0 +1,58 @@ +// Copyright (c) 2026. Tony Robalik. +// SPDX-License-Identifier: Apache-2.0 +package com.autonomousapps.jvm + +import com.autonomousapps.jvm.projects.BuildLogicVersionCatalogProject +import com.autonomousapps.utils.Colors + +import static com.autonomousapps.utils.Runner.build +import static com.google.common.truth.Truth.assertThat + +// TODO(tsr): I can imagine improving this further and permitting analysis of "Gradle Jars". See ArtifactsReportTask and +// `filterNonGradle()`. +final class BuildLogicVersionCatalogSpec extends AbstractJvmSpec { + + // The bad advice looks like this: + // > Task :buildHealth + // Advice for root project + // Unused dependencies which should be removed: + // implementation '' + // + // Or in model form: + // Advice(coordinates=FlatCoordinates(identifier=), fromConfiguration=implementation, toConfiguration=null) + def "can handle an opaque dependency on the gradle version catalog (#gradleVersion)"() { + given: + def project = new BuildLogicVersionCatalogProject(true) + gradleProject = project.gradleProject + + when: + def result = build(gradleVersion, gradleProject.rootDir, '-p', 'build-logic', 'buildHealth', ':reason', '--id', 'gradle-version-catalog') + + then: + assertThat(project.actualProjectAdvice()).containsExactlyElementsIn(project.expectedProjectAdvice()) + + and: + assertThat(Colors.decolorize(result.output)).contains(project.expectedReason()) + + where: + gradleVersion << gradleVersions() + } + + def "advice contains meaningful representation of flat coordinates (#gradleVersion)"() { + given: + def project = new BuildLogicVersionCatalogProject(false) + gradleProject = project.gradleProject + + when: + def result = build(gradleVersion, gradleProject.rootDir, '-p', 'build-logic', 'buildHealth', ':reason', '--id', 'gradle-version-catalog') + + then: + assertThat(project.actualProjectAdvice()).containsExactlyElementsIn(project.expectedProjectAdvice()) + + and: + assertThat(Colors.decolorize(result.output)).contains(project.expectedReason()) + + where: + gradleVersion << gradleVersions() + } +} diff --git a/src/functionalTest/groovy/com/autonomousapps/jvm/projects/BuildLogicVersionCatalogProject.groovy b/src/functionalTest/groovy/com/autonomousapps/jvm/projects/BuildLogicVersionCatalogProject.groovy new file mode 100644 index 000000000..588e9bf85 --- /dev/null +++ b/src/functionalTest/groovy/com/autonomousapps/jvm/projects/BuildLogicVersionCatalogProject.groovy @@ -0,0 +1,133 @@ +// Copyright (c) 2026. Tony Robalik. +// SPDX-License-Identifier: Apache-2.0 +package com.autonomousapps.jvm.projects + +import com.autonomousapps.AbstractProject +import com.autonomousapps.internal.utils.OpaqueNames +import com.autonomousapps.kit.GradleProject +import com.autonomousapps.kit.Source +import com.autonomousapps.kit.gradle.Plugin +import com.autonomousapps.kit.gradle.dependencies.Plugins +import com.autonomousapps.model.Advice +import com.autonomousapps.model.ProjectAdvice + +import static com.autonomousapps.AdviceHelper.* +import static com.autonomousapps.kit.gradle.Dependency.implementation + +final class BuildLogicVersionCatalogProject extends AbstractProject { + + private static final String BUILD_LOGIC = 'build-logic' + private final boolean used + final GradleProject gradleProject + + BuildLogicVersionCatalogProject(boolean used) { + this.used = used + this.gradleProject = build() + } + + private GradleProject build() { + return newGradleProjectBuilder() + .withRootProject { r -> + r.withVersionCatalog( + """\ + [versions] + dagp = "3.7.0" + + [libraries] + dagp = { module = "com.autonomousapps:dependency-analysis-gradle-plugin", version.ref = "dagp" } + + [plugins] + dependencyAnalysis = { id = "com.autonomousapps.dependency-analysis", version.ref = "dagp" }""".stripIndent() + ) + r.settingsScript.additions = "includeBuild '$BUILD_LOGIC'" + } + .withIncludedBuild(BUILD_LOGIC) { buildLogic -> + buildLogic.withRootProject { r -> + r.gradleProperties += ADDITIONAL_PROPERTIES + r.withSettingsScript { s -> + s.additions = """\ + dependencyResolutionManagement { + versionCatalogs { + create("libs") { + from(files("../gradle/libs.versions.toml")) + } + } + }""".stripIndent() + } + r.withBuildScript { bs -> + bs.plugins(Plugins.dependencyAnalysis, Plugins.kotlinJvm, Plugin.javaGradle) + bs.dependencies( + // Kotlin DSL: files(libs::class.java.superclass.protectionDomain.codeSource.location) + implementation('files(libs.class.superclass.protectionDomain.codeSource.location)').raw() + ) + } + r.sources = pluginSources() + } + } + .write() + } + + private List pluginSources() { + if (used) { + [ + Source.kotlin( + '''\ + package mutual.aid + + import org.gradle.accessors.dm.LibrariesForLibs + import org.gradle.api.Plugin + import org.gradle.api.Project + + abstract class MyPlugin : Plugin { + override fun apply(target: Project) { + val libs: LibrariesForLibs = target.extensions.getByName("libs") as LibrariesForLibs + val dagp = libs.plugins.dependencyAnalysis + } + }'''.stripIndent() + ).build() + ] + } else { + [ + Source.kotlin( + '''\ + package mutual.aid + + import org.gradle.api.Plugin + import org.gradle.api.Project + + abstract class MyPlugin : Plugin { + override fun apply(target: Project) {} + }'''.stripIndent() + ).build() + ] + } + } + + Set actualProjectAdvice() { + return [ + actualProjectAdviceForProject(gradleProject.includedBuilds.first(), ':'), + ] + } + + private Set unusedAdvice() { + [Advice.ofRemove(flatCoordinates(OpaqueNames.GRADLE_VERSION_CATALOG), 'implementation')] + } + + Set expectedProjectAdvice() { + if (used) { + [emptyProjectAdviceFor(':')] + } else { + [projectAdviceForDependencies(':', unusedAdvice())] + } + } + + String expectedReason() { + if (used) { + '* Uses 2 classes: org.gradle.accessors.dm.LibrariesForLibs, org.gradle.accessors.dm.LibrariesForLibs$PluginAccessors (implies implementation).' + } else { + '''\ + You asked about the dependency 'gradle-version-catalog'. + You have been advised to remove this dependency from 'implementation'.'''.stripIndent() + } + } +} diff --git a/src/functionalTest/groovy/com/autonomousapps/jvm/projects/IncludedBuildProject.groovy b/src/functionalTest/groovy/com/autonomousapps/jvm/projects/IncludedBuildProject.groovy index f9b471158..a868b2550 100644 --- a/src/functionalTest/groovy/com/autonomousapps/jvm/projects/IncludedBuildProject.groovy +++ b/src/functionalTest/groovy/com/autonomousapps/jvm/projects/IncludedBuildProject.groovy @@ -6,13 +6,13 @@ import com.autonomousapps.AbstractProject import com.autonomousapps.kit.GradleProject import com.autonomousapps.kit.Source import com.autonomousapps.kit.SourceType -import com.autonomousapps.kit.gradle.Dependency import com.autonomousapps.kit.gradle.Plugin import com.autonomousapps.kit.gradle.dependencies.Plugins import com.autonomousapps.model.Advice import com.autonomousapps.model.ProjectAdvice import static com.autonomousapps.AdviceHelper.* +import static com.autonomousapps.kit.gradle.Dependency.implementation import static com.autonomousapps.kit.gradle.Dependency.testImplementation final class IncludedBuildProject extends AbstractProject { @@ -29,7 +29,7 @@ final class IncludedBuildProject extends AbstractProject { .withRootProject { root -> root.withBuildScript { bs -> bs.plugins.add(Plugin.javaLibrary) - bs.dependencies = [new Dependency('implementation', 'second:second-build:1.0')] + bs.dependencies(implementation('second:second-build:1.0')) bs.group = 'first' bs.version = '1.0' } diff --git a/src/main/kotlin/com/autonomousapps/internal/analyzer/DependencyAnalyzer.kt b/src/main/kotlin/com/autonomousapps/internal/analyzer/DependencyAnalyzer.kt index 9d86d547a..5b47961d3 100644 --- a/src/main/kotlin/com/autonomousapps/internal/analyzer/DependencyAnalyzer.kt +++ b/src/main/kotlin/com/autonomousapps/internal/analyzer/DependencyAnalyzer.kt @@ -6,6 +6,7 @@ package com.autonomousapps.internal.analyzer import com.autonomousapps.internal.OutputPaths import com.autonomousapps.internal.artifactsFor +import com.autonomousapps.internal.opaqueComponentArtifacts import com.autonomousapps.internal.utils.project.buildPath import com.autonomousapps.model.DuplicateClass import com.autonomousapps.model.source.SourceKind @@ -16,6 +17,7 @@ import org.gradle.api.UnknownDomainObjectException import org.gradle.api.artifacts.Configuration import org.gradle.api.provider.Provider import org.gradle.api.tasks.TaskProvider +import org.gradle.internal.component.local.model.OpaqueComponentArtifactIdentifier /** Abstraction for differentiating between android-app, android-lib, and java-lib projects. */ internal interface DependencyAnalyzer { @@ -215,6 +217,9 @@ internal abstract class AbstractDependencyAnalyzer( t.setConfiguration(project.configurations.named(compileConfigurationName)) { c -> c.artifactsFor(attributeValueJar) } + t.setOpaqueConfiguration(project.configurations.named(compileConfigurationName)) { c -> + c.opaqueComponentArtifacts() + } t.buildPath.set(project.buildPath(compileConfigurationName)) t.output.set(outputPaths.compileArtifactsPath) @@ -227,6 +232,9 @@ internal abstract class AbstractDependencyAnalyzer( t.setConfiguration(project.configurations.named(runtimeConfigurationName)) { c -> c.artifactsFor(attributeValueJar) } + t.setOpaqueConfiguration(project.configurations.named(runtimeConfigurationName)) { c -> + c.opaqueComponentArtifacts() + } t.buildPath.set(project.buildPath(runtimeConfigurationName)) t.output.set(outputPaths.runtimeArtifactsPath) diff --git a/src/main/kotlin/com/autonomousapps/internal/artifactViews.kt b/src/main/kotlin/com/autonomousapps/internal/artifactViews.kt index a55e65c80..a49064c6b 100644 --- a/src/main/kotlin/com/autonomousapps/internal/artifactViews.kt +++ b/src/main/kotlin/com/autonomousapps/internal/artifactViews.kt @@ -9,6 +9,7 @@ import org.gradle.api.artifacts.component.ModuleComponentIdentifier import org.gradle.api.artifacts.result.ResolvedDependencyResult import org.gradle.api.attributes.Attribute import org.gradle.api.attributes.Category +import org.gradle.internal.component.local.model.OpaqueComponentArtifactIdentifier /** * This is different than [org.gradle.api.attributes.Category.CATEGORY_ATTRIBUTE], which has type @@ -20,6 +21,13 @@ private val attributeKey = Attribute.of("artifactType", String::class.java) internal fun Configuration.artifactsFor(attrValue: String): ArtifactCollection = artifactViewFor(attrValue).artifacts +/** Captures things like the Gradle version catalog and Gradle API jar. */ +internal fun Configuration.opaqueComponentArtifacts(): ArtifactCollection = incoming.artifactView { view -> + view + .componentFilter { id -> id is OpaqueComponentArtifactIdentifier } + .lenient(true) +}.artifacts + private fun Configuration.artifactViewFor(attrValue: String): ArtifactView = incoming.artifactView { it.attributes.attribute(attributeKey, attrValue) it.lenient(true) diff --git a/src/main/kotlin/com/autonomousapps/internal/utils/gradleStrings.kt b/src/main/kotlin/com/autonomousapps/internal/utils/gradleStrings.kt index 6f4c99553..2ed76d9a8 100644 --- a/src/main/kotlin/com/autonomousapps/internal/utils/gradleStrings.kt +++ b/src/main/kotlin/com/autonomousapps/internal/utils/gradleStrings.kt @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 package com.autonomousapps.internal.utils +import com.autonomousapps.internal.utils.OpaqueNames.GRADLE_VERSION_CATALOG import com.autonomousapps.model.* import org.gradle.api.GradleException import org.gradle.api.artifacts.* @@ -27,6 +28,20 @@ import org.gradle.internal.component.local.model.OpaqueComponentIdentifier import java.io.File import java.io.Serializable +public object OpaqueNames { + public const val GRADLE_VERSION_CATALOG: String = "gradle-version-catalog" + + /** + * An example file/URL path is: + * ``` + * "file:/Users/tony/.gradle/caches/9.2.1/dependencies-accessors/d174284f561fa71e6bcefa60fe17d9f7c7a5acf7/classes/" + * ``` + */ + internal fun isGradleVersionCatalog(fileLike: String): Boolean { + return fileLike.contains("dependencies-accessors") + } +} + /** Converts this [ResolvedDependencyResult] to group-artifact-version (GAV) coordinates in a tuple of (GA, V?). */ internal fun ResolvedDependencyResult.toCoordinates(): Coordinates = compositeRequest() ?: selected.id.wrapInIncludedBuildCoordinates(resolvedVariant) @@ -130,7 +145,13 @@ private fun ComponentIdentifier.toIdentifier(): String = when (this) { // e.g. "Gradle API" is OpaqueComponentIdentifier -> displayName // for a file dependency - is OpaqueComponentArtifactIdentifier -> displayName + is OpaqueComponentArtifactIdentifier -> { + if (OpaqueNames.isGradleVersionCatalog(file.toString())) { + GRADLE_VERSION_CATALOG + } else { + displayName + } + } else -> throw GradleException("Cannot identify ComponentIdentifier subtype. Was ${javaClass.simpleName}, named $this") }.intern() @@ -221,10 +242,34 @@ internal fun Dependency.toIdentifier(): Pair null is Function0<*> -> null // "() -> Any?" is Provider<*> -> null // "property 'destinationDirectory'" is File -> firstFile.invariantSeparatorsPath.substringAfterLast('/') - else -> firstFile?.toString()?.let { it.substring(it.lastIndexOfAny(charArrayOf('/', '\\')) + 1) } + + // TODO: can be a URL too + // TODO: cleanup + + else -> { + // This can result in an empty string when this is the value (a gradle version catalog): + // "file:/Users/tony/.gradle/caches/9.2.1/dependencies-accessors/d174284f561fa71e6bcefa60fe17d9f7c7a5acf7/classes/" + val s = firstFile.toString() + + if (OpaqueNames.isGradleVersionCatalog(s)) { + // "...dependencies-accessors..." => GRADLE_VERSION_CATALOG + GRADLE_VERSION_CATALOG + } else if (s.endsWith('/') || s.endsWith('\\')) { + // ".../classes/" => "classes" + // "...\classes\" => "classes" + s + .substringBeforeLast('/').substringBeforeLast('\\') + .let { it.substring(it.lastIndexOfAny(charArrayOf('/', '\\')) + 1) } + } else { + // ".../foo" => "foo" + // "...\foo" => "foo" + s.let { it.substring(it.lastIndexOfAny(charArrayOf('/', '\\')) + 1) } + } + } } }?.let { Pair(ModuleInfo(it.intern()), GradleVariantIdentification.EMPTY) diff --git a/src/main/kotlin/com/autonomousapps/tasks/ArtifactsReportTask.kt b/src/main/kotlin/com/autonomousapps/tasks/ArtifactsReportTask.kt index 9e695d6b9..d23d176ed 100644 --- a/src/main/kotlin/com/autonomousapps/tasks/ArtifactsReportTask.kt +++ b/src/main/kotlin/com/autonomousapps/tasks/ArtifactsReportTask.kt @@ -23,9 +23,9 @@ import org.gradle.api.tasks.* /** * Produces a report of all the artifacts required to build the given project; i.e., the artifacts on the compile - * classpath, the runtime classpath, and a few others. See - * [FindDeclarationsTask.Locator] for the full list of analyzed [Configuration][org.gradle.api.artifacts.Configuration]s. These artifacts are - * physical files on disk, such as jars. + * classpath, the runtime classpath, and a few others. See [com.autonomousapps.model.internal.declaration.Locator] for + * the full list of analyzed [Configuration][org.gradle.api.artifacts.Configuration]s. These artifacts are physical + * files on disk, such as jars. */ @CacheableTask public abstract class ArtifactsReportTask : DefaultTask() { @@ -37,6 +37,9 @@ public abstract class ArtifactsReportTask : DefaultTask() { @get:Internal public abstract val artifacts: Property + @get:Internal + public abstract val opaqueArtifacts: Property + /** * This is the "official" input for wiring task dependencies correctly, but is otherwise * unused. This needs to use [InputFiles] and [PathSensitivity.ABSOLUTE] because the path to the @@ -48,6 +51,11 @@ public abstract class ArtifactsReportTask : DefaultTask() { @InputFiles // TODO(tsr): can I avoid using `get()`? public fun getClasspathArtifactFiles(): FileCollection = artifacts.get().artifactFiles + /** @see [getClasspathArtifactFiles] */ + @PathSensitive(PathSensitivity.ABSOLUTE) + @InputFiles // TODO(tsr): can I avoid using `get()`? + public fun getClasspathOpaqueArtifactFiles(): FileCollection = opaqueArtifacts.get().artifactFiles + /** * This artifact collection is the result of resolving the compile or runtime classpath. */ @@ -59,6 +67,13 @@ public abstract class ArtifactsReportTask : DefaultTask() { artifacts.set(configuration.map { c -> action(c) }) } + public fun setOpaqueConfiguration( + configuration: NamedDomainObjectProvider, + action: (Configuration) -> ArtifactCollection, + ) { + opaqueArtifacts.set(configuration.map { c -> action(c) }) + } + /** Needed to make sure task gives the same result if the build configuration in a composite changed between runs. */ @get:Input public abstract val buildPath: Property @@ -66,9 +81,7 @@ public abstract class ArtifactsReportTask : DefaultTask() { @get:Input public abstract val excludedIdentifiers: SetProperty - /** - * [PhysicalArtifact]s used to compile or run main source. - */ + /** [PhysicalArtifact]s used to compile or run main source. */ @get:OutputFile public abstract val output: RegularFileProperty @@ -81,9 +94,10 @@ public abstract class ArtifactsReportTask : DefaultTask() { val excludedIdentifiersOutput = excludedIdentifiersOutput.getAndDelete() val allArtifacts = toPhysicalArtifacts(artifacts.get()) + val opaqueArtifacts = toPhysicalArtifacts(opaqueArtifacts.get()) val excludedIdentifiers = getExcludedIdentifiers() - output.bufferWriteJsonSet(allArtifacts) + output.bufferWriteJsonSet(allArtifacts + opaqueArtifacts) excludedIdentifiersOutput.writeText(excludedIdentifiers.toJson()) } diff --git a/testkit/gradle-testkit-support/src/main/kotlin/com/autonomousapps/kit/gradle/Dependency.kt b/testkit/gradle-testkit-support/src/main/kotlin/com/autonomousapps/kit/gradle/Dependency.kt index d14450b73..1ebf05bda 100644 --- a/testkit/gradle-testkit-support/src/main/kotlin/com/autonomousapps/kit/gradle/Dependency.kt +++ b/testkit/gradle-testkit-support/src/main/kotlin/com/autonomousapps/kit/gradle/Dependency.kt @@ -14,6 +14,7 @@ public data class Dependency @JvmOverloads constructor( private val ext: String? = null, private val capability: String? = null, private val isVersionCatalog: Boolean = false, + private val isRaw: Boolean = false, ) : Element.Line { private val isProject = dependency.startsWith(":") @@ -75,6 +76,16 @@ public data class Dependency @JvmOverloads constructor( return copy(ext = ext) } + /** + * Specify that this [Dependency], when rendered, should omit enclosing quotation marks. For example: + * ``` + * implementation(files(libs::class.java.superclass.protectionDomain.codeSource.location)) + * ``` + */ + public fun raw(): Dependency { + return copy(isRaw = true) + } + /** Specify that this [Dependency] uses a version catalog accessor. */ public fun versionCatalog(): Dependency { return copy(isVersionCatalog = true) @@ -92,10 +103,7 @@ public data class Dependency @JvmOverloads constructor( dependency.startsWith(':') -> "$configuration project('$dependency')" // function call dependency.endsWith("()") -> "$configuration $dependency" - // Some kind of custom notation - !dependency.contains(":") -> "$configuration $dependency" - // version catalog reference - isVersionCatalog -> "$configuration $dependency" + noQuotes() -> "$configuration $dependency" // normal dependency else -> { @@ -149,10 +157,7 @@ public data class Dependency @JvmOverloads constructor( dependency.startsWith(':') -> "$configuration(project(\"$dependency\"))" // function call dependency.endsWith("()") -> "$configuration($dependency)" - // Some kind of custom notation - !dependency.contains(":") -> "$configuration($dependency)" - // version catalog reference - isVersionCatalog -> "$configuration($dependency)" + noQuotes() -> "$configuration($dependency)" // normal dependency else -> { @@ -193,6 +198,15 @@ public data class Dependency @JvmOverloads constructor( s.append(text) } + /** + * 1. some kind of custom notation; or + * 2. version catalog reference; or + * 3. we just don't want enclosing quotation marks + */ + private fun noQuotes(): Boolean { + return !dependency.contains(":") || isVersionCatalog || isRaw + } + override fun toString(): String { error("don't call toString()") } diff --git a/testkit/gradle-testkit-support/src/main/kotlin/com/autonomousapps/kit/gradle/Plugin.kt b/testkit/gradle-testkit-support/src/main/kotlin/com/autonomousapps/kit/gradle/Plugin.kt index 1c8096bd2..1e6ce2f6f 100644 --- a/testkit/gradle-testkit-support/src/main/kotlin/com/autonomousapps/kit/gradle/Plugin.kt +++ b/testkit/gradle-testkit-support/src/main/kotlin/com/autonomousapps/kit/gradle/Plugin.kt @@ -61,6 +61,8 @@ public class Plugin @JvmOverloads constructor( @JvmStatic public val groovyGradle: Plugin = Plugin("groovy-gradle-plugin") @JvmStatic public val jacocoReportAggregation: Plugin = Plugin("jacoco-report-aggregation") @JvmStatic public val java: Plugin = Plugin("java") + + /** `java-gradle-plugin`. The Gradle Plugin Development Plugin. */ @JvmStatic public val javaGradle: Plugin = Plugin("java-gradle-plugin") @JvmStatic public val javaLibrary: Plugin = Plugin("java-library") @JvmStatic public val javaTestFixtures: Plugin = Plugin("java-test-fixtures")