diff --git a/src/functionalTest/groovy/com/autonomousapps/jvm/GraphViewProjectEdgeCacheSpec.groovy b/src/functionalTest/groovy/com/autonomousapps/jvm/GraphViewProjectEdgeCacheSpec.groovy new file mode 100644 index 000000000..69e769a23 --- /dev/null +++ b/src/functionalTest/groovy/com/autonomousapps/jvm/GraphViewProjectEdgeCacheSpec.groovy @@ -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() + } +} diff --git a/src/functionalTest/groovy/com/autonomousapps/jvm/projects/GraphViewProjectEdgeCacheProject.groovy b/src/functionalTest/groovy/com/autonomousapps/jvm/projects/GraphViewProjectEdgeCacheProject.groovy new file mode 100644 index 000000000..8d38a6d65 --- /dev/null +++ b/src/functionalTest/groovy/com/autonomousapps/jvm/projects/GraphViewProjectEdgeCacheProject.groovy @@ -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 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 DIRECT_SOURCE = [ + Source.kotlin( + '''\ + package com.example.direct + + class Direct'''.stripIndent() + ).build(), + ] + + private static final List TRANSITIVE_SOURCE = [ + Source.kotlin( + '''\ + package com.example.transitive + + class Transitive'''.stripIndent() + ).build(), + ] + + Set actualBuildHealth() { + return actualProjectAdvice(gradleProject) + } + + private final Set consumerAdvice = [ + Advice.ofAdd(projectCoordinates(':transitive'), 'implementation') + ] + + private static Set directAdvice = [ + Advice.ofRemove(projectCoordinates(':transitive'), 'api') + ] + + final Set expectedBuildHealth = [ + projectAdviceForDependencies(':consumer', consumerAdvice), + projectAdviceForDependencies(':direct', directAdvice), + emptyProjectAdviceFor(':transitive'), + ] +} diff --git a/src/main/kotlin/com/autonomousapps/internal/OutputPaths.kt b/src/main/kotlin/com/autonomousapps/internal/OutputPaths.kt index 71902c8ec..c080c6da7 100644 --- a/src/main/kotlin/com/autonomousapps/internal/OutputPaths.kt +++ b/src/main/kotlin/com/autonomousapps/internal/OutputPaths.kt @@ -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" diff --git a/src/main/kotlin/com/autonomousapps/tasks/GraphViewTask.kt b/src/main/kotlin/com/autonomousapps/tasks/GraphViewTask.kt index 03b2ba1eb..4f6c2829c 100644 --- a/src/main/kotlin/com/autonomousapps/tasks/GraphViewTask.kt +++ b/src/main/kotlin/com/autonomousapps/tasks/GraphViewTask.kt @@ -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 @@ -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 + + /** See [compileClasspathComponentIds]. */ + @get:Input + public abstract val runtimeClasspathComponentIds: SetProperty + /** 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 @@ -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 }) } @@ -152,4 +164,22 @@ public abstract class GraphViewTask : DefaultTask() { outputRuntime.bufferWriteJson(runtimeGraphView) outputRuntimeDot.writeText(graphWriter.toDot(runtimeGraph)) } + + private fun ResolvedComponentResult.allComponentIds(): Set { + val visited = mutableSetOf() + val queue = ArrayDeque() + queue.add(this) + + while (queue.isNotEmpty()) { + val node = queue.removeFirst() + if (!visited.add(node.id.displayName)) { + continue + } + node.dependencies.asSequence() + .filterIsInstance() + .forEach { queue.add(it.selected) } + } + + return visited + } }