diff --git a/.github/workflows/jacoco_check.yml b/.github/workflows/jacoco_check.yml index dc118b8..4f91ded 100644 --- a/.github/workflows/jacoco_check.yml +++ b/.github/workflows/jacoco_check.yml @@ -34,29 +34,23 @@ jobs: steps: - name: Checkout code uses: actions/checkout@v2 + with: + persist-credentials: false + fetch-depth: 0 - name: Setup Scala uses: olafurpg/setup-scala@v10 with: java-version: "adopt@1.11" - name: Build and run tests run: sbt ++${{matrix.scala}} jacoco - - name: Add coverage to PR - id: jacoco - uses: madrapps/jacoco-report@v1.3 - with: - paths: ${{ github.workspace }}/api/target/scala-${{ matrix.scala_short }}/jacoco/report/jacoco.xml - token: ${{ secrets.GITHUB_TOKEN }} - min-coverage-overall: ${{ matrix.overall }} - min-coverage-changed-files: ${{ matrix.changed }} - title: JaCoCo code coverage report - scala:${{ matrix.scala }} - update-comment: true - - name: Get the Coverage info - run: | - echo "Total coverage ${{ steps.jacoco.outputs.coverage-overall }}" - echo "Changed Files coverage ${{ steps.jacoco.outputs.coverage-changed-files }}" - - name: Fail PR if changed files coverage is less than ${{ matrix.changed }}% - if: ${{ steps.jacoco.outputs.coverage-changed-files < 80.0 }} - uses: actions/github-script@v6 + - name: Add JaCoCo Report in PR comments + uses: MoranaApps/jacoco-report@v2 with: - script: | - core.setFailed('Changed files coverage is less than ${{ matrix.changed }}%!') + token: '${{ secrets.GITHUB_TOKEN }}' + paths: | + **/target/jacoco/report/jacoco.xml + min-coverage-overall: 43.0 + min-coverage-changed-files: 70.0 + sensitivity: "detail" + comment-mode: 'multi' + skip-unchanged: 'true' diff --git a/README.md b/README.md index 584e308..218638d 100644 --- a/README.md +++ b/README.md @@ -389,7 +389,7 @@ sbt jacoco ``` Code coverage will be generated on path: ``` -{project-root}/{module}/target/scala-{scala_version}/jacoco/report/html +{project-root}/{module}/target/jacoco/report/index.html ``` ## Health check endpoint Springboot Actuator is enabled for this project. This provides the user with an endpoint (readable via HTTP or JMX) @@ -459,4 +459,4 @@ This requires Git to be installed and available on the host. The example `git.properties` file provided may be edited manually if the git generation is functioning incorrectly. ## Client Library -See Readme in [clientLibrary](clientLibrary/README.md) module. \ No newline at end of file +See Readme in [clientLibrary](clientLibrary/README.md) module. diff --git a/build.sbt b/build.sbt index 6b8406e..4584027 100644 --- a/build.sbt +++ b/build.sbt @@ -14,7 +14,6 @@ */ import Dependencies._ -import com.github.sbt.jacoco.report.JacocoReportSettings ThisBuild / organization := "za.co.absa" @@ -24,23 +23,24 @@ lazy val scala213 = "2.13.12" ThisBuild / scalaVersion := scala212 ThisBuild / versionScheme := Some("early-semver") -lazy val commonJacocoReportSettings: JacocoReportSettings = JacocoReportSettings( - formats = Seq(JacocoReportFormats.HTML, JacocoReportFormats.XML) -) - lazy val commonJacocoExcludes: Seq[String] = Seq( "za.co.absa.loginsvc.rest.Application*" - // "za.co.absa.loginsvc.rest.config.BaseConfig" // class only +// "za.co.absa.loginsvc.rest.config.BaseConfig" // class only ) lazy val commonJavacOptions = Seq("-source", "1.8", "-target", "1.8", "-Xlint") // deliberately making backwards compatible with J8 lazy val parent = (project in file(".")) .aggregate(api, clientLibrary, examples) + .enablePlugins(FilteredJacocoAgentPlugin) .settings( name := "login-service", javacOptions ++= commonJavacOptions, - publish / skip := true + publish / skip := true, + // Global defaults (override per-module below if you wish) + FilteredJacocoAgentPlugin.autoImport.jacocoIncludes := Seq("za.co.absa.*"), + FilteredJacocoAgentPlugin.autoImport.jacocoExcludes ++= commonJacocoExcludes, + FilteredJacocoAgentPlugin.autoImport.jacocoFailOnMissingExec := false ) lazy val api = project // no need to define file, because path is same as val name @@ -50,11 +50,10 @@ lazy val api = project // no need to define file, because path is same as val na webappWebInfClasses := true, inheritJarManifest := true, javacOptions ++= commonJavacOptions, - jacocoReportSettings := commonJacocoReportSettings.withTitle(s"login-service:service Jacoco Report - scala:${scalaVersion.value}"), - jacocoExcludes := commonJacocoExcludes, publish / skip := true ).enablePlugins(TomcatPlugin) .enablePlugins(AutomateHeaderPlugin) + .enablePlugins(FilteredJacocoAgentPlugin) lazy val clientLibrary = project // no need to define file, because path is same as val name .settings( @@ -63,6 +62,7 @@ lazy val clientLibrary = project // no need to define file, because path is same javacOptions ++= commonJavacOptions, crossScalaVersions := Seq(scala212, scala213) ).enablePlugins(AutomateHeaderPlugin) + .enablePlugins(FilteredJacocoAgentPlugin) lazy val examples = project // no need to define file, because path is same as val name .settings( @@ -71,4 +71,9 @@ lazy val examples = project // no need to define file, because path is same as v javacOptions ++= commonJavacOptions, publish / skip := true ).enablePlugins(AutomateHeaderPlugin) - .dependsOn(clientLibrary) \ No newline at end of file + .dependsOn(clientLibrary) + +// Run activate jacoco + clean + test + per-module reports across the whole build + deactivate jacoco +addCommandAlias("jacoco", "; jacocoOn; clean; test; jacocoReportAll; jacocoOff") +addCommandAlias("jacocoOff", "; set every jacocoPluginEnabled := false") +addCommandAlias("jacocoOn", "; set every jacocoPluginEnabled := true") diff --git a/jmf-rules.txt b/jmf-rules.txt new file mode 100644 index 0000000..491d545 --- /dev/null +++ b/jmf-rules.txt @@ -0,0 +1,154 @@ +# jacoco-method-filter — Default Rules & HowTo (Scala + Java) +# +# This file defines which methods should be annotated as *Generated so JaCoCo ignores them. +# One rule per line. +# +# ───────────────────────────────────────────────────────────────────────────── +# HOW TO USE (quick) +# 1) Replace YOUR.PACKAGE.ROOT with your project’s package root (e.g., com.example.app). +# 2) Start with the CONSERVATIVE section only. +# 3) If clean, enable STANDARD. Use AGGRESSIVE only inside DTO/auto‑generated packages. +# 4) Keep rules narrow (by package), prefer flags (synthetic/bridge) for compiler artifacts, +# and add `id:` labels so logs are easy to read. +# +# ───────────────────────────────────────────────────────────────────────────── +# ALLOWED SYNTAX (cheat sheet) +# +# General form: +# #() [FLAGS and PREDICATES...] +# +# FQCN_glob (dot form; $ allowed for inner classes): +# Examples: *.model.*, com.example.*, * +# +# method_glob (glob on method name): +# Examples: copy | $anonfun$* | get* | *_$eq +# +# descriptor_glob (JVM descriptor in (args)ret). You may omit it entirely. +# • Omitting descriptor ⇒ treated as "(*)*" (any args, any return). +# • Short/empty forms "", "()", "(*)" normalize to "(*)*". +# Examples: +# (I)I # takes int, returns int +# (Ljava/lang/String;)V # takes String, returns void +# () or (*) or omitted # any args, any return +# +# FLAGS (optional) — space or comma separated: +# public | protected | private | synthetic | bridge | static | abstract +# +# PREDICATES (optional): +# ret: # match return type only (e.g., ret:V, ret:I, ret:Lcom/example/*;) +# id: # identifier shown in logs/reports +# name-contains: # method name must contain +# name-starts: # method name must start with +# name-ends: # method name must end with +# +# Notes +# - Always use dot-form (com.example.Foo) for class names. +# - Comments (# …) and blank lines are ignored. +# +# ───────────────────────────────────────────────────────────────────────────── +# QUICK EXAMPLES +# +# Simple wildcards +# *#*(*) +# → Match EVERY method in EVERY class (any package). Useful only for diagnostics. +# "(*)" normalizes to "(*)*" ⇒ any args, any return. +# *.dto.*#*(*) +# → Match every method on any class under any package segment named "dto". +# Good when you treat DTOs as generated/boilerplate. + +# Scala case class helpers +# *.model.*#copy(*) +# → Matches Scala case-class `copy` methods under `*.model.*`. +# Hides boilerplate clones with any parameter list and any return. +# *.model.*#productArity() +# → Matches zero-arg `productArity` (case-class/Product API). +# *.model.*#productElement(*) +# → Matches `productElement(int)` (or any descriptor form) on case classes. +# *.model.*#productPrefix() +# → Matches `productPrefix()`; returns the case class' constructor name. + +# Companion objects and defaults +# *.model.*$*#apply(*) +# → Matches companion `apply` factories under `*.model.*` (any args). +# BE CAREFUL: can hide real factory logic; keep the package scope narrow. +# *.model.*$*#unapply(*) +# → Matches extractor `unapply` methods in companions under `*.model.*`. +# *#*$default$*(*) +# → Matches Scala-generated default-argument helpers everywhere. +# Safe to keep enabled; they’re compiler-synthesized. + +# Anonymous / synthetic / bridge +# *#$anonfun$* +# → Matches any method whose name contains `$anonfun$` (Scala lambdas). +# Consider adding `synthetic` and/or a package scope in real configs. +# *#*(*):synthetic # any synthetic +# → Matches ANY method marked `synthetic` (compiler-generated). +# Powerful; scope by package to avoid hiding intentional glue code. +# *#*(*):bridge # any bridge +# → Matches Java generic bridge methods the compiler inserts. +# Usually safe globally, but scoping is still recommended. + +# Setters / fluent APIs +# *.dto.*#*_$eq(*) +# → Matches Scala var setters in DTO packages (e.g., `name_=(...)`). +# Good for excluding trivial field writes. +# *.builder.*#with*(*) +# → Matches builder-style fluent setters (`withXxx(...)`) in builder pkgs. +# Treats chainable configuration as boilerplate. +# *.client.*#with*(*) ret:Lcom/api/client/* +# → Like above but ONLY when the return type matches your client package. +# The `ret:` predicate protects real logic that returns other types. + +# Return-type constraints +# *.jobs.*#*(*):ret:V +# → Any method under `*.jobs.*` returning `void` (`V`). Often orchestration. +# *.math.*#*(*):ret:I +# → Any method under `*.math.*` returning primitive int (`I`). +# *.model.*#*(*):ret:Lcom/example/model/* +# → Any method under `*.model.*` that returns a type in `com.example.model`. +# Handy when the *return type* uniquely identifies boilerplate. + +# ───────────────────────────────────────────────────────────────────────────── +# GLOBALS RULES +# ───────────────────────────────────────────────────────────────────────────── +# ** all case class boilerplate + +# Scala case class helpers +*#canEqual(*) id:case-canequal +*#equals(*) id:case-equals +*#apply(*) id:case-apply +*#unapply(*) id:case-unapply +*#hashCode(*) id:case-hashcode +*#copy(*) id:case-copy +*#copy$default$*(*) id:case-copy-defaults +*#productElement() id:case-prod-element +*#productArity() id:case-prod-arity +*#productPrefix() id:case-prod-prefix +*#productIterator() id:case-prod-iterator +*#tupled() id:case-tupled +*#curried() id:case-curried +*#toString() id:case-tostring +*#name() id:case-name +*#groups() id:case-groups +*#optionalAttributes() id:case-optionalAttributes + +# Companion objects, constructors, and static definitions +*$#(*) id:gen-ctor # constructors +*$#() id:gen-clinit # static initializer blocks + +# Companion objects and defaults +*$*#apply(*) id:comp-apply +*$*#unapply(*) id:comp-unapply +*$*#toString(*) id:comp-tostring +*$*#readResolve(*) id:comp-readresolve + +# anonymous class created by a macro expansion +*$macro$*#$anonfun$inst$macro$* id:macro-inst +*$macro$*#inst$macro$* id:macro-inst + +# lambda +*#* synthetic name-contains:$anonfun$ id:scala-anonfun + +# ───────────────────────────────────────────────────────────────────────────── +# DIRECT RULES +# ───────────────────────────────────────────────────────────────────────────── diff --git a/project/FilteredJacocoAgentPlugin.scala b/project/FilteredJacocoAgentPlugin.scala new file mode 100644 index 0000000..1f80d6a --- /dev/null +++ b/project/FilteredJacocoAgentPlugin.scala @@ -0,0 +1,335 @@ +import JacocoBaseKeysPlugin.autoImport.* +import sbt.Keys.* +import sbt.{ScopeFilter, inProjects as inP, *} + +import scala.sys.process.* + +/** + * JacocoAgentPlugin (no aggregation/merge) + * --------------------------------------- + * - Attaches JaCoCo agent to forked JVMs per module (Test + optional IntegrationTest) + * - Writes per-module .exec files (no merging) + * - Generates per-module reports + * - Provides root helpers: jacocoCleanAll / jacocoReportAll that just iterate modules (no merge) + */ +object FilteredJacocoAgentPlugin extends AutoPlugin { + object autoImport { + val jacocoVersion = settingKey[String]("JaCoCo version") + val jacocoExecFile = settingKey[File]("Per-module JaCoCo .exec file (Test)") + val jacocoItExecFile = settingKey[File]("Per-module JaCoCo .exec file (IntegrationTest)") + val jacocoReportDir = settingKey[File]("Per-module report directory") + val jacocoIncludes = settingKey[Seq[String]]("Include patterns (JaCoCo syntax)") + val jacocoExcludes = settingKey[Seq[String]]("Exclude patterns (JaCoCo syntax)") + val jacocoAppend = settingKey[Boolean]("Append to existing .exec instead of overwrite (default: false)") + val jacocoFailOnMissingExec = + settingKey[Boolean]("Fail jacocoReport if .exec is missing (default: false – warn & skip)") + + val jacocoReportName = settingKey[String]("Title used for JaCoCo HTML report") + + // Root-only helpers (NO MERGE): just run per-module tasks across aggregated projects + val jacocoCleanAll = taskKey[Unit]("Run jacocoClean in all aggregated modules (no merge)") + val jacocoReportAll = taskKey[Unit]("Run jacocoReport in all aggregated modules (no merge)") + + val jacocoSetUserDirToBuildRoot = settingKey[Boolean]("Mimic non-forked runs by setting -Duser.dir to the build root for forked tests") + + val jmfCoreVersion = settingKey[String]("JMF core library version") + val Jmf = config("jmf").hide + val jmfRewrite = taskKey[File]("Rewrite compiled classes using JMF tool; returns output dir") + val jmfOutDir = settingKey[File]("JMF output base dir") + val jmfRulesFile = settingKey[File]("JMF rules file") + val jmfCliMain = settingKey[String]("Main class of the JMF CLI") + val jmfDryRun = settingKey[Boolean]("Dry-run rewriter") + val jmfEnabled = settingKey[Boolean]("Enable JMF rewriting") + val jmfPrepareForTests = taskKey[Unit]("Run JMF rewrite when enabled (no self-ref to test)") + } + import autoImport.* + + override def requires = JacocoBaseKeysPlugin + override def trigger = noTrigger + + // ---- helper: all aggregated descendants (BFS), excluding the root itself + private def aggregatedDescendants(e: Extracted, root: ProjectRef): Vector[ProjectRef] = { + val s = e.structure + val seen = scala.collection.mutable.LinkedHashSet[ProjectRef](root) + val queue = scala.collection.mutable.Queue[ProjectRef](root) + while (queue.nonEmpty) { + val ref = queue.dequeue() + val kids = Project.getProject(ref, s).toList.flatMap(_.aggregate) + kids.foreach { k => if (!seen(k)) { seen += k; queue.enqueue(k) } } + } + seen.toVector.tail // drop root + } + + // ---- helper: only those that set jacocoPluginEnabled := true + private def enabledUnder(state: State): Vector[ProjectRef] = { + val e = Project.extract(state) + val here = e.currentRef + val all = aggregatedDescendants(e, here) // children only (no root) + all.filter { ref => + e.getOpt((ref / jacocoPluginEnabled): SettingKey[Boolean]).getOrElse(false) + } + } + + // ---- commands + private lazy val jacocoCleanAllCmd = Command.command("jacocoCleanAll") { state => + val targets = enabledUnder(state) + if (targets.isEmpty) { println("[jacoco] nothing to clean (no enabled modules under this aggregate)."); state } + else targets.foldLeft(state) { (st, ref) => Command.process(s"${ref.project}/jacocoClean", st) } + } + + private lazy val jacocoReportAllCmd = Command.command("jacocoReportAll") { state => + val targets = enabledUnder(state) + if (targets.isEmpty) { println("[jacoco] nothing to report (no enabled modules under this aggregate)."); state } + else targets.foldLeft(state) { (st, ref) => Command.process(s"${ref.project}/jacocoReport", st) } + } + + // ---- global defaults so keys exist everywhere (safe no-ops on projects without the plugin) + override def buildSettings: Seq[Def.Setting[_]] = Seq( + jacocoPluginEnabled := false, // overridden to true in projects that enable the plugin + // register commands + a convenient alias like sbt-jacoco had + commands ++= Seq(jacocoCleanAllCmd, jacocoReportAllCmd) + ) + + private def findOnCp(cp: Seq[Attributed[File]])(p: File => Boolean): Option[File] = + cp.map(_.data).find(p) + + private def agentJar(cp: Seq[Attributed[File]]): File = { + val files = cp.map(_.data) + files.find(f => f.getName.startsWith("org.jacoco.agent-") && f.getName.contains("-runtime")) + .orElse(files.find(f => f.getName.contains("jacoco") && f.getName.contains("agent") && f.getName.contains("runtime"))) + .orElse(files.find(f => f.getName.startsWith("org.jacoco.agent-") && f.getName.endsWith(".jar"))) // last resort + .getOrElse(sys.error("JaCoCo runtime agent JAR not found on Test / dependencyClasspath")) + } + + private def cliJar(cp: Seq[Attributed[File]]): File = { + val files = cp.map(_.data) + files.find(f => f.getName.startsWith("org.jacoco.cli-") && f.getName.contains("nodeps")) + .orElse(files.find(_.getName.startsWith("org.jacoco.cli-"))) // fallback, but we won't use it + .getOrElse(sys.error("org.jacoco.cli (nodeps) JAR not found on Test / dependencyClasspath")) + } + + private val defaultIncludes = Seq("**") + private val defaultExcludes = Seq("scala.*", "java.*", "sun.*", "jdk.*") + + override def projectSettings: Seq[Setting[_]] = Seq( + jacocoPluginEnabled := false, + + // ---- coordinates + jacocoVersion := "0.8.12", + jmfCoreVersion := "0.1.7", + libraryDependencies ++= Seq( + // pull the agent with the runtime classifier (this is the actual -javaagent jar) + ("org.jacoco" % "org.jacoco.agent" % jacocoVersion.value % Test).classifier("runtime"), + ("org.jacoco" % "org.jacoco.cli" % jacocoVersion.value % Test).classifier("nodeps"), + "io.github.moranaapps" % "jacoco-method-filter-core_2.12" % jmfCoreVersion.value % Jmf.name, + ), + jacocoSetUserDirToBuildRoot := true, + + // ---- defaults + jacocoExecFile := target.value / "jacoco" / "jacoco.exec", + jacocoReportDir := target.value / "jacoco" / "report", + jacocoIncludes := defaultIncludes, + jacocoExcludes := defaultExcludes, + jacocoAppend := false, + jacocoFailOnMissingExec := false, + + jacocoReportName := { + val moduleId = thisProject.value.id // or: thisProjectRef.value.project + s"Report: $moduleId - scala:${scalaVersion.value}" + }, + + // --- JMF tool wiring + ivyConfigurations += Jmf, + + jmfOutDir := target.value / "jmf", + jmfRulesFile:= (ThisBuild / baseDirectory).value / "jmf-rules.txt", + jmfCliMain := "io.moranaapps.jacocomethodfilter.CoverageRewriter", + jmfDryRun := false, + jmfEnabled := true, + + // the rewrite task (your code, lightly cleaned) + jmfRewrite := { + val log = streams.value.log + val enabled = jacocoPluginEnabled.value + + // ensure classes exist (safe to always do; test would compile anyway) + val _ = (Compile / compile).value + + val classesIn = (Compile / classDirectory).value + val rules = jmfRulesFile.value + val upd = (Jmf / update).value // hoisted + + if (!enabled) classesIn + else if (!classesIn.exists) { + log.warn(s"[jmf] compiled classes dir not found, skipping: ${classesIn.getAbsolutePath}"); classesIn + } else { + val hasClasses = (classesIn ** sbt.GlobFilter("*.class")).get.nonEmpty + if (!hasClasses) { log.warn(s"[jmf] no .class files under ${classesIn.getAbsolutePath}; skipping."); classesIn } + else if (!rules.exists) { log.warn(s"[jmf] rules file missing: ${rules.getAbsolutePath}; skipping."); classesIn } + else { + val outDir = jmfOutDir.value / "classes-filtered" + IO.delete(outDir); IO.createDirectory(outDir) + + val toolJars = upd.matching(artifactFilter(`type` = "jar")).distinct + log.info("[jmf] tool CP:\n" + toolJars.map(f => s" - ${f.getAbsolutePath}").mkString("\n")) + + val cpStr = toolJars.mkString(java.io.File.pathSeparator) + val args = Seq("java","-cp", cpStr, jmfCliMain.value, + "--in", classesIn.getAbsolutePath, + "--out", outDir.getAbsolutePath, + "--rules", rules.getAbsolutePath) ++ + (if (jmfDryRun.value) Seq("--dry-run") else Seq()) + + log.info(s"[jmf] rewrite: ${args.mkString(" ")}") + val code = scala.sys.process.Process(args, baseDirectory.value).! + if (code != 0) sys.error(s"[jmf] rewriter failed ($code)") + outDir + } + } + }, + + // 1) preparatory task (already defined earlier) + jmfPrepareForTests := Def.taskDyn { + if (jmfEnabled.value) Def.task { jmfRewrite.value; () } + else Def.task { () } + }.value, + + Test / fullClasspath := Def.taskDyn { + // Gather the usual ingredients + val testOut = (Test / classDirectory).value // test classes dir + val mainOut = (Compile / classDirectory).value // original main classes dir + val deps = (Test / internalDependencyClasspath).value + val ext = (Test / externalDependencyClasspath).value + val unmanaged = (Test / unmanagedClasspath).value + val scalaJars = (Test / scalaInstance).value.allJars.map(Attributed.blank(_)).toVector + val resources = (Test / resourceDirectories).value.map(Attributed.blank) + + def build(rewrittenOpt: Option[File]) = Def.task { + val rewrittenDifferent = rewrittenOpt.filter(_ != mainOut) + val prefix = rewrittenDifferent.toVector.map(Attributed.blank) :+ Attributed.blank(testOut) + val rest = (deps ++ ext ++ scalaJars ++ resources ++ unmanaged) + .filterNot(a => a.data == mainOut || a.data == testOut || rewrittenDifferent.exists(_ == a.data)) + (prefix ++ rest :+ Attributed.blank(mainOut)) + } + + if (jacocoPluginEnabled.value) build(Some(jmfRewrite.value)) + else build(None) + }.value, + + // ---- fork so -javaagent is applied + Test / fork := true, + + // Attach agent for Test + Test / forkOptions := { + val fo0 = (Test / forkOptions).value + val rootDir = (LocalRootProject / baseDirectory).value + val baseFO = fo0.withWorkingDirectory(rootDir) // keep tests running from repo root + + // pre-compute values (avoids sbt linter warning about .value inside if) + val cp = (Test / dependencyClasspath).value + val agent = agentJar(cp) + val dest = jacocoExecFile.value.getAbsolutePath + val inc = jacocoIncludes.value.mkString(":") + val exc = jacocoExcludes.value.mkString(":") + val append = if (jacocoAppend.value) "true" else "false" + val agentOpt = + s"-javaagent:${agent.getAbsolutePath}=destfile=$dest,append=$append,output=file,includes=$inc,excludes=$exc,inclbootstrapclasses=false,jmx=false" + + val log = streams.value.log + log.info(s"[jacoco] setting fork working dir to: $rootDir") + + if (jacocoPluginEnabled.value) { + log.info(s"[jacoco] agent jar: ${agent.getName} (enabled)") + baseFO.withRunJVMOptions(baseFO.runJVMOptions :+ agentOpt) + } else { + log.info("[jacoco] disabled (jacocoPluginEnabled=false); NOT adding -javaagent") + baseFO + } + }, + + // Print one sanity line per test fork + Test / testOptions += Tests.Setup { () => + val status = + try { + val rt = Class.forName("org.jacoco.agent.rt.RT") + val m = rt.getMethod("getAgent") + m.invoke(null) // throws if not attached + "attached" + } catch { + case _: ClassNotFoundException => "rt-jar-not-on-classpath" + case _: Throwable => "present-but-not-attached" + } + println(s"[jacoco] agent status: $status; user.dir=" + System.getProperty("user.dir")) + }, + + + // ---- per-module clean + jacocoClean := { + val log = streams.value.log + val outDir = target.value / "jacoco" + IO.delete(outDir) + IO.createDirectory(outDir) + IO.delete(jmfOutDir.value) + + // remove sbt-jacoco leftovers if they ever existed + val instrDir = (Test / crossTarget).value / "jacoco" / "instrumented-classes" + if (instrDir.exists) { + log.info(s"[jacoco] removing sbt-jacoco leftovers: ${instrDir.getAbsolutePath}") + IO.delete(instrDir) + } + log.info(s"[jacoco] cleaned: ${outDir.getAbsolutePath}") + }, + + // ---- per-module report (only this module, no merge) + jacocoReport := { + val log = streams.value.log + val execFile = jacocoExecFile.value + val reportDir = jacocoReportDir.value + IO.createDirectory(reportDir) + + // PRE-compute (avoid linter warnings) + val moduleName = name.value + val baseDir = baseDirectory.value + val failOnMiss = jacocoFailOnMissingExec.value + val cp = (Test / dependencyClasspath).value + val cli = cliJar(cp) + val title = jacocoReportName.value + + // Class dirs (filter to existing) + val classesIn = (Compile / classDirectory).value + val filteredDir = jmfOutDir.value / "classes-filtered" + val mainClasses = + if (jacocoPluginEnabled.value && filteredDir.exists) filteredDir + else classesIn + + val classDirs = Seq(mainClasses).filter(_.exists) + + // Source dirs: unmanaged + managed (filter to existing) + val unmanagedSrc = (Compile / unmanagedSourceDirectories).value + val managedSrc = (Compile / managedSourceDirectories).value + val srcDirs = (unmanagedSrc ++ managedSrc).filter(_.exists) + + if (!execFile.exists) { + val msg = s"[jacoco] .exec not found for $moduleName: $execFile . Run tests first." + if (failOnMiss) sys.error(msg) else { log.warn(msg); reportDir } + } else if (classDirs.isEmpty) { + log.warn(s"[jacoco] no class dirs for $moduleName; skipping report.") + reportDir + } else { + // repeat flags per path + val args = Seq("java","-jar", cli.getAbsolutePath, "report", execFile.getAbsolutePath) ++ + classDirs.flatMap(d => Seq("--classfiles", d.getAbsolutePath)) ++ + srcDirs .flatMap(d => Seq("--sourcefiles", d.getAbsolutePath)) ++ + Seq("--name", title, + "--html", reportDir.getAbsolutePath, + "--xml", (reportDir / "jacoco.xml").getAbsolutePath, + "--csv", (reportDir / "jacoco.csv").getAbsolutePath) + + val exit = scala.sys.process.Process(args, baseDir).! + if (exit != 0) sys.error("JaCoCo report generation failed") + log.info(s"[jacoco] per-module HTML: ${reportDir / "index.html"}") + reportDir + } + } + ) +} diff --git a/project/JacocoBaseKeysPlugin.scala b/project/JacocoBaseKeysPlugin.scala new file mode 100644 index 0000000..1746f3b --- /dev/null +++ b/project/JacocoBaseKeysPlugin.scala @@ -0,0 +1,26 @@ +import sbt._ +import sbt.Keys._ + +object JacocoBaseKeysPlugin extends AutoPlugin { + object autoImport { + val jacocoPluginEnabled = settingKey[Boolean]("Marker for JaCoCo plugin participation") + val jacocoClean = taskKey[Unit]("Clean JaCoCo outputs") + val jacocoReport = taskKey[File]("Generate per-module JaCoCo report") + } + import autoImport._ + + // apply to every project (project scope → target.value etc. are valid) + override def trigger = allRequirements + override def requires = plugins.JvmPlugin + + override def projectSettings: Seq[Def.Setting[_]] = Seq( + jacocoPluginEnabled := false, // default: not participating + jacocoClean := { streams.value.log.debug("[jacoco] not enabled here; clean no-op.") }, + jacocoReport := { + val d = target.value / "jacoco" / "report" // safe placeholder dir + IO.createDirectory(d) + streams.value.log.debug("[jacoco] not enabled here; report no-op.") + d + } + ) +} diff --git a/project/plugins.sbt b/project/plugins.sbt index 771d0f2..38f2bc5 100644 --- a/project/plugins.sbt +++ b/project/plugins.sbt @@ -17,23 +17,4 @@ addSbtPlugin("com.earldouglas" % "xsbt-web-plugin" % "4.2.4") addSbtPlugin("de.heikoseeberger" % "sbt-header" % "5.9.0") addSbtPlugin("com.github.sbt" % "sbt-ci-release" % "1.5.12") -// sbt-jacoco - workaround related dependencies required to download -lazy val ow2Version = "9.5" -lazy val jacocoVersion = "0.8.11-absa.1" - -def jacocoUrl(artifactName: String): String = s"https://github.com/AbsaOSS/jacoco/releases/download/$jacocoVersion/org.jacoco.$artifactName-$jacocoVersion.jar" -def ow2Url(artifactName: String): String = s"https://repo1.maven.org/maven2/org/ow2/asm/$artifactName/$ow2Version/$artifactName-$ow2Version.jar" - -addSbtPlugin("com.jsuereth" %% "scala-arm" % "2.0" from "https://repo1.maven.org/maven2/com/jsuereth/scala-arm_2.11/2.0/scala-arm_2.11-2.0.jar") -addSbtPlugin("com.jsuereth" %% "scala-arm" % "2.0" from "https://repo1.maven.org/maven2/com/jsuereth/scala-arm_2.12/2.0/scala-arm_2.12-2.0.jar") - -addSbtPlugin("za.co.absa.jacoco" % "report" % jacocoVersion from jacocoUrl("report")) -addSbtPlugin("za.co.absa.jacoco" % "core" % jacocoVersion from jacocoUrl("core")) -addSbtPlugin("za.co.absa.jacoco" % "agent" % jacocoVersion from jacocoUrl("agent")) -addSbtPlugin("org.ow2.asm" % "asm" % ow2Version from ow2Url("asm")) -addSbtPlugin("org.ow2.asm" % "asm-commons" % ow2Version from ow2Url("asm-commons")) -addSbtPlugin("org.ow2.asm" % "asm-tree" % ow2Version from ow2Url("asm-tree")) - -addSbtPlugin("za.co.absa.sbt" % "sbt-jacoco" % "3.4.1-absa.3" from "https://github.com/AbsaOSS/sbt-jacoco/releases/download/3.4.1-absa.3/sbt-jacoco-3.4.1-absa.3.jar") - addDependencyTreePlugin