Skip to content

Commit 6b90188

Browse files
feat: support analysis of a Gradle version catalog dependency.
A declaration on that 'dependency' typically looks like this: ``` // kotlin DSL // gradle/gradle#15383 implementation(files(libs::class.java.superclass.protectionDomain.codeSource.location)) ``` With this infrastructure in place, it may be possible to support things like 'gradleApi()' as well, in the future.
1 parent 39ea9f9 commit 6b90188

9 files changed

Lines changed: 306 additions & 19 deletions

File tree

src/functionalTest/groovy/com/autonomousapps/AdviceHelper.groovy

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,10 @@ final class AdviceHelper {
3131
return STRATEGY.actualComprehensiveAdviceForProject(gradleProject, projectName)
3232
}
3333

34+
static FlatCoordinates flatCoordinates(String identifier) {
35+
return new FlatCoordinates(identifier)
36+
}
37+
3438
static ModuleCoordinates moduleCoordinates(com.autonomousapps.kit.gradle.Dependency dep) {
3539
return moduleCoordinates(dep.identifier, dep.version)
3640
}
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
// Copyright (c) 2026. Tony Robalik.
2+
// SPDX-License-Identifier: Apache-2.0
3+
package com.autonomousapps.jvm
4+
5+
import com.autonomousapps.jvm.projects.BuildLogicVersionCatalogProject
6+
import com.autonomousapps.utils.Colors
7+
8+
import static com.autonomousapps.utils.Runner.build
9+
import static com.google.common.truth.Truth.assertThat
10+
11+
// TODO(tsr): I can imagine improving this further and permitting analysis of "Gradle Jars". See ArtifactsReportTask and
12+
// `filterNonGradle()`.
13+
final class BuildLogicVersionCatalogSpec extends AbstractJvmSpec {
14+
15+
// The bad advice looks like this:
16+
// > Task :buildHealth
17+
// Advice for root project
18+
// Unused dependencies which should be removed:
19+
// implementation ''
20+
//
21+
// Or in model form:
22+
// Advice(coordinates=FlatCoordinates(identifier=), fromConfiguration=implementation, toConfiguration=null)
23+
def "can handle an opaque dependency on the gradle version catalog (#gradleVersion)"() {
24+
given:
25+
def project = new BuildLogicVersionCatalogProject(true)
26+
gradleProject = project.gradleProject
27+
28+
when:
29+
def result = build(gradleVersion, gradleProject.rootDir, '-p', 'build-logic', 'buildHealth', ':reason', '--id', 'gradle-version-catalog')
30+
31+
then:
32+
assertThat(project.actualProjectAdvice()).containsExactlyElementsIn(project.expectedProjectAdvice())
33+
34+
and:
35+
assertThat(Colors.decolorize(result.output)).contains(project.expectedReason())
36+
37+
where:
38+
gradleVersion << gradleVersions()
39+
}
40+
41+
def "advice contains meaningful representation of flat coordinates (#gradleVersion)"() {
42+
given:
43+
def project = new BuildLogicVersionCatalogProject(false)
44+
gradleProject = project.gradleProject
45+
46+
when:
47+
def result = build(gradleVersion, gradleProject.rootDir, '-p', 'build-logic', 'buildHealth', ':reason', '--id', 'gradle-version-catalog')
48+
49+
then:
50+
assertThat(project.actualProjectAdvice()).containsExactlyElementsIn(project.expectedProjectAdvice())
51+
52+
and:
53+
assertThat(Colors.decolorize(result.output)).contains(project.expectedReason())
54+
55+
where:
56+
gradleVersion << gradleVersions()
57+
}
58+
}
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
// Copyright (c) 2026. Tony Robalik.
2+
// SPDX-License-Identifier: Apache-2.0
3+
package com.autonomousapps.jvm.projects
4+
5+
import com.autonomousapps.AbstractProject
6+
import com.autonomousapps.internal.utils.OpaqueNames
7+
import com.autonomousapps.kit.GradleProject
8+
import com.autonomousapps.kit.Source
9+
import com.autonomousapps.kit.gradle.Plugin
10+
import com.autonomousapps.kit.gradle.dependencies.Plugins
11+
import com.autonomousapps.model.Advice
12+
import com.autonomousapps.model.ProjectAdvice
13+
14+
import static com.autonomousapps.AdviceHelper.*
15+
import static com.autonomousapps.kit.gradle.Dependency.implementation
16+
17+
final class BuildLogicVersionCatalogProject extends AbstractProject {
18+
19+
private static final String BUILD_LOGIC = 'build-logic'
20+
private final boolean used
21+
final GradleProject gradleProject
22+
23+
BuildLogicVersionCatalogProject(boolean used) {
24+
this.used = used
25+
this.gradleProject = build()
26+
}
27+
28+
private GradleProject build() {
29+
return newGradleProjectBuilder()
30+
.withRootProject { r ->
31+
r.withVersionCatalog(
32+
"""\
33+
[versions]
34+
dagp = "3.7.0"
35+
36+
[libraries]
37+
dagp = { module = "com.autonomousapps:dependency-analysis-gradle-plugin", version.ref = "dagp" }
38+
39+
[plugins]
40+
dependencyAnalysis = { id = "com.autonomousapps.dependency-analysis", version.ref = "dagp" }""".stripIndent()
41+
)
42+
r.settingsScript.additions = "includeBuild '$BUILD_LOGIC'"
43+
}
44+
.withIncludedBuild(BUILD_LOGIC) { buildLogic ->
45+
buildLogic.withRootProject { r ->
46+
r.gradleProperties += ADDITIONAL_PROPERTIES
47+
r.withSettingsScript { s ->
48+
s.additions = """\
49+
dependencyResolutionManagement {
50+
versionCatalogs {
51+
create("libs") {
52+
from(files("../gradle/libs.versions.toml"))
53+
}
54+
}
55+
}""".stripIndent()
56+
}
57+
r.withBuildScript { bs ->
58+
bs.plugins(Plugins.dependencyAnalysis, Plugins.kotlinJvm, Plugin.javaGradle)
59+
bs.dependencies(
60+
// Kotlin DSL: files(libs::class.java.superclass.protectionDomain.codeSource.location)
61+
implementation('files(libs.class.superclass.protectionDomain.codeSource.location)').raw()
62+
)
63+
}
64+
r.sources = pluginSources()
65+
}
66+
}
67+
.write()
68+
}
69+
70+
private List<Source> pluginSources() {
71+
if (used) {
72+
[
73+
Source.kotlin(
74+
'''\
75+
package mutual.aid
76+
77+
import org.gradle.accessors.dm.LibrariesForLibs
78+
import org.gradle.api.Plugin
79+
import org.gradle.api.Project
80+
81+
abstract class MyPlugin : Plugin<Project> {
82+
override fun apply(target: Project) {
83+
val libs: LibrariesForLibs = target.extensions.getByName("libs") as LibrariesForLibs
84+
val dagp = libs.plugins.dependencyAnalysis
85+
}
86+
}'''.stripIndent()
87+
).build()
88+
]
89+
} else {
90+
[
91+
Source.kotlin(
92+
'''\
93+
package mutual.aid
94+
95+
import org.gradle.api.Plugin
96+
import org.gradle.api.Project
97+
98+
abstract class MyPlugin : Plugin<Project> {
99+
override fun apply(target: Project) {}
100+
}'''.stripIndent()
101+
).build()
102+
]
103+
}
104+
}
105+
106+
Set<ProjectAdvice> actualProjectAdvice() {
107+
return [
108+
actualProjectAdviceForProject(gradleProject.includedBuilds.first(), ':'),
109+
]
110+
}
111+
112+
private Set<Advice> unusedAdvice() {
113+
[Advice.ofRemove(flatCoordinates(OpaqueNames.GRADLE_VERSION_CATALOG), 'implementation')]
114+
}
115+
116+
Set<ProjectAdvice> expectedProjectAdvice() {
117+
if (used) {
118+
[emptyProjectAdviceFor(':')]
119+
} else {
120+
[projectAdviceForDependencies(':', unusedAdvice())]
121+
}
122+
}
123+
124+
String expectedReason() {
125+
if (used) {
126+
'* Uses 2 classes: org.gradle.accessors.dm.LibrariesForLibs, org.gradle.accessors.dm.LibrariesForLibs$PluginAccessors (implies implementation).'
127+
} else {
128+
'''\
129+
You asked about the dependency 'gradle-version-catalog'.
130+
You have been advised to remove this dependency from 'implementation'.'''.stripIndent()
131+
}
132+
}
133+
}

