Repository: autonomousapps/dependency-analysis-android-gradle-plugin
Issue: `reason` can't find dependency that's only on the runtime classpath

```
./gradlew :foo:reason --id com.foo:bar:1.0
```
may fail when `com.foo:bar:1.0` is declared `runtimeOnly`.

Changed files:
src/main/kotlin/com/autonomousapps/tasks/ReasonTask.kt, src/main/kotlin/com/autonomousapps/subplugin/ProjectPlugin.kt, src/main/kotlin/com/autonomousapps/internal/OutputPaths.kt, src/main/kotlin/com/autonomousapps/tasks/GraphViewTask.kt

diff --git a/src/main/kotlin/com/autonomousapps/internal/OutputPaths.kt b/src/main/kotlin/com/autonomousapps/internal/OutputPaths.kt
index 67e19c63..331fbfb0 100644
--- a/src/main/kotlin/com/autonomousapps/internal/OutputPaths.kt
+++ b/src/main/kotlin/com/autonomousapps/internal/OutputPaths.kt
@@ -46,6 +46,8 @@ internal class OutputPaths(
   private val graphDir = "${variantDirectory}/graph"
   val compileGraphPath = file("${graphDir}/graph-compile.json")
   val compileGraphDotPath = file("${graphDir}/graph-compile.gv")
+  val runtimeGraphPath = file("${graphDir}/graph-runtime.json")
+  val runtimeGraphDotPath = file("${graphDir}/graph-runtime.gv")
   val dominatorConsolePath = file("${graphDir}/graph-dominator.txt")
   val dominatorGraphPath = file("${graphDir}/graph-dominator.gv")
 }
