Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
// Copyright (c) 2026. Tony Robalik.
// SPDX-License-Identifier: Apache-2.0
package com.autonomousapps.jvm

import com.autonomousapps.internal.OutputPathsKt
import com.autonomousapps.jvm.projects.GraphViewProjectEdgeCacheProject

import static com.autonomousapps.kit.truth.BuildTaskSubject.buildTasks
import static com.autonomousapps.utils.Runner.build
import static com.google.common.truth.Truth.assertAbout
import static com.google.common.truth.Truth.assertThat

final class GraphViewProjectEdgeCacheSpec extends AbstractJvmSpec {

def "graphViewTask is sensitive to new transitive project edges (#gradleVersion)"() {
given:
def project = new GraphViewProjectEdgeCacheProject()
gradleProject = project.gradleProject
def task = ':consumer:graphViewMain'

when: 'First build, without the direct -> transitive edge'
def result = build(gradleVersion, gradleProject.rootDir, ':buildHealth', '--build-cache')
def graphCompilePath = OutputPathsKt.getGraphCompilePath('main')
def graphOutput = gradleProject.singleArtifact('consumer', graphCompilePath).asFile

then: 'Task executed and transitive is not in the graph'
assertAbout(buildTasks()).that(result.task(task)).succeeded()
assertThat(graphOutput.text).doesNotContain(':transitive')

when: 'Second build, after the direct project adds an api dependency on transitive'
result = build(gradleVersion, gradleProject.rootDir, 'clean', ':buildHealth', '--build-cache', '-Dedge=true')

then: 'Task executed (not FROM_CACHE) and transitive is in the graph'
assertAbout(buildTasks()).that(result.task(task)).succeeded()
assertThat(graphOutput.text).contains(':transitive')

where:
gradleVersion << gradleVersions()
}

def "stale project graph does not suppress dependency advice (#gradleVersion)"() {
given:
def project = new GraphViewProjectEdgeCacheProject()
gradleProject = project.gradleProject

when: 'First build, without the direct -> transitive edge'
build(gradleVersion, gradleProject.rootDir, ':buildHealth', '--build-cache')

then: 'There is no advice'
assertThat(actualProjectAdvice('consumer').dependencyAdvice).isEmpty()

when: 'Second build, after the direct project adds an api dependency on transitive'
build(gradleVersion, gradleProject.rootDir, 'clean', ':buildHealth', '--build-cache', '-Dedge=true')

then: 'Advises declaring the used transitive dependency directly'
assertThat(project.actualBuildHealth()).containsExactlyElementsIn(project.expectedBuildHealth)

where:
gradleVersion << gradleVersions()
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
// Copyright (c) 2026. Tony Robalik.
// SPDX-License-Identifier: Apache-2.0
package com.autonomousapps.jvm.projects

import com.autonomousapps.AbstractProject
import com.autonomousapps.kit.GradleProject
import com.autonomousapps.kit.Source
import com.autonomousapps.kit.gradle.SettingsScript
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 GraphViewProjectEdgeCacheProject extends AbstractProject {

final GradleProject gradleProject

GraphViewProjectEdgeCacheProject() {
this.gradleProject = build()
}

private GradleProject build() {
return newGradleProjectBuilder()
.withRootProject { s ->
s.settingsScript = new SettingsScript().tap {
// Since this test exercises the build cache, we can't rely on the default location
additions = """
buildCache {
local {
directory = new File(rootDir, 'build-cache')
}
}""".stripIndent()
}
}
.withSubproject('consumer') { s ->
s.sources = CONSUMER_SOURCE
s.withBuildScript { bs ->
bs.plugins = kotlin
bs.dependencies(implementation(':direct'))
bs.withGroovy("""\
if (providers.systemProperty('edge').present) {
sourceSets.main.kotlin.srcDir('src/edge/kotlin')
}""".stripIndent())
}
}
.withSubproject('direct') { s ->
s.sources = DIRECT_SOURCE
s.withBuildScript { bs ->
bs.plugins = kotlin
// The system property models an upstream change adding a project edge: the
// consumer's own build script and declarations are untouched by it.
bs.withGroovy("""\
if (providers.systemProperty('edge').present) {
dependencies {
api project(':transitive')
}
}""")
}
}
.withSubproject('transitive') { s ->
s.sources = TRANSITIVE_SOURCE
s.withBuildScript { bs ->
bs.plugins = kotlin
}
}
.write()
}

private static final List<Source> CONSUMER_SOURCE = [
Source.kotlin(
'''\
package com.example

import com.example.direct.Direct

class Main {
private val direct = Direct()
}'''.stripIndent()
).build(),
Source.kotlin(
'''\
package com.example

import com.example.transitive.Transitive

class UsesTransitive {
private val transitive = Transitive()
}'''.stripIndent()
)
.withSourceSet('edge')
.build(),
]

private static final List<Source> DIRECT_SOURCE = [
Source.kotlin(
'''\
package com.example.direct

class Direct'''.stripIndent()
).build(),
]

private static final List<Source> TRANSITIVE_SOURCE = [
Source.kotlin(
'''\
package com.example.transitive

class Transitive'''.stripIndent()
).build(),
]

Set<ProjectAdvice> actualBuildHealth() {
return actualProjectAdvice(gradleProject)
}

private final Set<Advice> consumerAdvice = [
Advice.ofAdd(projectCoordinates(':transitive'), 'implementation')
]

private static Set<Advice> directAdvice = [
Advice.ofRemove(projectCoordinates(':transitive'), 'api')
]

final Set<ProjectAdvice> expectedBuildHealth = [
projectAdviceForDependencies(':consumer', consumerAdvice),
projectAdviceForDependencies(':direct', directAdvice),
emptyProjectAdviceFor(':transitive'),
]
}
1 change: 1 addition & 0 deletions src/main/kotlin/com/autonomousapps/internal/OutputPaths.kt
Original file line number Diff line number Diff line change
Expand Up @@ -146,3 +146,4 @@ public fun getResolvedDependenciesReport(): String = "$ROOT_DIR/resolved-depende
public fun getResolvedVersionsTomlPath(): String = "$ROOT_DIR/resolvedAllLibs.versions.toml"
public fun getTypeUsagePath(variantName: String = "main"): String = "$ROOT_DIR/$variantName/type-usage.json"
public fun getPublicTypeUsagePath(): String = "$ROOT_DIR/public-type-usage-report.json"
public fun getGraphCompilePath(variantName: String): String = "reports/dependency-analysis/$variantName/graph/graph-compile.json"
30 changes: 30 additions & 0 deletions src/main/kotlin/com/autonomousapps/tasks/GraphViewTask.kt
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import org.gradle.api.Project
import org.gradle.api.artifacts.Configuration
import org.gradle.api.artifacts.FileCollectionDependency
import org.gradle.api.artifacts.result.ResolvedComponentResult
import org.gradle.api.artifacts.result.ResolvedDependencyResult
import org.gradle.api.file.ConfigurableFileCollection
import org.gradle.api.file.RegularFileProperty
import org.gradle.api.provider.Property
Expand Down Expand Up @@ -65,6 +66,14 @@ public abstract class GraphViewTask : DefaultTask() {
@get:InputFile
public abstract val declarations: RegularFileProperty

/** Tracks resolved components so project dependency changes invalidate this task. */
@get:Input
public abstract val compileClasspathComponentIds: SetProperty<String>

/** See [compileClasspathComponentIds]. */
@get:Input
public abstract val runtimeClasspathComponentIds: SetProperty<String>

/** 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<String>
Expand Down Expand Up @@ -118,6 +127,9 @@ public abstract class GraphViewTask : DefaultTask() {
.mapNotNullToSet { it.toCoordinates() }
})

compileClasspathComponentIds.set(compileClasspathResult.map { it.allComponentIds() })
runtimeClasspathComponentIds.set(runtimeClasspathResult.map { it.allComponentIds() })

compileFiles.setFrom(project.provider { compileClasspath.externalArtifactsFor(jarAttr).artifactFiles })
runtimeFiles.setFrom(project.provider { runtimeClasspath.externalArtifactsFor(jarAttr).artifactFiles })
}
Expand Down Expand Up @@ -152,4 +164,22 @@ public abstract class GraphViewTask : DefaultTask() {
outputRuntime.bufferWriteJson(runtimeGraphView)
outputRuntimeDot.writeText(graphWriter.toDot(runtimeGraph))
}

private fun ResolvedComponentResult.allComponentIds(): Set<String> {
val visited = mutableSetOf<String>()

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've made this a Set<String> instead of a Set<ResolvedComponentResult>. AFAIK, ResolvedComponentResult has no real hashCode() function, and so has no legitimate set semantics.

val queue = ArrayDeque<ResolvedComponentResult>()
queue.add(this)

while (queue.isNotEmpty()) {
val node = queue.removeFirst()
if (!visited.add(node.id.displayName)) {
continue
}
node.dependencies.asSequence()
.filterIsInstance<ResolvedDependencyResult>()
.forEach { queue.add(it.selected) }
}

return visited
}
}