diff --git a/README.md b/README.md index 55b1b85295..e0b71bee23 100644 --- a/README.md +++ b/README.md @@ -211,21 +211,15 @@ diktat { } ``` -Also `diktat` extension has different reporters. You can specify `json`, `html`, `sarif`, `plain` (default) or your own custom reporter (it should be added as a dependency into `diktat` configuration): +Also in `diktat` extension you can configure different reporters and their output. You can specify `json`, `html`, `sarif`, `plain` (default). +If `output` is set, it should be a file path. If not set, results will be printed to stdout. ```kotlin diktat { // since 1.2.1 to keep in line with maven properties reporter = "json" // "html", "json", "plain" (default), "sarif" // before 1.2.1 // reporterType = "json" // "html", "json", "plain" (default), "sarif" -} -``` -You can also specify an output. -```kotlin -diktat { - // since 1.2.1 (reporterType for old versions) - reporter = "json" output = "someFile.json" } ``` @@ -302,7 +296,7 @@ Diktat can be run via spotless-maven-plugin since version 2.8.0 ``` -## GitHub Native Integration +## GitHub Integration We suggest everyone to use common ["sarif"](https://docs.oasis-open.org/sarif/sarif/v2.0/sarif-v2.0.html) format as a `reporter` (`reporterType`) in CI/CD. GitHub has an [integration](https://docs.github.com/en/code-security/code-scanning/integrating-with-code-scanning/sarif-support-for-code-scanning) with SARIF format and provides you a native reporting of diktat issues in Pull Requests. @@ -337,6 +331,18 @@ mvn -B diktat:check@diktat -Ddiktat.githubActions=true with: sarif_file: ${{ github.workspace }} ``` + +*Note*: `codeql-action/upload-sarif` limits the number of uploaded files at 15. If your project has more than 15 subprojects, +the limit will be exceeded and the step will fail. To solve this issue one can merge SARIF reports. + +`diktat-gradle-plugin` provides this capability with `mergeDiktatReports` task. This task aggregates reports of all diktat tasks +of all Gradle project, which produce SARIF reports, and outputs the merged report into root project's build directory. Then this single +file can be used as an input for Github action: +```yaml +with: + sarif_file: build/reports/diktat/diktat-merged.sarif +``` + ## Customizations via `diktat-analysis.yml` diff --git a/diktat-gradle-plugin/build.gradle.kts b/diktat-gradle-plugin/build.gradle.kts index 95c506dbe5..b5872f5860 100644 --- a/diktat-gradle-plugin/build.gradle.kts +++ b/diktat-gradle-plugin/build.gradle.kts @@ -34,6 +34,7 @@ val junitVersion = project.properties.getOrDefault("junitVersion", "5.8.1") as S val jacocoVersion = project.properties.getOrDefault("jacocoVersion", "0.8.7") as String dependencies { implementation(kotlin("gradle-plugin-api")) + implementation("io.github.detekt.sarif4k:sarif4k:0.0.1") implementation("org.cqfn.diktat:diktat-common:$diktatVersion") { exclude("org.jetbrains.kotlin", "kotlin-compiler-embeddable") diff --git a/diktat-gradle-plugin/src/main/kotlin/org/cqfn/diktat/plugin/gradle/DiktatGradlePlugin.kt b/diktat-gradle-plugin/src/main/kotlin/org/cqfn/diktat/plugin/gradle/DiktatGradlePlugin.kt index e799c81815..f0a0acbc36 100644 --- a/diktat-gradle-plugin/src/main/kotlin/org/cqfn/diktat/plugin/gradle/DiktatGradlePlugin.kt +++ b/diktat-gradle-plugin/src/main/kotlin/org/cqfn/diktat/plugin/gradle/DiktatGradlePlugin.kt @@ -1,5 +1,6 @@ package org.cqfn.diktat.plugin.gradle +import org.cqfn.diktat.plugin.gradle.tasks.configureMergeReportsTask import generated.DIKTAT_VERSION import generated.KTLINT_VERSION import org.gradle.api.Plugin @@ -26,9 +27,7 @@ class DiktatGradlePlugin : Plugin { diktatConfigFile = project.rootProject.file("diktat-analysis.yml") } - // only gradle 7+ (or maybe 6.8) will embed kotlin 1.4+, kx.serialization is incompatible with kotlin 1.3, so until then we have to use JavaExec wrapper - // FixMe: when gradle with kotlin 1.4 is out, proper configurable tasks should be added - // configuration to provide JavaExec with correct classpath + // Configuration that will be used as classpath for JavaExec task. val diktatConfiguration = project.configurations.create(DIKTAT_CONFIGURATION) { configuration -> configuration.isVisible = false configuration.dependencies.add(project.dependencies.create("com.pinterest:ktlint:$KTLINT_VERSION", closureOf { @@ -47,6 +46,7 @@ class DiktatGradlePlugin : Plugin { project.registerDiktatCheckTask(diktatExtension, diktatConfiguration, patternSet) project.registerDiktatFixTask(diktatExtension, diktatConfiguration, patternSet) + project.configureMergeReportsTask(diktatExtension) } companion object { @@ -70,6 +70,11 @@ class DiktatGradlePlugin : Plugin { */ const val DIKTAT_FIX_TASK = "diktatFix" + /** + * Name of the task that merges SARIF reports of diktat tasks + */ + internal const val MERGE_SARIF_REPORTS_TASK_NAME = "mergeDiktatReports" + /** * Version of JVM with more strict module system, which requires `add-opens` for kotlin compiler */ diff --git a/diktat-gradle-plugin/src/main/kotlin/org/cqfn/diktat/plugin/gradle/DiktatJavaExecTaskBase.kt b/diktat-gradle-plugin/src/main/kotlin/org/cqfn/diktat/plugin/gradle/DiktatJavaExecTaskBase.kt index a33ede567a..abd9e6fccf 100644 --- a/diktat-gradle-plugin/src/main/kotlin/org/cqfn/diktat/plugin/gradle/DiktatJavaExecTaskBase.kt +++ b/diktat-gradle-plugin/src/main/kotlin/org/cqfn/diktat/plugin/gradle/DiktatJavaExecTaskBase.kt @@ -27,8 +27,6 @@ import org.gradle.api.tasks.util.PatternSet import org.gradle.util.GradleVersion import java.io.File -import java.nio.file.Files -import java.nio.file.Paths import javax.inject.Inject /** @@ -148,27 +146,21 @@ open class DiktatJavaExecTaskBase @Inject constructor( @Suppress("FUNCTION_BOOLEAN_PREFIX") override fun getIgnoreFailures(): Boolean = ignoreFailuresProp.getOrElse(false) + @Suppress("AVOID_NULL_CHECKS") private fun reporterFlag(diktatExtension: DiktatExtension): String = buildString { val reporterFlag = project.createReporterFlag(diktatExtension) append(reporterFlag) - val isSarifReporterActive = reporterFlag.contains("sarif") - if (isSarifReporterActive) { + if (isSarifReporterActive(reporterFlag)) { // need to set user.home specially for ktlint, so it will be able to put a relative path URI in SARIF systemProperty("user.home", project.rootDir.toString()) } - val outFlag = when { - // githubActions should have higher priority than a custom input - diktatExtension.githubActions -> { - val reportDir = Files.createDirectories(Paths.get("${project.buildDir}/reports/diktat")) - outputs.dir(reportDir) - ",output=${reportDir.resolve("diktat.sarif")}" - } - diktatExtension.output.isNotEmpty() -> ",output=${diktatExtension.output}" - else -> "" + val outputFile = project.getOutputFile(diktatExtension) + if (outputFile != null) { + outputs.file(outputFile) + val outFlag = ",output=$outputFile" + append(outFlag) } - - append(outFlag) } @Suppress("MagicNumber") diff --git a/diktat-gradle-plugin/src/main/kotlin/org/cqfn/diktat/plugin/gradle/Utils.kt b/diktat-gradle-plugin/src/main/kotlin/org/cqfn/diktat/plugin/gradle/Utils.kt index bc5a7bf1eb..9181043b48 100644 --- a/diktat-gradle-plugin/src/main/kotlin/org/cqfn/diktat/plugin/gradle/Utils.kt +++ b/diktat-gradle-plugin/src/main/kotlin/org/cqfn/diktat/plugin/gradle/Utils.kt @@ -8,6 +8,9 @@ package org.cqfn.diktat.plugin.gradle import groovy.lang.Closure import org.gradle.api.Project +import java.io.File +import java.nio.file.Files +import java.nio.file.Paths @Suppress( "MISSING_KDOC_TOP_LEVEL", @@ -38,18 +41,18 @@ fun Any.closureOf(action: T.() -> Unit): Closure = KotlinClosure1(action, this, this) /** - * Create CLI flag to select reporter based on [diktatExtension] + * Create CLI flag to set reporter for ktlint based on [diktatExtension]. + * [DiktatExtension.githubActions] should have higher priority than a custom input. * - * @param diktatExtension project extension of type [DiktatExtension] - * @return CLI flag + * @param diktatExtension extension of type [DiktatExtension] + * @return CLI flag as string */ -internal fun Project.createReporterFlag(diktatExtension: DiktatExtension): String { +fun Project.createReporterFlag(diktatExtension: DiktatExtension): String { val name = diktatExtension.reporter.trim() val validReporters = listOf("sarif", "plain", "json", "html") val reporterFlag = when { diktatExtension.githubActions -> { if (diktatExtension.reporter.isNotEmpty()) { - // githubActions should have higher priority than custom input logger.warn("`diktat.githubActions` is set to true, so custom reporter [$name] will be ignored and SARIF reporter will be used") } "--reporter=sarif" @@ -67,3 +70,27 @@ internal fun Project.createReporterFlag(diktatExtension: DiktatExtension): Strin return reporterFlag } + +/** + * Get destination file for Diktat report or null if stdout is used. + * [DiktatExtension.githubActions] should have higher priority than a custom input. + * + * @param diktatExtension extension of type [DiktatExtension] + * @return destination [File] or null if stdout is used + */ +internal fun Project.getOutputFile(diktatExtension: DiktatExtension): File? = when { + diktatExtension.githubActions -> { + val reportDir = Files.createDirectories(Paths.get("${project.buildDir}/reports/diktat")) + reportDir.resolve("diktat.sarif").toFile() + } + diktatExtension.output.isNotEmpty() -> file(diktatExtension.output) + else -> null +} + +/** + * Whether SARIF reporter is enabled or not + * + * @param reporterFlag + * @return whether SARIF reporter is enabled + */ +internal fun isSarifReporterActive(reporterFlag: String) = reporterFlag.contains("sarif") diff --git a/diktat-gradle-plugin/src/main/kotlin/org/cqfn/diktat/plugin/gradle/tasks/SarifReportMergeTask.kt b/diktat-gradle-plugin/src/main/kotlin/org/cqfn/diktat/plugin/gradle/tasks/SarifReportMergeTask.kt new file mode 100644 index 0000000000..a94725749c --- /dev/null +++ b/diktat-gradle-plugin/src/main/kotlin/org/cqfn/diktat/plugin/gradle/tasks/SarifReportMergeTask.kt @@ -0,0 +1,101 @@ +package org.cqfn.diktat.plugin.gradle.tasks + +import org.cqfn.diktat.plugin.gradle.DiktatExtension +import org.cqfn.diktat.plugin.gradle.DiktatGradlePlugin.Companion.MERGE_SARIF_REPORTS_TASK_NAME +import org.cqfn.diktat.plugin.gradle.DiktatJavaExecTaskBase +import org.cqfn.diktat.plugin.gradle.createReporterFlag +import org.cqfn.diktat.plugin.gradle.getOutputFile +import org.cqfn.diktat.plugin.gradle.isSarifReporterActive +import io.github.detekt.sarif4k.SarifSchema210 +import org.gradle.api.DefaultTask +import org.gradle.api.Project +import org.gradle.api.file.ConfigurableFileCollection +import org.gradle.api.file.RegularFileProperty +import org.gradle.api.tasks.InputFiles +import org.gradle.api.tasks.OutputFile +import org.gradle.api.tasks.PathSensitive +import org.gradle.api.tasks.PathSensitivity +import org.gradle.api.tasks.TaskAction +import org.gradle.api.tasks.TaskExecutionException +import kotlinx.serialization.SerializationException +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json + +/** + * A task to merge SARIF reports produced by diktat check / diktat fix tasks. + */ +abstract class SarifReportMergeTask : DefaultTask() { + /** + * Source reports that should be merged + */ + @get:InputFiles + @get:PathSensitive(PathSensitivity.RELATIVE) + abstract val input: ConfigurableFileCollection + + /** + * Destination for the merged report + */ + @get:OutputFile + abstract val output: RegularFileProperty + + /** + * @throws TaskExecutionException if failed to deserialize SARIF + */ + @TaskAction + fun mergeReports() { + val sarifReports = input.files + .filter { it.exists() } + .also { logger.info("Merging SARIF reports from files $it") } + .map { + try { + Json.decodeFromString(it.readText()) + } catch (e: SerializationException) { + logger.error("Couldn't deserialize JSON: is ${it.canonicalPath} a SARIF file?") + throw TaskExecutionException(this, e) + } + } + + if (sarifReports.isEmpty()) { + logger.warn("Cannot perform merging of SARIF reports because no matching files were found; " + + "Is SARIF reporter active?" + ) + return + } + + // All reports should contain identical metadata, so we are using the first one as a base. + val templateReport = sarifReports.first() + val allResults = sarifReports.flatMap { sarifSchema -> + sarifSchema.runs + .flatMap { it.results.orEmpty() } + } + val mergedSarif = templateReport.copy( + runs = listOf(templateReport.runs.first().copy(results = allResults)) + ) + + output.get().asFile.writeText(Json.encodeToString(mergedSarif)) + } +} + +/** + * @param diktatExtension extension of type [DiktatExtension] + */ +internal fun Project.configureMergeReportsTask(diktatExtension: DiktatExtension) { + if (path == rootProject.path) { + tasks.register(MERGE_SARIF_REPORTS_TASK_NAME, SarifReportMergeTask::class.java) { reportMergeTask -> + val diktatReportsDir = "${project.buildDir}/reports/diktat" + val mergedReportFile = project.file("$diktatReportsDir/diktat-merged.sarif") + reportMergeTask.outputs.file(mergedReportFile) + reportMergeTask.output.set(mergedReportFile) + } + } + val reportMergeTaskTaskProvider = rootProject.tasks.named(MERGE_SARIF_REPORTS_TASK_NAME, SarifReportMergeTask::class.java) { reportMergeTask -> + if (isSarifReporterActive(createReporterFlag(diktatExtension))) { + getOutputFile(diktatExtension)?.let { reportMergeTask.input.from(it) } + reportMergeTask.shouldRunAfter(tasks.withType(DiktatJavaExecTaskBase::class.java)) + } + } + tasks.withType(DiktatJavaExecTaskBase::class.java).configureEach { diktatJavaExecTaskBase -> + diktatJavaExecTaskBase.finalizedBy(reportMergeTaskTaskProvider) + } +} diff --git a/diktat-gradle-plugin/src/test/kotlin/org/cqfn/diktat/plugin/gradle/DiktatJavaExecTaskTest.kt b/diktat-gradle-plugin/src/test/kotlin/org/cqfn/diktat/plugin/gradle/DiktatJavaExecTaskTest.kt index 07af3cd3b9..a4d79fb08d 100644 --- a/diktat-gradle-plugin/src/test/kotlin/org/cqfn/diktat/plugin/gradle/DiktatJavaExecTaskTest.kt +++ b/diktat-gradle-plugin/src/test/kotlin/org/cqfn/diktat/plugin/gradle/DiktatJavaExecTaskTest.kt @@ -91,7 +91,7 @@ class DiktatJavaExecTaskTest { @Test fun `check command line has reporter type and output`() { assertCommandLineEquals( - listOf(null, "--reporter=json,output=some.txt") + listOf(null, "--reporter=json,output=${project.projectDir.resolve("some.txt")}") ) { inputs { exclude("*") } diktatConfigFile = project.file("../diktat-analysis.yml") diff --git a/diktat-gradle-plugin/src/test/kotlin/org/cqfn/diktat/plugin/gradle/ReporterSelectionTest.kt b/diktat-gradle-plugin/src/test/kotlin/org/cqfn/diktat/plugin/gradle/ReporterSelectionTest.kt new file mode 100644 index 0000000000..472480f89f --- /dev/null +++ b/diktat-gradle-plugin/src/test/kotlin/org/cqfn/diktat/plugin/gradle/ReporterSelectionTest.kt @@ -0,0 +1,34 @@ +package org.cqfn.diktat.plugin.gradle + +import org.gradle.api.Project +import org.gradle.api.tasks.util.PatternSet +import org.gradle.testfixtures.ProjectBuilder +import org.junit.jupiter.api.Assertions +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +class ReporterSelectionTest { + private val projectBuilder = ProjectBuilder.builder() + private lateinit var project: Project + + @BeforeEach + fun setUp() { + project = projectBuilder.build() + // mock kotlin sources + project.mkdir("src/main/kotlin") + project.file("src/main/kotlin/Test.kt").createNewFile() + project.pluginManager.apply(DiktatGradlePlugin::class.java) + } + + @Test + fun `should fallback to plain reporter for unknown reporter types`() { + val diktatExtension = DiktatExtension(PatternSet()).apply { + reporter = "jsonx" + } + + Assertions.assertEquals( + "--reporter=plain", + project.createReporterFlag(diktatExtension) + ) + } +}