diff --git a/src/main/kotlin/com/autonomousapps/subplugin/ProjectPlugin.kt b/src/main/kotlin/com/autonomousapps/subplugin/ProjectPlugin.kt
index 5ea19b11..74a77477 100644
--- a/src/main/kotlin/com/autonomousapps/subplugin/ProjectPlugin.kt
+++ b/src/main/kotlin/com/autonomousapps/subplugin/ProjectPlugin.kt
@@ -483,13 +483,17 @@ internal class ProjectPlugin(private val project: Project) {
     // Produce a DAG of the compile classpath rooted on this project.
     val graphViewTask = tasks.register<GraphViewTask>("graphView$taskNameSuffix") {
       setCompileClasspath(configurations[dependencyAnalyzer.compileConfigurationName])
+      setRuntimeClasspath(configurations[dependencyAnalyzer.runtimeConfigurationName])
       jarAttr.set(dependencyAnalyzer.attributeValueJar)
       projectPath.set(thisProjectPath)
       variant.set(variantName)
       kind.set(dependencyAnalyzer.kind)
       declarations.set(findDeclarationsTask.flatMap { it.output })
+
       output.set(outputPaths.compileGraphPath)
       outputDot.set(outputPaths.compileGraphDotPath)
+      outputRuntime.set(outputPaths.runtimeGraphPath)
+      outputRuntimeDot.set(outputPaths.runtimeGraphDotPath)
     }
 
     val computeDominatorTask = tasks.register<ComputeDominatorTreeTask>("computeDominatorTree$taskNameSuffix") {
@@ -505,7 +509,8 @@ internal class ProjectPlugin(private val project: Project) {
     }
 
     reasonTask.configure {
-      dependencyGraphViews.add(graphViewTask.flatMap { it.output })
+      dependencyGraphViews.add(graphViewTask.flatMap { it.output /* compile graph*/ })
+      dependencyGraphViews.add(graphViewTask.flatMap { it.outputRuntime })
     }
 
     /* ******************************
diff --git a/src/main/kotlin/com/autonomousapps/tasks/GraphViewTask.kt b/src/main/kotlin/com/autonomousapps/tasks/GraphViewTask.kt
index c03d7883..45673b5f 100644
--- a/src/main/kotlin/com/autonomousapps/tasks/GraphViewTask.kt
+++ b/src/main/kotlin/com/autonomousapps/tasks/GraphViewTask.kt
@@ -37,6 +37,13 @@ abstract class GraphViewTask : DefaultTask() {
     this.compileClasspath = compileClasspath
   }
 
+  @Transient
+  private lateinit var runtimeClasspath: Configuration
+
+  fun setRuntimeClasspath(runtimeClasspath: Configuration) {
+    this.runtimeClasspath = runtimeClasspath
+  }
+
   @get:Internal
   abstract val jarAttr: Property<String>
 
@@ -46,6 +53,12 @@ abstract class GraphViewTask : DefaultTask() {
     .artifactsFor(jarAttr.get())
     .artifactFiles
 
+  @PathSensitive(PathSensitivity.NAME_ONLY)
+  @InputFiles
+  fun getRuntimeClasspath(): FileCollection = runtimeClasspath
+    .artifactsFor(jarAttr.get())
+    .artifactFiles
+
   /**
    * Unused, except to influence the up-to-date-ness of this task. Declaring a transitive dependency doesn't change the
    * compile classpath, but it must influence the output of this task.
@@ -64,26 +77,45 @@ abstract class GraphViewTask : DefaultTask() {
   @get:Input
   abstract val kind: Property<SourceSetKind>
 
-  /** Output in json format. */
+  /** Output in json format for compile classpath graph. */
   @get:OutputFile
   abstract val output: RegularFileProperty
 
-  /** Output in graphviz format. */
+  /** Output in graphviz format for compile classpath graph. */
   @get:OutputFile
   abstract val outputDot: RegularFileProperty
 
+  /** Output in json format for runtime classpath graph. */
+  @get:OutputFile
+  abstract val outputRuntime: RegularFileProperty
+
+  /** Output in graphviz format for runtime classpath graph. */
+  @get:OutputFile
+  abstract val outputRuntimeDot: RegularFileProperty
+
   @TaskAction fun action() {
     val output = output.getAndDelete()
     val outputDot = outputDot.getAndDelete()
+    val outputRuntime = outputRuntime.getAndDelete()
+    val outputRuntimeDot = outputRuntimeDot.getAndDelete()
 
-    val graph = GraphViewBuilder(compileClasspath).graph
-    val graphView = DependencyGraphView(
+    val compileGraph = GraphViewBuilder(compileClasspath).graph
+    val compileGraphView = DependencyGraphView(
       variant = Variant(variant.get(), kind.get()),
       configurationName = compileClasspath.name,
-      graph = graph
+      graph = compileGraph
+    )
+
+    val runtimeGraph = GraphViewBuilder(runtimeClasspath).graph
+    val runtimeGraphView = DependencyGraphView(
+      variant = Variant(variant.get(), kind.get()),
+      configurationName = runtimeClasspath.name,
+      graph = runtimeGraph
     )
 
-    output.writeText(graphView.toJson())
-    outputDot.writeText(GraphWriter.toDot(graph))
+    output.writeText(compileGraphView.toJson())
+    outputDot.writeText(GraphWriter.toDot(compileGraph))
+    outputRuntime.writeText(runtimeGraphView.toJson())
+    outputRuntimeDot.writeText(GraphWriter.toDot(runtimeGraph))
   }
 }
diff --git a/src/main/kotlin/com/autonomousapps/tasks/ReasonTask.kt b/src/main/kotlin/com/autonomousapps/tasks/ReasonTask.kt
index b9fc7506..9c6145b5 100644
--- a/src/main/kotlin/com/autonomousapps/tasks/ReasonTask.kt
+++ b/src/main/kotlin/com/autonomousapps/tasks/ReasonTask.kt
@@ -101,20 +101,21 @@ abstract class ReasonTask @Inject constructor(
     private val logger = getLogger<ReasonTask>()
 
     private val projectPath = parameters.projectPath.get()
-    private val coord by unsafeLazy { getRequestedCoordinates() }
-    private val dependencyUsages by unsafeLazy { parameters.dependencyUsageReport.fromJsonMapSet<String, Usage>() }
-    private val annotationProcessorUsages by unsafeLazy { parameters.annotationProcessorUsageReport.fromJsonMapSet<String, Usage>() }
-    private val unfilteredProjectAdvice by unsafeLazy { parameters.unfilteredAdviceReport.fromJson<ProjectAdvice>() }
-    private val finalProjectAdvice by unsafeLazy { parameters.finalAdviceReport.fromJson<ProjectAdvice>() }
+    private val dependencyGraph = parameters.dependencyGraphViews.get()
+      .map { it.fromJson<DependencyGraphView>() }
+      .associateBy { "${it.name},${it.configurationName}" }
+    private val dependencyUsages = parameters.dependencyUsageReport.fromJsonMapSet<String, Usage>()
+    private val annotationProcessorUsages = parameters.annotationProcessorUsageReport.fromJsonMapSet<String, Usage>()
+    private val unfilteredProjectAdvice = parameters.unfilteredAdviceReport.fromJson<ProjectAdvice>()
+    private val finalProjectAdvice = parameters.finalAdviceReport.fromJson<ProjectAdvice>()
+
+    // Derived, compute lazily
     private val finalAdvice by unsafeLazy { findAdviceIn(finalProjectAdvice) }
+    private val coord by unsafeLazy { getRequestedCoordinates() }
     private val unfilteredAdvice by unsafeLazy { findAdviceIn(unfilteredProjectAdvice) }
+    private val usages by unsafeLazy { getUsageFor(coord.gav()) }
 
     override fun execute() {
-      val usages = getUsageFor(coord.gav())
-      val dependencyGraph = parameters.dependencyGraphViews.get()
-        .map { it.fromJson<DependencyGraphView>() }
-        .associateBy { it.name }
-
       val reason = DeepThought(
         project = ProjectCoordinates(projectPath),
         target = coord,
@@ -140,21 +141,28 @@ abstract class ReasonTask @Inject constructor(
       """.trimIndent()
       )
 
+      fun findInGraph(): String? = dependencyGraph.values.asSequence()
+        .flatMap { it.nodes }
+        .map { it.gav() }
+        .find { gav ->
+          gav == id || gav.startsWith(id)
+        }
+
       // Guaranteed to find full GAV or throw
       val gav = dependencyUsages.entries.find(id::equalsKey)?.key
         ?: dependencyUsages.entries.find(id::startsWithKey)?.key
         ?: annotationProcessorUsages.entries.find(id::equalsKey)?.key
         ?: annotationProcessorUsages.entries.find(id::startsWithKey)?.key
+        ?: findInGraph()
         ?: throw InvalidUserDataException("There is no dependency with coordinates '$id' in this project.")
       return Coordinates.of(gav)
     }
 
     private fun getUsageFor(id: String): Set<Usage> {
-      // One of these is guaranteed to be non-null
       return dependencyUsages.entries.find(id::equalsKey)?.value?.toSortedSet(Usage.BY_VARIANT)
         ?: annotationProcessorUsages.entries.find(id::equalsKey)?.value?.toSortedSet(Usage.BY_VARIANT)
-        // This should really be impossible
-        ?: throw InvalidUserDataException("No usage found for coordinates '$id'.")
+        // Will be empty for runtimeOnly dependencies (no detected usages)
+        ?: emptySet()
     }
 
     private fun findAdviceIn(projectAdvice: ProjectAdvice): Advice? {
@@ -198,28 +206,7 @@ abstract class ReasonTask @Inject constructor(
       dependencyGraph.forEach { printGraph(it.value) }
 
       // Usages
-      usages.forEach { usage ->
-        val variant = usage.variant
-
-        appendReproducibleNewLine()
-        sourceText(variant).let { txt ->
-          append(Colors.BOLD)
-          appendReproducibleNewLine(txt)
-          append("-".repeat(txt.length))
-          appendReproducibleNewLine(Colors.NORMAL)
-        }
-
-        val reasons = usage.reasons.filter { it !is Reason.Unused && it !is Reason.Undeclared }
-        val isCompileOnly = reasons.any { it is Reason.CompileTimeAnnotations }
-        reasons.forEach { reason ->
-          append("""* """)
-          val prefix = if (variant.kind == SourceSetKind.MAIN) "" else "test"
-          appendReproducibleNewLine(reason.reason(prefix, isCompileOnly))
-        }
-        if (reasons.isEmpty()) {
-          appendReproducibleNewLine("(no usages)")
-        }
-      }
+      printUsages()
     }
 
     private val bundle = "bundle".colorize(Colors.BOLD)
@@ -293,6 +280,37 @@ abstract class ReasonTask @Inject constructor(
       }
     }
 
+    private fun StringBuilder.printUsages() {
+      if (usages.isEmpty()) {
+        appendReproducibleNewLine()
+        appendReproducibleNewLine("No compile-time usages detected for this runtime-only dependency.")
+        return
+      }
+
+      usages.forEach { usage ->
+        val variant = usage.variant
+
+        appendReproducibleNewLine()
+        sourceText(variant).let { txt ->
+          append(Colors.BOLD)
+          appendReproducibleNewLine(txt)
+          append("-".repeat(txt.length))
+          appendReproducibleNewLine(Colors.NORMAL)
+        }
+
+        val reasons = usage.reasons.filter { it !is Reason.Unused && it !is Reason.Undeclared }
+        val isCompileOnly = reasons.any { it is Reason.CompileTimeAnnotations }
+        reasons.forEach { reason ->
+          append("""* """)
+          val prefix = if (variant.kind == SourceSetKind.MAIN) "" else "test"
+          appendReproducibleNewLine(reason.reason(prefix, isCompileOnly))
+        }
+        if (reasons.isEmpty()) {
+          appendReproducibleNewLine("(no usages)")
+        }
+      }
+    }
+
     private fun ProjectCoordinates.printableName(): String {
       val gav = gav()
       return if (gav == ":") "root project" else gav