All Downloads are FREE. Search and download functionalities are using the official Maven repository.

com.autonomousapps.tasks.ReasonTask.kt Maven / Gradle / Ivy

There is a newer version: 2.6.1
Show newest version
// Copyright (c) 2024. Tony Robalik.
// SPDX-License-Identifier: Apache-2.0
package com.autonomousapps.tasks

import com.autonomousapps.TASK_GROUP_DEP
import com.autonomousapps.extension.DependenciesHandler.Companion.toLambda
import com.autonomousapps.internal.reason.DependencyAdviceExplainer
import com.autonomousapps.internal.reason.ModuleAdviceExplainer
import com.autonomousapps.internal.utils.*
import com.autonomousapps.internal.utils.CoordinatesString.Companion.equalsKey
import com.autonomousapps.internal.utils.CoordinatesString.Companion.firstCoordinatesKeySegment
import com.autonomousapps.internal.utils.CoordinatesString.Companion.matchesKey
import com.autonomousapps.internal.utils.CoordinatesString.Companion.secondCoordinatesKeySegment
import com.autonomousapps.internal.utils.strings.replaceExceptLast
import com.autonomousapps.model.*
import com.autonomousapps.model.Coordinates.Companion.copy
import com.autonomousapps.model.internal.DependencyGraphView
import com.autonomousapps.model.internal.intermediates.BundleTrace
import com.autonomousapps.model.internal.intermediates.Usage
import org.gradle.api.DefaultTask
import org.gradle.api.InvalidUserDataException
import org.gradle.api.file.RegularFile
import org.gradle.api.file.RegularFileProperty
import org.gradle.api.provider.ListProperty
import org.gradle.api.provider.MapProperty
import org.gradle.api.provider.Property
import org.gradle.api.tasks.*
import org.gradle.api.tasks.options.Option
import org.gradle.workers.WorkAction
import org.gradle.workers.WorkParameters
import org.gradle.workers.WorkerExecutor
import javax.inject.Inject

