diff --git a/.agents/memory/MEMORY.md b/.agents/memory/MEMORY.md index bd335ea3..a118d87f 100644 --- a/.agents/memory/MEMORY.md +++ b/.agents/memory/MEMORY.md @@ -16,6 +16,7 @@ See [README.md](README.md) for the format and routing rules. - [config-build-verification](project/config-build-verification.md) — config root has no `build` task; verify buildSrc via `./gradlew :buildSrc:test detekt` with JAVA_HOME exported. - [plugin-testkit-assertions-live-in-tool-base](project/plugin-testkit-assertions-live-in-tool-base.md) — Generic Gradle-plugin functional-test assertions (testkit-truth) belong in tool-base/plugin-testlib, not per-plugin `*-testlib` modules. - [gradle-10-third-party-deprecations](project/gradle-10-third-party-deprecations.md) — Two Gradle 9.6 deprecation nags come from Detekt and Gradle Doctor (not our build logic) — don't chase them in `buildSrc`; Kover's was fixed by bumping to 0.9.9. +- [pom-report-per-project-collectors](project/pom-report-per-project-collectors.md) — `generatePom` must never resolve other projects' configurations; capturing `rootComponent` `Provider`s does not help — per-project collector tasks are the working design. ## Reference (external systems) diff --git a/.agents/memory/project/pom-report-per-project-collectors.md b/.agents/memory/project/pom-report-per-project-collectors.md new file mode 100644 index 00000000..d05f596b --- /dev/null +++ b/.agents/memory/project/pom-report-per-project-collectors.md @@ -0,0 +1,36 @@ +--- +name: pom-report-per-project-collectors +description: generatePom must never resolve other projects' configurations; capturing rootComponent Providers does not help — per-project collector tasks are the working design. +metadata: + type: project + since: 2026-08-13 +--- + +The `generatePom` report collects the versions selected by dependency resolution +through per-project `collectResolvedVersions` tasks (`ResolvedVersions.kt` in +`buildSrc`), each resolving only the configurations of its own project and writing +a file the root task merges. Do not "simplify" this back to resolving from the +root task, and do not replace it with `incoming.resolutionResult.rootComponent` +`Provider`s captured at configuration time: a `Provider` resolves lazily, so its +first `.get()` from the root task still performs cross-project resolution and +fails Gradle's exclusive-lock check (`IllegalResolutionException`, hard error +since Gradle 9.x). + +**Why:** the pre-2026-08 implementation resolved subproject configurations inside +the root task's `doLast`, swallowed the lock failure at `info`, and fell back to +declared versions — producing a `pom.xml` that differed between `gradle build` +and `gradle generatePom` and emitting false "several versions" warnings +(discovered in `compiler`, task `pom-report-cross-project-resolution`). Both the +direct and the captured-`Provider` variants were disproved empirically on Gradle 9.6.1. + +**How to apply:** when changing the pom report or porting it, keep resolution +inside each project's own task. Keep the full `isCanBeResolved` configuration +scope: declared dependencies are collected from *all* configurations, so +narrowing resolution to the source-set classpaths reintroduces declared-version +fallbacks for plugin-owned configurations (e.g. `kotlin-build-tools-impl` on +`kotlinBuildToolsApiClasspath`) and with them the false warnings. Do not wrap +`resolutionResult.allComponents` in a defensive catch: reading the graph is +lenient (unresolvable modules, `failOnVersionConflict()` casualties, and +crashing resolution rules all become `UnresolvedDependencyResult` edges — probed +on Gradle 9.6.1), so a catch is dead code that can only hide real bugs. The +regression guard is `PomGeneratorIgTest` (Gradle TestKit). diff --git a/.agents/tasks/pom-report-cross-project-resolution.md b/.agents/tasks/pom-report-cross-project-resolution.md new file mode 100644 index 00000000..078218ae --- /dev/null +++ b/.agents/tasks/pom-report-cross-project-resolution.md @@ -0,0 +1,205 @@ +--- +slug: pom-report-cross-project-resolution +branch: fix-pom-report-resolution +owner: claude +status: in-review +started: 2026-08-12 +related-memories: + - pom-report-per-project-collectors +--- + +## Goal + +Make `generatePom` produce the **same** `docs/dependencies/pom.xml` regardless of +which tasks ran before it, and stop it from emitting false "The project uses +several versions of `X`" warnings for artifacts whose version conflict is already +settled by a `force(...)` directive. + +Success looks like: `./gradlew generatePom --rerun-tasks` and +`./gradlew clean build` write byte-identical `pom.xml` files, and every +"several versions" warning names a genuinely unreconciled artifact. + +## Context + +Discovered on 2026-08-12 while forcing dependency versions in the `compiler` +repo (branch `bump-tool-base`), where the same `./gradlew` invocation reported +different version conflicts depending on how it was launched. + +`docs/dependencies/pom.xml` is committed to every consumer repo and is expected +to be regenerated in each PR, so a non-deterministic generator produces +spurious diffs and hides real ones. + +Only `pom.xml` is affected. `docs/dependencies/dependencies.md` comes from +`LicenseReporter.mergeAllReports` and uses a different code path. + +### Symptom + +In the `compiler` repo, two invocations disagree about which artifacts conflict: + +```bash +./gradlew clean build +``` + +reports exactly one conflict — a real one, `spine-time` genuinely resolved to +two versions across modules: + +``` +The project uses several versions of `io.spine:spine-time` dependency. +module: api, configuration: implementation, version: 2.0.0-SNAPSHOT.250 +module: params, configuration: implementation, version: 2.0.0-SNAPSHOT.244 +``` + +while + +```bash +./gradlew generatePom --rerun-tasks +``` + +reports two entirely different conflicts, **both false positives**: + +``` +The project uses several versions of `org.jetbrains.kotlin:kotlin-build-tools-impl` dependency. +module: compiler, configuration: kotlinBuildToolsApiClasspath, version: 2.3.21 +module: api, configuration: kotlinBuildToolsApiClasspath, version: null + +The project uses several versions of `io.spine:spine-validation-jvm-runtime` dependency. +module: api, configuration: implementation, version: 2.0.0-SNAPSHOT.460 +module: backend, configuration: implementation, version: 2.0.0-SNAPSHOT.446 +``` + +`spine-validation-jvm-runtime` is already forced to `.460` in the `compiler` +root `build.gradle.kts`, and `dependencyInsight` confirms it resolves to `.460` +on `compileClasspath`, `runtimeClasspath`, `testCompileClasspath`, and +`testRuntimeClasspath` of `:backend`. The `.446` in the report is the version +the CoreJvm Compiler plugin *declares*, never the one used. + +### Root cause + +1. `PomGenerator.applyTo` registers `generatePom` on the **root** project + ([`PomGenerator.kt:86`][pom-generator]), with the report written from + a `doLast` action. +2. That action reaches into every subproject: + `collectScopedDependencies` iterates `subprojects` and calls + `subproject.resolvedVersions()` ([`DependencyWriter.kt:150`][dependency-writer]), + which touches `configuration.incoming.resolutionResult.allComponents` for + *every resolvable configuration* of *every subproject* + ([`DependencyWriter.kt:208`, `:218-220`][dependency-writer]). +3. Gradle 9.6 forbids resolving another project's configuration from a task + action that does not hold that project's lock: + + ``` + org.gradle.api.internal.artifacts.configurations.DefaultConfiguration$IllegalResolutionException: + Resolution of the configuration ':api:compileClasspath' was attempted + without an exclusive lock. This is unsafe and not allowed. + ``` + +4. The `catch (e: Exception)` at [`DependencyWriter.kt:209`][dependency-writer] + swallows this and logs at `info`, which is invisible at the default log + level. The configuration contributes no versions. +5. `depsFromAllConfigurations` then falls back to the **declared** version via + `?: dependency.version` ([`DependencyWriter.kt:176-177`][dependency-writer]), + silently defeating the whole point of the "report the resolved version" + behaviour introduced in `56a72c23`. + +The reason a full build looks correct is incidental: by the time root `build` +finalizes into `generatePom`, each subproject has already resolved its own +classpaths through its own tasks, so the cached resolution result is returned +without a fresh resolve. Nothing in the task graph guarantees this — it is a +side effect of the invocation, which is exactly why the output is unstable. + +Confirm the scale of the degradation with: + +```bash +./gradlew generatePom --rerun-tasks --info 2>&1 | grep -c "Skipping configuration" +``` + +Roughly 40 configurations per subproject are skipped in the standalone run. + +## Plan + +- [x] Reproduce in `config` itself (or a consumer repo) and capture the + before/after `pom.xml` for a regression fixture. + - Reproduced twice: in a scratch multi-project build (exact + `IllegalResolutionException`), and in `compiler` itself (both false + warnings, verbatim). The regression fixture is `PomGeneratorIgTest`, + which drives a real multi-project build via Gradle TestKit. +- [x] Make version collection lock-safe. ~~Preferred: resolve at **configuration + time** into a task input~~ — **disproved empirically**: the + `rootComponent` `Provider` is lazy, so its first `.get()` from the root + task's `doLast` still resolves cross-project on the root task's thread + and fails with the same `IllegalResolutionException`. Implemented the + alternative instead: a per-project `collectResolvedVersions` task + (`ResolvedVersions.kt`) resolves only the configurations of its own + project — lock-safe by construction — and writes `group:name=version` + lines under `build/pom/`; `generatePom` depends on the collectors and + merges their outputs. Mirrors the `LicenseReporter` per-project + merge-task structure. +- [x] ~~Narrow the set of configurations consulted~~ — **rejected, on + purpose**: declared dependencies are collected from *all* configurations, + so dropping resolution of plugin-owned ones would fall back to declared + versions exactly where the false positives live. The + `kotlin-build-tools-impl` warning above is the counterexample: the + artifact sits only on `kotlinBuildToolsApiClasspath` (declared `2.3.21` + in one module, version-less in another), so with resolution narrowed to + the four source-set classpaths the report would again warn and emit + version-less entries. The full `isCanBeResolved` scope is kept; the cost + concern is addressed by the collectors running in parallel, one per project. +- [x] Stop the silent degradation: the catch is **removed entirely**. Probed + empirically on Gradle 9.6.1: reading a resolution graph is lenient — an + unresolvable module, a `failOnVersionConflict()` casualty, and even a + crashing `eachDependency` rule all become `UnresolvedDependencyResult` + edges and contribute no version; none of them throws from + `allComponents`. So there is no expected exception to catch: the report + cannot break the build by Gradle's own design, and anything actually + thrown (such as the lock error this task fixes) is a bug that now fails + the collector loudly instead of being swallowed. +- [x] Decide what the declared-version fallback should mean once resolution is + reliable: kept, and documented as legitimate — with per-project collectors + the lock failure cannot occur, so a module absent from the resolved map + really is on no resolvable configuration (e.g., BOM-managed), and the + declared version is what the build uses. +- [x] Extend the specs: `PomGeneratorIgTest` runs `generatePom` via TestKit + (parallel execution on) over a root + two subprojects with a local + metadata-only Maven repo. Covers: a `force(...)`-pinned artifact reported + at the forced version with no warning; a genuine cross-module conflict + still warned and reported at the newest version; standalone + `generatePom` and `clean build` writing identical files. + `DependencyWriterSpec` keeps all cases via spec-local helpers over the + new injection point. +- [x] Verify determinism: covered by `PomGeneratorIgTest` and confirmed on + `compiler` — see the log entry below. +- [x] Re-check the `compiler` repo: the `spine-validation-jvm-runtime` and + `kotlin-build-tools-impl` false warnings are gone. The `spine-time` + conflict no longer exists on `bump-tool-base` (all modules resolve + `2.0.0-SNAPSHOT.250` now — reconciled after this task was drafted), so no + warning is the correct report; genuine-conflict reporting is locked by + the functional test instead. + +## Log + +- 2026-08-12 — drafted from findings in the `compiler` repo (branch + `bump-tool-base`). Not started; branch not yet created. +- 2026-08-13 — reproduced both candidate designs in a scratch build on + Gradle 9.6.1: direct `doLast` resolution and the captured-`Provider` variant + both fail with `IllegalResolutionException`; a per-project collector task + works and observes `force(...)` per module. Implemented the collector design. +- 2026-08-13 — verified on `compiler` (clean tree, fixed `buildSrc` overlaid + temporarily, then restored): standalone `generatePom -x assemble + --rerun-tasks` previously emitted both false warnings and wrote a `pom.xml` + missing a dozen versions and a whole artifact (`detekt-cli`); with the fix it + emits no warnings and writes a file **byte-identical to the committed + `pom.xml`** produced by a full build. `:buildSrc:build detekt` passes. +- 2026-08-13 — four review agents ran (`spine-code-review`, `kotlin-engineer`, + `gradle-review`, `review-docs`). Applied: `group = SpineTaskGroup.name` on + the collector task; tests driving a whole-configuration resolution failure + (unit + TestKit, via `failOnVersionConflict()`); KDoc link fixes; a comment + explaining the deliberate absence of input/output wiring. Writing the + requested resolution-failure test disproved the reviewers' (and the plan's) + premise that such a failure throws: the graph API is lenient (see the + reworked "silent degradation" item above), so the catch was removed rather + than narrowed. Deliberately not applied: typed `CommandLineArgumentProvider` + (optional per reviewer; the main runtime classpath is already tracked via + the test task's own classpath) and the `Project.dependencies()` rename + (pre-existing public name). + +[pom-generator]: ../../buildSrc/src/main/kotlin/io/spine/gradle/report/pom/PomGenerator.kt +[dependency-writer]: ../../buildSrc/src/main/kotlin/io/spine/gradle/report/pom/DependencyWriter.kt diff --git a/buildSrc/build.gradle.kts b/buildSrc/build.gradle.kts index 8c2bdb33..99d000fe 100644 --- a/buildSrc/build.gradle.kts +++ b/buildSrc/build.gradle.kts @@ -213,11 +213,20 @@ dependencies { testImplementation(platform("org.junit:junit-bom:$junitVersion")) testImplementation("org.junit.jupiter:junit-jupiter") testImplementation("io.kotest:kotest-assertions-core:$kotestVersion") + testImplementation(gradleTestKit()) testRuntimeOnly("org.junit.platform:junit-platform-launcher") } tasks.test { useJUnitPlatform() + + // Functional tests run real Gradle builds via TestKit and inject the production + // classes of `buildSrc` into the build script classpath of those builds. + // The argument provider defers resolving the classpath to execution time. + val mainClasspath = sourceSets.main.get().runtimeClasspath + jvmArgumentProviders.add(CommandLineArgumentProvider { + listOf("-DbuildSrc.classpath=${mainClasspath.asPath}") + }) } dependOnBuildSrcJar() diff --git a/buildSrc/src/main/kotlin/io/spine/dependency/lib/Jackson.kt b/buildSrc/src/main/kotlin/io/spine/dependency/lib/Jackson.kt index 7d078ed9..9ef91fce 100644 --- a/buildSrc/src/main/kotlin/io/spine/dependency/lib/Jackson.kt +++ b/buildSrc/src/main/kotlin/io/spine/dependency/lib/Jackson.kt @@ -33,7 +33,8 @@ import io.spine.dependency.DependencyWithBom * Jackson library dependencies. * * Jackson 3.x uses the `tools.jackson` group ID and the matching `tools.jackson.*` - * packages (JSTEP-1). The sole exception is `jackson-annotations`: Jackson 3.x keeps + * packages ([JSTEP-1](https://github.com/FasterXML/jackson-future-ideas/wiki/JSTEP-1)). + * The sole exception is `jackson-annotations`: Jackson 3.x keeps * consuming the 2.x artifact, so both its coordinates and its * `com.fasterxml.jackson.annotation` package stay unchanged. * @@ -152,7 +153,7 @@ object Jackson : DependencyWithBom() { val javaXMoney = "$group:$infix-javax-money" // https://github.com/FasterXML/jackson-datatypes-misc/tree/3.x/moneta - val moneta = "$group:jackson-datatype-moneta" + val moneta = "$group:$infix-moneta" override val modules = listOf( guava, diff --git a/buildSrc/src/main/kotlin/io/spine/dependency/lib/JacksonV2.kt b/buildSrc/src/main/kotlin/io/spine/dependency/lib/JacksonV2.kt index 256918f8..1a2d62bf 100644 --- a/buildSrc/src/main/kotlin/io/spine/dependency/lib/JacksonV2.kt +++ b/buildSrc/src/main/kotlin/io/spine/dependency/lib/JacksonV2.kt @@ -33,8 +33,10 @@ import io.spine.dependency.DependencyWithBom * Jackson 2.x dependencies. * * Jackson 2.x artifacts keep the `com.fasterxml.jackson.*` group IDs, unlike - * Jackson 3.x, which moved to `tools.jackson` (JSTEP-1). We declare the 2.x line - * to align the versions of the artifacts pulled transitively by third-party + * Jackson 3.x, which moved to `tools.jackson` + * ([JSTEP-1](https://github.com/FasterXML/jackson-future-ideas/wiki/JSTEP-1)). + * + * We declare the 2.x line to align the versions of the artifacts pulled transitively by third-party * dependencies, while our own code uses Jackson 3.x declared by [Jackson]. * * The `jackson-annotations` artifact, although it belongs to the 2.x line, is diff --git a/buildSrc/src/main/kotlin/io/spine/dependency/local/Base.kt b/buildSrc/src/main/kotlin/io/spine/dependency/local/Base.kt index 5e777498..f18db326 100644 --- a/buildSrc/src/main/kotlin/io/spine/dependency/local/Base.kt +++ b/buildSrc/src/main/kotlin/io/spine/dependency/local/Base.kt @@ -33,8 +33,8 @@ package io.spine.dependency.local */ @Suppress("ConstPropertyName", "unused") object Base { - const val version = "2.0.0-SNAPSHOT.426" - const val versionForBuildScript = "2.0.0-SNAPSHOT.426" + const val version = "2.0.0-SNAPSHOT.440" + const val versionForBuildScript = "2.0.0-SNAPSHOT.440" const val group = Spine.group private const val prefix = "spine" const val libModule = "$prefix-base" diff --git a/buildSrc/src/main/kotlin/io/spine/dependency/local/Compiler.kt b/buildSrc/src/main/kotlin/io/spine/dependency/local/Compiler.kt index b2f2372d..69288934 100644 --- a/buildSrc/src/main/kotlin/io/spine/dependency/local/Compiler.kt +++ b/buildSrc/src/main/kotlin/io/spine/dependency/local/Compiler.kt @@ -72,7 +72,7 @@ object Compiler : Dependency() { * The version of the Compiler dependencies. */ override val version: String - private const val fallbackVersion = "2.0.0-SNAPSHOT.064" + private const val fallbackVersion = "2.0.0-SNAPSHOT.066" /** * The distinct version of the Compiler used by other build tools. @@ -81,7 +81,7 @@ object Compiler : Dependency() { * transitive dependencies, this is the version used to build the project itself. */ val dogfoodingVersion: String - private const val fallbackDfVersion = "2.0.0-SNAPSHOT.064" + private const val fallbackDfVersion = "2.0.0-SNAPSHOT.066" /** * The artifact for the Compiler Gradle plugin. diff --git a/buildSrc/src/main/kotlin/io/spine/dependency/local/CoreJvm.kt b/buildSrc/src/main/kotlin/io/spine/dependency/local/CoreJvm.kt index d60780d0..a06ad279 100644 --- a/buildSrc/src/main/kotlin/io/spine/dependency/local/CoreJvm.kt +++ b/buildSrc/src/main/kotlin/io/spine/dependency/local/CoreJvm.kt @@ -39,7 +39,7 @@ typealias CoreJava = CoreJvm @Suppress("ConstPropertyName", "unused") object CoreJvm { const val group = Spine.group - const val version = "2.0.0-SNAPSHOT.522" + const val version = "2.0.0-SNAPSHOT.523" const val coreArtifact = "spine-core" const val clientArtifact = "spine-client" diff --git a/buildSrc/src/main/kotlin/io/spine/dependency/local/CoreJvmCompiler.kt b/buildSrc/src/main/kotlin/io/spine/dependency/local/CoreJvmCompiler.kt index c8d28e7f..8309e478 100644 --- a/buildSrc/src/main/kotlin/io/spine/dependency/local/CoreJvmCompiler.kt +++ b/buildSrc/src/main/kotlin/io/spine/dependency/local/CoreJvmCompiler.kt @@ -46,12 +46,12 @@ object CoreJvmCompiler { /** * The version used in the build classpath. */ - const val dogfoodingVersion = "2.0.0-SNAPSHOT.080" + const val dogfoodingVersion = "2.0.0-SNAPSHOT.082" /** * The version to be used for integration tests. */ - const val version = "2.0.0-SNAPSHOT.080" + const val version = "2.0.0-SNAPSHOT.082" /** * The ID of the Gradle plugin. diff --git a/buildSrc/src/main/kotlin/io/spine/dependency/local/ToolBase.kt b/buildSrc/src/main/kotlin/io/spine/dependency/local/ToolBase.kt index 5a742dfa..5b945e28 100644 --- a/buildSrc/src/main/kotlin/io/spine/dependency/local/ToolBase.kt +++ b/buildSrc/src/main/kotlin/io/spine/dependency/local/ToolBase.kt @@ -34,8 +34,8 @@ package io.spine.dependency.local @Suppress("ConstPropertyName", "unused") object ToolBase { const val group = Spine.toolsGroup - const val version = "2.0.0-SNAPSHOT.404" - const val dogfoodingVersion = "2.0.0-SNAPSHOT.404" + const val version = "2.0.0-SNAPSHOT.410" + const val dogfoodingVersion = "2.0.0-SNAPSHOT.410" const val lib = "$group:tool-base:$version" const val classicCodegen = "$group:classic-codegen:$version" diff --git a/buildSrc/src/main/kotlin/io/spine/dependency/local/Validation.kt b/buildSrc/src/main/kotlin/io/spine/dependency/local/Validation.kt index 6348fa21..9abb839d 100644 --- a/buildSrc/src/main/kotlin/io/spine/dependency/local/Validation.kt +++ b/buildSrc/src/main/kotlin/io/spine/dependency/local/Validation.kt @@ -36,7 +36,7 @@ object Validation { /** * The version of the Validation library artifacts. */ - const val version = "2.0.0-SNAPSHOT.460" + const val version = "2.0.0-SNAPSHOT.462" const val group = Spine.toolsGroup private const val prefix = "validation" diff --git a/buildSrc/src/main/kotlin/io/spine/gradle/report/pom/DependencyWriter.kt b/buildSrc/src/main/kotlin/io/spine/gradle/report/pom/DependencyWriter.kt index 2a2a4c1c..2b9e5e5b 100644 --- a/buildSrc/src/main/kotlin/io/spine/gradle/report/pom/DependencyWriter.kt +++ b/buildSrc/src/main/kotlin/io/spine/gradle/report/pom/DependencyWriter.kt @@ -32,9 +32,7 @@ import java.io.Writer import java.util.* import kotlin.reflect.full.isSubclassOf import org.gradle.api.Project -import org.gradle.api.artifacts.Configuration import org.gradle.api.artifacts.Dependency -import org.gradle.api.artifacts.result.ResolvedComponentResult import org.gradle.api.internal.artifacts.dependencies.AbstractExternalModuleDependency import org.gradle.kotlin.dsl.withGroovyBuilder @@ -75,9 +73,16 @@ private constructor( /** * Creates the `DependencyWriter` for the passed [project]. + * + * The version of each dependency is taken from the map returned by + * [resolvedVersionsOf] for the project the dependency comes from. + * See the [dependencies] extension function for details. */ - fun of(project: Project): DependencyWriter { - return DependencyWriter(project.dependencies()) + fun of( + project: Project, + resolvedVersionsOf: (Project) -> Map + ): DependencyWriter { + return DependencyWriter(project.dependencies(resolvedVersionsOf)) } } @@ -111,37 +116,17 @@ private constructor( } } -/** - * Returns the [scoped dependencies][ScopedDependency] of a Gradle project. - * - * The version of each dependency is the one selected by dependency resolution - * for the project it comes from. See [resolvedVersions]. - */ -fun Project.dependencies(): SortedSet = - collectScopedDependencies { it.resolvedVersions() } - -/** - * Returns the [scoped dependencies][ScopedDependency] of a Gradle project, taking - * the version of each dependency from the given [resolvedVersions] map instead of - * resolving the project's own configurations. - * - * This overload exists for tests: a project created with `ProjectBuilder` cannot - * resolve its configurations against real repositories, so the resolved versions - * are supplied directly. The keys are the `"group:name"` of the modules. - */ -internal fun Project.dependencies( - resolvedVersions: Map -): SortedSet = - collectScopedDependencies { resolvedVersions } - /** * Collects the [scoped dependencies][ScopedDependency] of this project and its * subprojects, deduplicates them, and returns them in the conventional Maven order. * * The version of each dependency is taken from the map returned by the supplied - * `resolvedVersionsOf` function for the project the dependency comes from. + * [resolvedVersionsOf] function for the project the dependency comes from — normally + * the versions selected by dependency resolution, as [collected][ResolvedVersions] + * by the per-project tasks the [PomGenerator] registers. Tests supply the map + * directly, or resolve in place via [resolvedVersions]. */ -private fun Project.collectScopedDependencies( +internal fun Project.dependencies( resolvedVersionsOf: (Project) -> Map ): SortedSet { val dependencies = mutableSetOf() @@ -161,9 +146,10 @@ private fun Project.collectScopedDependencies( * Returns the external dependencies of the project from all the project configurations. * * The version of each returned dependency is taken from [resolvedVersions] by its - * `"group:name"` key, falling back to the declared version when the module is on no - * resolvable configuration — for example, a version managed by a BOM, which carries - * no explicit version of its own. + * `"group:name"` key. When the module is absent from the map — i.e., it is on no + * resolvable configuration of the project, as with a version managed by a BOM, which + * carries no explicit version of its own — the declared version is what the build + * uses, so it is reported as the fallback. */ private fun Project.depsFromAllConfigurations( resolvedVersions: Map @@ -183,46 +169,6 @@ private fun Project.depsFromAllConfigurations( return result } -/** - * Returns the versions selected by dependency resolution for this project, keyed - * by the `"group:name"` of each module. - * - * The declared version of a dependency is what the build script *requested*, which - * may differ from what the build *uses*: a `force(...)`, a platform/BOM constraint, - * or Gradle's conflict resolution can all select another version. Reading the - * resolution result captures the selected version, so the report describes the - * dependencies actually on the classpath rather than the requested ones. - * - * Only resolvable configurations contribute. When a module resolves to different - * versions across configurations, the newest one (by [VersionComparator]) is kept, - * matching the deduplication applied afterwards. A configuration that fails to - * resolve in isolation is skipped and logged, so the report never breaks the build. - */ -private fun Project.resolvedVersions(): Map { - // Resolving an individual configuration may fail for reasons unrelated to the - // report — missing repositories for a niche configuration, an unsatisfiable - // constraint, and the like. Such a configuration contributes no versions. - @Suppress("TooGenericExceptionCaught") // Any resolution failure is non-fatal here. - fun componentsOf(configuration: Configuration): Set = - try { - configuration.incoming.resolutionResult.allComponents - } catch (e: Exception) { - logger.info( - "Skipping configuration `${configuration.name}` " + - "while collecting resolved dependency versions.", - e - ) - emptySet() - } - - return configurations - .filter { it.isCanBeResolved } - .flatMap { componentsOf(it) } - .mapNotNull { it.moduleVersion } - .groupBy { moduleKey(it.group, it.name) } - .mapValues { (_, versions) -> versions.maxOfWith(VersionComparator) { it.version } } -} - /** * Builds the `"group:name"` key under which a module's resolved version is recorded * and looked up. @@ -231,7 +177,7 @@ private fun Project.resolvedVersions(): Map { * consistent with what [resolvedVersions] records and with the grouping done by * [deduplicate]. */ -private fun moduleKey(group: String?, name: String): String = "$group:$name" +internal fun moduleKey(group: String?, name: String): String = "$group:$name" /** * Tells whether the dependency is an external module dependency. diff --git a/buildSrc/src/main/kotlin/io/spine/gradle/report/pom/PomGenerator.kt b/buildSrc/src/main/kotlin/io/spine/gradle/report/pom/PomGenerator.kt index 9ecb3624..8f163e7d 100644 --- a/buildSrc/src/main/kotlin/io/spine/gradle/report/pom/PomGenerator.kt +++ b/buildSrc/src/main/kotlin/io/spine/gradle/report/pom/PomGenerator.kt @@ -61,6 +61,16 @@ import org.gradle.api.plugins.BasePlugin * them. If the project does not have these values, and they are not specified in the `ext` * block, the resulting `pom.xml` file is going to contain empty blocks, * e.g., ``. + * + * The version reported for each dependency is the one selected by dependency + * resolution. A task of one project must not resolve the configurations of + * another, so `generatePom` does not resolve anything itself. Instead, a helper + * task named [ResolvedVersions.taskName] is registered for the project passed + * to [applyTo] and each of its subprojects. Every helper resolves only the + * configurations of its own project and stores the result under its build + * directory; `generatePom` depends on the helpers and merges their outputs. + * This keeps the generated file the same no matter which other tasks run in + * the same Gradle invocation. */ @Suppress("unused") object PomGenerator { @@ -83,15 +93,22 @@ object PomGenerator { plugin(BasePlugin::class.java) } + val collectors = project.allprojects.map { ResolvedVersions.registerTaskIn(it) } + val task = project.tasks.register("generatePom") { group = SpineTaskGroup.name description = "Generates a `pom.xml` file describing project dependencies" + // Plain ordering on purpose: both the collectors and this task declare + // no inputs or outputs, so they always run. Do not replace this with + // input/output wiring — up-to-date skipping would reintroduce the + // stale-report bug this design cures. + dependsOn(collectors) doLast { val pomFile = Paths.outputFile(project.rootDir, pomFilename) pomFile.parentFile.mkdirs() val projectData = project.metadata() - val writer = PomXmlWriter(projectData) + val writer = PomXmlWriter(projectData, ResolvedVersions::readFrom) writer.writeTo(pomFile) } diff --git a/buildSrc/src/main/kotlin/io/spine/gradle/report/pom/PomXmlWriter.kt b/buildSrc/src/main/kotlin/io/spine/gradle/report/pom/PomXmlWriter.kt index 7d03a59d..25cb3786 100644 --- a/buildSrc/src/main/kotlin/io/spine/gradle/report/pom/PomXmlWriter.kt +++ b/buildSrc/src/main/kotlin/io/spine/gradle/report/pom/PomXmlWriter.kt @@ -31,6 +31,7 @@ import io.spine.gradle.report.pom.PomFormatting.writeStart import java.io.File import java.io.FileWriter import java.io.StringWriter +import org.gradle.api.Project /** * Writes the dependencies of a Gradle project and its subprojects as a `pom.xml` file. @@ -38,10 +39,15 @@ import java.io.StringWriter * The resulting file is not usable for `maven` build tasks but serves as a description * of the first-level dependencies for each project or subproject. * Their transitive dependencies are not included in the result. + * + * The version of each dependency is taken from the map returned by + * [resolvedVersionsOf] for the project the dependency comes from. + * See the [dependencies] extension function for details. */ internal class PomXmlWriter internal constructor( - private val projectMetadata: ProjectMetadata + private val projectMetadata: ProjectMetadata, + private val resolvedVersionsOf: (Project) -> Map ) { /** @@ -77,7 +83,7 @@ internal constructor( */ private fun projectDependencies(): String { val destination = StringWriter() - val dependencyWriter = DependencyWriter.of(projectMetadata.project) + val dependencyWriter = DependencyWriter.of(projectMetadata.project, resolvedVersionsOf) dependencyWriter.writeXmlTo(destination) return destination.toString() } diff --git a/buildSrc/src/main/kotlin/io/spine/gradle/report/pom/ResolvedVersions.kt b/buildSrc/src/main/kotlin/io/spine/gradle/report/pom/ResolvedVersions.kt new file mode 100644 index 00000000..103b5496 --- /dev/null +++ b/buildSrc/src/main/kotlin/io/spine/gradle/report/pom/ResolvedVersions.kt @@ -0,0 +1,154 @@ +/* + * Copyright 2026, TeamDev. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Redistribution and use in source and/or binary forms, with or without + * modification, must retain the above copyright notice and the following + * disclaimer. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +package io.spine.gradle.report.pom + +import io.spine.gradle.SpineTaskGroup +import io.spine.gradle.VersionComparator +import java.io.File +import org.gradle.api.Project +import org.gradle.api.Task +import org.gradle.api.tasks.TaskProvider + +/** + * Stores the versions selected by dependency resolution, per project. + * + * Gradle forbids resolving the configurations of one project from a task of + * another: a task holds the lock of its own project only, and cross-project + * resolution fails with `IllegalResolutionException`. So the `generatePom` task + * of the root project cannot resolve the configurations of subprojects itself. + * Instead, [a task][registerTaskIn] is registered for every project, resolving + * only the configurations of its own project — which is always lock-safe — and + * storing the result under the project's build directory. `generatePom` depends + * on these tasks and [reads their outputs][readFrom]. + * + * This makes the report independent of which other tasks run in the same Gradle + * invocation. Reading `resolutionResult` from the root task directly worked only + * when a previous task of the owning project had already resolved the configuration, + * so the report differed between `gradle build` and `gradle generatePom`. + * + * @see PomGenerator + */ +internal object ResolvedVersions { + + /** + * The name of the per-project task registered by [registerTaskIn]. + */ + const val taskName = "collectResolvedVersions" + + /** + * The path to the output file of the [taskName] task within + * the build directory of its project. + */ + private const val relativePath = "pom/resolved-versions.txt" + + /** + * Registers the [taskName] task in the given [project]. + * + * The task resolves the resolvable configurations of this project only and + * writes the [resolved versions][resolvedVersions] to a file under the + * project's build directory, one `group:name=version` line per module. + * + * The task declares no inputs or outputs on purpose: it runs on every + * invocation, so the stored versions always reflect the current build + * scripts rather than a previously cached state. + * + * The task orders itself after `clean`: Gradle does not order the two + * otherwise, so in a `gradle clean build` invocation a late-running `clean` + * could delete a freshly written file. + */ + fun registerTaskIn(project: Project): TaskProvider = + project.tasks.register(taskName) { + group = SpineTaskGroup.name + description = "Collects the versions of dependencies of " + + "the `${project.name}` project selected by dependency resolution" + mustRunAfter(project.tasks.matching { it.name == "clean" }) + doLast { + val file = outputFileIn(project) + file.parentFile.mkdirs() + file.writeText(serialize(project.resolvedVersions())) + } + } + + /** + * Reads the versions stored by the [taskName] task of the given [project], + * keyed by the `"group:name"` of each module. + * + * Returns an empty map when the task has not run, e.g., for a project + * with no resolvable configurations in a test environment. + */ + fun readFrom(project: Project): Map { + val file = outputFileIn(project) + if (!file.exists()) { + return emptyMap() + } + return file.readLines() + .filter { it.isNotBlank() } + .associate { it.substringBefore('=') to it.substringAfter('=') } + } + + private fun outputFileIn(project: Project): File = + project.layout.buildDirectory.file(relativePath).get().asFile + + private fun serialize(versions: Map): String = + versions.entries + .sortedBy { it.key } + .joinToString(separator = "\n", postfix = "\n") { (module, version) -> + "$module=$version" + } +} + +/** + * Returns the versions selected by dependency resolution for this project, keyed + * by the `"group:name"` of each module. + * + * The declared version of a dependency is what the build script *requested*, which + * may differ from what the build *uses*: a `force(...)`, a platform/BOM constraint, + * or Gradle's conflict resolution can all select another version. Reading the + * resolution result captures the selected version, so the report describes the + * dependencies actually on the classpath rather than the requested ones. + * + * Only resolvable configurations of this project contribute. When a module resolves + * to different versions across configurations, the newest one (by [VersionComparator]) + * is kept, matching the deduplication applied by [DependencyWriter] afterwards. + * + * Reading a resolution graph is lenient: a module that cannot be resolved — be it + * missing from the repositories, or a casualty of `failOnVersionConflict()` — + * becomes an `UnresolvedDependencyResult` edge and simply contributes no version, + * never an exception. So the report cannot break the build, and no failure needs + * to be — or is — swallowed here: anything actually thrown is unexpected and + * fails the collecting task loudly. + * + * Must be called either from a task of this very project, or before the task + * execution starts — otherwise Gradle rejects the resolution as unsafe. + */ +internal fun Project.resolvedVersions(): Map = + configurations + .filter { it.isCanBeResolved } + .flatMap { it.incoming.resolutionResult.allComponents } + .mapNotNull { it.moduleVersion } + .groupBy { moduleKey(it.group, it.name) } + .mapValues { (_, versions) -> versions.maxOfWith(VersionComparator) { it.version } } diff --git a/buildSrc/src/test/kotlin/io/spine/gradle/report/pom/DependencyWriterSpec.kt b/buildSrc/src/test/kotlin/io/spine/gradle/report/pom/DependencyWriterSpec.kt index baec7877..ad5ebadc 100644 --- a/buildSrc/src/test/kotlin/io/spine/gradle/report/pom/DependencyWriterSpec.kt +++ b/buildSrc/src/test/kotlin/io/spine/gradle/report/pom/DependencyWriterSpec.kt @@ -28,11 +28,13 @@ package io.spine.gradle.report.pom import io.kotest.matchers.ints.shouldBeGreaterThan import io.kotest.matchers.ints.shouldBeLessThan +import io.kotest.matchers.maps.shouldNotContainKey import io.kotest.matchers.shouldBe import io.kotest.matchers.string.shouldContain import io.kotest.matchers.string.shouldNotContain import java.io.File import java.io.StringWriter +import java.util.SortedSet import org.gradle.api.Action import org.gradle.api.Project import org.gradle.api.artifacts.repositories.MavenArtifactRepository @@ -69,6 +71,30 @@ internal class DependencyWriterSpec { dependencies.add(configuration, notation) } + /** + * Collects the dependencies of this project and its subprojects, resolving + * their configurations in place. + * + * Feeds [resolvedVersions] of each project to the production collection code, + * mirroring what the per-project [ResolvedVersions] tasks provide in a real + * build. `ProjectBuilder`-based tests execute no tasks, so resolving the + * configurations of subprojects directly is safe here. + */ + private fun Project.dependencies(): SortedSet = + dependencies { it.resolvedVersions() } + + /** + * Collects the dependencies of this project and its subprojects, taking the + * version of each dependency from the given [resolved] map instead of + * resolving any configurations. + * + * A project created with `ProjectBuilder` cannot resolve its configurations + * against real repositories, so the resolved versions are supplied directly. + * The keys are the `"group:name"` of the modules. + */ + private fun Project.dependencies(resolved: Map): SortedSet = + dependencies { resolved } + @Nested inner class `merge an artifact duplicated across modules` { @@ -328,6 +354,30 @@ internal class DependencyWriterSpec { dependency.dependency().version shouldBe "1.0.40" } + /** + * A module failing to resolve — here, by being requested in two + * conflicting versions under `failOnVersionConflict()` — contributes + * no version instead of failing the collection: reading a resolution + * graph is lenient, recording the failure as an unresolved edge. + */ + @Test + fun `contribute no version for a module failing to resolve`(@TempDir repoDir: File) { + val group = "io.spine.validation" + val name = "spine-validation-java-runtime" + publishPom(repoDir, group, name, "1.0.40") + publishPom(repoDir, group, name, "1.0.61") + + val text = subproject("text") + text.addMavenRepository(repoDir) + val api = text.configurations.create("api") + api.isCanBeResolved = true + api.resolutionStrategy.failOnVersionConflict() + text.dependencies.add("api", "$group:$name:1.0.40") + text.dependencies.add("api", "$group:$name:1.0.61") + + text.resolvedVersions() shouldNotContainKey "$group:$name" + } + /** Writes a metadata-only Maven POM for the module under [repoDir]. */ private fun publishPom(repoDir: File, group: String, name: String, version: String) { val dir = File(repoDir, "${group.replace('.', '/')}/$name/$version") @@ -371,7 +421,7 @@ internal class DependencyWriterSpec { subproject("b-lib").declare("api", SPINE_BASE) val out = StringWriter() - DependencyWriter.of(rootProject).writeXmlTo(out) + DependencyWriter.of(rootProject) { it.resolvedVersions() }.writeXmlTo(out) val xml = out.toString() xml shouldContain "grpc-stub" @@ -385,7 +435,7 @@ internal class DependencyWriterSpec { subproject("b-lib").declare("api", SPINE_BASE) val out = StringWriter() - DependencyWriter.of(rootProject).writeXmlTo(out) + DependencyWriter.of(rootProject) { it.resolvedVersions() }.writeXmlTo(out) val xml = out.toString() xml shouldContain "spine-base" @@ -401,7 +451,7 @@ internal class DependencyWriterSpec { subproject("d-lib").declare("api", SPINE_BASE) val out = StringWriter() - DependencyWriter.of(rootProject).writeXmlTo(out) + DependencyWriter.of(rootProject) { it.resolvedVersions() }.writeXmlTo(out) val xml = out.toString() val compileAt = xml.indexOf("compile") diff --git a/buildSrc/src/test/kotlin/io/spine/gradle/report/pom/PomGeneratorIgTest.kt b/buildSrc/src/test/kotlin/io/spine/gradle/report/pom/PomGeneratorIgTest.kt new file mode 100644 index 00000000..cc5c83b5 --- /dev/null +++ b/buildSrc/src/test/kotlin/io/spine/gradle/report/pom/PomGeneratorIgTest.kt @@ -0,0 +1,282 @@ +/* + * Copyright 2026, TeamDev. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Redistribution and use in source and/or binary forms, with or without + * modification, must retain the above copyright notice and the following + * disclaimer. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +package io.spine.gradle.report.pom + +import io.kotest.matchers.shouldBe +import io.kotest.matchers.string.shouldContain +import io.kotest.matchers.string.shouldNotContain +import java.io.File +import org.gradle.testkit.runner.BuildResult +import org.gradle.testkit.runner.GradleRunner +import org.gradle.testkit.runner.TaskOutcome +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir + +/** + * Verifies the `generatePom` task against a real multi-project build, run via + * Gradle TestKit with parallel execution on, as in the SDK repositories. + * + * The fixture reproduces the situations that a `ProjectBuilder`-based test + * cannot: an artifact whose version conflict is settled by a `force(...)` + * directive, an artifact genuinely resolving to different versions across + * modules, and a configuration failing to resolve as a whole. Reporting the + * declared versions instead of the resolved ones — as happened when a + * standalone `generatePom` run failed to resolve the configurations of + * subprojects — makes the forced artifact look conflicting and the generated + * file differ between invocations. + */ +@DisplayName("`generatePom` task should") +internal class PomGeneratorIgTest { + + @TempDir + lateinit var projectDir: File + + @BeforeEach + fun setUpProject() { + file("settings.gradle.kts").writeText( + """ + rootProject.name = "pom-sample" + include("api", "backend") + """.trimIndent() + ) + file("gradle.properties").writeText("org.gradle.parallel=true") + file("build.gradle.kts").writeText( + """ + buildscript { + dependencies { + classpath(files( + ${buildSrcClasspath()} + )) + } + } + + group = "io.spine.sample" + version = "1.0.0" + + subprojects { + configurations.all { + resolutionStrategy.force("$FORCED_LIB:$FORCED_VERSION") + } + } + + io.spine.gradle.report.pom.PomGenerator.applyTo(project) + """.trimIndent() + ) + subproject( + name = "api", + forcedLibVersion = "2.0.1", + conflictingLibVersion = "3.0.1", + withUnresolvableConfiguration = true + ) + subproject( + name = "backend", + forcedLibVersion = "1.5.1", + conflictingLibVersion = "4.0.1" + ) + publishPom(FORCED_LIB, "1.0.1") + publishPom(FORCED_LIB, "1.5.1") + publishPom(FORCED_LIB, "2.0.1") + publishPom(CONFLICTING_LIB, "3.0.1") + publishPom(CONFLICTING_LIB, "4.0.1") + publishPom(STRICT_LIB, "5.0.1") + publishPom(STRICT_LIB, "6.0.1") + } + + /** + * A standalone `generatePom` run resolves nothing before the report, which + * is exactly the case that used to degrade to the declared versions: the + * forced artifact was reported as conflicting, with versions `2.0.1` and + * `1.5.1` never present on any classpath. + */ + @Test + fun `report the versions selected by dependency resolution`() { + val result = runGradle("generatePom") + + result.task(":generatePom")?.outcome shouldBe TaskOutcome.SUCCESS + result.task(":api:$COLLECTOR")?.outcome shouldBe TaskOutcome.SUCCESS + result.task(":backend:$COLLECTOR")?.outcome shouldBe TaskOutcome.SUCCESS + + val pom = pomFile().readText() + pom shouldContain "forced-lib" + pom shouldContain "$FORCED_VERSION" + pom shouldNotContain "2.0.1" + pom shouldNotContain "1.5.1" + + // Of a genuine cross-module conflict, the newest version is retained. + pom shouldContain "conflicting-lib" + pom shouldContain "4.0.1" + pom shouldNotContain "3.0.1" + + // A module failing to resolve — reading a resolution graph is lenient, + // so the failing configuration cannot break the build — falls back + // to the newest declared version. + pom shouldContain "strict-lib" + pom shouldContain "6.0.1" + pom shouldNotContain "5.0.1" + } + + @Test + fun `warn only about a genuinely unreconciled artifact`() { + val result = runGradle("generatePom") + + result.output shouldContain + "The project uses several versions of `$CONFLICTING_LIB` dependency." + // A module that fails to resolve is genuinely unreconciled, too: + // both declared versions fall back into the report, and the conflict + // between them is legitimately warned about. + result.output shouldContain + "The project uses several versions of `$STRICT_LIB` dependency." + result.output shouldNotContain "several versions of `$FORCED_LIB`" + } + + @Test + fun `write the same file for a standalone run and for a full build`() { + runGradle("generatePom") + val standalone = pomFile().readText() + + val fullBuild = runGradle("clean", "build") + + fullBuild.task(":generatePom")?.outcome shouldBe TaskOutcome.SUCCESS + pomFile().readText() shouldBe standalone + } + + private fun runGradle(vararg args: String): BuildResult = + GradleRunner.create() + .withProjectDir(projectDir) + .withArguments(*args, "--stacktrace") + .build() + + private fun pomFile(): File = file("docs/dependencies/pom.xml") + + private fun file(relativePath: String): File = projectDir.resolve(relativePath) + + /** + * Renders the classpath with the production classes of `buildSrc` as + * arguments of `files(...)`, for injection into the build script classpath + * of the generated build. + * + * The classpath comes from the `test` task in `buildSrc/build.gradle.kts`. + */ + private fun buildSrcClasspath(): String { + val classpath = requireNotNull(System.getProperty("buildSrc.classpath")) { + "The `buildSrc.classpath` system property is not set." + + " It is supplied by the `test` task in `buildSrc/build.gradle.kts`." + } + return classpath.split(File.pathSeparator).joinToString(",\n") { + " \"${File(it).invariantSeparatorsPath}\"" + } + } + + /** + * Writes the build script of a subproject declaring the fixture dependencies. + * + * With [withUnresolvableConfiguration], the subproject also declares + * an `unresolvable` configuration requesting two conflicting versions + * of [STRICT_LIB] under `failOnVersionConflict()`, so resolving that + * configuration as a whole fails. + */ + private fun subproject( + name: String, + forcedLibVersion: String, + conflictingLibVersion: String, + withUnresolvableConfiguration: Boolean = false + ) { + val header = """ + plugins { + `java-library` + } + + repositories { + maven { + url = uri(rootDir.resolve("repo")) + } + } + """.trimIndent() + val unresolvable = """ + val unresolvable by configurations.creating + unresolvable.resolutionStrategy.failOnVersionConflict() + """.trimIndent() + val dependencies = buildString { + appendLine("dependencies {") + appendLine(" implementation(\"$FORCED_LIB:$forcedLibVersion\")") + appendLine(" implementation(\"$CONFLICTING_LIB:$conflictingLibVersion\")") + if (withUnresolvableConfiguration) { + appendLine(" \"unresolvable\"(\"$STRICT_LIB:5.0.1\")") + appendLine(" \"unresolvable\"(\"$STRICT_LIB:6.0.1\")") + } + append("}") + } + val script = file("$name/build.gradle.kts") + script.parentFile.mkdirs() + val sections = listOfNotNull( + header, + unresolvable.takeIf { withUnresolvableConfiguration }, + dependencies + ) + script.writeText(sections.joinToString(separator = "\n\n", postfix = "\n")) + } + + /** Writes a metadata-only Maven POM for the module under the local repository. */ + private fun publishPom(module: String, version: String) { + val (group, name) = module.split(':') + val dir = file("repo/${group.replace('.', '/')}/$name/$version") + dir.mkdirs() + File(dir, "$name-$version.pom").writeText( + """ + + 4.0.0 + $group + $name + $version + + """.trimIndent() + ) + } + + private companion object { + + /** The `"group:name"` of the artifact pinned by a `force(...)` directive. */ + const val FORCED_LIB = "io.test:forced-lib" + + /** The version [FORCED_LIB] is forced to, older than any declared one. */ + const val FORCED_VERSION = "1.0.1" + + /** The `"group:name"` of the artifact resolving differently across modules. */ + const val CONFLICTING_LIB = "io.test:conflicting-lib" + + /** + * The `"group:name"` of the artifact declared in two conflicting versions + * under `failOnVersionConflict()`, failing its configuration as a whole. + */ + const val STRICT_LIB = "io.test:strict-lib" + + /** The name of the per-project version-collecting task. */ + const val COLLECTOR = ResolvedVersions.taskName + } +}