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
153 changes: 153 additions & 0 deletions .agents/tasks/up-to-date-compiler-tasks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
---
slug: up-to-date-compiler-tasks
branch: write-settings-before-launch (Changes 1–2)
owner: claude
status: in-progress
started: 2026-10-19
---

## Goal

`LaunchSpineCompiler` stays `UP-TO-DATE` when nothing it depends on has changed,
also in a fresh Gradle daemon and after `clean`. It reruns when a settings DSL
value changes. Two runs over the same inputs produce byte-identical sources.

## Context

Follow-ups from SpineEventEngine/compiler#94 ("Stop the integration-test build
from recompiling the root modules").

Findings (verified 2026-10-19 on Gradle 9.6.1):

- `LaunchSpineCompiler.settingsDirectory` (`build/spine/compiler/settings`) is an
`@InputFiles` property shared by all source sets of a project. The `compiler`
Gradle API has no way for other plugins to contribute settings; they write
files there directly.
- Validation writes `JavaValidationRenderer.pb.json` in a `doFirst` of every
`LaunchSpineCompiler`, i.e. after Gradle has fingerprinted the inputs.
In `core-jvm-compiler :annotation`:
- same daemon: next run `UP-TO-DATE` (the daemon's VFS keeps the snapshot
taken before `doFirst`, which hides the problem);
- fresh daemon: reruns with "file …JavaValidationRenderer.pb.json has been added";
- after `clean`: the cache key holds the 7 CoreJvm files only; `FROM-CACHE`
skips `doFirst`, so the renderer settings never affect the cache key.
- Changing the DSL without `clean` leaves the task `UP-TO-DATE` with stale
settings (minimal repro).
- CoreJvm's `writeSpineCompilerPluginsSettings` declares the whole settings
directory as `@OutputDirectory` and its DSL options as `@Internal`. With no
inputs it stays `UP-TO-DATE` when the DSL changes (minimal repro).
- Generated sources are non-deterministic for two reasons:
1. `mangled()` appends `SecureRandomString` suffixes
(`PatternGenerator`, `RequireOptionGenerator`).
2. Generators iterate `select<View>().all()`. `core-jvm`'s `TenantRecords`
stores records in a `HashMap` keyed by proto IDs, whose `hashCode()` mixes
in the descriptor's identity hash. In `compiler :api`, 35 of 39 differing
files differ only by the order of constraint blocks.
- `Pipeline.generateId()` (compiler) also uses `SecureRandomString`, but the ID
never reaches the generated output. Out of scope.

## Plan

### Change 1 — `validation/gradle-plugin`: write settings before fingerprinting
- [x] Add a `WriteValidationSettings` task (`writeValidationSettings`):
- `@Input Property<Boolean>` lazily wired to
`validation.java.suppressWarnings.unsignedFields`;
- `@OutputFile` = `<compilerWorkingDir>/settings/io.spine.tools.validation.java.JavaValidationRenderer.pb.json`;
- builds `JavaValidationRendererSettings` and serializes it in `@TaskAction`
(the JSON printer needs the plugin context class loader);
- `@DisableCachingByDefault`; `mustRunAfter(clean)`.
- [x] Replace the `doFirst` with `dependsOn(writer)` on every `LaunchSpineCompiler`
(still only when `validation.enabled`).
- [x] Update the KDoc that explains the `doFirst` choice, and the
`ValidationGradlePluginSpec` class KDoc.
- [x] `ProjectBuilder` specs: the task is registered, wired to the launch tasks,
and its input reflects the DSL value.
- [x] Version bump.

### Change 2 — `validation/java`: deterministic generated code
- [x] `mangled()`: derive the suffix from SHA-256 of the identifier (JDK
`MessageDigest`, base64url, Java-identifier chars only, same length: `length`
counts digest bytes, as it counted random bytes before). Keep the
signature; KDoc states that the same identifier yields the same name.
- [x] Sort the views each generator selects by declaration order
(`Field.orderOfDeclaration`; oneof order for `(choice)`), including
`SetOnceRenderer`.
- `(choice)`: the `OneofGroup` in `ChoiceOneof` has no fields (the
`OneofOptionDiscovered` event carries only the group header), so the order
comes from `MessageType.oneof_group` of the declaring type.
- [x] Specs for `mangled()` (deterministic, valid identifier, distinct inputs →
distinct names) and for the declaration order of generated constraints
(`JavaIdentifiersSpec`; `DeclarationOrderIgTest` in `tests/validating`).
- [x] Drop the `SecureRandomString` import from Validation.
- [x] Version bump: shares `.466` with Change 1 (same branch).

