Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .agents/memory/MEMORY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
36 changes: 36 additions & 0 deletions .agents/memory/project/pom-report-per-project-collectors.md
Original file line number Diff line number Diff line change
@@ -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).
205 changes: 205 additions & 0 deletions .agents/tasks/pom-report-cross-project-resolution.md
Original file line number Diff line number Diff line change
@@ -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
9 changes: 9 additions & 0 deletions buildSrc/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
5 changes: 3 additions & 2 deletions buildSrc/src/main/kotlin/io/spine/dependency/lib/Jackson.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down Expand Up @@ -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,
Expand Down
6 changes: 4 additions & 2 deletions buildSrc/src/main/kotlin/io/spine/dependency/lib/JacksonV2.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions buildSrc/src/main/kotlin/io/spine/dependency/local/Base.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Loading