Skip to content

Commit

Permalink
Add task versionPolicyExportCompatibilityReport to export the compa…
Browse files Browse the repository at this point in the history
…tibility reports in a machine-readable format (JSON)
  • Loading branch information
julienrf committed Dec 5, 2023
1 parent eebc935 commit 357164b
Show file tree
Hide file tree
Showing 18 changed files with 363 additions and 33 deletions.
21 changes: 20 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@ This plugin:

- configures [MiMa] to check for binary or source incompatibilities,
- ensures that none of your dependencies are bumped or removed in an incompatible way,
- reports incompatibilities with previous releases.
- reports incompatibilities with previous releases,
- sets the [`versionScheme`](https://www.scala-sbt.org/1.x/docs/Publishing.html#Version+scheme) of the project to `"early-semver"`.

## Install

Expand Down Expand Up @@ -266,6 +267,24 @@ In this mode, you can use sbt-version-policy to assess the incompatibilities int
}
~~~

### How to generate compatibility reports?

You can export the compatibility reports in JSON format with the task `versionPolicyExportCompatibilityReport`.

1. It does not matter whether `versionPolicyIntention` is set or not. If it is set, the report will list the incompatibilities that violate the intended compatibility level. If it is not set, all the incompatibilities will be reported.
2. Invoke the task `versionPolicyExportCompatibilityReport` on the module you want to generate a report for. For example, for the default root module:
~~~ shell
sbt versionPolicyExportCompatibilityReport
~~~
The task automatically aggregates the compatibility reports of all the aggregated submodules.
3. Read the file `target/scala-2.13/compatibility-report.json` (or `target/scala-3/compatibility-report.json`).
You can see an example of compatibility report [here](./sbt-version-policy/src/sbt-test/sbt-version-policy/export-compatibility-report/expected-compatibility-report.json).

Here are examples of how to read some specific fields of the compatibility report with `jq`:
~~~ shell
jq ...
~~~

## How does `versionPolicyCheck` work?

The `versionPolicyCheck` task:
Expand Down
1 change: 1 addition & 0 deletions build.sbt
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ lazy val `sbt-version-policy` = project
libraryDependencies ++= Seq(
"io.get-coursier" % "interface" % "1.0.18",
"io.get-coursier" %% "versions" % "0.3.1",
"com.lihaoyi" %% "ujson" % "3.1.3", // TODO shade
"com.eed3si9n.verify" %% "verify" % "2.0.1" % Test,
),
testFrameworks += new TestFramework("verify.runner.Framework"),
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package sbtversionpolicy

import coursier.version.{ Version, VersionCompatibility }
import com.typesafe.tools.mima.core.Problem
import coursier.version.{Version, VersionCompatibility}
import sbt.VersionNumber

/** Compatibility level between two version values.
Expand Down Expand Up @@ -60,6 +61,22 @@ object Compatibility {
}
}

def fromIssues(dependencyIssues: DependencyCheckReport, apiIssues: Seq[(IncompatibilityType, Problem)]): Compatibility = {
if (
dependencyIssues.validated(IncompatibilityType.SourceIncompatibility) &&
apiIssues.isEmpty
) {
Compatibility.BinaryAndSourceCompatible
} else if (
dependencyIssues.validated(IncompatibilityType.BinaryIncompatibility) &&
!apiIssues.exists(_._1 == IncompatibilityType.BinaryIncompatibility)
) {
Compatibility.BinaryCompatible
} else {
Compatibility.None
}
}

/**
* Validates that the given new `version` matches the claimed `compatibility` level.
* @return Some validation error, or None if the version is valid.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
package sbtversionpolicy

import sbt.*
import com.typesafe.tools.mima.core.Problem
import upickle.core.LinkedHashMap

/**
* @param moduleReport Compatibility report for one module
* @param submoduleReports Compatibility reports for the aggregated submodules
*/
case class CompatibilityReport(
moduleReport: Option[CompatibilityModuleReport],
submoduleReports: Option[(Compatibility, Seq[CompatibilityReport])]
)

/**
* @param previousRelease Module ID of the previous release of this module, against which the compatibility was assessed
* @param compatibility Assessed compatibility level based on both dependency issues and API issues
* @param dependencyIssues Dependency issues found for this module
* @param apiIssues API issues (ie, Mima issue) found for this module
*/
case class CompatibilityModuleReport(
previousRelease: ModuleID,
compatibility: Compatibility,
dependencyIssues: DependencyCheckReport,
apiIssues: Seq[(IncompatibilityType, Problem)]
)