### Change 3 — `core-jvm-compiler/gradle-plugin`: settings writer picks up DSL changes
(after Validation with Changes 1–2 is published)
- [ ] `WriteCompilerPluginsSettings`: declare the exact `@OutputFiles` instead of
the whole directory, so it no longer overlaps Validation's file.
- [ ] Make the task always run (`outputs.upToDateWhen { false }`). Writing is
cheap, and `LaunchSpineCompiler` fingerprints content, so it still stays
`UP-TO-DATE` when the content is unchanged (verified in a minimal repro).
Modelling every DSL option as `@Input` was rejected: proto-valued options would
need a deterministic serialization at fingerprinting time.
- [ ] `mustRunAfter(clean)`.
- [ ] Bump the Validation dependency.
- Released CoreJvm (`.097`) cannot run with Validation `.466` yet: the Compiler
fat CLI it uses bundles the Protobuf `4.36.0` runtime, while Validation's
gencode is `4.36.2` (`ProtobufRuntimeVersionException` on loading
`JavaValidationRendererSettings`). Rebuilding CoreJvm against `.466` fixes it.
- [ ] TestKit regression in `CoreJvmPluginIgTest`: run `launchSpineCompiler`
twice with `--no-watch-fs` (so the VFS cannot hide the problem) and expect
`UP-TO-DATE`; change a CoreJvm and a Validation DSL value and expect a rerun
with the new settings; `clean` + run + run → `UP-TO-DATE`.
- [ ] Version bump.

### Verification (each change)
- [ ] `./gradlew build` (and `dokkaGenerate` for code changes).
- [ ] In a consumer: `launchSpineCompiler`, `./gradlew --stop`,
`launchSpineCompiler --info` → `UP-TO-DATE`.
- [ ] Determinism: `:api:launchSpineCompiler --rerun` twice in `compiler`,
`diff -r` of `api/generated` → no differences.

### Follow-ups (not in this plan)
- A settings API in `compiler` (one writer, `@Input` map consumer ID → content),
replacing per-plugin writers.
- Deterministic `select().all()` ordering in `compiler`/`core-jvm` for all
Compiler plugins.
- Review the names of the integration tests under `tests/` (`*ITest`) against
the `XIgTest` convention in `.agents/guidelines/testing.md`.

## Log
- 2026-10-19 — analysis done; scope chosen: Validation + CoreJvm writer; plan drafted, awaiting
approval.
- 2026-10-19 — approved; Change 1 started on `write-settings-before-launch` (based on
`origin/master`, version `.465`).
- 2026-10-19 — Change 1 implemented (uncommitted, except the `.466` bump commit). `./gradlew build`
and `:gradle-plugin:dokkaGenerate` pass. A standalone consumer of the published `.466` plugin
stays `UP-TO-DATE` across `--no-daemon` runs, reruns when the DSL value changes, and works after
`clean`; on `.465` the second run reruns with "…JavaValidationRenderer.pb.json has been added".
`:docs:embedCode` can't run here (x86-64 binary, no Rosetta); `build-and-release.md` updated by
hand.
- 2026-10-19 — reviews (Kotlin, Spine rules, Gradle, docs) addressed: the task owns the
consumer ID and file name, takes an `@Internal` settings directory (set lazily) and
derives its `@OutputFile`; properties are `internal`; a spec pins the `@Input`/`@OutputFile`
declarations. With CoreJvm `.097` + Validation `.466`, both writers coexist with
`processResources`/`sourcesJar` (no overlap or implicit-dependency warnings, fresh rerun
`UP-TO-DATE`, `clean` OK); the compiler run itself is blocked by the Protobuf mismatch above.
The automated end-to-end (TestKit) check stays in Change 3.
- 2026-10-19 — session resumed; `./gradlew build` re-run green on the final sources. Change 1
committed (code + specs, then reports + docs). The `config`, `docs/_time`, and `docs/_examples`
submodule pointers drifted during the session and stay uncommitted: `docs/_examples` was
rewound to its `master` tip, behind the recorded `align-protobuf-runtime` commit.
- 2026-10-19 — Change 2 implemented on the same branch (uncommitted). Determinism check over
the 9 test-module Compiler tasks that have protos (`--rerun`, `diff -r` of
`tests/*/generated`): 0 differing files (52 with the `java/src/main` changes reverted).
`DeclarationOrderITest` fails on the old code and passes on the new one.
- 2026-10-19 — reviews (Kotlin, Spine rules, docs) addressed: a `(min)` case added to
`DeclarationOrderITest`, `(choice)` positions looked up with `getValue`, `length` checked
before hashing, KDoc wording. `clean build` and `dokkaGenerate` green; determinism
re-check on the final code: 0 differing files. Kept the module's `ITest` suffix over
`IgTest` for consistency with its 38 neighbours.
- 2026-10-19 — Change 2 committed (`58dea9b841`). The new suite renamed to
`DeclarationOrderIgTest`: the testing guideline takes precedence over the legacy
`ITest` names around it, which are to be reviewed separately (see Follow-ups).
2 changes: 1 addition & 1 deletion .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ on:
jobs:
publish:
name: Publish to Maven repositories
uses: SpineEventEngine/config/.github/workflows/publishing.yml@a0cbbbfe66c12475224563439fb6deb3da71ba58
uses: SpineEventEngine/config/.github/workflows/publishing.yml@3516efc5dee84e88bc541944ff743f435eec6a1d
permissions:
contents: read
packages: write
Expand Down
4 changes: 2 additions & 2 deletions .idea/copyright/profiles_settings.xml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

