From fd71939df0c5e29911c8b1b1e21eb4baea7dec5a Mon Sep 17 00:00:00 2001 From: Claw Date: Wed, 22 Jul 2026 09:19:09 -0700 Subject: [PATCH] =?UTF-8?q?Enable=20JaCoCo=20with=20=E2=89=A560%=20happy-p?= =?UTF-8?q?ath=20line=20coverage=20gate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire JaCoCo reports across plugins unit-tested modules, add a filtered happy-path coverage report/verification (core engine + pure config/deps/jvm surface), and fail check/build under 60% LINE covered ratio. Extra pure unit tests for deps model/catalog factories; document in TEST-COVERAGE.md. --- .github/workflows/main.yml | 2 + README.md | 1 + docs/PROGRESS.md | 22 ++ docs/TEST-COVERAGE.md | 68 ++++++ plugins/build.gradle.kts | 8 + .../buildSrc/src/main/kotlin/formaCoverage.kt | 227 ++++++++++++++++++ .../deps/catalog/CatalogFactoriesTest.kt | 77 ++++++ .../core/DepsModelAndPluginHappyPathTest.kt | 106 ++++++++ 8 files changed, 511 insertions(+) create mode 100644 docs/TEST-COVERAGE.md create mode 100644 plugins/buildSrc/src/main/kotlin/formaCoverage.kt create mode 100644 plugins/deps/src/test/kotlin/tools/forma/deps/catalog/CatalogFactoriesTest.kt create mode 100644 plugins/deps/src/test/kotlin/tools/forma/deps/core/DepsModelAndPluginHappyPathTest.kt diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 0d02d3e5..4fc6ec2b 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -27,6 +27,8 @@ jobs: uses: gradle/actions/setup-gradle@v4 - name: Build plugins working-directory: plugins + # `build` → `check` → jacocoHappyPathCoverageVerification (LINE ≥60% happy-path). + # Reports: build/reports/jacoco/jacocoHappyPathReport/ run: ./gradlew build --stacktrace --console=plain build_includer: diff --git a/README.md b/README.md index 2061424c..bdeb9896 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,7 @@ systems like Buck and Bazel. 12. [Bazel adapter spike](bazel-adapter/README.md) — generate/check BUILD from Forma model (F-041; JVM-first) 13. [Bazel sample (experimental)](bazel-sample/) — minimal `kt_jvm_*` workspace exercising forma-core matrix + `impl` ↛ `impl` (F-042) 14. [**Progressive examples + agent skills**](docs/PROGRESSIVE-EXAMPLES.md) — feature-by-feature ladders (`examples/`) + coding-agent skills (F-050) +15. [**Test coverage**](docs/TEST-COVERAGE.md) — JaCoCo on `plugins/`, happy-path LINE ≥60% gate Configuration made easy: diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index 21d87a51..00d37368 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -2,6 +2,28 @@ Newest entries first. +## 2026-07-22 — Plugins JaCoCo + happy-path ≥60% gate + +- **Branch:** `forma/test-coverage-jacoco` (from origin/v2) +- **Ask:** run full test suite, enable coverage, ensure ≥60% happy-path coverage +- **Actions:** + - JaCoCo 0.8.13 on `plugins/` subprojects (`formaCoverage.kt` in buildSrc) + - Per-module `jacocoTestReport` finalized from `test` + - Aggregate `jacocoRootReport` (core/config/deps/jvm) + - **Happy-path** report + verification: pure engine/catalog/registry/config model + classes only (see `docs/TEST-COVERAGE.md`); LINE **COVEREDRATIO ≥ 0.60** + - Wired into root `check` (so `./gradlew build` / CI Plugins job enforce) + - Extra pure unit tests: `DepsModelAndPluginHappyPathTest`, `CatalogFactoriesTest` + - CI comment on Plugins job; README link +- **Full test suite (host, real runs):** + - `plugins/`: `./gradlew cleanTest test jacocoHappyPathCoverageVerification jacocoRootReport` → **BUILD SUCCESSFUL** + - Happy-path LINE **~95.7%** (branch ~79%, instruction ~88%) — above 60% + - `includer/`: test + functionalTest → SUCCESS + - `depgen/`: test + functionalTest → SUCCESS + - `bazel-adapter/`: test → SUCCESS +- **Blockers:** none +- **Next:** board top `todo` (unchanged by this infra slice) + ## 2026-07-22 — F-091: BuildFeatures under Forma (GH #88) - **Ticket:** F-091 → `done` diff --git a/docs/TEST-COVERAGE.md b/docs/TEST-COVERAGE.md new file mode 100644 index 00000000..9515a750 --- /dev/null +++ b/docs/TEST-COVERAGE.md @@ -0,0 +1,68 @@ +# Test coverage (plugins) + +Forma enables **JaCoCo** on the `plugins/` multi-project and gates **happy-path** +line coverage at **≥ 60%**. + +## Commands + +From `plugins/`: + +```bash +source ../scripts/env-mac.sh # mac host JDK/SDK env when needed +./gradlew test # unit tests + per-module jacocoTestReport +./gradlew jacocoHappyPathReport +./gradlew jacocoHappyPathCoverageVerification # fails if happy-path LINE < 60% +./gradlew jacocoRootReport # all unit-tested modules (core/config/deps/jvm) +./gradlew check # includes happy-path verification +./gradlew build # CI path — also enforces the gate +``` + +Reports: + +| Report | Path | +|--------|------| +| Happy-path HTML | `plugins/build/reports/jacoco/jacocoHappyPathReport/html/index.html` | +| Aggregate HTML | `plugins/build/reports/jacoco/jacocoRootReport/html/index.html` | +| Per-module | `plugins//build/reports/jacoco/test/html/index.html` | + +## What is “happy path”? + +Pure, unit-tested engine surface (no AGP TestKit / `Project` apply paths): + +| Area | Classes | +|------|---------| +| **forma-core** | all `tools.forma.core.**` | +| **config model** | `AndroidProjectSettings`, `FormaBuildFeatures`, `FormaSettingsStore`, dependency-validation helpers | +| **deps pure** | catalog `Generators*`, `TargetPlugin*` registry, configuration/dep model types | +| **jvm targets** | `tools.forma.jvm.target.**` | + +**Out of the gate** (still reported in aggregate when present): Android DSL apply, +`applyDependencies`, fleet Gradle tasks, plugin entrypoints, Settings +`projectDependencies` / `VersionCatalogBuilder` wiring. Those are covered by +sample `application/` builds and progressive examples, not line-count unit suites. + +Implementation: `plugins/buildSrc/src/main/kotlin/formaCoverage.kt`. + +## Other test suites + +CI / full local verify also runs (separate Gradle trees): + +| Tree | Command | +|------|---------| +| `plugins/` | `./gradlew build` (tests + happy-path coverage) | +| `includer/` | `./gradlew build` (unit + functionalTest) | +| `depgen/` | `./gradlew build` (unit + functionalTest) | +| `bazel-adapter/` | `./gradlew test` | +| `application/` | `./gradlew build` (sample app; needs Android SDK) | + +## Baseline (2026-07-22) + +Measured on host after enabling the gate: + +| Scope | LINE | BRANCH | INSTRUCTION | +|-------|------|--------|-------------| +| **Happy-path gate set** | **~96%** | ~79% | ~88% | +| Aggregate unit-tested modules | ~44% | ~41% | ~44% | +| `:core` alone | ~95% | ~72% | ~88% | + +The gate fails the build if happy-path **LINE** covered ratio drops below **0.60**. diff --git a/plugins/build.gradle.kts b/plugins/build.gradle.kts index d0de37c8..303b8d7c 100644 --- a/plugins/build.gradle.kts +++ b/plugins/build.gradle.kts @@ -1,6 +1,7 @@ plugins { `kotlin-dsl-base` id("com.gradle.plugin-publish") version "1.2.1" apply false + jacoco } // Shared Plugin Portal metadata for all subprojects (F-016 / GH #132). @@ -19,6 +20,13 @@ formaPluginConfiguration { tags = listOf("kotlin", "android", "structure", "target", "rules", "project") } +// Jacoco reports on unit-tested modules + LINE ≥60% gate on happy-path class set. +// See formaCoverage.kt and docs/TEST-COVERAGE.md. +subprojects { + configureFormaCoverage() +} +registerFormaAggregateCoverageReport() + /** * Publish every plugin module + :core to ~/.m2/repository for consumer testing * without includeBuild / Plugin Portal credentials. diff --git a/plugins/buildSrc/src/main/kotlin/formaCoverage.kt b/plugins/buildSrc/src/main/kotlin/formaCoverage.kt new file mode 100644 index 00000000..b5bdddae --- /dev/null +++ b/plugins/buildSrc/src/main/kotlin/formaCoverage.kt @@ -0,0 +1,227 @@ +import org.gradle.api.Project +import org.gradle.api.file.FileCollection +import org.gradle.api.file.FileTree +import org.gradle.api.tasks.testing.Test +import org.gradle.kotlin.dsl.apply +import org.gradle.kotlin.dsl.configure +import org.gradle.kotlin.dsl.named +import org.gradle.kotlin.dsl.register +import org.gradle.kotlin.dsl.withType +import org.gradle.testing.jacoco.plugins.JacocoPlugin +import org.gradle.testing.jacoco.plugins.JacocoPluginExtension +import org.gradle.testing.jacoco.plugins.JacocoTaskExtension +import org.gradle.testing.jacoco.tasks.JacocoCoverageVerification +import org.gradle.testing.jacoco.tasks.JacocoReport +import java.math.BigDecimal +import java.util.concurrent.Callable + +/** + * Modules that run unit tests and contribute to Jacoco reports. + * Platform facades without unit suites (android/target/…) are sample-build covered. + */ +internal val coverageReportModules = setOf("core", "config", "deps", "jvm") + +/** + * Minimum LINE covered ratio for the **happy-path** class set (see + * [happyPathClassIncludes]). Instruction counters are noisier on Kotlin. + */ +internal val minHappyPathLineCoverage: BigDecimal = BigDecimal("0.60") + +/** + * Jacoco class include patterns for happy-path surface: + * - forma-core engine (all packages) + * - config data model (not plugin entry) + * - deps catalog name generators + type-owned plugin registry + * - jvm target registry (not AGP/DSL apply paths) + * + * Gradle Project apply paths (`applyDependencies`, fleet tasks, plugin mains, + * VersionCatalogBuilder Settings API) stay out of the gate — they need TestKit. + */ +internal val happyPathClassIncludes = + listOf( + "**/tools/forma/core/**", + "**/tools/forma/config/AndroidProjectSettings*", + "**/tools/forma/config/FormaBuildFeatures*", + "**/tools/forma/config/FormaSettingsStore*", + "**/tools/forma/config/DependencyValidation*", + "**/tools/forma/deps/catalog/Generators*", + "**/tools/forma/deps/core/TargetPlugin*", + "**/tools/forma/deps/core/DefaultTargetPlugin*", + "**/tools/forma/deps/core/ConfigurationType*", + "**/tools/forma/deps/core/CustomConfiguration*", + "**/tools/forma/deps/core/Implementation*", + "**/tools/forma/deps/core/CompileOnly*", + "**/tools/forma/deps/core/RuntimeOnly*", + "**/tools/forma/deps/core/AnnotationProcessor*", + "**/tools/forma/deps/core/Kapt*", + "**/tools/forma/deps/core/Ksp*", + "**/tools/forma/deps/core/DepSpec*", + "**/tools/forma/deps/core/TargetSpec*", + "**/tools/forma/deps/core/FileSpec*", + "**/tools/forma/deps/core/NameSpec*", + "**/tools/forma/deps/core/PlatformSpec*", + "**/tools/forma/deps/core/FormaDependency*", + "**/tools/forma/deps/core/EmptyDependency*", + "**/tools/forma/deps/core/NamedDependency*", + "**/tools/forma/deps/core/PlatformDependency*", + "**/tools/forma/deps/core/TargetDependency*", + "**/tools/forma/deps/core/FileDependency*", + "**/tools/forma/deps/core/MixedDependency*", + "**/tools/forma/jvm/target/**", + ) + +/** Apply Jacoco reporting to a plugins subproject (when it has Java + tests). */ +fun Project.configureFormaCoverage() { + pluginManager.withPlugin("java") { + apply() + + extensions.configure { + toolVersion = "0.8.13" + } + + tasks.withType().configureEach { + extensions.configure { + isIncludeNoLocationClasses = true + excludes = listOf("jdk.internal.*") + } + finalizedBy(tasks.named("jacocoTestReport")) + } + + tasks.named("jacocoTestReport") { + dependsOn(tasks.withType()) + reports { + xml.required.set(true) + html.required.set(true) + csv.required.set(false) + } + } + } +} + +/** + * Root aggregate report + **happy-path coverage gate** (LINE ≥ 60%). + * + * ./gradlew test jacocoRootReport jacocoHappyPathCoverageVerification + * open build/reports/jacoco/jacocoRootReport/html/index.html + * open build/reports/jacoco/jacocoHappyPathReport/html/index.html + */ +fun Project.registerFormaAggregateCoverageReport() { + apply() + + fun coverageSubprojects(): List = + subprojects.filter { it.name in coverageReportModules } + + fun mainClassDirs(): FileCollection = + files( + Callable { + coverageSubprojects().flatMap { sp -> + listOf( + sp.layout.buildDirectory.dir("classes/kotlin/main").get().asFile, + sp.layout.buildDirectory.dir("classes/java/main").get().asFile, + ).filter { it.exists() } + } + } + ) + + fun happyPathClassDirs(): FileTree = + mainClassDirs().asFileTree.matching { + happyPathClassIncludes.forEach { include(it) } + } + + fun mainSourceDirs(): FileCollection = + files( + Callable { + coverageSubprojects().flatMap { sp -> + listOf(sp.file("src/main/java"), sp.file("src/main/kotlin")).filter { it.exists() } + } + } + ) + + fun execData(): FileCollection = + files( + Callable { + coverageSubprojects().map { + it.layout.buildDirectory.file("jacoco/test.exec").get().asFile + }.filter { it.exists() } + } + ) + + fun testTaskPaths(): List = + coverageSubprojects().map { "${it.path}:test" } + + tasks.register("jacocoRootReport") { + group = "verification" + description = "Aggregate Jacoco report for unit-tested Forma plugin modules" + + dependsOn(Callable { testTaskPaths() }) + dependsOn(Callable { coverageSubprojects().map { "${it.path}:jacocoTestReport" } }) + + classDirectories.from(mainClassDirs()) + sourceDirectories.from(mainSourceDirs()) + executionData.from(execData()) + + reports { + xml.required.set(true) + html.required.set(true) + csv.required.set(false) + } + + doLast { + logger.lifecycle("Aggregate Jacoco HTML: ${reports.html.outputLocation.get().asFile}") + } + } + + val happyPathReport = + tasks.register("jacocoHappyPathReport") { + group = "verification" + description = + "Jacoco report for Forma happy-path classes only (core engine + pure deps/config/jvm)" + + dependsOn(Callable { testTaskPaths() }) + + classDirectories.from(happyPathClassDirs()) + sourceDirectories.from(mainSourceDirs()) + executionData.from(execData()) + + reports { + xml.required.set(true) + html.required.set(true) + csv.required.set(false) + } + + doLast { + logger.lifecycle( + "Happy-path Jacoco HTML: ${reports.html.outputLocation.get().asFile}" + ) + } + } + + tasks.register("jacocoHappyPathCoverageVerification") { + group = "verification" + description = + "Fail if happy-path LINE coverage is below " + + "${minHappyPathLineCoverage.multiply(BigDecimal(100)).toPlainString()}%" + + dependsOn(happyPathReport) + + classDirectories.from(happyPathClassDirs()) + sourceDirectories.from(mainSourceDirs()) + executionData.from(execData()) + + violationRules { + rule { + isEnabled = true + limit { + counter = "LINE" + value = "COVEREDRATIO" + minimum = minHappyPathLineCoverage + } + } + } + } + + // Wire into check so `./gradlew check` / CI `build` enforces the bar. + tasks.named("check") { + dependsOn("jacocoHappyPathCoverageVerification") + } +} diff --git a/plugins/deps/src/test/kotlin/tools/forma/deps/catalog/CatalogFactoriesTest.kt b/plugins/deps/src/test/kotlin/tools/forma/deps/catalog/CatalogFactoriesTest.kt new file mode 100644 index 00000000..fb3b898d --- /dev/null +++ b/plugins/deps/src/test/kotlin/tools/forma/deps/catalog/CatalogFactoriesTest.kt @@ -0,0 +1,77 @@ +package tools.forma.deps.catalog + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNull + +/** + * Catalog factory happy path (no Settings / VersionCatalogBuilder Gradle runtime). + */ +class CatalogFactoriesTest { + + @Test + fun `library factory keeps explicit name and default generator`() { + val named = library("io.coil-kt:coil:2.1.0", name = "coil") + assertEquals("io.coil-kt:coil:2.1.0", named.groupArtifactVersion) + assertEquals("coil", named.name) + + val auto = library("com.jakewharton.timber:timber:5.0.1") + assertNull(auto.name) + assertEquals("jakewhartonTimber", auto.nameGenerator(auto.groupArtifactVersion)) + } + + @Test + fun `bundle factory keeps member coordinates`() { + val b = + bundle( + name = "room", + "androidx.room:room-runtime:2.5.1", + "androidx.room:room-ktx:2.5.1", + ) + assertEquals("room", b.name) + assertEquals(2, b.groupArtifactVersions.size) + assertEquals( + "roomRuntime", + b.nameGenerator(b.groupArtifactVersions[0] as String) + ) + } + + @Test + fun `plugin factory keeps id version configuration and companion deps`() { + val p = + plugin( + id = "com.google.devtools.ksp:symbol-processing-gradle-plugin", + version = "2.0.21-1.0.28", + configuration = tools.forma.deps.core.CustomConfiguration("ksp"), + "androidx.room:room-compiler:2.5.1", + ) + assertEquals("com.google.devtools.ksp:symbol-processing-gradle-plugin", p.id) + assertEquals("2.0.21-1.0.28", p.version) + assertEquals("ksp", p.configuration?.name) + assertEquals(1, p.dependencies.size) + assertEquals( + "devtoolsKspSymbolProcessing", + p.nameGenerator(p.id) + ) + } + + @Test + fun `generateName rejects blank after filtering`() { + assertFailsWith { + generateName( + tokens = listOf("com", "android", "plugin"), + source = "com.android.plugin", + kind = "plugin", + ) + } + } + + @Test + fun `defaultNameGenerator keeps distinct non-filtered tokens`() { + assertEquals( + "squareupOkhttp3Okhttp", + defaultNameGenerator("com.squareup.okhttp3:okhttp:4.12.0") + ) + } +} diff --git a/plugins/deps/src/test/kotlin/tools/forma/deps/core/DepsModelAndPluginHappyPathTest.kt b/plugins/deps/src/test/kotlin/tools/forma/deps/core/DepsModelAndPluginHappyPathTest.kt new file mode 100644 index 00000000..9a17b706 --- /dev/null +++ b/plugins/deps/src/test/kotlin/tools/forma/deps/core/DepsModelAndPluginHappyPathTest.kt @@ -0,0 +1,106 @@ +package tools.forma.deps.core + +import java.io.File +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertSame +import kotlin.test.assertTrue +import tools.forma.core.target.targetType + +/** + * Happy-path unit coverage for pure deps model + type-owned plugin registry helpers + * (no Gradle Project apply paths). + */ +class DepsModelAndPluginHappyPathTest { + + @Test + fun `configuration type names match Gradle config ids`() { + assertEquals("implementation", Implementation.name) + assertEquals("compileOnly", CompileOnly.name) + assertEquals("runtimeOnly", RuntimeOnly.name) + assertEquals("annotationProcessor", AnnotationProcessor.name) + assertEquals("kapt", Kapt.name) + assertEquals("ksp", Ksp.name) + assertEquals("ksp", CustomConfiguration("ksp").name) + } + + @Test + fun `dependency model constructors preserve specs`() { + val name = NameSpec("g:a:1", Implementation, transitive = true) + val platform = PlatformSpec("g:bom:1", Implementation) + val file = FileSpec(File("/tmp/x.jar"), CompileOnly) + + val named = NamedDependency(listOf(name)) + val plat = PlatformDependency(listOf(platform)) + val files = FileDependency(listOf(file)) + val mixed = MixedDependency(names = listOf(name), files = listOf(file)) + + assertEquals(listOf(name), named.names) + assertEquals(listOf(platform), plat.names) + assertEquals(listOf(file), files.files) + assertEquals(listOf(name), mixed.names) + assertEquals(listOf(file), mixed.files) + assertSame(EmptyDependency, EmptyDependency) + assertTrue(EmptyDependency.dependency.isEmpty()) + } + + @Test + fun `targetPlugin factory defaults empty companion deps`() { + val spec = targetPlugin("androidx.navigation.safeargs.kotlin") + assertEquals("androidx.navigation.safeargs.kotlin", spec.id) + assertSame(EmptyDependency, spec.dependencies) + + val withDeps = targetPlugin("x.y", NamedDependency(listOf(NameSpec("g:a:1", Ksp)))) + assertEquals("x.y", withDeps.id) + assertTrue(withDeps.dependencies is NamedDependency) + } + + @Test + fun `DefaultTargetPluginRegistry all snapshot and empty get`() { + val registry = DefaultTargetPluginRegistry() + val type = targetType("test.snap-${System.nanoTime()}", "res") + assertTrue(registry.get(type).isEmpty()) + assertTrue(registry.all().isEmpty()) + + registry.register(type, listOf(targetPlugin("p.one"), targetPlugin("p.two"))) + assertEquals(listOf("p.one", "p.two"), registry.get(type).map { it.id }) + assertEquals(1, registry.all().size) + assertEquals(listOf("p.one", "p.two"), registry.all().getValue(type).map { it.id }) + } + + @Test + fun `registerTargetPlugin no-ops on empty varargs`() { + val type = targetType("test.empty-reg-${System.nanoTime()}", "impl") + registerTargetPlugin(type) + assertTrue(TargetPluginRegistry.get(type).isEmpty()) + } + + @Test + fun `deriveTargetType without coreRegistry still attaches plugins`() { + val base = targetType("test.base-only-${System.nanoTime()}", "res") + val derived = + deriveTargetType( + id = "test.derived-only-${System.nanoTime()}", + base = base, + plugins = listOf(targetPlugin("plugin.only")), + coreRegistry = null, + ) + assertEquals("res", derived.nameSuffix) + assertEquals(listOf("plugin.only"), TargetPluginRegistry.get(derived).map { it.id }) + } + + @Test + fun `deriveTargetType without base registration uses empty allow-list fallback`() { + val core = tools.forma.core.target.DefaultTargetRegistry() + val missingBase = targetType("test.missing-base-${System.nanoTime()}", "res") + val derived = + deriveTargetType( + id = "test.derived-fallback-${System.nanoTime()}", + base = missingBase, + plugins = emptyList(), + coreRegistry = core, + ) + val reg = core.all().first { it.type.id == derived.id } + assertTrue(reg.allowedDependencies.isEmpty()) + } +}