object CompatibilityReport {

def write(
targetFile: File,
compatibilityReport: CompatibilityReport,
log: Logger,
compatibilityLabel: Compatibility => String = defaultCompatibilityLabels
): Unit = {
IO.createDirectory(targetFile.getParentFile)
IO.write(targetFile, ujson.write(toJson(compatibilityReport, compatibilityLabel), indent = 2))
log.info(s"Wrote compatibility report in ${targetFile.absolutePath}")
}

// Human readable description of the compatibility levels
val defaultCompatibilityLabels: Compatibility => String = {
case Compatibility.None => "Incompatible"
case Compatibility.BinaryCompatible => "Binary compatible"
case Compatibility.BinaryAndSourceCompatible => "Binary and source compatible"
}

private def toJson(report: CompatibilityReport, compatibilityLabel: Compatibility => String): ujson.Value = {
val fields = LinkedHashMap[String, ujson.Value]()
report.moduleReport.foreach { moduleReport =>
fields ++= Seq(
"module-name" -> moduleReport.previousRelease.name,
"previous-version" -> moduleReport.previousRelease.revision,
"compatibility" -> toJson(moduleReport.compatibility, compatibilityLabel),
// TODO add issue details
// "issues" -> ujson.Obj("dependencies" -> ujson.Arr(), "api" -> ujson.Obj())
)
}
report.submoduleReports.foreach { case (aggregatedCompatibility, submoduleReports) =>
fields += "aggregated" -> ujson.Obj(
"compatibility" -> toJson(aggregatedCompatibility, compatibilityLabel),
"modules" -> ujson.Arr(submoduleReports.map(toJson(_, compatibilityLabel))*)
)
}
ujson.Obj(fields)
}

private def toJson(compatibility: Compatibility, compatibilityLabel: Compatibility => String): ujson.Value =
ujson.Obj(
"value" -> (compatibility match {
case Compatibility.None => ujson.Str("incompatible")
case Compatibility.BinaryCompatible => ujson.Str("binary-compatible")
case Compatibility.BinaryAndSourceCompatible => ujson.Str("binary-and-source-compatible")
}),
"label" -> ujson.Str(compatibilityLabel(compatibility))
)

}
Original file line number Diff line number Diff line change
Expand Up @@ -22,4 +22,6 @@ trait SbtVersionPolicyInternalKeys {

final val versionPolicyVersionCompatibility = settingKey[VersionCompatibility]("VersionCompatibility used to determine compatibility.")
final val versionPolicyVersionCompatResult = taskKey[Compatibility]("Calculated level of compatibility required according to the current project version and the versioning scheme.")

final def versionPolicyCollectCompatibilityReports = TaskKey[CompatibilityReport]("versionPolicyCollectCompatibilityReports", "Collect compatibility reports for the export task.")
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ trait SbtVersionPolicyKeys {
final val versionPolicyFindMimaIssues = taskKey[Seq[(ModuleID, Seq[(IncompatibilityType, Problem)])]]("Binary or source compatibility issues over the previously released artifacts.")
final val versionPolicyFindIssues = taskKey[Seq[(ModuleID, (DependencyCheckReport, Seq[(IncompatibilityType, Problem)]))]]("Find both dependency issues and Mima issues.")
final val versionPolicyAssessCompatibility = taskKey[Seq[(ModuleID, Compatibility)]]("Assess the compatibility level of the project compared to its previous releases.")
final def versionPolicyExportCompatibilityReport = TaskKey[Unit]("versionPolicyExportCompatibilityReport", "Export the compatibility report into a JSON file.")
final def versionPolicyCompatibilityReportPath = SettingKey[File]("versionPolicyCompatibilityReportPath", s"Path of the compatibility report (used by ${versionPolicyExportCompatibilityReport.key.label}).")
final val versionCheck = taskKey[Unit]("Checks that the version is consistent with the intended compatibility level defined via versionPolicyIntention")

final val versionPolicyIgnored = settingKey[Seq[OrganizationArtifactName]]("Exclude these dependencies from versionPolicyReportDependencyIssues.")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,8 @@ object SbtVersionPolicyPlugin extends AutoPlugin {

override def globalSettings =
SbtVersionPolicySettings.reconciliationGlobalSettings ++
SbtVersionPolicySettings.schemesGlobalSettings
SbtVersionPolicySettings.schemesGlobalSettings ++
SbtVersionPolicySettings.exportGlobalSettings

override def projectSettings =
SbtVersionPolicySettings.updateSettings ++
Expand All @@ -50,25 +51,49 @@ object SbtVersionPolicyPlugin extends AutoPlugin {
// Take all the projects aggregated by this project
val aggregatedProjects = Keys.thisProject.value.aggregate

// Compute the highest compatibility level that is satisfied by all the aggregated projects
val maxCompatibility: Compatibility = Compatibility.BinaryAndSourceCompatible
aggregatedProjects.foldLeft(Def.task { maxCompatibility }) { (highestCompatibilityTask, project) =>
aggregatedCompatibility(aggregatedProjects, log) { submodule =>
Def.task {
val highestCompatibility = highestCompatibilityTask.value
val compatibilities = (project / versionPolicyAssessCompatibility).value
// The most common case is to assess the compatibility with the latest release,
// so we look at the first element only and discard the others
compatibilities.headOption match {
case Some((_, compatibility)) =>
log.debug(s"Compatibility of aggregated project ${project.project} is ${compatibility}")
(submodule / versionPolicyAssessCompatibility).value
}
} { compatibilities =>
// The most common case is to assess the compatibility with the latest release,
// so we look at the first element only and discard the others
compatibilities.headOption.map(_._2)
}.map(_._1) // Discard submodules details
}

// Compute the highest compatibility level that is satisfied by all the aggregated projects
private[sbtversionpolicy] def aggregatedCompatibility[A](
submodules: Seq[ProjectRef],
log: Logger
)(
f: ProjectRef => Def.Initialize[Task[A]]
)(
compatibility: A => Option[Compatibility]
): Def.Initialize[Task[(Compatibility, Seq[A])]] =
submodules.foldLeft(
Def.task {
(Compatibility.BinaryAndSourceCompatible: Compatibility, Seq.newBuilder[A])
}
) { case (highestCompatibilityAndResults, module) =>
Def.task {
val (highestCompatibility, results) = highestCompatibilityAndResults.value
val result = f(module).value
compatibility(result) match {
case Some(compatibility) =>
log.debug(s"Compatibility of aggregated project ${module.project} is ${compatibility}")
(
// Take the lowest of both
Compatibility.ordering.min(highestCompatibility, compatibility)
case None =>
log.debug(s"Unable to assess the compatibility level of the aggregated project ${project.project}")
highestCompatibility
}
Compatibility.ordering.min(highestCompatibility, compatibility),
results += result
)
case None =>
log.debug(s"Unable to assess the compatibility level of the aggregated project ${module.project}")
(highestCompatibility, results)
}
}
}.map { case (compatibility, builder) =>
(compatibility, builder.result())
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ package sbtversionpolicy

import com.typesafe.tools.mima.core.Problem
import com.typesafe.tools.mima.plugin.MimaPlugin
import coursier.version.{ModuleMatchers, Version, VersionCompatibility}
import coursier.version.{ModuleMatchers, VersionCompatibility}
import sbt.*
import sbt.Keys.*
import sbt.librarymanagement.CrossVersion
Expand Down Expand Up @@ -168,6 +168,7 @@ object SbtVersionPolicySettings {
}
}
},

versionPolicyReportDependencyIssues := {
val log = streams.value.log
val sv = scalaVersion.value
Expand Down Expand Up @@ -213,6 +214,7 @@ object SbtVersionPolicySettings {
}
}
},

versionCheck := Def.ifS((versionCheck / skip).toTask)(Def.task {
()
})(Def.task {
Expand Down Expand Up @@ -240,12 +242,14 @@ object SbtVersionPolicySettings {
throw new MessageOnlyException(s"Module ${moduleName} has a declared version number ${versionValue} that does not conform to its declared versionPolicyIntention of ${intention}. $detail")
}
}).value,

versionPolicyCheck := Def.ifS((versionPolicyCheck / skip).toTask)(Def.task {
()
})(Def.task {
val ignored1 = versionPolicyMimaCheck.value
val ignored2 = versionPolicyReportDependencyIssues.value
}).value,

// For every previous module, returns a list of problems paired with the type of incompatibility
versionPolicyFindMimaIssues := Def.taskDyn[Seq[(ModuleID, Seq[(IncompatibilityType, Problem)])]] {
val compatibility =
Expand All @@ -266,6 +270,7 @@ object SbtVersionPolicySettings {
}
}
}.value,

versionPolicyMimaCheck := Def.taskDyn {
import Compatibility.*
val compatibility =
Expand Down Expand Up @@ -313,6 +318,7 @@ object SbtVersionPolicySettings {
}
}
}.value,

