diff --git a/README.asciidoc b/README.asciidoc index d77909e17..e159271b3 100644 --- a/README.asciidoc +++ b/README.asciidoc @@ -133,6 +133,20 @@ dependencyAnalysis { } ---- +If you wish to this report exported as the SARIF output, add this to the `dependencyAnalysis` DSL: + +.build.gradle.kts +[source,kotlin] +---- +dependencyAnalysis { + reporting { + sarifReport(true) + } +} +---- + +Above will generate `build/dependency-analysis/build-health-report.sarif` file on report. + == Repositories From 2.19.0 for releases, and 2.18.1-SNAPSHOT for snapshots, this plugin uses https://central.sonatype.com. To add this diff --git a/build.gradle.kts b/build.gradle.kts index 691d561d4..e2c93fbac 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -129,6 +129,7 @@ dependencies { } implementation(libs.relocated.antlr) implementation(libs.relocated.asm) + implementation(libs.sarif4k) runtimeOnly(libs.kotlin.reflect) { because("For Kotlin ABI analysis") diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 606e72541..a2479359d 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -37,6 +37,7 @@ moshix = "0.30.0" #moshix = "0.31.0" okio = "3.16.4" shadow = "8.3.9" +sarif4k = "0.6.0" spock = "2.3-groovy-4.0" truth = "1.4.5" @@ -83,6 +84,7 @@ okio-bom = { module = "com.squareup.okio:okio-bom", version.ref = "okio" } relocated-antlr = { module = "com.autonomousapps:antlr", version.ref = "antlr-shadowed" } relocated-asm = { module = "com.autonomousapps:asm-relocated", version.ref = "asm-relocated" } shadowGradlePlugin = { module = "com.gradleup.shadow:com.gradleup.shadow.gradle.plugin", version.ref = "shadow" } +sarif4k = { module = "io.github.detekt.sarif4k:sarif4k", version.ref = "sarif4k" } spock = { module = "org.spockframework:spock-core", version.ref = "spock" } truth = { module = "com.google.truth:truth", version.ref = "truth" } diff --git a/src/main/kotlin/com/autonomousapps/DependencyAnalysisSubExtension.kt b/src/main/kotlin/com/autonomousapps/DependencyAnalysisSubExtension.kt index 8dafaef7d..0b0d862a7 100644 --- a/src/main/kotlin/com/autonomousapps/DependencyAnalysisSubExtension.kt +++ b/src/main/kotlin/com/autonomousapps/DependencyAnalysisSubExtension.kt @@ -5,6 +5,7 @@ package com.autonomousapps import com.autonomousapps.extension.AbiHandler import com.autonomousapps.extension.DependenciesHandler import com.autonomousapps.extension.ProjectIssueHandler +import com.autonomousapps.extension.ReportingHandler import org.gradle.api.Action import org.gradle.api.Project import javax.naming.OperationNotSupportedException @@ -53,6 +54,11 @@ public abstract class DependencyAnalysisSubExtension( throw OperationNotSupportedException("Dependency bundles must be declared in the root project only") } + /** Customize issue reports. See [ReportingHandler] for more information. */ + public fun reporting(action: Action) { + action.execute(reportingHandler) + } + internal companion object { fun of(project: Project): DependencyAnalysisSubExtension { return project.extensions.create(NAME, DependencyAnalysisSubExtension::class.java, project) diff --git a/src/main/kotlin/com/autonomousapps/extension/ReportingHandler.kt b/src/main/kotlin/com/autonomousapps/extension/ReportingHandler.kt index 05876bdb1..cd379aecd 100644 --- a/src/main/kotlin/com/autonomousapps/extension/ReportingHandler.kt +++ b/src/main/kotlin/com/autonomousapps/extension/ReportingHandler.kt @@ -6,6 +6,8 @@ import org.gradle.api.model.ObjectFactory import org.gradle.api.provider.Property import org.gradle.api.tasks.Input import javax.inject.Inject +import org.gradle.api.file.RegularFileProperty +import org.jetbrains.kotlin.konan.file.File /** * Customize issue reports. @@ -31,6 +33,9 @@ public abstract class ReportingHandler @Inject constructor(private val objects: // value of the Gradle property, which itself supplies a default value. internal val printBuildHealth: Property = objects.property(Boolean::class.java) + internal val sarifReport: Property = objects.property(Boolean::class.java) + .convention(false) + /** * Whether to always include the postscript, or only when the report includes failure-level issues. */ @@ -57,6 +62,16 @@ public abstract class ReportingHandler @Inject constructor(private val objects: this.printBuildHealth.disallowChanges() } + /** + * Whether to generate a .sarif file report + */ + public fun sarifReport(report: Boolean) { + if (this.sarifReport.get() != report) { + this.sarifReport.set(report) + this.sarifReport.disallowChanges() + } + } + internal fun config(): Config { val config = objects.newInstance(Config::class.java) config.onlyOnFailure.set(onlyOnFailure) diff --git a/src/main/kotlin/com/autonomousapps/internal/OutputPaths.kt b/src/main/kotlin/com/autonomousapps/internal/OutputPaths.kt index 32ab265d8..98afa7b56 100644 --- a/src/main/kotlin/com/autonomousapps/internal/OutputPaths.kt +++ b/src/main/kotlin/com/autonomousapps/internal/OutputPaths.kt @@ -91,10 +91,12 @@ internal class NoVariantOutputPaths(private val project: Project) { */ val unfilteredAdvicePath = file("$ROOT_DIR/unfiltered-advice.json") + val unfilteredSourcedAdvicePath = file("$ROOT_DIR/unfiltered-sourced-advice.json") val bundledTracesPath = file("$ROOT_DIR/bundled-traces.json") val dependencyUsagesPath = file("$ROOT_DIR/usages-dependencies.json") val annotationProcessorUsagesPath = file("$ROOT_DIR/usages-annotation-processors.json") val filteredAdvicePath = file("$ROOT_DIR/final-advice.json") + val filteredSourcedAdvicePath = file("$ROOT_DIR/final-sourced-advice.json") val consoleReportPath = file("$ROOT_DIR/project-health-report.txt") } @@ -111,6 +113,7 @@ internal class RootOutputPaths(private val project: Project) { val consoleReportPath = file("$ROOT_DIR/build-health-report.txt") val allLibsVersionsTomlPath = file("$ROOT_DIR/allLibs.versions.toml") val shouldFailPath = file("$ROOT_DIR/should-fail.txt") + val sarifReportPath = file("$ROOT_DIR/build-health-report.sarif") val workPlanDir = dir("$ROOT_DIR/work-plan") } diff --git a/src/main/kotlin/com/autonomousapps/internal/advice/AdvicePrinter.kt b/src/main/kotlin/com/autonomousapps/internal/advice/AdvicePrinter.kt index a662aca1d..c8edc2dc2 100644 --- a/src/main/kotlin/com/autonomousapps/internal/advice/AdvicePrinter.kt +++ b/src/main/kotlin/com/autonomousapps/internal/advice/AdvicePrinter.kt @@ -31,6 +31,9 @@ internal class AdvicePrinter( fun toDeclaration(advice: Advice): String = " ${advice.toConfiguration}${gav(advice.coordinates)}" + fun fromDeclaration(advice: Advice): String = + " ${advice.fromConfiguration}${gav(advice.coordinates)}" + fun gav(coordinates: Coordinates): String { val quotedDep = coordinates.mapped() @@ -106,4 +109,4 @@ internal class AdvicePrinter( } } } -} \ No newline at end of file +} diff --git a/src/main/kotlin/com/autonomousapps/internal/advice/ProjectHealthSarifReportBuilder.kt b/src/main/kotlin/com/autonomousapps/internal/advice/ProjectHealthSarifReportBuilder.kt new file mode 100644 index 000000000..af5e22ce0 --- /dev/null +++ b/src/main/kotlin/com/autonomousapps/internal/advice/ProjectHealthSarifReportBuilder.kt @@ -0,0 +1,177 @@ +// Copyright (c) 2025. Tony Robalik. +// SPDX-License-Identifier: Apache-2.0 +package com.autonomousapps.internal.advice + +import com.autonomousapps.model.SourcedProjectAdvice +import io.github.detekt.sarif4k.* + +internal class ProjectHealthSarifReportBuilder( + projectAdvices: Collection, + dslKind: DslKind, + /** Customize how dependencies are printed. */ + dependencyMap: ((String) -> String?)? = null, + useTypesafeProjectAccessors: Boolean, +) { + + val sarif: SarifSchema210 + + private val advicePrinter = AdvicePrinter(dslKind, dependencyMap, useTypesafeProjectAccessors) + + init { + val pluginResults = projectAdvices.flatMap { projectAdvice -> + projectAdvice.pluginAdvice.map { advice -> + val location = projectAdvice.projectBuildFile?.let { buildFile -> + Location( + physicalLocation = PhysicalLocation( + artifactLocation = ArtifactLocation(uri = buildFile), + ), + ) + } + + Result( + locations = listOfNotNull(location), + message = Message(text = "Pluigin ${advice.redundantPlugin} should be removed: ${advice.reason}"), + ruleID = "dependencyAnalysis.Plugin" + ) + } + + } + val dependencyResults = projectAdvices.flatMap { projectAdvice -> + projectAdvice.dependencyAdvice.map { sourcedAdvice -> + val message: String + val ruleId: String + + val advice = sourcedAdvice.advice + when { + advice.isAdd() -> { + message = "Transitive dependency ${advicePrinter.toDeclaration(advice).trim()} should be declared directly" + ruleId = "dependencyanalysis.Add" + } + + advice.isRemove() -> { + message = "Unused dependency ${advicePrinter.fromDeclaration(advice).trim()} should be removed" + ruleId = "dependencyanalysis.Remove" + } + + advice.isChange() -> { + message = + "Dependency ${ + advicePrinter.fromDeclaration(advice).trim() + } should be modified to ${advice.toConfiguration} from ${advice.fromConfiguration}" + ruleId = "dependencyanalysis.Change" + } + + advice.isChangeToRuntimeOnly() -> { + message = + "Dependency ${advicePrinter.fromDeclaration(advice).trim()} should be removed or changed to runtime-only" + ruleId = "dependencyanalysis.ChangeRuntimeOnly" + } + + advice.isCompileOnly() -> { + message = "Dependency ${advicePrinter.fromDeclaration(advice).trim()} should be changed to compile-only" + ruleId = "dependencyanalysis.ChangeCompileOnly" + } + + advice.isProcessor() -> { + message = "Unused annotation processor ${advicePrinter.fromDeclaration(advice).trim()} should be removed" + ruleId = "dependencyanalysis.Processpr" + } + + else -> { + error("Unknown advice type: $advice") + } + } + + val location = projectAdvice.projectBuildFile?.let { buildFile -> + Location( + physicalLocation = PhysicalLocation( + artifactLocation = ArtifactLocation(uri = buildFile), + region = + Region( + startLine = sourcedAdvice.buildFileDeclarationLineNumber?.toLong(), + endLine = sourcedAdvice.buildFileDeclarationLineNumber?.toLong(), + ) + ), + ) + } + + Result( + locations = listOfNotNull(location), + message = Message(text = message), + ruleID = ruleId + ) + } + } + + sarif = SarifSchema210( + schema = "https://docs.oasis-open.org/sarif/sarif/v2.1.0/errata01/os/schemas/sarif-schema-2.1.0.json", + version = Version.The210, + runs = listOf( + Run( + results = dependencyResults + pluginResults, + tool = SARIF_TOOL, + ) + ) + ) + } +} + +private val SARIF_TOOL = Tool( + driver = ToolComponent( + guid = "f9137358-f4fb-44f2-8300-39ca0b85fb77", + informationURI = "https://github.com/autonomousapps/dependency-analysis-gradle-plugin", + language = "en", + name = "dependency-analysis-gradle-plugin", + rules = listOf( + ReportingDescriptor( + id = "dependencyanalysis.Add", + name = "Add", + shortDescription = MultiformatMessageString( + text = "These transitive dependencies should be declared directly" + ) + ), + ReportingDescriptor( + id = "dependencyanalysis.Remove", + name = "Remove", + shortDescription = MultiformatMessageString( + text = "Unused dependencies which should be removed" + ) + ), + ReportingDescriptor( + id = "dependencyanalysis.Change", + name = "Change", + shortDescription = MultiformatMessageString( + text = "Existing dependencies which should be modified to be as indicated" + ) + ), + ReportingDescriptor( + id = "dependencyanalysis.ChangeRuntimeOnly", + name = "ChangeRuntimeOnly", + shortDescription = MultiformatMessageString( + text = "Dependencies which should be removed or changed to runtime-only" + ) + ), + ReportingDescriptor( + id = "dependencyanalysis.ChangeCompileOnly", + name = "ChangeCompileOnly", + shortDescription = MultiformatMessageString( + text = "Dependencies which could be compile-only" + ) + ), + ReportingDescriptor( + id = "dependencyanalysis.Processor", + name = "Processor", + shortDescription = MultiformatMessageString( + text = "Unused annotation processors that should be removed" + ) + ), + ReportingDescriptor( + id = "dependencyanalysis.Plugin", + name = "Plugin", + shortDescription = MultiformatMessageString( + text = "Unused plugins that can be removed" + ) + ), + ), + ) +) diff --git a/src/main/kotlin/com/autonomousapps/internal/artifacts/DagpArtifacts.kt b/src/main/kotlin/com/autonomousapps/internal/artifacts/DagpArtifacts.kt index cfd3df992..ddc93bb96 100644 --- a/src/main/kotlin/com/autonomousapps/internal/artifacts/DagpArtifacts.kt +++ b/src/main/kotlin/com/autonomousapps/internal/artifacts/DagpArtifacts.kt @@ -17,6 +17,7 @@ internal interface DagpArtifacts : Named { enum class Kind : ArtifactDescription { COMBINED_GRAPH, PROJECT_HEALTH, + SOURCED_PROJECT_HEALTH, RESOLVED_DEPS, ; diff --git a/src/main/kotlin/com/autonomousapps/internal/transform/StandardTransform.kt b/src/main/kotlin/com/autonomousapps/internal/transform/StandardTransform.kt index 91f117eaa..e5d999d5c 100644 --- a/src/main/kotlin/com/autonomousapps/internal/transform/StandardTransform.kt +++ b/src/main/kotlin/com/autonomousapps/internal/transform/StandardTransform.kt @@ -417,7 +417,7 @@ internal class StandardTransform( advice += Advice.ofChange( coordinates = theRemove.coordinates, fromConfiguration = theRemove.fromConfiguration!!, - toConfiguration = theAdd.toConfiguration!! + toConfiguration = theAdd.toConfiguration!!, ) } } diff --git a/src/main/kotlin/com/autonomousapps/internal/utils/utils.kt b/src/main/kotlin/com/autonomousapps/internal/utils/utils.kt index 6901e7f90..10c086389 100644 --- a/src/main/kotlin/com/autonomousapps/internal/utils/utils.kt +++ b/src/main/kotlin/com/autonomousapps/internal/utils/utils.kt @@ -41,6 +41,15 @@ internal fun RegularFileProperty.getAndDelete(): File { return file } +/** + * Resolves the file from the property (if it is declared) and deletes its contents, then returns the file. + */ +internal fun RegularFileProperty.getAndDeleteNullable(): File? { + val file = orNull?.asFile + file?.delete() + return file +} + /** * Resolves the file from the provider and deletes its contents, then returns the file. */ diff --git a/src/main/kotlin/com/autonomousapps/model/Advice.kt b/src/main/kotlin/com/autonomousapps/model/Advice.kt index 01a65f517..49ae96a54 100644 --- a/src/main/kotlin/com/autonomousapps/model/Advice.kt +++ b/src/main/kotlin/com/autonomousapps/model/Advice.kt @@ -22,7 +22,7 @@ public data class Advice( * The configuration on which the dependency _should_ be declared. Will be null if the dependency is unused and * therefore ought to be removed. */ - val toConfiguration: String? = null + val toConfiguration: String? = null, ) : Comparable { override fun compareTo(other: Advice): Int = compareBy(Advice::coordinates) @@ -32,24 +32,37 @@ public data class Advice( public companion object { @JvmStatic - public fun ofAdd(coordinates: Coordinates, toConfiguration: String): Advice = Advice( + public fun ofAdd( + coordinates: Coordinates, + toConfiguration: String, + declarationLineNumber: Int? = null + ): Advice = Advice( coordinates = coordinates, fromConfiguration = null, - toConfiguration = toConfiguration + toConfiguration = toConfiguration, ) @JvmStatic - public fun ofRemove(coordinates: Coordinates, fromConfiguration: String): Advice = Advice( + public fun ofRemove( + coordinates: Coordinates, + fromConfiguration: String, + declarationLineNumber: Int? = null + ): Advice = Advice( coordinates = coordinates, - fromConfiguration = fromConfiguration, toConfiguration = null + fromConfiguration = fromConfiguration, + toConfiguration = null, ) @JvmStatic - internal fun ofRemove(coordinates: Coordinates, declaration: Declaration) = - ofRemove(coordinates, declaration.configurationName) + internal fun ofRemove(coordinates: Coordinates, declaration: Declaration, declarationLineNumber: Int? = null) = + ofRemove(coordinates, declaration.configurationName, declarationLineNumber) @JvmStatic - public fun ofChange(coordinates: Coordinates, fromConfiguration: String, toConfiguration: String): Advice { + public fun ofChange( + coordinates: Coordinates, + fromConfiguration: String, + toConfiguration: String, + ): Advice { require(fromConfiguration != toConfiguration) { "Change advice for ${coordinates.identifier} cannot be from and to the same configuration ($fromConfiguration in this case)" } @@ -57,7 +70,7 @@ public data class Advice( return Advice( coordinates = coordinates, fromConfiguration = fromConfiguration, - toConfiguration = toConfiguration + toConfiguration = toConfiguration, ) } } diff --git a/src/main/kotlin/com/autonomousapps/model/ProjectAdvice.kt b/src/main/kotlin/com/autonomousapps/model/ProjectAdvice.kt index bbbc1ebf0..c6382c8ef 100644 --- a/src/main/kotlin/com/autonomousapps/model/ProjectAdvice.kt +++ b/src/main/kotlin/com/autonomousapps/model/ProjectAdvice.kt @@ -14,7 +14,7 @@ public data class ProjectAdvice( val moduleAdvice: Set = emptySet(), val warning: Warning = Warning.empty(), /** True if there is any advice in a category for which the user has declared they want the build to fail. */ - val shouldFail: Boolean = false + val shouldFail: Boolean = false, ) : Comparable { /** Returns true if this has no advice, nor any warnings. */ diff --git a/src/main/kotlin/com/autonomousapps/model/SourcedAdvice.kt b/src/main/kotlin/com/autonomousapps/model/SourcedAdvice.kt new file mode 100644 index 000000000..d2d00fb87 --- /dev/null +++ b/src/main/kotlin/com/autonomousapps/model/SourcedAdvice.kt @@ -0,0 +1,16 @@ +// Copyright (c) 2025. Tony Robalik. +// SPDX-License-Identifier: Apache-2.0 +package com.autonomousapps.model + +import com.squareup.moshi.JsonClass + + +/** + * Wrapper for advice class with source data attached + * (e.g. in which line of the build file does the source of this advice originate) + */ +@JsonClass(generateAdapter = false) +public data class SourcedAdvice( + val advice: Advice, + val buildFileDeclarationLineNumber: Int? = null, +) diff --git a/src/main/kotlin/com/autonomousapps/model/SourcedProjectAdvice.kt b/src/main/kotlin/com/autonomousapps/model/SourcedProjectAdvice.kt new file mode 100644 index 000000000..1ab78e649 --- /dev/null +++ b/src/main/kotlin/com/autonomousapps/model/SourcedProjectAdvice.kt @@ -0,0 +1,17 @@ +// Copyright (c) 2025. Tony Robalik. +// SPDX-License-Identifier: Apache-2.0 +package com.autonomousapps.model + +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = false) +public data class SourcedProjectAdvice( + val projectPath: String, + val dependencyAdvice: Set = emptySet(), + val pluginAdvice: Set = emptySet(), + val moduleAdvice: Set = emptySet(), + val warning: Warning = Warning.empty(), + /** True if there is any advice in a category for which the user has declared they want the build to fail. */ + val shouldFail: Boolean = false, + val projectBuildFile: String? = null, +) diff --git a/src/main/kotlin/com/autonomousapps/subplugin/ProjectPlugin.kt b/src/main/kotlin/com/autonomousapps/subplugin/ProjectPlugin.kt index 3bebd2569..dd4844c08 100644 --- a/src/main/kotlin/com/autonomousapps/subplugin/ProjectPlugin.kt +++ b/src/main/kotlin/com/autonomousapps/subplugin/ProjectPlugin.kt @@ -13,6 +13,7 @@ import com.autonomousapps.Flags.checkBinaryCompat import com.autonomousapps.Flags.projectPathRegex import com.autonomousapps.Flags.shouldAnalyzeTests import com.autonomousapps.artifacts.Publisher.Companion.interProjectPublisher +import com.autonomousapps.extension.DependenciesHandler import com.autonomousapps.internal.AbiExclusions import com.autonomousapps.internal.NoVariantOutputPaths import com.autonomousapps.internal.UsagesExclusions @@ -101,6 +102,11 @@ internal class ProjectPlugin(private val project: Project) { project = project, artifactDescription = DagpArtifacts.Kind.PROJECT_HEALTH, ) + + private val sourcedProjectHealthPublisher = interProjectPublisher( + project = project, + artifactDescription = DagpArtifacts.Kind.SOURCED_PROJECT_HEALTH, + ) private val resolvedDependenciesPublisher = interProjectPublisher( project = project, artifactDescription = DagpArtifacts.Kind.RESOLVED_DEPS, @@ -598,7 +604,7 @@ internal class ProjectPlugin(private val project: Project) { if (pluginManager.hasPlugin(SPRING_BOOT_PLUGIN)) { logger.warn( "(dependency analysis) You have both java-library and org.springframework.boot applied. You probably " + - "want java, not java-library." + "want java, not java-library." ) } @@ -745,14 +751,14 @@ internal class ProjectPlugin(private val project: Project) { // Lists the dependencies declared for running the project, along with their physical artifacts (jars). val artifactsReportRuntime = tasks.register("artifactsReportRuntime$taskNameSuffix", ArtifactsReportTask::class.java) { - it.setConfiguration(configurations.named(dependencyAnalyzer.runtimeConfigurationName)) { c -> - c.artifactsFor(dependencyAnalyzer.attributeValueJar) - } - it.buildPath.set(buildPath(dependencyAnalyzer.runtimeConfigurationName)) + it.setConfiguration(configurations.named(dependencyAnalyzer.runtimeConfigurationName)) { c -> + c.artifactsFor(dependencyAnalyzer.attributeValueJar) + } + it.buildPath.set(buildPath(dependencyAnalyzer.runtimeConfigurationName)) - it.output.set(outputPaths.runtimeArtifactsPath) - it.excludedIdentifiersOutput.set(outputPaths.excludedIdentifiersRuntimePath) - } + it.output.set(outputPaths.runtimeArtifactsPath) + it.excludedIdentifiersOutput.set(outputPaths.excludedIdentifiersRuntimePath) + } // Produce a DAG of the compile and runtime classpaths rooted on this project. val graphViewTask = tasks.register("graphView$taskNameSuffix", GraphViewTask::class.java) { @@ -837,22 +843,22 @@ internal class ProjectPlugin(private val project: Project) { // Generates graph view of local (project) dependencies val generateProjectGraphTask = tasks.register("generateProjectGraph$taskNameSuffix", GenerateProjectGraphTask::class.java) { - it.buildPath.set(buildPath(dependencyAnalyzer.compileConfigurationName)) + it.buildPath.set(buildPath(dependencyAnalyzer.compileConfigurationName)) - it.compileClasspath.set( - configurations.getByName(dependencyAnalyzer.compileConfigurationName) - .incoming - .resolutionResult - .rootComponent - ) - it.runtimeClasspath.set( - configurations.getByName(dependencyAnalyzer.runtimeConfigurationName) - .incoming - .resolutionResult - .rootComponent - ) - it.output.set(outputPaths.projectGraphDir) - } + it.compileClasspath.set( + configurations.getByName(dependencyAnalyzer.compileConfigurationName) + .incoming + .resolutionResult + .rootComponent + ) + it.runtimeClasspath.set( + configurations.getByName(dependencyAnalyzer.runtimeConfigurationName) + .incoming + .resolutionResult + .rootComponent + ) + it.output.set(outputPaths.projectGraphDir) + } // Prints some help text relating to generateProjectGraphTask. This is the "user-facing" task. tasks.register("projectGraph$taskNameSuffix", ProjectGraphTask::class.java) { @@ -1002,30 +1008,30 @@ internal class ProjectPlugin(private val project: Project) { // Synthesizes the above into a single view of this project's usages. val synthesizeProjectViewTask = tasks.register("synthesizeProjectView$taskNameSuffix", SynthesizeProjectViewTask::class.java) { - it.projectPath.set(thisProjectPath) - it.buildType.set(dependencyAnalyzer.buildType) - it.flavor.set(dependencyAnalyzer.flavorName) - it.variant.set(variantName) - it.sourceKind.set(dependencyAnalyzer.sourceKind) - it.graph.set(graphViewTask.flatMap { it.output }) - it.annotationProcessors.set(declaredProcsTask.flatMap { it.output }) - it.explodedBytecode.set(explodeBytecodeTask.flatMap { it.output }) - it.explodedSourceCode.set(explodeCodeSourceTask.flatMap { it.output }) - it.usagesExclusions.set(usagesExclusionsProvider) - it.excludedIdentifiers.set(artifactsReport.flatMap { it.excludedIdentifiersOutput }) - // Optional: only exists for libraries. - abiAnalysisTask?.let { t -> it.explodingAbi.set(t.flatMap { it.output }) } - // Optional: only exists for Android libraries. - explodeXmlSourceTask?.let { t -> - it.androidResSource.set(t.flatMap { it.output }) - it.androidResSourceRuntime.set(t.flatMap { it.outputRuntime }) + it.projectPath.set(thisProjectPath) + it.buildType.set(dependencyAnalyzer.buildType) + it.flavor.set(dependencyAnalyzer.flavorName) + it.variant.set(variantName) + it.sourceKind.set(dependencyAnalyzer.sourceKind) + it.graph.set(graphViewTask.flatMap { it.output }) + it.annotationProcessors.set(declaredProcsTask.flatMap { it.output }) + it.explodedBytecode.set(explodeBytecodeTask.flatMap { it.output }) + it.explodedSourceCode.set(explodeCodeSourceTask.flatMap { it.output }) + it.usagesExclusions.set(usagesExclusionsProvider) + it.excludedIdentifiers.set(artifactsReport.flatMap { it.excludedIdentifiersOutput }) + // Optional: only exists for libraries. + abiAnalysisTask?.let { t -> it.explodingAbi.set(t.flatMap { it.output }) } + // Optional: only exists for Android libraries. + explodeXmlSourceTask?.let { t -> + it.androidResSource.set(t.flatMap { it.output }) + it.androidResSourceRuntime.set(t.flatMap { it.outputRuntime }) + } + // Optional: only exists for Android libraries. + explodeAssetSourceTask?.let { t -> it.androidAssetsSource.set(t.flatMap { it.output }) } + // Optional: only exists for Android projects. + it.testInstrumentationRunner.set(dependencyAnalyzer.testInstrumentationRunner) + it.output.set(outputPaths.syntheticProjectPath) } - // Optional: only exists for Android libraries. - explodeAssetSourceTask?.let { t -> it.androidAssetsSource.set(t.flatMap { it.output }) } - // Optional: only exists for Android projects. - it.testInstrumentationRunner.set(dependencyAnalyzer.testInstrumentationRunner) - it.output.set(outputPaths.syntheticProjectPath) - } // Discover duplicates on compile and runtime classpaths val duplicateClassesCompile = @@ -1060,7 +1066,7 @@ internal class ProjectPlugin(private val project: Project) { t.checkSuperClasses.set(dagpExtension.usageHandler.analysisHandler.checkSuperClasses) // Currently only modeling this via Gradle property. May hoist it to the DSL if it's necessary. t.checkBinaryCompat.set(checkBinaryCompat()) - + t.graph.set(graphViewTask.flatMap { it.output }) t.declarations.set(findDeclarationsTask.flatMap { it.output }) t.dependencies.set(synthesizeDependenciesTask.flatMap { it.outputDir }) @@ -1084,6 +1090,9 @@ internal class ProjectPlugin(private val project: Project) { androidScoreTask?.let { a -> t.androidScoreReports.add(a.flatMap { it.output }) } } filterAdviceTask.configure { t -> + if (dagpExtension.reportingHandler.sarifReport.get()) { + t.buildFile.set(project.buildFile) + } t.buildPath.set(buildPath(dependencyAnalyzer.compileConfigurationName)) t.dependencyGraphViews.add(graphViewTask.flatMap { it.output }) t.dependencyGraphViews.add(graphViewTask.flatMap { it.outputRuntime }) @@ -1145,16 +1154,31 @@ internal class ProjectPlugin(private val project: Project) { // ...and produces this output. t.output.set(paths.filteredAdvicePath) + t.sourcedOutput.set( + dagpExtension.reportingHandler.sarifReport.flatMap { enableSarifReport -> + if (enableSarifReport) { + paths.filteredSourcedAdvicePath + } else { + provider { null } + } + } + ) + t.dependencyMap.set( + objects.newInstance(DependenciesHandler::class.java).apply { + withVersionCatalogs(project) + }.map + ) + t.rootFolder.set(project.layout.settingsDirectory.asFile) } val generateProjectHealthReport = tasks.register("generateConsoleReport", GenerateProjectHealthReportTask::class.java) { - it.projectAdvice.set(filterAdviceTask.flatMap { it.output }) - it.reportingConfig.set(dagpExtension.reportingHandler.config()) - it.dslKind.set(DslKind.from(buildFile)) - it.dependencyMap.set(dagpExtension.dependenciesHandler.map) - it.useTypesafeProjectAccessors.set(dagpExtension.useTypesafeProjectAccessors) - it.output.set(paths.consoleReportPath) - } + it.projectAdvice.set(filterAdviceTask.flatMap { it.output }) + it.reportingConfig.set(dagpExtension.reportingHandler.config()) + it.dslKind.set(DslKind.from(buildFile)) + it.dependencyMap.set(dagpExtension.dependenciesHandler.map) + it.useTypesafeProjectAccessors.set(dagpExtension.useTypesafeProjectAccessors) + it.output.set(paths.consoleReportPath) + } tasks.register("projectHealth", ProjectHealthTask::class.java) { it.buildFilePath.set(project.buildFile.path) @@ -1181,9 +1205,9 @@ internal class ProjectPlugin(private val project: Project) { } computeResolvedDependenciesTask = tasks.register("computeResolvedDependencies", ComputeResolvedDependenciesTask::class.java) { - it.output.set(paths.resolvedDepsPath) - it.outputToml.set(paths.resolvedAllLibsVersionsTomlPath) - } + it.output.set(paths.resolvedDepsPath) + it.outputToml.set(paths.resolvedAllLibsVersionsTomlPath) + } mergeProjectGraphsTask = tasks.register("generateMergedProjectGraph", MergeProjectGraphsTask::class.java) { it.output.set(paths.mergedProjectGraphPath) @@ -1199,6 +1223,9 @@ internal class ProjectPlugin(private val project: Project) { // Publish our artifacts combinedGraphPublisher.publish(mergeProjectGraphsTask.flatMap { it.output }) projectHealthPublisher.publish(filterAdviceTask.flatMap { it.output }) + if (dagpExtension.reportingHandler.sarifReport.get()) { + sourcedProjectHealthPublisher.publish(filterAdviceTask.flatMap { it.sourcedOutput }) + } resolvedDependenciesPublisher.publish(computeResolvedDependenciesTask.flatMap { it.output }) } @@ -1242,8 +1269,8 @@ internal class ProjectPlugin(private val project: Project) { private class JavaSources(project: Project, dagpExtension: AbstractExtension) { val sourceSets: NamedDomainObjectSet = project.extensions.getByType(SourceSetContainer::class.java).matching { s -> - project.shouldAnalyzeSourceSetForProject(dagpExtension, s.name, project.path) - } + project.shouldAnalyzeSourceSetForProject(dagpExtension, s.name, project.path) + } val hasJava: Provider = project.provider { sourceSets.flatMap { it.java() }.isNotEmpty() } } diff --git a/src/main/kotlin/com/autonomousapps/subplugin/RootPlugin.kt b/src/main/kotlin/com/autonomousapps/subplugin/RootPlugin.kt index b1e89a187..330d3b1a5 100644 --- a/src/main/kotlin/com/autonomousapps/subplugin/RootPlugin.kt +++ b/src/main/kotlin/com/autonomousapps/subplugin/RootPlugin.kt @@ -43,6 +43,10 @@ internal class RootPlugin(private val project: Project) { project = project, artifactDescription = DagpArtifacts.Kind.PROJECT_HEALTH, ) + private val sourcedAdviceResolver = interProjectResolver( + project = project, + artifactDescription = DagpArtifacts.Kind.SOURCED_PROJECT_HEALTH, + ) private val combinedGraphResolver = interProjectResolver( project = project, artifactDescription = DagpArtifacts.Kind.COMBINED_GRAPH, @@ -106,6 +110,7 @@ internal class RootPlugin(private val project: Project) { val generateBuildHealthTask = tasks.register("generateBuildHealth", GenerateBuildHealthTask::class.java) { it.projectHealthReports.setFrom(adviceResolver.internal.map { it.artifactsFor("json").artifactFiles }) + it.sourcedProjectHealthReports.setFrom(sourcedAdviceResolver.internal.map { it.artifactsFor("json").artifactFiles }) it.reportingConfig.set(dagpExtension.reportingHandler.config()) it.projectCount.set(allprojects.size) it.dslKind.set(DslKind.from(buildFile)) @@ -115,12 +120,18 @@ internal class RootPlugin(private val project: Project) { it.output.set(paths.buildHealthPath) it.consoleOutput.set(paths.consoleReportPath) it.outputFail.set(paths.shouldFailPath) + it.sarifOutput.set( + dagpExtension.reportingHandler.sarifReport.flatMap { sarifReportEnabled -> + if (sarifReportEnabled) paths.sarifReportPath else null + } + ) } tasks.register("buildHealth", BuildHealthTask::class.java) { it.shouldFail.set(generateBuildHealthTask.flatMap { it.outputFail }) it.buildHealth.set(generateBuildHealthTask.flatMap { it.output }) it.consoleReport.set(generateBuildHealthTask.flatMap { it.consoleOutput }) + it.sarifReport.set(generateBuildHealthTask.flatMap { it.sarifOutput }) it.printBuildHealth.set(dagpExtension.reportingHandler.printBuildHealth.orElse(printBuildHealth())) it.postscript.set(dagpExtension.reportingHandler.postscript) } @@ -140,6 +151,10 @@ internal class RootPlugin(private val project: Project) { project = this, artifactDescription = DagpArtifacts.Kind.PROJECT_HEALTH, ) + val sourcedProjectHealthPublisher = interProjectPublisher( + project = this, + artifactDescription = DagpArtifacts.Kind.SOURCED_PROJECT_HEALTH, + ) val resolvedDependenciesPublisher = interProjectPublisher( project = this, artifactDescription = DagpArtifacts.Kind.RESOLVED_DEPS, @@ -149,6 +164,7 @@ internal class RootPlugin(private val project: Project) { dependencies.let { d -> d.add(combinedGraphPublisher.declarableName, d.project(mapOf("path" to p.path))) d.add(projectHealthPublisher.declarableName, d.project(mapOf("path" to p.path))) + d.add(sourcedProjectHealthPublisher.declarableName, d.project(mapOf("path" to p.path))) d.add(resolvedDependenciesPublisher.declarableName, d.project(mapOf("path" to p.path))) } } diff --git a/src/main/kotlin/com/autonomousapps/tasks/BuildHealthTask.kt b/src/main/kotlin/com/autonomousapps/tasks/BuildHealthTask.kt index c81dfad62..d7454edc5 100644 --- a/src/main/kotlin/com/autonomousapps/tasks/BuildHealthTask.kt +++ b/src/main/kotlin/com/autonomousapps/tasks/BuildHealthTask.kt @@ -36,6 +36,11 @@ public abstract class BuildHealthTask : DefaultTask() { @get:Input public abstract val printBuildHealth: Property + @get:PathSensitive(PathSensitivity.NONE) + @get:InputFile + @get:Optional + public abstract val sarifReport: RegularFileProperty + @get:Input public abstract val postscript: Property diff --git a/src/main/kotlin/com/autonomousapps/tasks/ComputeAdviceTask.kt b/src/main/kotlin/com/autonomousapps/tasks/ComputeAdviceTask.kt index 71692c7a7..2e612051a 100644 --- a/src/main/kotlin/com/autonomousapps/tasks/ComputeAdviceTask.kt +++ b/src/main/kotlin/com/autonomousapps/tasks/ComputeAdviceTask.kt @@ -132,6 +132,7 @@ public abstract class ComputeAdviceTask @Inject constructor( public interface ComputeAdviceParameters : WorkParameters { public val projectPath: Property public val buildPath: Property + public val dependencyUsageReports: ListProperty public val dependencyGraphViews: ListProperty public val androidScoreReports: ListProperty @@ -161,6 +162,7 @@ public abstract class ComputeAdviceTask @Inject constructor( val projectPath = parameters.projectPath.get() val buildPath = parameters.buildPath.get() + val declarations = parameters.declarations.fromJsonSet() val dependencyGraph = DependencyGraphView.asMap(parameters.dependencyGraphViews) val androidScore = parameters.androidScoreReports.get() diff --git a/src/main/kotlin/com/autonomousapps/tasks/FilterAdviceTask.kt b/src/main/kotlin/com/autonomousapps/tasks/FilterAdviceTask.kt index fed262290..3147e2b82 100644 --- a/src/main/kotlin/com/autonomousapps/tasks/FilterAdviceTask.kt +++ b/src/main/kotlin/com/autonomousapps/tasks/FilterAdviceTask.kt @@ -12,6 +12,8 @@ import com.autonomousapps.internal.advice.SeverityHandler import com.autonomousapps.internal.utils.bufferWriteJson import com.autonomousapps.internal.utils.fromJson import com.autonomousapps.internal.utils.getAndDelete +import com.autonomousapps.internal.utils.getAndDeleteNullable +import com.autonomousapps.internal.utils.readLines import com.autonomousapps.model.* import com.autonomousapps.model.internal.DependencyGraphView import org.gradle.api.DefaultTask @@ -24,6 +26,7 @@ import org.gradle.workers.WorkAction import org.gradle.workers.WorkParameters import org.gradle.workers.WorkerExecutor import javax.inject.Inject +import org.gradle.api.provider.MapProperty @CacheableTask public abstract class FilterAdviceTask @Inject constructor( @@ -37,6 +40,11 @@ public abstract class FilterAdviceTask @Inject constructor( @get:Input public abstract val buildPath: Property + @get:Optional + @get:PathSensitive(PathSensitivity.NONE) + @get:InputFile + public abstract val buildFile: RegularFileProperty + @get:PathSensitive(PathSensitivity.NONE) @get:InputFile public abstract val projectAdvice: RegularFileProperty @@ -84,6 +92,18 @@ public abstract class FilterAdviceTask @Inject constructor( @get:OutputFile public abstract val output: RegularFileProperty + @get:OutputFile + @get:Optional + public abstract val sourcedOutput: RegularFileProperty + + @get:Input + public abstract val dependencyMap: MapProperty + + // Do not depend on the contents of the directory, + // as that would cause the task to depend on every single thing inside the gradle project + @get:Internal + public abstract val rootFolder: RegularFileProperty + @TaskAction public fun action() { workerExecutor.noIsolation().submit(FilterAdviceAction::class.java) { it.buildPath.set(buildPath) @@ -102,6 +122,10 @@ public abstract class FilterAdviceTask @Inject constructor( it.redundantPluginsBehavior.set(redundantPluginsBehavior) it.moduleStructureBehavior.set(moduleStructureBehavior) it.output.set(output) + it.sourcedOutput.set(sourcedOutput) + it.rootFolder.set(rootFolder) + it.buildFile.set(buildFile) + it.dependencyMap = dependencyMap.get() } } @@ -122,6 +146,10 @@ public abstract class FilterAdviceTask @Inject constructor( public val redundantPluginsBehavior: Property public val moduleStructureBehavior: Property public val output: RegularFileProperty + public val sourcedOutput: RegularFileProperty + public val buildFile: RegularFileProperty + public var dependencyMap: Map + public val rootFolder: RegularFileProperty } public abstract class FilterAdviceAction : WorkAction { @@ -155,6 +183,7 @@ public abstract class FilterAdviceTask @Inject constructor( override fun execute() { val output = parameters.output.getAndDelete() + val sourcedOutput = parameters.sourcedOutput.getAndDeleteNullable() val projectAdvice = parameters.projectAdvice.fromJson() val dependencyAdvice: Set = projectAdvice.dependencyAdvice.asSequence() @@ -218,6 +247,24 @@ public abstract class FilterAdviceTask @Inject constructor( ) output.bufferWriteJson(filteredAdvice) + + if (sourcedOutput != null) { + val buildFileLines = parameters.buildFile.readLines() + val sourcedAdvice = dependencyAdvice.addLineNumbers(buildFileLines) + + val sourcedFilteredAdvice = SourcedProjectAdvice( + projectPath = filteredAdvice.projectPath, + pluginAdvice = pluginAdvice, + moduleAdvice = moduleAdvice, + warning = filteredAdvice.warning, + shouldFail = filteredAdvice.shouldFail, + dependencyAdvice = sourcedAdvice, + projectBuildFile = parameters.buildFile.orNull?.asFile + ?.relativeTo(parameters.rootFolder.get().asFile) + ?.path, + ) + sourcedOutput.bufferWriteJson(sourcedFilteredAdvice) + } } private fun Sequence.filterOf( @@ -305,6 +352,20 @@ public abstract class FilterAdviceTask @Inject constructor( (byGlobal(duplicateClass) || bySourceSets(duplicateClass)) } } + + private fun Set.addLineNumbers(buildFileLines: List): Set = map { advice -> + val lineNumber = buildFileLines + .indexOfFirst { buildFileLine -> buildFileLine.contains(advice.coordinates.identifier) } + .takeIf { it >= 0 } + ?: parameters.dependencyMap[advice.coordinates.identifier]?.let { mappedIdentifier -> + buildFileLines + .indexOfFirst { buildFileLine -> + buildFileLine.contains(mappedIdentifier) + }.takeIf { it >= 0 } + } + + SourcedAdvice(advice, buildFileDeclarationLineNumber = lineNumber?.plus(1)) + }.toSet() } private companion object { diff --git a/src/main/kotlin/com/autonomousapps/tasks/GenerateBuildHealthTask.kt b/src/main/kotlin/com/autonomousapps/tasks/GenerateBuildHealthTask.kt index 113fbc199..8cf8cc22e 100644 --- a/src/main/kotlin/com/autonomousapps/tasks/GenerateBuildHealthTask.kt +++ b/src/main/kotlin/com/autonomousapps/tasks/GenerateBuildHealthTask.kt @@ -8,15 +8,19 @@ import com.autonomousapps.extension.ReportingHandler import com.autonomousapps.extension.getEffectivePostscript import com.autonomousapps.internal.advice.DslKind import com.autonomousapps.internal.advice.ProjectHealthConsoleReportBuilder +import com.autonomousapps.internal.advice.ProjectHealthSarifReportBuilder import com.autonomousapps.internal.utils.Colors import com.autonomousapps.internal.utils.Colors.colorize import com.autonomousapps.internal.utils.bufferWriteJson import com.autonomousapps.internal.utils.fromJson import com.autonomousapps.internal.utils.getAndDelete +import com.autonomousapps.internal.utils.getAndDeleteNullable import com.autonomousapps.model.AndroidScore import com.autonomousapps.model.BuildHealth import com.autonomousapps.model.BuildHealth.AndroidScoreMetrics import com.autonomousapps.model.ProjectAdvice +import com.autonomousapps.model.SourcedProjectAdvice +import io.github.detekt.sarif4k.SarifSerializer import org.gradle.api.DefaultTask import org.gradle.api.file.ConfigurableFileCollection import org.gradle.api.file.RegularFileProperty @@ -35,6 +39,10 @@ public abstract class GenerateBuildHealthTask : DefaultTask() { @get:InputFiles public abstract val projectHealthReports: ConfigurableFileCollection + @get:PathSensitive(PathSensitivity.RELATIVE) + @get:InputFiles + public abstract val sourcedProjectHealthReports: ConfigurableFileCollection + // TODO(tsr): this shouldn't be a Property for Complicated Reasons @get:Nested public abstract val reportingConfig: Property @@ -61,10 +69,15 @@ public abstract class GenerateBuildHealthTask : DefaultTask() { @get:OutputFile public abstract val outputFail: RegularFileProperty + @get:OutputFile + @get:Optional + public abstract val sarifOutput: RegularFileProperty + @TaskAction public fun action() { val output = output.getAndDelete() val consoleOutput = consoleOutput.getAndDelete() val outputFail = outputFail.getAndDelete() + val sarifOutput = sarifOutput.getAndDeleteNullable() var didWrite = false var shouldFail = false @@ -161,6 +174,19 @@ public abstract class GenerateBuildHealthTask : DefaultTask() { consoleOutput.appendText("\n\n${ps.colorize(Colors.BOLD)}") } } + + if (sarifOutput != null) { + val sourcedAdvice = sourcedProjectHealthReports.files.map { it.fromJson() } + + val sarifReport = ProjectHealthSarifReportBuilder( + projectAdvices = sourcedAdvice, + dslKind = dslKind.get(), + dependencyMap = dependencyMap.get().toLambda(), + useTypesafeProjectAccessors = useTypesafeProjectAccessors.get(), + ).sarif + + sarifOutput.writeText(SarifSerializer.toJson(sarifReport)) + } } private fun isFunctionallyEmpty(advice: Collection): Boolean {