abstract class ReasonTask @Inject constructor(
  private val workerExecutor: WorkerExecutor,
) : DefaultTask() {

  init {
    group = TASK_GROUP_DEP
    description = "Explain how a dependency is used"
  }

  @get:Input
  abstract val rootProjectName: Property

  @get:Input
  abstract val projectPath: Property

  /**
   * The dependency identifier or GAV coordinates being queried. By default, reports on the main [capability].
   *
   * See also [capability] and [module].
   */
  @get:Optional
  @get:Input
  @set:Option(
    option = "id",
    description = "The dependency you'd like to reason about (com.foo:bar:1.0 or :other:module)"
  )
  var id: String? = null

  /**
   * The capability to be queried. If not specified, defaults to main capability.
   *
   * See also [id].
   */
  @get:Optional
  @get:Input
  @set:Option(
    option = "capability",
    description = "The capability you're interested in. Defaults to main capability. A typical option is 'test-fixtures'"
  )
  var capability: String? = null

  /**
   * The category of module-structure advice to query for. Only available option at this time is 'android'.
   *
   * See also [id].
   */
  @get:Optional
  @get:Input
  @set:Option(
    option = "module",
    description = "The module-structure-related advice you'd like more insight into ('android')"
  )
  var module: String? = null

  @get:Input
  abstract val dependencyMap: MapProperty

  @get:PathSensitive(PathSensitivity.NONE)
  @get:InputFile
  abstract val dependencyUsageReport: RegularFileProperty

  @get:PathSensitive(PathSensitivity.NONE)
  @get:InputFile
  abstract val annotationProcessorUsageReport: RegularFileProperty

  @get:PathSensitive(PathSensitivity.NONE)
  @get:InputFile
  abstract val unfilteredAdviceReport: RegularFileProperty

  @get:PathSensitive(PathSensitivity.NONE)
  @get:InputFile
  abstract val finalAdviceReport: RegularFileProperty

  @get:PathSensitive(PathSensitivity.NONE)
  @get:InputFile
  abstract val bundleTracesReport: RegularFileProperty

  @get:PathSensitive(PathSensitivity.RELATIVE)
  @get:InputFiles
  abstract val dependencyGraphViews: ListProperty

  // TODO InputDirectory of all dependencies for finding capabilities

  @TaskAction fun action() {
    val options = options()

    // Explain dependency advice
    options.id?.let { dependency ->
      workerExecutor.noIsolation().submit(ExplainDependencyAdviceAction::class.java) {
        id.set(dependency)
        capability.set(options.capability ?: "")
        rootProjectName.set([email protected])
        projectPath.set([email protected])
        dependencyMap.set([email protected])
        dependencyUsageReport.set([email protected])
        annotationProcessorUsageReport.set([email protected])
        unfilteredAdviceReport.set([email protected])
        finalAdviceReport.set([email protected])
        bundleTracesReport.set([email protected])
        dependencyGraphViews.set([email protected])
      }
    }

    // Explain module structure advice
    options.module?.let { moduleStructure ->
      workerExecutor.noIsolation().submit(ExplainModuleAdviceAction::class.java) {
        module.set(moduleStructure)
        projectPath.set([email protected])
        unfilteredAdviceReport.set([email protected])
        finalAdviceReport.set([email protected])
      }
    }
  }

  private fun options(): Options {
    val id = id
    val module = module
    val capability = capability

    // One of these must be non-null, or there is no valid request.
    if (id == null && module == null) {
      throw InvalidUserDataException(help())
    }

    // capability only makes sense if the user also is making an id request.
    if (capability != null && id == null) {
      throw InvalidUserDataException(help())
    }

    return Options(id = id, capability = capability, module = module)
  }

  private class Options(
    val id: String?,
    val capability: String?,
    val module: String?,
  )

  private fun help() = projectPath.get().let { path ->
    """
      You must call 'reason' with either the `--id` or `--module` option, or both.
      You may also specify a `--capability`, but this only influences the results of an `--id` query.
      
      Usage for --id:
        ./gradlew ${path}:reason --id com.foo:bar:1.0
        ./gradlew ${path}:reason --id com.foo:bar
        ./gradlew ${path}:reason --id :other:module
        ./gradlew ${path}:reason --id  --capability test-fixtures
        
      For external dependencies, the version is optional.
      Capability is optional. If unspecified, defaults to main capability.
      
      Usage for --module:
        ./gradlew ${path}:reason --module android
    """.trimIndent()
  }

  interface ExplainDependencyAdviceParams : WorkParameters {
    val id: Property
    val capability: Property
    val rootProjectName: Property
    val projectPath: Property
    val dependencyMap: MapProperty
    val dependencyUsageReport: RegularFileProperty
    val annotationProcessorUsageReport: RegularFileProperty
    val unfilteredAdviceReport: RegularFileProperty
    val finalAdviceReport: RegularFileProperty
    val bundleTracesReport: RegularFileProperty
    val dependencyGraphViews: ListProperty
  }

  abstract class ExplainDependencyAdviceAction : WorkAction {

    private val logger = getLogger()

    private val capability = parameters.capability.get()
    private val rootProjectName = parameters.rootProjectName.get()
    private val projectPath = parameters.projectPath.get()
    private val dependencyGraph = parameters.dependencyGraphViews.get()
      .map { it.fromJson() }
      .associateBy { "${it.name},${it.configurationName}" }
    private val unfilteredProjectAdvice = parameters.unfilteredAdviceReport.fromJson()
    private val finalProjectAdvice = parameters.finalAdviceReport.fromJson()
    private val dependencyMap = parameters.dependencyMap.get().toLambda()

    private val dependencyUsages = parameters.dependencyUsageReport.fromJsonMapSet()
    private val annotationProcessorUsages = parameters.annotationProcessorUsageReport.fromJsonMapSet()

    // Derived from the above
    private val targetCoord = getRequestedCoordinates(true)
    private val requestedCoord = getRequestedCoordinates(false)
    private val finalAdvice = findAdviceIn(finalProjectAdvice)
    private val unfilteredAdvice = findAdviceIn(unfilteredProjectAdvice)
    private val usages = getUsageFor(targetCoord)

    override fun execute() {
      val reason = DependencyAdviceExplainer(
        rootProjectName = rootProjectName,
        project = ProjectCoordinates(projectPath, GradleVariantIdentification(setOf("ROOT"), emptyMap()), ":"),
        requested = requestedCoord,
        target = targetCoord,
        requestedCapability = capability,
        usages = usages,
        advice = finalAdvice,
        dependencyGraph = dependencyGraph,
        bundleTraces = bundleTraces(),
        wasFiltered = wasFiltered(),
        dependencyMap = dependencyMap,
      ).computeReason()

      logger.quiet(reason)
    }

    /**
     * Returns the requested ID as [Coordinates], even if user passed in a prefix.
     *
     * `normalized == true` to return 'group:coordinate' notation even if the user requested :project-path notation.
     */
    private fun getRequestedCoordinates(normalize: Boolean): Coordinates {
      val requestedId = parameters.id.get()
      val requestedCapability = capability
      val requestedViaProjectPath = requestedId.startsWith(":")

      fun findInGraph(): String? = dependencyGraph.values.asSequence()
        .flatMap { it.nodes }
        .find { coordinates ->
          val gav = coordinates.gav()
          gav == requestedId
            || gav.startsWith("$requestedId:")
            || dependencyMap(gav) == requestedId
            || dependencyMap(coordinates.identifier) == requestedId
        }?.gav()

      // Guaranteed to find full GAV or throw
      val gavKey = findFilteredDependencyKey(dependencyUsages.entries, requestedId)
        ?: findFilteredDependencyKey(annotationProcessorUsages.entries, requestedId)
        ?: findInGraph()
        ?: throw InvalidUserDataException("There is no dependency with coordinates '$requestedId' in this project.")

      val gav = if (requestedViaProjectPath && !normalize) {
        secondCoordinatesKeySegment(gavKey) ?: gavKey
      } else {
        firstCoordinatesKeySegment(gavKey)
      }

      val capabilitySuffix = if (requestedCapability.isEmpty()) {
        ""
      } else if (requestedCapability == "testFixtures") {
        "-test-fixtures"
      } else {
        "-$requestedCapability"
      }

      val baseCapability = CoordinatesString.of(gavKey).capabilities?.singleOrNull() ?: ""
      val syntheticCapability = "$baseCapability$capabilitySuffix"

      val includedBuildId = if (requestedId.count { it == ':' } == 1) {
        "$rootProjectName$requestedId"
      } else {
        "$rootProjectName${requestedId.replaceExceptLast(":", ".")}"
      }

      // In this first case, we have a synthetic IncludedBuildCoordinates that really points to a local project in the
      // same (main) build.
      return if (gav == includedBuildId) {
        val gradleVariantIdentification = GradleVariantIdentification(
          capabilities = setOf(syntheticCapability),
          attributes = emptyMap(),
        )

        IncludedBuildCoordinates(
          identifier = includedBuildId,
          resolvedProject = ProjectCoordinates(
            identifier = requestedId,
            gradleVariantIdentification = gradleVariantIdentification,
            buildPath = ":",
          ),
          gradleVariantIdentification = gradleVariantIdentification,
        )
      } else {
        val capabilities = if (syntheticCapability.isNotEmpty()) {
          setOf(syntheticCapability)
        } else {
          emptySet()
        }

        val coord = Coordinates.of(gav)
        coord.copy(
          gradleVariantIdentification = GradleVariantIdentification(
            capabilities = capabilities.ifEmpty { setOf(coord.identifier) },
            attributes = emptyMap(),
          )
        )
      }
    }

    private fun getUsageFor(coordinates: Coordinates): Set {
      // First check regular dependencies
      return dependencyUsages.entries.find { entry ->
        CoordinatesString.of(entry.key).matches(coordinates)
      }?.value?.softSortedSet(Usage.BY_VARIANT)
      // Then check annotation processors
        ?: annotationProcessorUsages.entries.find { entry ->
          CoordinatesString.of(entry.key).matches(coordinates)
        }?.value?.softSortedSet(Usage.BY_VARIANT)
        // Will be empty for runtimeOnly dependencies (no detected usages)
        ?: emptySet()
    }

    /** Returns null if there is no advice for the given id. */
    private fun findAdviceIn(projectAdvice: ProjectAdvice): Advice? {
      return projectAdvice.dependencyAdvice.find { advice ->
        val adviceGav = advice.coordinates.gav()
        adviceGav == targetCoord.gav() || adviceGav == requestedCoord.gav()
      }
    }

    // TODO: I think for any target, there's only 0 or 1 trace?
    /** Find all bundle traces where the [BundleTrace.top] or [BundleTrace.bottom] is [targetCoord]. */
    private fun bundleTraces(): Set {
      return parameters.bundleTracesReport.fromJsonSet().filterToSet {
        it.top.gav() == targetCoord.gav() || it.bottom.gav() == targetCoord.gav()
      }
    }

    private fun wasFiltered(): Boolean = finalAdvice == null && unfilteredAdvice != null

    internal companion object {
      internal fun findFilteredDependencyKey(dependencies: Set>, requestedId: String): String? {
        val filteredKeys = LinkedHashSet()
        for (entry in dependencies) {
          if (equalsKey(requestedId, entry)) {
            // for exact equal - return immediately
            return entry.key
          }
          if (matchesKey(requestedId, entry)) {
            filteredKeys.add(CoordinatesString.of(entry.key).fullGav())
          }
        }

        return if (filteredKeys.isEmpty()) {
          null
        } else if (filteredKeys.size == 1) {
          filteredKeys.iterator().next()
        } else {
          throw InvalidUserDataException(
            "Coordinates '$requestedId' matches more than 1 dependency " +
              "${filteredKeys.map { secondCoordinatesKeySegment(it) ?: it }}"
          )
        }
      }
    }
  }

  interface ExplainModuleAdviceParams : WorkParameters {
    val module: Property
    val projectPath: Property
    val unfilteredAdviceReport: RegularFileProperty
    val finalAdviceReport: RegularFileProperty
  }

  abstract class ExplainModuleAdviceAction : WorkAction {

    private val logger = getLogger()

    private val projectPath = parameters.projectPath.get()
    private val module = parameters.module.get()
    private val unfilteredAndroidScore = parameters.unfilteredAdviceReport
      .fromJson()
      .moduleAdvice
      .filterIsInstance()
      .singleOrNull()
    private val finalAndroidScore = parameters.finalAdviceReport
      .fromJson()
      .moduleAdvice
      .filterIsInstance()
      .singleOrNull()

    override fun execute() {
      validateModuleOption()
      val reason = ModuleAdviceExplainer(
        project = ProjectCoordinates(projectPath, GradleVariantIdentification.EMPTY),
        unfilteredAndroidScore = unfilteredAndroidScore,
        finalAndroidScore = finalAndroidScore,
      ).computeReason()

      logger.quiet(reason)
    }

    private fun validateModuleOption() {
      if (module != "android") {
        throw InvalidUserDataException(
          "'$module' unexpected. The only valid option for '--module' at this time is 'android'."
        )
      }
    }
  }

  internal interface Explainer {
    fun computeReason(): String
  }
}




© 2015 - 2025 Weber Informatics LLC | Privacy Policy