versionPolicyFindIssues := Def.ifS((versionPolicyFindIssues / skip).toTask)(Def.task {
streams.value.log.debug("Not finding incompatibilities with previous releases because 'versionPolicyFindIssues / skip' is 'true'")
Seq.empty[(ModuleID, (DependencyCheckReport, Seq[(IncompatibilityType, Problem)]))]
Expand All @@ -338,6 +344,7 @@ object SbtVersionPolicySettings {
}
})
).value,

versionPolicyAssessCompatibility := Def.ifS((versionPolicyAssessCompatibility / skip).toTask)(Def.task {
streams.value.log.debug("Not assessing the compatibility with previous releases because 'versionPolicyAssessCompatibility / skip' is 'true'")
Seq.empty[(ModuleID, Compatibility)]
Expand All @@ -349,21 +356,60 @@ object SbtVersionPolicySettings {
}
val issues = versionPolicyFindIssues.value
issues.map { case (previousRelease, (dependencyIssues, mimaIssues)) =>
val compatibility =
if (
dependencyIssues.validated(IncompatibilityType.SourceIncompatibility) &&
mimaIssues.isEmpty
) {
Compatibility.BinaryAndSourceCompatible
} else if (
dependencyIssues.validated(IncompatibilityType.BinaryIncompatibility) &&
!mimaIssues.exists(_._1 == IncompatibilityType.BinaryIncompatibility)
) {
Compatibility.BinaryCompatible
} else {
Compatibility.None
previousRelease -> Compatibility.fromIssues(dependencyIssues, mimaIssues)
}
}).value,

versionPolicyExportCompatibilityReport := {
val log = streams.value.log
val compatibilityReport = versionPolicyCollectCompatibilityReports.value
val targetFile =
versionPolicyCompatibilityReportPath.?.value
.getOrElse(crossTarget.value / "compatibility-report.json")
CompatibilityReport.write(targetFile, compatibilityReport, log)
},

versionPolicyCollectCompatibilityReports := Def.ifS(Def.task {
(versionPolicyCollectCompatibilityReports / skip).value
})(Def.task {
CompatibilityReport(None, None)
})(Def.taskDyn {
val module = thisProjectRef.value
val submodules = thisProject.value.aggregate
val log = streams.value.log

// Compatibility report of the current module
val maybeModuleReport =
Def.task {
val issues = (module / versionPolicyFindIssues).value
if (issues.size > 1) {
log.warn(s"Ignoring compatibility reports with versions ${issues.drop(1).map(_._1.revision).mkString(", ")} for module ${issues.head._1.name}. Remove this warning by setting 'versionPolicyPreviousVersions' to a single previous version.")
}
issues.headOption.map {
case (previousRelease, (dependencyIssues, apiIssues)) =>
val compatibility = Compatibility.fromIssues(dependencyIssues, apiIssues)
CompatibilityModuleReport(previousRelease, compatibility, dependencyIssues, apiIssues)
}
}

// Compatibility reports of the aggregated modules (recursively computed)
val maybeAggregatedReports = Def.ifS[Option[(Compatibility, Seq[CompatibilityReport])]]({
Def.task { submodules.isEmpty }
})(Def.task {
None
})(SbtVersionPolicyPlugin.aggregatedCompatibility(submodules, log) { submodule =>
Def.task {
(submodule / versionPolicyCollectCompatibilityReports).value
}
previousRelease -> compatibility
} { compatibilityReport =>
compatibilityReport.moduleReport.map(_.compatibility)
}.map(Some(_)))

Def.task {
CompatibilityReport(
maybeModuleReport.value,
maybeAggregatedReports.value
)
}
}).value
)
Expand All @@ -379,6 +425,13 @@ object SbtVersionPolicySettings {
versionScheme := Some("early-semver")
)

val exportGlobalSettings: Seq[Def.Setting[?]] = Seq(
// Default [aggregation behavior](https://www.scala-sbt.org/1.x/docs/Multi-Project.html#Aggregation)
// is disabled for the “export” tasks because they handle
// their aggregated projects by themselves
versionPolicyExportCompatibilityReport / aggregate := false,
)

/** All the modules (as pairs of organization name and artifact name) defined
* by the current build definition, and whose version number matches the regex
* defined by the key `versionPolicyIgnoredInternalDependencyVersions`.
Expand Down
Loading

0 comments on commit 357164b

Please sign in to comment.