34 changes: 23 additions & 11 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,17 +20,29 @@ Shared skills, scripts, and guidelines come from the `.agents/shared` submodule
(and some shallow clones / cloud checkouts) start with NO submodules checked out, so
those symlinks dangle and no skills are found. Bootstrap such a tree with
**`./init-submodules`** — a root script that materializes the missing
*config-managed* submodules at their pinned commits: `config` itself, plus every
submodule that declares a tracked `branch` in `.gitmodules` (`.agents/shared`, and
any shared submodule added later) — the same rule `./config/pull` uses to decide
what it floats. Submodules the consumer owns (a Hugo theme, a vendored library,
doc-example submodules, …) declare no tracked branch and are left untouched, so the
automatic `SessionStart` run never tries to clone — or fail on credentials for — a
submodule this project does not manage. It depends on no pre-existing `config`
submodule, so it works before `./config/pull` (which lives inside the `config`
submodule) can. Claude Code runs it automatically via a `SessionStart` hook; other
agents and humans run it by hand, then `./config/pull` to float the shared submodules
to their branch tips.
*config-managed* submodules at the tip of their branch (`master` for `config`, the
tracked `branch` for the rest): `config` itself, plus every submodule that declares
a tracked `branch` in `.gitmodules` (`.agents/shared`, and any shared submodule
added later) — the same rule `./config/pull` uses to decide what it floats. It
deliberately does not stop at the commit the superproject pins for such a
submodule: nothing keeps that pin current — `ignore = all` hides the submodule from
`git status`, and `./config/pull` floats the on-disk copy without committing the
moved gitlink — so materializing the pin as-is means months-old hook scripts,
skills, and guidelines. If the float fails — a branch the remote does not have,
say — a warning is printed and the submodule is left wherever the steps that
did succeed put it, at worst the pin, so a session still starts with a usable
tree; it is not retried on a later run, which is why that warning points at
`./config/pull`. With no network at all the clone itself fails and the
submodule is left uninitialized, as it was before. Submodules the consumer owns
(a Hugo theme, a vendored library, doc-example submodules, …) declare no tracked
branch and are left untouched, so the automatic `SessionStart` run never tries
to clone — or fail on credentials for — a submodule this project does not
manage. A submodule that is already checked out is left exactly as it is, which
keeps repeated runs cheap and offline-safe and never rewinds a tree someone
parked on a particular commit; run `./config/pull` to float those. It depends on
no pre-existing `config` submodule, so it works before `./config/pull` (which
lives inside the `config` submodule) can. Claude Code runs it automatically via
a `SessionStart` hook; other agents and humans run it by hand.

## Commit and history safety

