Skip to content

Commit 78d7a31

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 78d7a31

9 files changed

Lines changed: 280 additions & 11 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: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
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+
16+
final class BuildLogicVersionCatalogProject extends AbstractProject {
17+
18+
private static final String BUILD_LOGIC = 'build-logic'
19+
private final boolean used
20+
final GradleProject gradleProject
21+
22+
BuildLogicVersionCatalogProject(boolean used) {
23+
this.used = used
24+
this.gradleProject = build()
25+
}
26+
27+
private GradleProject build() {
28+
return newGradleProjectBuilder()
29+
.withRootProject { r ->
30+
r.withVersionCatalog(
31+
"""\
32+
[versions]
33+
dagp = "3.7.0"
34+
35+
[libraries]
36+
dagp = { module = "com.autonomousapps:dependency-analysis-gradle-plugin", version.ref = "dagp" }
37+
38+
[plugins]
39+
dependencyAnalysis = { id = "com.autonomousapps.dependency-analysis", version.ref = "dagp" }""".stripIndent()
40+
)
41+
r.settingsScript.additions = "includeBuild '$BUILD_LOGIC'"
42+
}
43+
.withIncludedBuild(BUILD_LOGIC) { buildLogic ->
44+
buildLogic.withRootProject { r ->
45+
r.gradleProperties += ADDITIONAL_PROPERTIES
46+
r.withSettingsScript { s ->
47+
s.additions = """\
48+
dependencyResolutionManagement {
49+
versionCatalogs {
50+
create("libs") {
51+
from(files("../gradle/libs.versions.toml"))
52+
}
53+
}
54+
}""".stripIndent()
55+
}
56+
r.withBuildScript { bs ->
57+
bs.plugins(Plugins.dependencyAnalysis, Plugins.kotlinJvm, Plugin.javaGradle)
58+
bs.withGroovy(
59+
"""\
60+
dependencies {
61+
// Kotlin DSL: files(libs::class.java.superclass.protectionDomain.codeSource.location)
62+
implementation(files(libs.class.superclass.protectionDomain.codeSource.location))
63+
}""".stripIndent()
64+
)
65+
}
66+
r.sources = pluginSources()
67+
}
68+
}
69+
.write()
70+
}
71+
72+
private List<Source> pluginSources() {
73+
if (used) {
74+
[
75+
Source.kotlin(
76+
'''\
77+
package mutual.aid
78+
79+
import org.gradle.accessors.dm.LibrariesForLibs
80+
import org.gradle.api.Plugin
81+
import org.gradle.api.Project
82+
83+
abstract class MyPlugin : Plugin<Project> {
84+
override fun apply(target: Project) {
85+
val libs: LibrariesForLibs = target.extensions.getByName("libs") as LibrariesForLibs
86+
val dagp = libs.plugins.dependencyAnalysis
87+
}
88+
}'''.stripIndent()
89+
).build()
90+
]
91+
} else {
92+
[
93+
Source.kotlin(
94+
'''\
95+
package mutual.aid
96+
97+
import org.gradle.api.Plugin
98+
import org.gradle.api.Project
99+
100+
abstract class MyPlugin : Plugin<Project> {
101+
override fun apply(target: Project) {}
102+
}'''.stripIndent()
103+
).build()
104+
]
105+
}
106+
}
107+
108+
Set<ProjectAdvice> actualProjectAdvice() {
109+
return [
110+
actualProjectAdviceForProject(gradleProject.includedBuilds.first(), ':'),
111+
]
112+
}
113+
114+
private Set<Advice> unusedAdvice() {
115+
[Advice.ofRemove(flatCoordinates(OpaqueNames.GRADLE_VERSION_CATALOG), 'implementation')]
116+
}
117+
118+
Set<ProjectAdvice> expectedProjectAdvice() {
119+
if (used) {
120+
[emptyProjectAdviceFor(':')]
121+
} else {
122+
[projectAdviceForDependencies(':', unusedAdvice())]
123+
}
124+
}
125+
126+
String expectedReason() {
127+
if (used) {
128+
'* Uses 2 classes: org.gradle.accessors.dm.LibrariesForLibs, org.gradle.accessors.dm.LibrariesForLibs$PluginAccessors (implies implementation).'
129+
} else {
130+
'''\
131+
You asked about the dependency 'gradle-version-catalog'.
132+
You have been advised to remove this dependency from 'implementation'.'''.stripIndent()
133+
}
134+
}
135+
}

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: 9 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)

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)