src/functionalTest/groovy/com/autonomousapps/jvm/projects/IncludedBuildProject.groovy

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,13 @@ import com.autonomousapps.AbstractProject
66
import com.autonomousapps.kit.GradleProject
77
import com.autonomousapps.kit.Source
88
import com.autonomousapps.kit.SourceType
9-
import com.autonomousapps.kit.gradle.Dependency
109
import com.autonomousapps.kit.gradle.Plugin
1110
import com.autonomousapps.kit.gradle.dependencies.Plugins
1211
import com.autonomousapps.model.Advice
1312
import com.autonomousapps.model.ProjectAdvice
1413

1514
import static com.autonomousapps.AdviceHelper.*
15+
import static com.autonomousapps.kit.gradle.Dependency.implementation
1616
import static com.autonomousapps.kit.gradle.Dependency.testImplementation
1717

1818
final class IncludedBuildProject extends AbstractProject {
@@ -29,7 +29,7 @@ final class IncludedBuildProject extends AbstractProject {
2929
.withRootProject { root ->
3030
root.withBuildScript { bs ->
3131
bs.plugins.add(Plugin.javaLibrary)
32-
bs.dependencies = [new Dependency('implementation', 'second:second-build:1.0')]
32+
bs.dependencies(implementation('second:second-build:1.0'))
3333
bs.group = 'first'
3434
bs.version = '1.0'
3535
}

src/main/kotlin/com/autonomousapps/internal/analyzer/DependencyAnalyzer.kt

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import org.gradle.api.UnknownDomainObjectException
1616
import org.gradle.api.artifacts.Configuration
1717
import org.gradle.api.provider.Provider
1818
import org.gradle.api.tasks.TaskProvider
19+
import org.gradle.internal.component.local.model.OpaqueComponentArtifactIdentifier
1920

2021
/** Abstraction for differentiating between android-app, android-lib, and java-lib projects. */
2122
internal interface DependencyAnalyzer {
@@ -215,6 +216,14 @@ internal abstract class AbstractDependencyAnalyzer(
215216
t.setConfiguration(project.configurations.named(compileConfigurationName)) { c ->
216217
c.artifactsFor(attributeValueJar)
217218
}
219+
t.setOpaqueConfiguration(project.configurations.named(compileConfigurationName)) { c ->
220+
// We want "opaque" artifacts so we can capture the gradle version catalog (and maybe eventually other things)
221+
c.incoming.artifactView { view ->
222+
view
223+
.componentFilter { id -> id is OpaqueComponentArtifactIdentifier }
224+
.lenient(true)
225+
}.artifacts
226+
}
218227
t.buildPath.set(project.buildPath(compileConfigurationName))
219228

220229
t.output.set(outputPaths.compileArtifactsPath)
@@ -227,6 +236,14 @@ internal abstract class AbstractDependencyAnalyzer(
227236
t.setConfiguration(project.configurations.named(runtimeConfigurationName)) { c ->
228237
c.artifactsFor(attributeValueJar)
229238
}
239+
t.setOpaqueConfiguration(project.configurations.named(runtimeConfigurationName)) { c ->
240+
// We want "opaque" artifacts so we can capture the gradle version catalog (and maybe eventually other things)
241+
c.incoming.artifactView { view ->
242+
view
243+
.componentFilter { id -> id is OpaqueComponentArtifactIdentifier }
244+
.lenient(true)
245+
}.artifacts
246+
}
230247
t.buildPath.set(project.buildPath(runtimeConfigurationName))
231248

232249
t.output.set(outputPaths.runtimeArtifactsPath)

src/main/kotlin/com/autonomousapps/internal/utils/gradleStrings.kt

Lines changed: 47 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
// SPDX-License-Identifier: Apache-2.0
33
package com.autonomousapps.internal.utils
44

5+
import com.autonomousapps.internal.utils.OpaqueNames.GRADLE_VERSION_CATALOG
56
import com.autonomousapps.model.*
67
import org.gradle.api.GradleException
78
import org.gradle.api.artifacts.*
@@ -27,6 +28,20 @@ import org.gradle.internal.component.local.model.OpaqueComponentIdentifier
2728
import java.io.File
2829
import java.io.Serializable
2930

31+
public object OpaqueNames {
32+
public const val GRADLE_VERSION_CATALOG: String = "gradle-version-catalog"
33+
34+
/**
35+
* An example file/URL path is:
36+
* ```
37+
* "file:/Users/tony/.gradle/caches/9.2.1/dependencies-accessors/d174284f561fa71e6bcefa60fe17d9f7c7a5acf7/classes/"
38+
* ```
39+
*/
40+
internal fun isGradleVersionCatalog(fileLike: String): Boolean {
41+
return fileLike.contains("dependencies-accessors")
42+
}
43+
}
44+
3045
/** Converts this [ResolvedDependencyResult] to group-artifact-version (GAV) coordinates in a tuple of (GA, V?). */
3146
internal fun ResolvedDependencyResult.toCoordinates(): Coordinates =
3247
compositeRequest() ?: selected.id.wrapInIncludedBuildCoordinates(resolvedVariant)
@@ -130,7 +145,13 @@ private fun ComponentIdentifier.toIdentifier(): String = when (this) {
130145
// e.g. "Gradle API"
131146
is OpaqueComponentIdentifier -> displayName
132147
// for a file dependency
133-
is OpaqueComponentArtifactIdentifier -> displayName
148+
is OpaqueComponentArtifactIdentifier -> {
149+
if (OpaqueNames.isGradleVersionCatalog(file.toString())) {
150+
GRADLE_VERSION_CATALOG
151+
} else {
152+
displayName
153+
}
154+
}
134155
else -> throw GradleException("Cannot identify ComponentIdentifier subtype. Was ${javaClass.simpleName}, named $this")
135156
}.intern()
136157

@@ -221,10 +242,34 @@ internal fun Dependency.toIdentifier(): Pair<ModuleInfo, GradleVariantIdentifica
221242
// Handle weirdness that seems to come from KGP? Unclear. See comments from
222243
// https://github.com/autonomousapps/dependency-analysis-gradle-plugin/issues/997#issuecomment-1826627186
223244
when (firstFile) {
245+
null -> null
224246
is Function0<*> -> null // "() -> Any?"
225247
is Provider<*> -> null // "property 'destinationDirectory'"
226248
is File -> firstFile.invariantSeparatorsPath.substringAfterLast('/')
227-
else -> firstFile?.toString()?.let { it.substring(it.lastIndexOfAny(charArrayOf('/', '\\')) + 1) }
249+
250+
// TODO: can be a URL too
251+
// TODO: cleanup
252+
253+
else -> {
254+
// This can result in an empty string when this is the value (a gradle version catalog):
255+
// "file:/Users/tony/.gradle/caches/9.2.1/dependencies-accessors/d174284f561fa71e6bcefa60fe17d9f7c7a5acf7/classes/"
256+
val s = firstFile.toString()
257+
258+
if (OpaqueNames.isGradleVersionCatalog(s)) {
259+
// "...dependencies-accessors..." => GRADLE_VERSION_CATALOG
260+
GRADLE_VERSION_CATALOG
261+
} else if (s.endsWith('/') || s.endsWith('\\')) {
262+
// ".../classes/" => "classes"
263+
// "...\classes\" => "classes"
264+
s
265+
.substringBeforeLast('/').substringBeforeLast('\\')
266+
.let { it.substring(it.lastIndexOfAny(charArrayOf('/', '\\')) + 1) }
267+
} else {
268+
// ".../foo" => "foo"
269+
// "...\foo" => "foo"
270+
s.let { it.substring(it.lastIndexOfAny(charArrayOf('/', '\\')) + 1) }
271+
}
272+
}
228273
}
229274
}?.let {
230275
Pair(ModuleInfo(it.intern()), GradleVariantIdentification.EMPTY)

0 commit comments

Comments
 (0)