Expand Down
32 changes: 29 additions & 3 deletions buildSrc/src/main/kotlin/DokkaExts.kt
Original file line number Diff line number Diff line change
Expand Up @@ -334,19 +334,45 @@ private fun abbreviationPattern(abbreviation: String): Regex {
private val wordStart = Regex("(?=\\p{javaUpperCase})")

/**
* Disables Dokka and Javadoc tasks in this `Project`.
* Disables the Dokka and Javadoc tasks generating the documentation of this `Project`.
*
* This function could be useful to improve build speed when building subprojects containing
* test environments or integration test projects.
*
* The JARs packing the documentation stay enabled, as they do under
* [skipDokkaWhenPublishingToMavenLocal]. A publication declares one artifact
* file per JAR, and a JAR that a disabled task never writes would be missing
* both when publishing and when the published files are attested. With nothing
* generated to pack, such a JAR costs almost nothing: it contains only
* a manifest, or the output of an earlier Dokka run.
*/
@Suppress("unused")
fun Project.disableDocumentationTasks() {
gradle.taskGraph.whenReady {
tasks.forEach { task ->
val lowercaseName = task.name.lowercase()
if (lowercaseName.contains("dokka") || lowercaseName.contains("javadoc")) {
if (task.generatesDocumentation()) {
task.enabled = false
}
}
}
}

/**
* Tells if this task generates documentation, rather than packing already generated
* documentation into a JAR.
*
* A [Jar] task is recognized by its type rather than by its name, so that
* `javadocJar` and `htmlDocsJar` are treated alike — as is any documentation JAR
* added later, whatever it is called. Any other task generates documentation if
* `dokka` or `javadoc` occurs in its name, ignoring case.
*
* [selectsDocsTask] keeps matching the same JARs by name, because the names it
* is given come from the command line rather than from the task graph.
*/
private fun Task.generatesDocumentation(): Boolean {
if (this is Jar) {
return false
}
return name.contains("dokka", ignoreCase = true)
|| name.contains("javadoc", ignoreCase = true)
}
10 changes: 6 additions & 4 deletions buildSrc/src/main/kotlin/config-tester.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -17,17 +17,19 @@ import io.spine.gradle.SpineRepos
import io.spine.gradle.SpineTaskGroup
import io.spine.gradle.cleanFolder
import java.nio.file.Path
import java.nio.file.Paths

// A reference to `config` to use along with the `ConfigTester`.
val config: Path = Paths.get("./")
//
// Both paths below are resolved against the project directory, because a relative path
// would be resolved against the working directory of the Gradle daemon instead.
val config: Path = layout.projectDirectory.asFile.toPath()

// A temp folder to use to check out the sources of other repositories with the `ConfigTester`.
val tempFolder = File("./tmp")
val tempFolder = layout.projectDirectory.dir("tmp").asFile

// Creates a Gradle task that checks out and builds the selected Spine repositories
// with the local version of `config` and `config/buildSrc`.
ConfigTester(config, tasks, tempFolder)
ConfigTester(config = config, tasks = tasks, tempFolder = tempFolder)
.addRepo(SpineRepos.baseTypes) // Builds `base-types` at `master`.
.addRepo(SpineRepos.base) // Builds `base` at `master`.
.addRepo(SpineRepos.coreJvm) // Builds `core-jvm` at `master`.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
/*
* Copyright 2026 CodeMatters, Lda.
*
* 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
*
* Unless required by applicable law or agreed to in writing, software distributed under
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND,
* either express or implied. See the License for the specific language governing permissions
* and limitations under the License.
*/

package io.spine.dependency.lib

import io.spine.dependency.Dependency

/**
* The Kotlin Multiplatform API and SDK for OpenTelemetry.
*
* The project is currently in the alpha stage, and its API is marked
* with `@io.opentelemetry.kotlin.ExperimentalApi`. Modules using these artifacts
* must opt in to that marker.
*
* The project does not publish a BOM, so the version is embedded in each
* artifact coordinate below.
*
* @see <a href="https://github.com/open-telemetry/opentelemetry-kotlin">GitHub project</a>
* @see <a href="https://opentelemetry.io/docs/languages/kotlin/">Kotlin documentation</a>
*/
@Suppress("unused")
object OpenTelemetryKotlin : Dependency() {

// https://central.sonatype.com/artifact/io.opentelemetry.kotlin/api
//
// The project is in the alpha stage, so bump this version only together
// with the code that consumes it.
override val version = "0.4.0"
override val group = "io.opentelemetry.kotlin"

/**
* The Kotlin Multiplatform API surface (traces, metrics, logs).
*
* This is the artifact production code compiles against; the SDK artifacts
* below are for applications and tests.
*/
val api = "$group:api:$version"

/**
* A no-op implementation of the [api] artifact.
*/
val noop = "$group:noop:$version"

/**
* The Kotlin SDK API: span processors, exporters, samplers, and the
* configuration DSL.
*
* This artifact exposes the SDK types on the compile classpath, while the
* `createOpenTelemetry { }` entry point that instantiates them lives in
* [implementation]. Applications and tests configure an `OpenTelemetry`
* instance using these two artifacts; production code needs only
* the [api] artifact.
*/
val core = "$group:core:$version"

/**
* The Kotlin SDK implementation, including the `createOpenTelemetry { }`
* entry point.
*
* Use it together with [core].
*/
val implementation = "$group:implementation:$version"

/**
* The compatibility bridge to the OpenTelemetry Java SDK.
*/
val compat = "$group:compat:$version"

override val modules: List<String> = listOf(
"$group:api",
"$group:noop",
"$group:core",
"$group:implementation",
"$group:compat",
)
}
7 changes: 5 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 @@ -19,10 +19,13 @@ package io.spine.dependency.local
*
* @see <a href="https://github.com/SpineEventEngine/base-libraries">spine-base-libraries</a>
*/
@Suppress("ConstPropertyName", "unused")
@Suppress(
"ConstPropertyName" /* We use a custom convention for artifact properties. */,
"unused" /* Some subprojects do not use all Base artifacts. */,
)
object Base {
const val version = "2.0.0-SNAPSHOT.461"
const val versionForBuildScript = "2.0.0-SNAPSHOT.460"
const val versionForBuildScript = "2.0.0-SNAPSHOT.461"
const val group = Spine.group
private const val prefix = "spine"
const val libModule = "$prefix-base"
Expand Down
Loading
Loading