diff --git a/.agents/_TOC.md b/.agents/_TOC.md deleted file mode 100644 index 2f9ba238b2..0000000000 --- a/.agents/_TOC.md +++ /dev/null @@ -1,25 +0,0 @@ -# Table of Contents - -1. [Quick Reference Card](quick-reference-card.md) -2. [JVM project requirements](jvm-project.md) โ€” language, build, and review checklist shared by all JVM repos -3. [Coding guidelines](coding-guidelines.md) -4. [Documentation & comments](documentation-guidelines.md) -5. [Documentation tasks](documentation-tasks.md) -6. [Running builds](running-builds.md) -7. [Version policy](version-policy.md) -8. [Project structure expectations](project-structure-expectations.md) -9. [Testing](testing.md) -10. [Safety rules](safety-rules.md) -11. [Advanced safety rules](advanced-safety-rules.md) -12. [Refactoring guidelines](refactoring-guidelines.md) -13. [Common tasks](common-tasks.md) -14. [Team memory](memory/MEMORY.md) -15. [Task plans](tasks/README.md) -16. [Java to Kotlin conversion](skills/java-to-kotlin/SKILL.md) -17. [Dependency update](skills/dependency-update/SKILL.md) -18. [Documentation review](skills/review-docs/SKILL.md) -19. [Pre-PR checklist](skills/pre-pr/SKILL.md) -20. [Kotlin code review](skills/kotlin-review/SKILL.md) -21. [Dependency audit](skills/dependency-audit/SKILL.md) -22. [Gradle review](skills/gradle-review/SKILL.md) -23. [Raise test coverage](skills/raise-coverage/SKILL.md) diff --git a/.agents/advanced-safety-rules.md b/.agents/advanced-safety-rules.md deleted file mode 100644 index e4105813fc..0000000000 --- a/.agents/advanced-safety-rules.md +++ /dev/null @@ -1,6 +0,0 @@ -# ๐Ÿšจ Advanced safety rules - -- Do **not** auto-update external dependencies without explicit request. -- Do **not** inject analytics or telemetry code. -- Flag any usage of unsafe constructs (e.g., reflection, I/O on the main thread). -- Avoid generating blocking calls inside coroutines. diff --git a/.agents/coding-guidelines.md b/.agents/coding-guidelines.md deleted file mode 100644 index 8c0a60f34a..0000000000 --- a/.agents/coding-guidelines.md +++ /dev/null @@ -1,41 +0,0 @@ -# ๐Ÿงพ Coding guidelines - -## Core principles - -- Adhere to [Spine Event Engine Documentation][spine-docs] for coding style. -- Generate code that compiles cleanly and passes static analysis. -- Respect existing architecture, naming conventions, and project structure. -- Write clear, incremental commits with descriptive messages. -- Include automated tests for any code change that alters functionality. - -## Kotlin best practices - -### โœ… Prefer -- **Kotlin idioms** over Java-style approaches: - - Extension functions - - `when` expressions - - Smart casts - - Data classes and sealed classes - - Immutable data structures -- **Simple nouns** over composite nouns (`user` > `userAccount`) -- **Generic parameters** over explicit variable types (`val list = mutableList()`) -- **Java interop annotations** only when needed (`@file:JvmName`, `@JvmStatic`) -- **Kotlin DSL** for Gradle files -- **Kotlin Protobuf DSL** (`myMessage { field = value }`) over Java builder chains - -### โŒ Avoid -- Mutable data structures -- Java-style verbosity (builders with setters) -- Java Protobuf builders in Kotlin code (`newBuilder()`, `toBuilder()`) unless interop requires them -- Redundant null checks (`?.let` misuse) -- Using `!!` unless clearly justified -- Type names in variable names (`userObject`, `itemList`) -- String duplication (use constants in companion objects) -- Mixing Groovy and Kotlin DSLs in build logic -- Reflection unless specifically requested - -## Text formatting - - โœ… Replace double empty lines with a single empty line in the code. - - โœ… Remove trailing space characters in the code. - -[spine-docs]: https://github.com/SpineEventEngine/documentation/wiki diff --git a/.agents/common-tasks.md b/.agents/common-tasks.md deleted file mode 100644 index 5ee954d835..0000000000 --- a/.agents/common-tasks.md +++ /dev/null @@ -1,6 +0,0 @@ -# ๐Ÿ“‹ Common tasks - -- **Adding a new dependency**: Update relevant files in `buildSrc` directory. -- **Creating a new module**: Follow existing module structure patterns. -- **Documentation**: Use KDoc style for public and internal APIs. -- **Testing**: Create comprehensive tests using Kotest assertions. diff --git a/.agents/documentation-guidelines.md b/.agents/documentation-guidelines.md deleted file mode 100644 index e034501e46..0000000000 --- a/.agents/documentation-guidelines.md +++ /dev/null @@ -1,40 +0,0 @@ -# Documentation & comments - -## Commenting guidelines -- Avoid inline comments in production code unless necessary. -- Inline comments are helpful in tests. -- When using TODO comments, follow the format on the [dedicated page][todo-comments]. -- File and directory names should be formatted as code. - -## API documentation scope - -KDoc and Javadoc describe the API as it appears to a consumer of the published -artifact. Keep them focused on behaviour, parameters, return values, and usage -examples. - -Do **not** reference repository-internal locations from API docs: - -- Build infrastructure paths such as `buildSrc/` or `config/` (the `config` - repository, `config/buildSrc/`, and similar). -- Agent-facing material under `.agents/` โ€” task plans, skill rules, review - notes, conventions, or any other file rooted there. -- Branch names, commit SHAs, issue numbers, or other repo workflow artefacts. - -These details are invisible to a consumer who only sees the artifact's -sources/Javadoc/KDoc and rot quickly as the repository evolves. If the rationale -for an API decision lives in such a file, summarise the *outcome* in the -KDoc instead of linking to the source. Cross-repository parity notes and -work-in-progress justifications belong in the task plan under -`.agents/tasks/`, not in the published API documentation. - -## Protobuf file headers -- In `.proto` files, a multi-paragraph documentation header must end with a - trailing empty comment line (`//`). -- Single-paragraph headers do not require the trailing empty comment line. - -## Avoid widows, runts, orphans, or rivers - -Agents should **AVOID** text flow patters illustrated -on [this diagram](widow-runt-orphan.jpg). - -[todo-comments]: https://github.com/SpineEventEngine/documentation/wiki/TODO-comments diff --git a/.agents/documentation-tasks.md b/.agents/documentation-tasks.md deleted file mode 100644 index 8ac4660dbe..0000000000 --- a/.agents/documentation-tasks.md +++ /dev/null @@ -1,20 +0,0 @@ -# ๐Ÿ“„ Documentation tasks - -1. Ensure all public and internal APIs have KDoc examples. -2. Add in-line code blocks for clarity in tests. -3. Convert inline API comments in Java to KDoc in Kotlin: - ```java - // Literal string to be inlined whenever a placeholder references a non-existent argument. - private final String missingArgumentMessage = "[MISSING ARGUMENT]"; - ``` - transforms to: - ```kotlin - /** - * Literal string to be inlined whenever a placeholder references a non-existent argument. - */ - private val missingArgumentMessage = "[MISSING ARGUMENT]" - ``` - -4. Javadoc -> KDoc conversion tasks: - - Remove `

` tags in the line with text: `"

This"` -> `"This"`. - - Replace `

` with empty line if the tag is the only text in the line. diff --git a/.agents/guidelines b/.agents/guidelines new file mode 120000 index 0000000000..6f9d96637c --- /dev/null +++ b/.agents/guidelines @@ -0,0 +1 @@ +shared/guidelines \ No newline at end of file diff --git a/.agents/jvm-project.md b/.agents/jvm-project.md deleted file mode 100644 index e3c5d650d1..0000000000 --- a/.agents/jvm-project.md +++ /dev/null @@ -1,37 +0,0 @@ -# JVM Project Requirements - -General requirements for all JVM projects in the Spine SDK organisation. -Repo-specific `project.md` files link here and add their own context. - -## Language and build - -- **Languages**: Kotlin (primary), Java (secondary). -- **Build**: Gradle with Kotlin DSL. -- **Static analysis**: detekt, ErrorProne, Checkstyle, PMD. -- **Testing**: JUnit 5, Kotest Assertions, Codecov. - -## Code review checklist - -**Correctness and safety** -- Code compiles and passes static analysis (detekt, ErrorProne, Checkstyle, PMD). -- No reflection or unsafe code unless explicitly approved in scope. -- No analytics, telemetry, or tracking code. -- No blocking calls inside coroutines. - -**Kotlin/Java style** -- Kotlin idioms preferred: extension functions, `when` expressions, data/sealed - classes, immutable data structures. -- No `!!` unless provably safe. No unchecked casts. -- No mutable state without justification. -- No string duplication โ€” use constants. - -**Tests** -- New or changed functionality must include tests. -- Use stubs, not mocks. -- Prefer [Kotest assertions][kotest-assertions] over JUnit or Google Truth. - -**Versioning** -- If the repo has `version.gradle.kts`, every PR must include a version bump. - Flag the absence as a required change. - -[kotest-assertions]: https://kotest.io/docs/assertions/assertions.html diff --git a/.agents/project-structure-expectations.md b/.agents/project-structure-expectations.md deleted file mode 100644 index 22a3ab7d61..0000000000 --- a/.agents/project-structure-expectations.md +++ /dev/null @@ -1,21 +0,0 @@ -# ๐Ÿ“ Project structure expectations - -```yaml -.github -buildSrc/ - - src/ - โ”œโ”€โ”€ main/ - โ”‚ โ”œโ”€โ”€ kotlin/ # Kotlin source files - โ”‚ โ””โ”€โ”€ java/ # Legacy Java code - โ”œโ”€โ”€ test/ - โ”‚ โ””โ”€โ”€ kotlin/ # Unit and integration tests - build.gradle.kts # Kotlin-based build configuration - - -build.gradle.kts # Kotlin-based build configuration -settings.gradle.kts # Project structure and settings -README.md # Project overview -AGENTS.md # Entry point for LLM agent instructions -version.gradle.kts # Declares the project version in versioned Gradle Build Tools repos. -``` diff --git a/.agents/project.md b/.agents/project.md deleted file mode 100644 index ba2879a044..0000000000 --- a/.agents/project.md +++ /dev/null @@ -1,19 +0,0 @@ -# Project: Validation - -## Overview - -Validation is a Spine SDK JVM library that provides the project's validation -model and related integration points. Its role in the organisation is to define -and expose validation behaviour in a reusable form so other modules can apply -consistent validation rules and report validation results through a stable API. - -## Architecture - -This repository is a library module. Its public surface should stay focused on -validation concepts, rule execution, and result/reporting types that other Spine -SDK components can depend on without pulling in application-specific behaviour. -Keep implementation details behind the library boundary, prefer additive changes -to public APIs, and preserve compatibility for downstream JVM consumers. - -Read [`.agents/jvm-project.md`](jvm-project.md) for build stack, coding style, -tests, and versioning. diff --git a/.agents/project.md b/.agents/project.md new file mode 120000 index 0000000000..7e0bf9bd38 --- /dev/null +++ b/.agents/project.md @@ -0,0 +1 @@ +../docs/project.md \ No newline at end of file diff --git a/.agents/project.template.md b/.agents/project.template.md deleted file mode 100644 index b6882e03af..0000000000 --- a/.agents/project.template.md +++ /dev/null @@ -1,18 +0,0 @@ - - -# Project: - -## Overview - -*One paragraph: what this repo is, what problem it solves, and its role in the -Spine SDK organisation.* - -## Architecture - -*Role in the org: library / tool / Gradle plugin / application. -Key patterns, public API boundaries, and constraints specific to this repo.* - - diff --git a/.agents/quick-reference-card.md b/.agents/quick-reference-card.md deleted file mode 100644 index 2e890e4289..0000000000 --- a/.agents/quick-reference-card.md +++ /dev/null @@ -1,7 +0,0 @@ -# ๐Ÿ“ Quick Reference Card - -๐Ÿšซ **Do not write to git history** (commit/push/tag/rebase/merge/cherry-pick/reset/`gh pr merge`) without explicit authorization. See -[`safety-rules.md`](safety-rules.md) โ†’ *Commits and history-writing*. -Authorization comes only from a skill's `## Commit authorization` -section or from the user's current prompt โ€” never from prior turns or -memory. diff --git a/.agents/refactoring-guidelines.md b/.agents/refactoring-guidelines.md deleted file mode 100644 index 191db49f5f..0000000000 --- a/.agents/refactoring-guidelines.md +++ /dev/null @@ -1,3 +0,0 @@ -# โš™๏ธ Refactoring guidelines - -- Do NOT replace Kotest assertions with standard Kotlin's built-in test assertions. diff --git a/.agents/running-builds.md b/.agents/running-builds.md deleted file mode 100644 index db0338d6f9..0000000000 --- a/.agents/running-builds.md +++ /dev/null @@ -1,18 +0,0 @@ -# Running builds - -1. When modifying code, run: - ```bash - ./gradlew build - ``` - -2. If Protobuf (`.proto`) files are modified run: - ```bash - ./gradlew clean build - ``` - -3. Documentation-only changes in Kotlin or Java sources run: - ```bash - ./gradlew dokka - ``` - -4. Documentation-only changes do not require running tests! diff --git a/.agents/safety-rules.md b/.agents/safety-rules.md deleted file mode 100644 index e7fece3ccb..0000000000 --- a/.agents/safety-rules.md +++ /dev/null @@ -1,49 +0,0 @@ -# Safety rules - -- โœ… All code must compile and pass static analysis. -- โœ… Do not auto-update external dependencies. -- โŒ Never use reflection or unsafe code without an explicit approval. -- โŒ No analytics or telemetry code. -- โŒ No blocking calls inside coroutines. - -## Commits and history-writing - -**Default: do not write to git history.** This is a hard rule for every -agent โ€” the main thread, every subagent, every skill. It overrides any -local convenience or "the change looks done" instinct. - -The rule covers all of these operations: - -- `git commit`, `git commit-tree` -- `git push`, `git push --force` -- `git tag` -- `git rebase`, `git merge`, `git cherry-pick` against shared history -- `git reset` that discards committed work -- `gh release create`, `gh pr merge` - -Authorization to perform one of these operations exists only when **one** -of the following is true *right now*: - -1. **Skill-declared.** The currently active skill's `SKILL.md` contains - a `## Commit authorization` section that explicitly authorizes the - operation and constrains it (which files may be staged, the exact - commit subject, the maximum number of commits). The mere mention of - a commit message inside skill prose is **not** authorization โ€” the - section heading must be present. -2. **User-instructed.** The user's *current* prompt explicitly tells - the agent to perform the operation. Examples that qualify: - "commit this", "make a commit with subject X", "push the branch", - "tag this release". Authorization from previous turns, from - `CLAUDE.md`, or from any memory file does **not** carry over. - -If neither holds, the agent: - -1. Stages relevant changes with `git add` (only if helpful for review). -2. Prints the proposed commit subject (if any) and `git diff --staged`. -3. **Stops.** The user runs the commit themselves, or replies with - explicit authorization in the next prompt. - -The project's `.claude/settings.json` keeps `Bash(git commit:*)` in -`permissions.ask` as defense-in-depth, but the primary enforcement is -this rule โ€” agents must not propose commit attempts that rely on the -user clicking the prompt. diff --git a/.agents/scripts b/.agents/scripts new file mode 120000 index 0000000000..96bf06e128 --- /dev/null +++ b/.agents/scripts @@ -0,0 +1 @@ +shared/scripts \ No newline at end of file diff --git a/.agents/scripts/api-discovery/.gitignore b/.agents/scripts/api-discovery/.gitignore deleted file mode 100644 index c824ff1d5b..0000000000 --- a/.agents/scripts/api-discovery/.gitignore +++ /dev/null @@ -1,3 +0,0 @@ -# Per-developer override of for the api-discovery -# extraction cache. Contains an absolute path; do not commit. -.workspace-root diff --git a/.agents/scripts/api-discovery/README.md b/.agents/scripts/api-discovery/README.md deleted file mode 100644 index de4c631a11..0000000000 --- a/.agents/scripts/api-discovery/README.md +++ /dev/null @@ -1,158 +0,0 @@ -# `api-discovery` scripts - -Resolve the on-disk location of a Maven artifact's source code for -agents and developers, without repeatedly `unzip`-ing JARs out of the -Gradle cache. - -The agent-facing documentation lives in -[`../../skills/api-discovery/SKILL.md`](../../skills/api-discovery/SKILL.md); -this file is the implementation reference. - -## Why - -Agents investigating library APIs used to run dozens of `find -~/.gradle/caches` + `unzip -l` + `unzip -p` calls per question. Each -`unzip` decompresses the archive from scratch โ€” slow and token-heavy. - -This package replaces that pattern with two cheap reads: - -1. **Sibling-first** โ€” every Spine artifact maps to a sibling clone - under `//`. The source tree is already on - disk; we just resolve the right submodule path. -2. **Extraction cache** โ€” non-Spine artifacts (Jackson, Guava, etc.) - have their `-sources.jar` extracted **once** to a per-workstation - cache. Subsequent queries return instantly. - -## Layout - -``` -.agents/scripts/api-discovery/ -โ”œโ”€โ”€ README.md # this file -โ”œโ”€โ”€ lib/common.sh # shared bash helpers -โ”œโ”€โ”€ discover # main entry โ€” resolve a coordinate to a path -โ”œโ”€โ”€ extract-sources # one-shot JAR extraction (race-safe) -โ”œโ”€โ”€ update-sibling # `git pull --ff-only` a stale sibling (safe-guarded) -โ””โ”€โ”€ clean-cache # prune the extraction cache -``` - -The cache itself is **not** under the repo. It lives at: - -``` -/.agents/caches/api-discovery/sources//// -``` - -`` defaults to the parent of the consumer repo (e.g. -`/Users//Projects/Spine/` when the consumer repo is -`.../Spine/config/`). To override, write the absolute path to an -alternative root into `.workspace-root` next to this README (the -script is gitignored). - -## Bootstrap - -First-time use needs the cache directory created. The scripts detect -its absence and exit `10`; the skill instructs the agent to ask the -user whether to: - -1. **Approve** the default path, -2. **Provide an alternative** workspace root, -3. **Disable** the cache for this repo (recorded in per-developer - auto-memory; sibling-first resolution still works). - -## Scripts - -### `discover` - -``` -discover :: -discover : # version pulled from buildSrc -discover # Spine-only; group + version inferred -``` - -- **stdout** โ€” absolute path to a directory you can `Grep`/`Read`. -- **stderr** โ€” `STALE` warnings when the sibling's `versionToPublish` - differs from the declared dependency version, plus other - diagnostics. Always inspect. -- **exit 0** โ€” path resolved (even if stale; the warning is on stderr). -- **exit 1** โ€” unresolvable (missing sibling AND no sources JAR). -- **exit 10** โ€” cache uninitialized; run the bootstrap flow. - -### `extract-sources` - -``` -extract-sources :: -``` - -Idempotent and race-safe. If the target directory is already populated -the script returns its path immediately. Concurrent first-time -extractions race on an atomic `mv` of a per-PID temp directory; the -loser discards its temp. - -### `update-sibling` - -``` -update-sibling # resolved under -update-sibling # acts on that path directly -``` - -Invoked by the agent (after user consent) when `discover` emits a -`STALE` warning. Safe by design: - -- Pulls **only** when the sibling is on `master` or `main` with a - clean working tree and a tracked upstream. -- On any other branch, exits `0` without touching anything โ€” the - user's "advancing multiple subprojects at once" workflow keeps - feature branches checked out as intentional staging state. -- Refuses on detached HEAD (`3`), uncommitted changes (`4`), or - missing upstream (`5`). -- Never switches branches, never `--rebase`, never `--force`. - -On success (exit `0`), the script writes a single stable token to -**stdout** that names the outcome โ€” callers should branch on the -token, not on stderr text. Failure paths produce empty stdout. - -Exit codes: - -| Code | stdout | Meaning | -|---|---|---| -| `0` | `pulled` | HEAD advanced to upstream tip | -| `0` | `up-to-date` | Already at upstream tip; nothing to do | -| `0` | `skipped-branch` | On a non-default branch; left untouched | -| `1` | _(empty)_ | Sibling not on disk | -| `2` | _(empty)_ | Not a git repository | -| `3` | _(empty)_ | Detached HEAD โ€” refused | -| `4` | _(empty)_ | Working tree dirty โ€” refused | -| `5` | _(empty)_ | No upstream tracking on default branch โ€” refused | -| `6` | _(empty)_ | `git pull --ff-only` failed (divergence, network, etc.) | -| `64` | _(empty)_ | Usage error (no/too many arguments) โ€” BSD `sysexits(3)` convention | - -### `clean-cache` - -``` -clean-cache --dry-run -clean-cache --older-than 30d [--dry-run] -clean-cache --all [--dry-run] -``` - -Manual pruning only. Nothing runs on a timer. - -## Conventions - -- **Bash 3.2 compatible** โ€” macOS ships 3.2 by default. -- **No external dependencies** beyond `bash`, coreutils, `grep`, - `sed`, `awk`, `unzip`, `find`, and `git` (used only by - `update-sibling`). -- **stdout** is always the answer; **stderr** is diagnostics. Mix - them only by piping. -- Scripts source `lib/common.sh` after setting - `SPINE_API_DISCOVERY_DIR`, so the workspace-root pointer file is - reachable. - -## Troubleshooting - -| Symptom | Likely cause | Fix | -|---|---|---| -| `cache not initialized` (exit 10) | Bootstrap not run | Follow the skill's bootstrap prompt | -| `sibling not on disk` | Spine repo not cloned | `git clone` it next to your consumer repo | -| `STALE: ...` | Sibling drifted from declared version | Run `update-sibling ` (auto-skips feature branches), or accept the warning | -| `is in the Gradle cache but publishes no -sources.jar` | Upstream artifact has no sources | Read the binary `.class` files via a different tool, or look at GitHub directly | -| `is not in the local Gradle cache` | Gradle has not fetched the dep | `./gradlew dependencies` to populate, then retry | diff --git a/.agents/scripts/api-discovery/clean-cache b/.agents/scripts/api-discovery/clean-cache deleted file mode 100755 index 30f6049202..0000000000 --- a/.agents/scripts/api-discovery/clean-cache +++ /dev/null @@ -1,143 +0,0 @@ -#!/usr/bin/env bash -# -# Prune the workstation api-discovery extraction cache. -# -# Usage: -# clean-cache --dry-run # list what would be removed (no deletes) -# clean-cache --older-than 30d # remove entries older than 30 days -# clean-cache --older-than 7d --dry-run -# clean-cache --all # wipe the whole sources cache -# clean-cache --all --dry-run -# -# The cache is at -# `/.agents/caches/api-discovery/sources////`. -# "Age" is the directory's mtime (recorded at extraction time). -# -# Exit codes (see lib/common.sh): -# 0 โ€” succeeded (with or without removals). -# 1 โ€” bad arguments or filesystem error. -# 10 โ€” cache not initialized (nothing to clean). -set -euo pipefail - -SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" -SPINE_API_DISCOVERY_DIR="$SCRIPT_DIR" -export SPINE_API_DISCOVERY_DIR -# shellcheck source=lib/common.sh -. "$SCRIPT_DIR/lib/common.sh" - -usage() { - cat >&2 <<'EOF' -Usage: - clean-cache --dry-run - clean-cache --older-than [--dry-run] - clean-cache --all [--dry-run] - -DURATION is a `find -mtime`-style suffix: `7d`, `30d`, `90d` (days only). -EOF - exit "$EX_FAIL" -} - -mode="" -days="" -dry_run=0 - -while [ "$#" -gt 0 ]; do - case "$1" in - --dry-run) - dry_run=1 - shift - ;; - --all) - mode="all" - shift - ;; - --older-than) - mode="older-than" - shift - [ "$#" -gt 0 ] || usage - case "$1" in - *d) days="${1%d}" ;; - *) log_warn "duration must end in 'd' (days), got: $1"; usage ;; - esac - case "$days" in - ''|*[!0-9]*) log_warn "duration days must be numeric, got: $1"; usage ;; - esac - shift - ;; - -h|--help) - usage - ;; - *) - log_warn "unknown argument: $1" - usage - ;; - esac -done - -# Default to a no-op listing if no mode was given. -[ -n "$mode" ] || mode="older-than-default" - -if ! cache_initialized; then - log_warn "cache not initialized: $(cache_root)" - exit "$EX_NO_CACHE" -fi - -sources="$(sources_root)" - -# Collect targets into a temp list so we can preview and act consistently. -list_file="$(mktemp -t api-discovery-clean.XXXXXX)" -trap 'rm -f -- "$list_file"' EXIT - -case "$mode" in - all) - find "$sources" -mindepth 3 -maxdepth 3 -type d -print > "$list_file" 2>/dev/null || true - ;; - older-than) - find "$sources" -mindepth 3 -maxdepth 3 -type d -mtime "+$days" -print \ - > "$list_file" 2>/dev/null || true - ;; - older-than-default) - log_warn "no mode specified; use --all or --older-than d" - usage - ;; -esac - -count="$(wc -l < "$list_file" | tr -d '[:space:]')" - -# Prune now-empty `/` and `//` parents so the cache -# layout stays tidy. Two passes (artifact dirs first, then group dirs) so -# that emptying a group's last artifact also reclaims the group dir. -# Skipped under --dry-run so the command stays read-only. -prune_empty_parents() { - find "$sources" -mindepth 2 -maxdepth 2 -type d -empty -exec rmdir -- {} + \ - 2>/dev/null || true - find "$sources" -mindepth 1 -maxdepth 1 -type d -empty -exec rmdir -- {} + \ - 2>/dev/null || true -} - -if [ "$count" -eq 0 ]; then - log_warn "no entries match; cache untouched" - [ "$dry_run" -eq 0 ] && prune_empty_parents - exit "$EX_OK" -fi - -if [ "$dry_run" -eq 1 ]; then - log_warn "would remove $count entr$( [ "$count" -eq 1 ] && printf 'y' || printf 'ies' ):" - cat -- "$list_file" - exit "$EX_OK" -fi - -removed=0 -while IFS= read -r path; do - [ -n "$path" ] || continue - if rm -rf -- "$path"; then - removed=$((removed + 1)) - else - log_warn "failed to remove: $path" - fi -done < "$list_file" - -prune_empty_parents - -log_warn "removed $removed entr$( [ "$removed" -eq 1 ] && printf 'y' || printf 'ies' )" -exit "$EX_OK" diff --git a/.agents/scripts/api-discovery/discover b/.agents/scripts/api-discovery/discover deleted file mode 100755 index 2b69d36bac..0000000000 --- a/.agents/scripts/api-discovery/discover +++ /dev/null @@ -1,153 +0,0 @@ -#!/usr/bin/env bash -# -# Resolve the on-disk location of source code for a Maven artifact, so -# agents can `Grep`/`Read` it directly instead of repeatedly `unzip`ing -# sources JARs out of the Gradle cache. -# -# Strategy: -# 1. Spine artifacts (group = io.spine / io.spine.tools / etc.) are -# served from a sibling clone under `//`. The -# sibling is identified from the `github.com/SpineEventEngine/` -# URL inside the matching `buildSrc/.../dependency/local/.kt` -# file. Submodule paths are resolved by trying canonical name -# transformations (see `resolve_submodule_path`). -# 2. Anything else (and Spine fallbacks when no sibling is on disk) -# is served from the per-workstation extraction cache populated by -# `extract-sources`. -# -# Usage: -# discover :: -# discover : -# discover -# -# Output: -# stdout: absolute path to a directory containing the source tree. -# stderr: freshness warnings and explanatory diagnostics. Always -# inspect stderr before relying on the resolved path. -# -# Exit codes (see lib/common.sh): -# 0 โ€” path resolved (path on stdout). -# 1 โ€” unresolvable (sibling missing AND extraction failed). -# 10 โ€” workstation cache directory not initialized AND the query -# requires the cache (i.e. sibling-first did not succeed). -# Spine-sibling resolution never triggers EX_NO_CACHE โ€” the -# skill's "Non-cached" mode keeps working without bootstrap. -set -euo pipefail - -SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" -SPINE_API_DISCOVERY_DIR="$SCRIPT_DIR" -export SPINE_API_DISCOVERY_DIR -# shellcheck source=lib/common.sh -. "$SCRIPT_DIR/lib/common.sh" - -usage() { - cat >&2 <<'EOF' -Usage: discover - -Where is one of: - group:artifact:version e.g. io.spine:spine-base:2.0.0-SNAPSHOT.390 - group:artifact e.g. io.spine:spine-base - artifact e.g. spine-base - -Spine artifacts resolve to the local sibling clone; non-Spine -artifacts resolve to the extracted-sources cache. -EOF - exit "$EX_FAIL" -} - -[ "$#" -eq 1 ] || usage - -parse_query "$1" -group="$Q_GROUP" -artifact="$Q_ARTIFACT" -version="$Q_VERSION" - -if [ -z "$artifact" ]; then - log_warn "empty artifact in query: $1" - exit "$EX_FAIL" -fi - -# NOTE: We intentionally do NOT check `cache_initialized` here. The -# Spine-sibling path doesn't need the cache, and the skill's -# "Non-cached" bootstrap option must keep that path working without -# bootstrap. If we end up falling through to `extract-sources`, that -# script enforces the cache check on its own and raises EX_NO_CACHE. - -# Try the sibling path for Spine artifacts. If the group is empty we -# still attempt the local-deps lookup; a hit means it's a Spine artifact. -try_sibling() { - local dep_file - dep_file="$(find_local_dep_file_for_artifact "$artifact")" - [ -n "$dep_file" ] || return 1 - - local sibling_name workspace sibling_path - sibling_name="$(sibling_name_from_dep_file "$dep_file")" - workspace="$(workspace_root)" || return 1 - sibling_path="$workspace/$sibling_name" - - if [ ! -d "$sibling_path" ]; then - log_warn "sibling not on disk: $sibling_path (declared in $dep_file)" - return 1 - fi - - local module_path - module_path="$(resolve_submodule_path "$sibling_path" "$artifact")" - - if [ ! -d "$module_path" ]; then - log_warn "resolved submodule path missing: $module_path" - return 1 - fi - - # Freshness check: declared version vs sibling's published version. - local declared sibling_v - declared="${version:-$(read_declared_version "$dep_file" || true)}" - sibling_v="$(read_sibling_version "$sibling_path" 2>/dev/null || true)" - - if [ -n "$declared" ] && [ -n "$sibling_v" ] && \ - [ "$declared" != "$sibling_v" ]; then - log_warn "STALE: $artifact declared $declared in $(basename -- "$dep_file") but sibling publishes $sibling_v" - log_warn "sources at $module_path may differ from the published artifact" - fi - - printf '%s\n' "$module_path" - return 0 -} - -# Try sibling first when it could plausibly be a Spine artifact. -if [ -z "$group" ] || is_spine_group "$group"; then - if try_sibling; then - exit "$EX_OK" - fi -fi - -# Fall back to the extraction cache. This needs a full coordinate. -if [ -z "$group" ] || [ -z "$artifact" ] || [ -z "$version" ]; then - # Try to fill in the missing pieces from a local dep file. - if [ -z "$version" ]; then - dep_file="$(find_local_dep_file_for_artifact "$artifact" || true)" - if [ -n "$dep_file" ]; then - version="$(read_declared_version "$dep_file" || true)" - fi - fi - if [ -z "$group" ]; then - # The local Spine objects all use Spine.group / Spine.toolsGroup. - # Without a sibling hit we can't disambiguate; require explicit group. - log_warn "cannot resolve $artifact without a Maven group" - log_warn "retry with :[:]" - exit "$EX_FAIL" - fi - if [ -z "$version" ]; then - log_warn "cannot resolve $group:$artifact without a version" - log_warn "retry with ::" - exit "$EX_FAIL" - fi -fi - -# Delegate to extract-sources. It handles "already cached" by returning -# the path immediately, so this path is fast on repeat queries. -# The `|| exit $?` idiom propagates extract-sources' exit code under -# `set -e`; do NOT split into `target=...; status=$?` โ€” `set -e` may -# terminate the script before the status check runs. -target="$("$SCRIPT_DIR/extract-sources" "$group:$artifact:$version")" || exit $? - -printf '%s\n' "$target" diff --git a/.agents/scripts/api-discovery/extract-sources b/.agents/scripts/api-discovery/extract-sources deleted file mode 100755 index a8456680a7..0000000000 --- a/.agents/scripts/api-discovery/extract-sources +++ /dev/null @@ -1,118 +0,0 @@ -#!/usr/bin/env bash -# -# Extract a `-sources.jar` from the local Gradle cache into the workstation -# api-discovery cache. Idempotent and race-safe: a second invocation that -# observes an existing target returns immediately; concurrent first-time -# extractions race on the atomic `mv` of a per-PID temp directory. -# -# Usage: -# extract-sources :: -# -# Output: -# stdout: absolute path to the extracted source tree. -# stderr: explanatory diagnostics on cache misses or failures. -# -# Exit codes (see lib/common.sh for the shared definitions): -# 0 โ€” extraction successful (or already cached). -# 1 โ€” sources unavailable (missing JAR, no `-sources` variant, etc.). -# 10 โ€” workstation cache directory not initialized. -# -set -euo pipefail - -SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" -SPINE_API_DISCOVERY_DIR="$SCRIPT_DIR" -export SPINE_API_DISCOVERY_DIR -# shellcheck source=lib/common.sh -. "$SCRIPT_DIR/lib/common.sh" - -usage() { - cat >&2 <<'EOF' -Usage: extract-sources :: - -Extracts the matching `-sources.jar` from the local Gradle cache into -`/.agents/caches/api-discovery/sources////`. -EOF - exit "$EX_FAIL" -} - -[ "$#" -eq 1 ] || usage - -parse_query "$1" -group="$Q_GROUP" -artifact="$Q_ARTIFACT" -version="$Q_VERSION" - -if [ -z "$group" ] || [ -z "$artifact" ] || [ -z "$version" ]; then - log_warn "extract-sources requires a full coordinate ::" - exit "$EX_FAIL" -fi - -if ! cache_initialized; then - log_warn "cache not initialized: $(cache_root)" - log_warn "run the api-discovery bootstrap flow to create it" - exit "$EX_NO_CACHE" -fi - -target="$(sources_root)/$group/$artifact/$version" - -if [ -d "$target" ] && [ -n "$(ls -A -- "$target" 2>/dev/null)" ]; then - printf '%s\n' "$target" - exit "$EX_OK" -fi - -sources_jar="$(find_gradle_cache_jar "$group" "$artifact" "$version" -sources)" -if [ -z "$sources_jar" ]; then - if [ -n "$(find_gradle_cache_jar "$group" "$artifact" "$version")" ]; then - log_warn "$group:$artifact:$version is in the Gradle cache but publishes no -sources.jar" - exit "$EX_FAIL" - fi - log_warn "$group:$artifact:$version is not in the local Gradle cache" - log_warn "run './gradlew dependencies' (or rebuild) to fetch it, then retry" - exit "$EX_FAIL" -fi - -parent="$(dirname -- "$target")" -mkdir -p -- "$parent" - -# Race-safe extraction: -# 1) extract into a sibling temp dir whose name embeds our PID, -# 2) check that the final target does not yet exist (race-lost -# detection); if it does, drop our temp and use the existing tree, -# 3) otherwise `mv tmp target`. Note that on macOS/Linux, `mv` into an -# existing directory silently moves the source INSIDE it โ€” we cannot -# rely on `mv` failing when the race is lost. The pre-test catches -# the common case; step 4 catches the narrow window between test -# and mv. -# 4) post-mv, detect the rare race where `target` materialized between -# our existence test and the mv: in that case `target/` -# now exists; remove it. -tmp="${target}.tmp.$$" -trap 'rm -rf -- "$tmp"' EXIT -rm -rf -- "$tmp" -mkdir -p -- "$tmp" - -if ! unzip -q -o -- "$sources_jar" -d "$tmp"; then - log_warn "unzip failed for $sources_jar" - exit "$EX_FAIL" -fi - -if [ -e "$target" ]; then - # Another process beat us to it. Discard our temp; use theirs. - rm -rf -- "$tmp" -else - if ! mv -- "$tmp" "$target"; then - log_warn "could not move extracted sources into $target" - exit "$EX_FAIL" - fi - # Close the narrow race window: if `target` appeared between the - # existence test and the mv, `mv` silently moved `$tmp` INSIDE the - # winning extraction. Clean up the nested debris. - nested="$target/$(basename -- "$tmp")" - if [ -e "$nested" ]; then - rm -rf -- "$nested" - fi -fi -trap - EXIT - -log_warn "extracted $group:$artifact:$version -> $target" -printf '%s\n' "$target" diff --git a/.agents/scripts/api-discovery/lib/common.sh b/.agents/scripts/api-discovery/lib/common.sh deleted file mode 100644 index fa3e9c833e..0000000000 --- a/.agents/scripts/api-discovery/lib/common.sh +++ /dev/null @@ -1,364 +0,0 @@ -#!/usr/bin/env bash -# -# Shared helpers for the api-discovery scripts. -# Sourced by ../discover, ../extract-sources, ../clean-cache. -# -# All functions write diagnostics to stderr; "return values" go to stdout. -# -# Conventions: -# - Bash 3.2 compatible (macOS default). -# - No external deps beyond coreutils, grep, sed, unzip. -# - `set -euo pipefail` is set by the caller, not here. - -# Exit codes used across the scripts. -readonly EX_OK=0 -readonly EX_FAIL=1 -readonly EX_NO_CACHE=10 # cache uninitialized; agent runs bootstrap - -# Resolve the consumer repository root โ€” the nearest ancestor of $PWD -# containing `buildSrc/src/main/kotlin/io/spine/dependency/`. Falls back to -# CLAUDE_PROJECT_DIR (set by Claude Code) if no such ancestor exists. -# Prints the absolute path to stdout. Exits non-zero if it cannot resolve. -consumer_repo_root() { - local dir="${1:-$PWD}" - while [ "$dir" != "/" ] && [ -n "$dir" ]; do - if [ -d "$dir/buildSrc/src/main/kotlin/io/spine/dependency" ]; then - printf '%s\n' "$dir" - return 0 - fi - dir="$(dirname -- "$dir")" - done - if [ -n "${CLAUDE_PROJECT_DIR:-}" ] && \ - [ -d "$CLAUDE_PROJECT_DIR/buildSrc/src/main/kotlin/io/spine/dependency" ]; then - printf '%s\n' "$CLAUDE_PROJECT_DIR" - return 0 - fi - return 1 -} - -# Resolve the workspace root (parent of the consumer repo by default). -# Honors an optional pointer file at -# `/.workspace-root` containing an absolute path; used when the -# user picks "alternative root" during bootstrap. -workspace_root() { - local scripts_dir="${SPINE_API_DISCOVERY_DIR:-}" - if [ -z "$scripts_dir" ]; then - local repo - repo="$(consumer_repo_root)" || return 1 - scripts_dir="$repo/.agents/scripts/api-discovery" - fi - local pointer="$scripts_dir/.workspace-root" - if [ -f "$pointer" ]; then - # Read the first line verbatim. `IFS=` keeps internal spaces - # (paths like `/Users/me/Spine Workspace` must survive intact); - # `read -r` strips the trailing newline. Strip a stray CR for - # Windows-style line endings. - local custom="" - IFS= read -r custom < "$pointer" 2>/dev/null || true - custom="${custom%$'\r'}" - if [ -n "$custom" ] && [ -d "$custom" ]; then - printf '%s\n' "$custom" - return 0 - fi - fi - local repo - repo="$(consumer_repo_root)" || return 1 - (cd "$repo/.." && pwd) -} - -# Directory that holds the per-workstation api-discovery cache. -cache_root() { - local ws - ws="$(workspace_root)" || return 1 - printf '%s/.agents/caches/api-discovery\n' "$ws" -} - -# Subdirectory under cache_root where extracted sources live. -sources_root() { - local root - root="$(cache_root)" || return 1 - printf '%s/sources\n' "$root" -} - -# Returns 0 if the sources cache directory exists; 1 otherwise. -cache_initialized() { - local s - s="$(sources_root)" || return 1 - [ -d "$s" ] -} - -# Returns the first Gradle-cache JAR path matching the coordinates and -# optional suffix ("-sources" or empty). Empty stdout means "not found". -find_gradle_cache_jar() { - local group="$1" artifact="$2" version="$3" suffix="${4:-}" - local base="$HOME/.gradle/caches/modules-2/files-2.1/$group/$artifact/$version" - [ -d "$base" ] || return 0 - local jar - jar="$(find "$base" -maxdepth 2 -type f \ - -name "${artifact}-${version}${suffix}.jar" 2>/dev/null \ - | head -n 1)" - [ -n "$jar" ] && printf '%s\n' "$jar" - return 0 -} - -# Extract the canonical `const val version` value from a Spine local/.kt -# file. Anchors at line start (with optional access modifier) so that -# `const val version` strings inside KDoc, comments, or other quoted text -# do not match. Each local/.kt is expected to declare exactly one -# top-level `version` constant; multi-artifact files use different -# constant names (e.g. `mcVersion`) for their non-canonical versions. -read_declared_version() { - local file="$1" - [ -f "$file" ] || return 1 - sed -nE 's/^[[:space:]]*(private[[:space:]]+|internal[[:space:]]+|public[[:space:]]+|protected[[:space:]]+)?const[[:space:]]+val[[:space:]]+version[[:space:]]*=[[:space:]]*"([^"]+)".*/\2/p' \ - "$file" | head -n 1 -} - -# Read a `val [: Type] by extra("VALUE")` declaration from a file. -# Prints VALUE on stdout; empty if not found. -_read_extra_val() { - local file="$1" name="$2" - sed -nE 's/^[[:space:]]*val[[:space:]]+'"$name"'([[:space:]]*:[[:space:]]*[A-Za-z]+)?[[:space:]]+by[[:space:]]+extra\("([^"]+)"\).*/\2/p' \ - "$file" | head -n 1 -} - -# Read the sibling's "main" version from `/version.gradle.kts`. -# Tries (in order): -# 1. `versionToPublish` โ€” canonical name used by most siblings. -# 2. `Version` โ€” e.g. `mcJavaVersion` -# for sibling `mc-java`, `protoDataVersion` for `ProtoData`. -# Returns non-zero if neither is found; callers treat that as -# "freshness check unavailable". -read_sibling_version() { - local sibling="$1" - local file="$sibling/version.gradle.kts" - [ -f "$file" ] || return 1 - - local v - v="$(_read_extra_val "$file" "versionToPublish")" - if [ -n "$v" ]; then - printf '%s\n' "$v" - return 0 - fi - - local sibling_name camel - sibling_name="$(basename -- "$sibling")" - camel="$(camel_case_lower "$sibling_name")Version" - v="$(_read_extra_val "$file" "$camel")" - if [ -n "$v" ]; then - printf '%s\n' "$v" - return 0 - fi - - return 1 -} - -# Convert a PascalCase name to kebab-case. -# Examples: Base -> base; CoreJvm -> core-jvm; CoreJvmCompiler -> core-jvm-compiler. -kebab_case() { - printf '%s\n' "$1" | sed -E 's/([a-z0-9])([A-Z])/\1-\2/g; s/([A-Z]+)([A-Z][a-z])/\1-\2/g' \ - | tr '[:upper:]' '[:lower:]' -} - -# Convert a kebab-case or PascalCase name to camelCase (first letter lowercase). -# Examples: base-libraries -> baseLibraries; mc-java -> mcJava; -# core-jvm-compiler -> coreJvmCompiler; ProtoData -> protoData. -camel_case_lower() { - local input="$1" - local pascal - pascal="$(printf '%s\n' "$input" | awk -F- '{ - out="" - for (i = 1; i <= NF; i++) { - out = out toupper(substr($i, 1, 1)) substr($i, 2) - } - print out - }')" - local first rest - first="$(printf '%s' "$pascal" | cut -c1 | tr '[:upper:]' '[:lower:]')" - rest="$(printf '%s' "$pascal" | cut -c2-)" - printf '%s%s\n' "$first" "$rest" -} - -# Given a Spine local/.kt file, deduce its sibling repository name. -# Priority: -# 1. `https://github.com/SpineEventEngine/` URL inside the file. -# 2. kebab-case of the file's basename (without `.kt`). -sibling_name_from_dep_file() { - local file="$1" - [ -f "$file" ] || return 1 - local from_url - from_url="$(sed -nE 's|.*github\.com/SpineEventEngine/([A-Za-z0-9._-]+).*|\1|p' \ - "$file" | head -n 1)" - if [ -n "$from_url" ]; then - # Trim any trailing slash or punctuation. - from_url="${from_url%/}" - printf '%s\n' "$from_url" - return 0 - fi - local base - base="$(basename -- "$file" .kt)" - kebab_case "$base" -} - -# Returns 0 if the given directory contains a Kotlin/Java source set. -# Recognizes plain `src/main` and Kotlin Multiplatform names such as -# `src/commonMain`, `src/jvmMain`, `src/jsMain`, `src/nativeMain`. -has_source_set() { - local dir="$1" - [ -d "$dir/src" ] || return 1 - local candidate - for candidate in main commonMain jvmMain jsMain nativeMain; do - [ -d "$dir/src/$candidate" ] && return 0 - done - return 1 -} - -# Resolve a submodule inside a sibling that owns a given artifact. -# Tries candidate subdirectory names in order, returning the first that -# contains a recognizable source set: -# 1. Sibling root (single-module siblings such as `reflect`, `testlib`). -# 2. `/` (artifact name == submodule name). -# 3. `/` -# (`spine-base` -> `base-libraries/base`). -# 4. `/-`-stripped>` -# (`spine-protodata-backend` -> `ProtoData/backend`). -# 5. `/` -# (covers `spine-tool-base` -> `tool-base/tool-base`). -# Falls back to the sibling root when no candidate matches. -resolve_submodule_path() { - local sibling="$1" artifact="$2" - [ -d "$sibling" ] || return 1 - - if has_source_set "$sibling"; then - printf '%s\n' "$sibling" - return 0 - fi - - local sibling_name lower_sibling - sibling_name="$(basename -- "$sibling")" - lower_sibling="$(printf '%s' "$sibling_name" | tr '[:upper:]' '[:lower:]')" - - local candidates=() - candidates+=("$artifact") - - case "$artifact" in - spine-*) candidates+=("${artifact#spine-}") ;; - esac - - case "$artifact" in - spine-${lower_sibling}-*) candidates+=("${artifact#spine-${lower_sibling}-}") ;; - ${lower_sibling}-*) candidates+=("${artifact#${lower_sibling}-}") ;; - esac - - candidates+=("$sibling_name") - - local cand - for cand in "${candidates[@]}"; do - [ -n "$cand" ] || continue - if has_source_set "$sibling/$cand"; then - printf '%s/%s\n' "$sibling" "$cand" - return 0 - fi - done - - printf '%s\n' "$sibling" -} - -# Identify whether a Maven group belongs to the Spine sibling ecosystem. -# Returns 0 (true) for Spine groups, 1 (false) otherwise. -is_spine_group() { - case "$1" in - io.spine|io.spine.tools|io.spine.protodata|io.spine.validation) - return 0 - ;; - *) - return 1 - ;; - esac -} - -# Parse a query into group, artifact, version. Sets the globals -# Q_GROUP, Q_ARTIFACT, Q_VERSION. Some may be empty. -# Accepts: `group:artifact:version`, `group:artifact`, `artifact`, or a -# free-form name that we treat as either an artifact or a sibling label. -# Returns 0 always; the caller decides what an empty field means. -parse_query() { - local q="$1" - Q_GROUP=""; Q_ARTIFACT=""; Q_VERSION="" - local rest - case "$q" in - *:*:*) - Q_GROUP="${q%%:*}" - rest="${q#*:}" - Q_ARTIFACT="${rest%%:*}" - Q_VERSION="${rest#*:}" - ;; - *:*) - Q_GROUP="${q%%:*}" - Q_ARTIFACT="${q#*:}" - ;; - *) - Q_ARTIFACT="$q" - ;; - esac -} - -# Escape every non-alphanumeric character so the result is safe to embed -# in a POSIX ERE pattern. Cheap overkill โ€” Maven artifact names should -# never need most of these, but the caller's input is untrusted. -escape_ere() { - printf '%s' "$1" | sed 's/[^A-Za-z0-9]/\\&/g' -} - -# Locate the consumer repo's local/.kt file that declares a Maven artifact. -# Some local files build artifact coordinates via Kotlin string templates -# (`"$prefix-base"`, `"$group:$prefix-java:$version"`). To match those we -# expand the per-file `prefix` constant before grepping. Other template -# variables resolve to literals already present in the source, so a plain -# grep finds them. -# Returns the path of the first matching file (or empty). -find_local_dep_file_for_artifact() { - local artifact="$1" - local repo - repo="$(consumer_repo_root)" || return 1 - local local_dir="$repo/buildSrc/src/main/kotlin/io/spine/dependency/local" - [ -d "$local_dir" ] || return 0 - - # Validate the artifact name against the Maven convention before - # building a regex from it. Reject anything we cannot guarantee is - # safe; this prevents shell-quoted regex metacharacters in - # caller-supplied input from being interpreted by `grep -E`. - case "$artifact" in - ''|*[!A-Za-z0-9._-]*) - log_warn "invalid artifact name (allowed: A-Z a-z 0-9 . _ -): $artifact" - return 1 - ;; - esac - local artifact_esc - artifact_esc="$(escape_ere "$artifact")" - - local file prefix expanded - for file in "$local_dir"/*.kt; do - [ -f "$file" ] || continue - prefix="$(sed -nE 's/.*const[[:space:]]+val[[:space:]]+prefix[[:space:]]*=[[:space:]]*"([^"]+)".*/\1/p' \ - "$file" | head -1)" - if [ -n "$prefix" ]; then - expanded="$(sed -e 's|\$prefix|'"$prefix"'|g; s|\${prefix}|'"$prefix"'|g' "$file")" - else - expanded="$(cat -- "$file")" - fi - # Match the artifact as a complete coordinate component: - # delimited by `"`, `:`, or `-` on either side, never as a substring. - if printf '%s\n' "$expanded" | grep -qE "[\":-]${artifact_esc}[\":-]|[\":-]${artifact_esc}\$|^${artifact_esc}\$"; then - printf '%s\n' "$file" - return 0 - fi - done - return 0 -} - -# Emit a stderr line tagged with the scripts' identity, so the agent can -# distinguish them from unrelated noise. -log_warn() { - printf 'api-discovery: %s\n' "$*" >&2 -} diff --git a/.agents/scripts/api-discovery/update-sibling b/.agents/scripts/api-discovery/update-sibling deleted file mode 100755 index e145aaee6a..0000000000 --- a/.agents/scripts/api-discovery/update-sibling +++ /dev/null @@ -1,159 +0,0 @@ -#!/usr/bin/env bash -# -# Refresh a Spine sibling repo on disk so api-discovery returns sources -# matching the most recent published version. -# -# Safe by design: -# - Pulls only when the sibling is on its default branch (master or -# main) with a clean working tree and a tracked upstream. -# - On any other branch, treats the local state as intentional (the -# user is "advancing multiple subprojects at the same time") and -# exits 0 without touching anything. -# - Refuses on detached HEAD, uncommitted changes, or missing upstream. -# - Never switches branches, never `--rebase`, never `--force`, never -# fetches a branch the user does not already track. The strictest -# action it performs is `git pull --ff-only`. -# -# This script is intended to be invoked by the api-discovery skill -# after the agent has surfaced a STALE warning to the user AND received -# explicit consent. -# -# Usage: -# update-sibling # resolved under -# update-sibling # acts on that path directly -# -# Output: -# stdout: exactly one stable token on success (`pulled`, `up-to-date`, -# or `skipped-branch` โ€” see Exit codes). Empty on any failure -# path so callers cannot misread an error as a result. -# stderr: human-facing diagnostics, including git's own pull output. -# -# Exit codes: -# 0 โ€” succeeded; stdout token names the outcome: -# `pulled` โ€” HEAD advanced to upstream tip. -# `up-to-date` โ€” already at upstream tip; nothing to do. -# `skipped-branch` โ€” on a non-default branch; left untouched. -# 1 โ€” sibling not on disk. -# 2 โ€” not a git repository. -# 3 โ€” detached HEAD; refused. -# 4 โ€” uncommitted tracked changes; refused. -# 5 โ€” default branch has no upstream tracking; refused. -# 6 โ€” `git pull --ff-only` failed (divergence, conflict, network, etc.). -# 64 โ€” usage error (no/too many args). -set -euo pipefail - -SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" -SPINE_API_DISCOVERY_DIR="$SCRIPT_DIR" -export SPINE_API_DISCOVERY_DIR -# shellcheck source=lib/common.sh -. "$SCRIPT_DIR/lib/common.sh" - -# Layered on top of the shared EX_OK / EX_FAIL / EX_NO_CACHE in common.sh. -readonly EX_NOT_GIT=2 -readonly EX_DETACHED=3 -readonly EX_DIRTY=4 -readonly EX_NO_UPSTREAM=5 -readonly EX_PULL_FAILED=6 -readonly EX_USAGE=64 # BSD sysexits(3) convention. - -usage() { - cat >&2 <<'EOF' -Usage: update-sibling - -Examples: - update-sibling base-libraries - update-sibling /Users/me/Projects/Spine/validation - -Pulls only when the sibling is on its default branch (master|main) -with a clean working tree and a tracked upstream. Otherwise it leaves -the sibling alone. - -On success, prints one of: pulled | up-to-date | skipped-branch. -EOF - exit "$EX_USAGE" -} - -[ "$#" -eq 1 ] || usage -arg="$1" - -# Resolve to an absolute sibling path. Accept either a bare repo name -# (looked up under ) or an absolute path. -case "$arg" in - /*) sibling="$arg" ;; - *) - ws="$(workspace_root)" || { - log_warn "cannot resolve workspace root" - exit "$EX_FAIL" - } - sibling="$ws/$arg" - ;; -esac - -if [ ! -d "$sibling" ]; then - log_warn "sibling not on disk: $sibling" - exit "$EX_FAIL" # distinct from EX_USAGE so callers can tell - # "you passed me a bad path" apart from - # "you didn't pass me anything". -fi - -# `.git` may be a directory (normal clone) or a file (worktree pointer). -if [ ! -e "$sibling/.git" ]; then - log_warn "not a git repository: $sibling" - exit "$EX_NOT_GIT" -fi - -branch="$(git -C "$sibling" rev-parse --abbrev-ref HEAD 2>/dev/null || true)" -if [ -z "$branch" ]; then - log_warn "failed to read current branch in $sibling" - exit "$EX_FAIL" -fi -if [ "$branch" = "HEAD" ]; then - log_warn "$sibling is in detached HEAD; not pulling" - exit "$EX_DETACHED" -fi - -# Non-default branch: the user is intentionally on a feature branch. -# Use the current code as-is. -case "$branch" in - master|main) - ;; - *) - log_warn "$sibling is on '$branch' (not master/main); using local code as-is" - printf 'skipped-branch\n' - exit "$EX_OK" - ;; -esac - -# Dirty-tree guard: refuse on TRACKED modifications, since a fast-forward -# could conflict with them. Untracked files are tolerated: they don't -# conflict with HEAD movement on their own, and any genuine overwrite -# conflict (upstream adds a file whose path already exists untracked -# locally) still surfaces below as EX_PULL_FAILED via git's own check. -if [ -n "$(git -C "$sibling" status --porcelain --untracked-files=no 2>/dev/null)" ]; then - log_warn "$sibling has uncommitted tracked changes on '$branch'; not pulling" - exit "$EX_DIRTY" -fi - -# Upstream guard: --ff-only against an undefined upstream is meaningless. -if ! git -C "$sibling" rev-parse --abbrev-ref --symbolic-full-name '@{u}' \ - >/dev/null 2>&1; then - log_warn "$sibling/$branch has no upstream tracking; not pulling" - exit "$EX_NO_UPSTREAM" -fi - -# Capture HEAD before/after so we can report what changed. -before="$(git -C "$sibling" rev-parse HEAD)" -if ! git -C "$sibling" pull --ff-only >&2; then - log_warn "git pull --ff-only failed in $sibling" - exit "$EX_PULL_FAILED" -fi -after="$(git -C "$sibling" rev-parse HEAD)" - -if [ "$before" = "$after" ]; then - log_warn "$sibling already up-to-date on '$branch' ($after)" - printf 'up-to-date\n' -else - log_warn "$sibling pulled '$branch': $before -> $after" - printf 'pulled\n' -fi -exit "$EX_OK" diff --git a/.agents/scripts/pre-pr-gate.sh b/.agents/scripts/pre-pr-gate.sh deleted file mode 100755 index 3ba36c0e59..0000000000 --- a/.agents/scripts/pre-pr-gate.sh +++ /dev/null @@ -1,82 +0,0 @@ -#!/usr/bin/env bash -# -# PreToolUse hook: block `gh pr create` unless /pre-pr has successfully run -# for the current HEAD. The hook is intentionally unaware of the repository's -# versioning or build system; the /pre-pr skill decides which checks apply. -# -# Input: hook JSON on stdin (tool_name, tool_input.command). -# Exit: 0 to allow, 2 to block (stderr is surfaced to Claude). -# -set -eu - -if ! command -v jq >/dev/null 2>&1; then - cat >&2 </dev/null) || exit 0 -sentinel="$repo_root/.git/pre-pr.ok" - -block() { - cat >&2 - exit 2 -} - -if [ ! -f "$sentinel" ]; then - block </dev/null 2>&1; then - cat >&2 <<'EOF' -This hook requires `jq` to validate edits to version.gradle.kts and cannot run safely without it. - -Install `jq` and retry. This hook fails closed to avoid silently allowing prohibited edits. -EOF - exit 2 -fi - -input=$(cat) -file=$(printf '%s' "$input" | jq -r '.tool_input.file_path // empty') -command=$(printf '%s' "$input" | jq -r '.tool_input.command // empty') - -touches_version_file() { - if [ "$file" = "version.gradle.kts" ] || [ "${file%/version.gradle.kts}" != "$file" ]; then - return 0 - fi - - printf '%s\n' "$command" \ - | grep -qE '^\*\*\* (Add|Update|Delete) File: (.+/)?version\.gradle\.kts$' -} - -if touches_version_file; then - cat >&2 <<'EOF' -Direct edits to version.gradle.kts are blocked by a project hook. - -If this repository already has a root version.gradle.kts, use the bump-version -skill instead: - /bump-version [snapshot|minor|major] - -If this repository does not have a root version.gradle.kts, do not add one just -to satisfy /pre-pr; the version check is not applicable. - -See: - - .agents/version-policy.md - - .agents/skills/bump-version/SKILL.md -EOF - exit 2 -fi - -exit 0 diff --git a/.agents/scripts/publish-version-gate.sh b/.agents/scripts/publish-version-gate.sh deleted file mode 100755 index 996bd25656..0000000000 --- a/.agents/scripts/publish-version-gate.sh +++ /dev/null @@ -1,95 +0,0 @@ -#!/usr/bin/env bash -# -# PreToolUse hook: block any `./gradlew` invocation that could publish to -# Maven Local without a version bump on the current branch. Wraps the -# Layer-1 deterministic check at `version-bumped.sh`. -# -# This is intentionally broad: it fires on `build`, `publish`, -# `publishToMavenLocal`, and any `:publish*` task. Many repos in this -# constellation chain `publishToMavenLocal` into `build` because -# integration tests consume those local artifacts, so `build` itself is -# publish-risky. False positives (blocking a pure compile) are preferable -# to overwriting a previously published snapshot that consuming repos -# rely on. -# -# Input: hook JSON on stdin (tool_name, tool_input.command). -# Exit: 0 to allow, 2 to block (stderr is surfaced to Claude). -# -set -eu - -command -v jq >/dev/null 2>&1 || exit 0 - -input=$(cat) -tool=$(printf '%s' "$input" | jq -r '.tool_name // empty') -[ "$tool" != "Bash" ] && exit 0 - -cmd=$(printf '%s' "$input" | jq -r '.tool_input.command // empty') - -# Split the command on shell separators (`;`, `&`, `|`) and inspect each -# segment. Only block when a segment, after optional whitespace, invokes -# `./gradlew` (or `./config/gradlew`) with a publish-risky task. Avoids -# false positives on `echo "./gradlew build"` or fixtures. -risky_segment() { - local seg="$1" - # Must start with a gradlew invocation. - printf '%s' "$seg" | grep -qE '^[[:space:]]*\.?/?(config/)?gradlew([[:space:]]|$)' || return 1 - # Must mention a publish-risky task. `build` is risky because it can - # finalize publishToMavenLocal in this config. The leading - # `(:[A-Za-z0-9_.-]+)*:?` covers qualified task paths - # (e.g. `:module:build`, `:a:b:publishToMavenLocal`) and a single - # leading-colon form (`:publishMavenJavaPublicationToMavenLocal`). - # `publish[^[:space:]]*` then catches every publish-task variant. - printf '%s' "$seg" | grep -qE '(^|[[:space:]])(:[A-Za-z0-9_.-]+)*:?(build|publish[^[:space:]]*|publishToMavenLocal|publishAllPublicationsToMavenLocal)([[:space:]]|$)' -} - -block_needed=0 -# `|| [ -n "$segment" ]` makes the loop process the final segment when the -# input has no trailing newline (which is the case for `printf '%s'`). -while IFS= read -r segment || [ -n "$segment" ]; do - if risky_segment "$segment"; then - block_needed=1 - break - fi -done < <(printf '%s' "$cmd" | tr ';&|' '\n\n\n') - -[ "$block_needed" -eq 0 ] && exit 0 - -repo_root=$(git rev-parse --show-toplevel 2>/dev/null) || exit 0 -script="$repo_root/.agents/skills/version-bumped/scripts/version-bumped.sh" - -# If the helper is missing (e.g. partial clone), don't pretend we gated. -if [ ! -x "$script" ]; then - exit 0 -fi - -# `&& rc=0 || rc=$?` captures the exit code regardless of success/failure. -# After `if cmd; then ... fi`, $? reflects the if-fi structural exit (0), -# not the failed test's exit code โ€” so we cannot use the if-fi form here. -err_file="/tmp/version-bumped.$$.err" -VERSION_BUMPED_QUIET=1 "$script" 2>"$err_file" && rc=0 || rc=$? -if [ "$rc" -eq 0 ]; then - rm -f "$err_file" - exit 0 -fi -err_payload=$(cat "$err_file" 2>/dev/null || true) -rm -f "$err_file" - -# Layer-1 returned a configuration error โ€” do not block, surface the note. -if [ "$rc" -ne 1 ]; then - printf '%s\n' "$err_payload" >&2 - exit 0 -fi - -cat >&2 </dev/null 2>&1 || exit 0 - -input=$(cat) -file=$(printf '%s' "$input" | jq -r '.tool_input.file_path // empty') -command=$(printf '%s' "$input" | jq -r '.tool_input.command // empty') - -sanitize_file() { - local path="$1" - - [ -z "$path" ] && return 0 - [ ! -f "$path" ] && return 0 - - case "$path" in - *.java|*.kt|*.kts) ;; - *) return 0 ;; - esac - - tmp=$(mktemp) - awk ' - { sub(/[ \t]+$/, "") } - /^$/ { blank++; if (blank > 1) next; print; next } - { blank = 0; print } - ' "$path" > "$tmp" && mv "$tmp" "$path" -} - -if [ -n "$file" ]; then - sanitize_file "$file" - exit 0 -fi - -printf '%s\n' "$command" \ - | sed -nE 's/^\*\*\* (Add|Update) File: (.*)$/\2/p' \ - | sort -u \ - | while IFS= read -r path; do - sanitize_file "$path" - done diff --git a/.agents/scripts/update-copyright.sh b/.agents/scripts/update-copyright.sh deleted file mode 100755 index b25282fda6..0000000000 --- a/.agents/scripts/update-copyright.sh +++ /dev/null @@ -1,48 +0,0 @@ -#!/usr/bin/env bash -# -# PostToolUse hook: refresh the copyright header of source files touched by -# Edit/Write/MultiEdit. Delegates to -# .agents/skills/update-copyright/scripts/update_copyright.py, which: -# - operates only on recognized source extensions, -# - never adds a header to a file that does not already have one, -# - rewrites `today.year` to the current year per the IntelliJ profile. -# -# Input: hook JSON on stdin. Claude Code passes `tool_input.file_path`; -# Codex `apply_patch` passes the patch text in `tool_input.command`. -# Exit: 0 always (post-tool-use; never block). -# -set -u - -# Required tools โ€” silently no-op if either is missing so the hook never blocks. -command -v jq >/dev/null 2>&1 || exit 0 -command -v python3 >/dev/null 2>&1 || exit 0 - -input=$(cat) -file=$(printf '%s' "$input" | jq -r '.tool_input.file_path // empty' 2>/dev/null || true) -command=$(printf '%s' "$input" | jq -r '.tool_input.command // empty' 2>/dev/null || true) - -root="${CLAUDE_PROJECT_DIR:-$(pwd)}" -script="$root/.agents/skills/update-copyright/scripts/update_copyright.py" - -[ -f "$script" ] || exit 0 - -update_path() { - local path="$1" - [ -z "$path" ] && return 0 - [ ! -f "$path" ] && return 0 - python3 "$script" --root "$root" "$path" >/dev/null 2>&1 || true -} - -if [ -n "$file" ]; then - update_path "$file" - exit 0 -fi - -printf '%s\n' "$command" \ - | sed -nE 's/^\*\*\* (Add|Update) File: (.*)$/\2/p' \ - | sort -u \ - | while IFS= read -r path; do - update_path "$path" - done - -exit 0 diff --git a/.agents/shared b/.agents/shared new file mode 160000 index 0000000000..733164224a --- /dev/null +++ b/.agents/shared @@ -0,0 +1 @@ +Subproject commit 733164224af85b1ea2bb9cfa054a2be7a3412d0c diff --git a/.agents/skills b/.agents/skills new file mode 120000 index 0000000000..f14734dde4 --- /dev/null +++ b/.agents/skills @@ -0,0 +1 @@ +shared/skills \ No newline at end of file diff --git a/.agents/skills/api-discovery/SKILL.md b/.agents/skills/api-discovery/SKILL.md deleted file mode 100644 index e8a616e3a3..0000000000 --- a/.agents/skills/api-discovery/SKILL.md +++ /dev/null @@ -1,288 +0,0 @@ ---- -name: api-discovery -description: > - Resolve the on-disk location of a Maven artifact's source code, - so you can inspect it directly instead of running `unzip` against JARs - in the Gradle cache. Use this whenever you need to inspect a library's - API or implementation โ€” definitions of public - types, method signatures, KDoc, internal helpers, etc. ---- - -# API discovery - -Before reading library source code, run the `discover` script in -`.agents/scripts/api-discovery/`. It returns a path you can hand -straight to normal search and file-reading tools such as `rg`, `sed`, -or the active agent's file viewer. - -Do **not** run `find ~/.gradle/caches` or `unzip` against cache JARs. -Each `unzip` decompresses the archive afresh โ€” slow and token-heavy. - -## How to call it - -From the consumer repository root: - -```bash -.agents/scripts/api-discovery/discover -``` - -Where `` is one of: - -| Form | Example | Notes | -|---|---|---| -| `group:artifact:version` | `io.spine:spine-base:2.0.0-SNAPSHOT.390` | Most explicit | -| `group:artifact` | `io.spine:spine-base` | Version inferred from `buildSrc` | -| `artifact` | `spine-base` | Spine-only; group inferred from `buildSrc` | - -The script writes the absolute resolved path to **stdout**, and any -freshness/diagnostic warnings to **stderr**. Always read stderr โ€” a -silent stdout means clean resolution; a noisy stderr means caveats -the user should know about. - -## Exit codes - -| Code | Meaning | What you do | -|---|---|---| -| `0` | Path on stdout is usable. | Search or read files under that path directly. If stderr is non-empty, surface the warning to the user before relying on the path. | -| `1` | Unresolvable (no sibling AND no JAR). | Report the failure. **Do not** fall back to `unzip ~/.gradle/caches/...`. | -| `10` | Cache directory not initialized. | Run the **bootstrap flow** below. | - -## Bootstrap flow (exit 10) - -On the first run in a fresh workstation the per-workstation cache -directory does not yet exist. The script exits `10` and names the -path it would create. Ask the user: - -> The shared cache directory `/.agents/caches/api-discovery/` -> does not exist yet. How would you like to proceed? -> -> 1. **Approve** โ€” create the directory at the default path. -> 2. **Alternative root** โ€” pick a different parent for the shared -> `.agents/` directory (e.g., `~/SpineWorkspace`, `/srv/spine`). -> 3. **Non-cached** โ€” skip the extraction cache. Sibling-first -> discovery still works for Spine artifacts; non-Spine deps will -> not be served by `api-discovery` in this repo. - -Then act on the user's reply: - -- **Approve** โ†’ `mkdir -p /.agents/caches/api-discovery/sources`, - then re-run the original `discover` query. -- **Alternative root** โ†’ ask for the absolute path ``, then: - ```bash - mkdir -p "/.agents/caches/api-discovery/sources" - printf '%s\n' "" \ - > .agents/scripts/api-discovery/.workspace-root - ``` - (the pointer file is gitignored). Then re-run `discover`. -- **Non-cached** โ†’ record the choice in **per-developer auto-memory** - (project memory, type `feedback`), `name: api-discovery-cache-disabled`, - describing the user's choice and giving the "How to apply" rule: - *do not invoke `extract-sources` in this repo; for non-Spine deps - fall back to other investigation tools*. Then proceed with - sibling-first only. - -Check that memory at session start. If it exists, skip cache-touching -paths entirely. - -## Workflow - -1. **Always** call `discover` before reading library source. -2. Use the returned path with search or file-reading tools directly. Do **not** - `cd` into the directory โ€” that adds path-prefix noise to tool calls - and makes line citations harder to read. -3. If stderr contains `STALE: ...`, the sibling on disk does not match - the version declared in `buildSrc`. Surface the warning AND offer - to refresh โ€” see *Refreshing a stale sibling* below. -4. If the script exits `1`, report the failure with its stderr - message and stop. Do not try `unzip` as a workaround. - -## Refreshing a stale sibling - -The user keeps siblings cloned locally as the source of truth and -sometimes works across several siblings at once with a feature branch -checked out in each. So a `STALE` line has two possible meanings, and -they require different handling: - -- **Sibling is behind `master`/`main`.** A `git pull --ff-only` will - bring it up to date. -- **Sibling is on a feature branch.** This is *intentional* โ€” the user - is staging changes across multiple subprojects. The local code is - the right code; **do not** pull. - -You cannot tell which case applies without inspecting the sibling. The -companion script `update-sibling` handles both safely: it pulls only -on the default branch with a clean tree and a tracked upstream, and -exits `0` without touching anything when on a feature branch. - -### Procedure - -When you see a `STALE: ...` line from `discover`: - -1. Surface the warning to the user. -2. Ask, in one short prompt: - > The sibling at `` is stale. Want me to try updating it? - > I'll only `git pull --ff-only` if it's on `master`/`main` with - > a clean working tree; if you have a feature branch checked out, - > I'll leave it as-is. -3. If the user agrees, run: - ```bash - .agents/scripts/api-discovery/update-sibling - ``` - `` is either the absolute path shown by - `discover` (preferred โ€” unambiguous) or just the sibling repo name - (resolved under ``). -4. Read **stdout** to decide what to do next โ€” it is a single stable - token, not free-form English: - - `pulled` โ€” HEAD advanced. Re-run `discover` so the STALE warning - clears (or, more rarely, reports a different discrepancy). - - `up-to-date` โ€” sibling was already at upstream tip. The STALE - warning is informational โ€” the declared `buildSrc` version and - the sibling's `versionToPublish` simply disagree. Proceed - without re-running `discover`. - - `skipped-branch` โ€” sibling is on a feature branch and was left - untouched. Use the local code as-is; proceed without re-running. - - stderr always carries the human-readable diagnostics; surface it - to the user, but do not parse it to drive control flow. -5. If the user declines, proceed without pulling. Do not ask again - for the same sibling in the same session unless the user revisits. - -### `update-sibling` exit codes - -Exit 0 is split into three outcomes by the **stdout token** โ€” read -that, not the stderr text. - -| Code | stdout | Meaning | What you do | -|---|---|---|---| -| `0` | `pulled` | HEAD advanced to upstream tip. | Re-run `discover` so the STALE warning clears. | -| `0` | `up-to-date` | Already at upstream tip; nothing to do. | Proceed; surface the STALE warning to the user as informational. | -| `0` | `skipped-branch` | On a non-default branch; left untouched. | Use the local code as-is; proceed without re-running. | -| `1` | _(empty)_ | Sibling not on disk. | Report the error. | -| `2` | _(empty)_ | Not a git repository. | Report the error; do not retry. | -| `3` | _(empty)_ | Detached HEAD โ€” refused. | Tell the user; do not retry. | -| `4` | _(empty)_ | Working tree dirty โ€” refused. | Tell the user; do not retry. | -| `5` | _(empty)_ | No upstream tracking on default branch โ€” refused. | Tell the user. | -| `6` | _(empty)_ | `git pull --ff-only` failed (divergence, network, etc.). | Surface the git error verbatim. | -| `64` | _(empty)_ | Usage error (no/too many arguments). | Fix the invocation; do not retry blindly. | - -Failure paths produce **empty stdout** so the agent can never misread -an error message as a result token. - -### "Don't ask me again" - -If the user says something like "stop offering" or "skip the prompt -this session", remember that for the rest of the conversation and do -not prompt on subsequent STALE warnings โ€” just surface the warning -and move on. This is **per-session** state; do not write it to -auto-memory. - -## Anti-patterns - -Stop doing these โ€” they are exactly what this skill exists to replace: - -- `find ~/.gradle/caches/modules-2/files-2.1/ -name '*-sources.jar'` -- `unzip -l ` to list classes -- `unzip -p path/in/jar` to read a file -- Any chain of `unzip` + `grep` against a Gradle-cache JAR - -If you find yourself wanting to do those, run `discover` instead. - -## Examples - -**Spine artifact, fresh sibling on disk:** - -```text -$ .agents/scripts/api-discovery/discover io.spine:spine-base -/Users//Projects/Spine/base-libraries/base -$ echo $? -0 -``` - -Follow-up searches then look like: - -- `rg --files /Users//Projects/Spine/base-libraries/base`. -- `rg -n 'class Identifier' /Users//Projects/Spine/base-libraries/base`. - -**Spine artifact, stale sibling:** - -```text -$ .agents/scripts/api-discovery/discover io.spine.tools:validation-java -api-discovery: STALE: validation-java declared 2.0.0-SNAPSHOT.433 in Validation.kt but sibling publishes 2.0.0-SNAPSHOT.440 -api-discovery: sources at /Users//Projects/Spine/validation/java may differ from the published artifact -/Users//Projects/Spine/validation/java -``` - -Surface the `STALE` line, then offer to refresh โ€” see *Refreshing a -stale sibling*. After the user agrees and the pull succeeds, re-run -`discover` and the warning clears. - -**Stale sibling, refresh on master:** - -```text -$ .agents/scripts/api-discovery/update-sibling /Users//Projects/Spine/validation -Updating abc1234..def5678 -Fast-forward - ... -api-discovery: /Users//Projects/Spine/validation pulled 'master': abc1234... -> def5678... -pulled -$ echo $? -0 -``` - -Stdout is `pulled` โ€” re-run `discover` to clear the STALE warning. - -**Stale sibling, already at upstream tip:** - -```text -$ .agents/scripts/api-discovery/update-sibling /Users//Projects/Spine/validation -Already up to date. -api-discovery: /Users//Projects/Spine/validation already up-to-date on 'master' (def5678...) -up-to-date -$ echo $? -0 -``` - -Stdout is `up-to-date` โ€” the sibling is fresh; the STALE warning -reflects a declared-version vs. `versionToPublish` discrepancy that -`git pull` cannot resolve. Surface it to the user as informational. - -**Stale sibling, feature branch (no-op):** - -```text -$ .agents/scripts/api-discovery/update-sibling /Users//Projects/Spine/validation -api-discovery: /Users//Projects/Spine/validation is on 'feature/new-rule' (not master/main); using local code as-is -skipped-branch -$ echo $? -0 -``` - -Stdout is `skipped-branch` โ€” feature branch is intentional local -state. Use the code as-is. - -**Non-Spine artifact, first use (extraction):** - -```text -$ .agents/scripts/api-discovery/discover com.google.guava:guava:33.5.0-jre -api-discovery: extracted com.google.guava:guava:33.5.0-jre -> .../guava/33.5.0-jre -/Users//Projects/Spine/.agents/caches/api-discovery/sources/com.google.guava/guava/33.5.0-jre -``` - -Second call returns the same path with no stderr (already cached). - -**Unresolvable:** - -```text -$ .agents/scripts/api-discovery/discover io.spine:does-not-exist:9.9.9 -api-discovery: io.spine:does-not-exist:9.9.9 is not in the local Gradle cache -api-discovery: run './gradlew dependencies' (or rebuild) to fetch it, then retry -$ echo $? -1 -``` - -Report the failure verbatim; do not try `unzip` as a workaround. - -## Related - -- Implementation reference: `.agents/scripts/api-discovery/README.md`. -- Sibling refresh on STALE: `.agents/scripts/api-discovery/update-sibling`. -- Manual cache pruning: `.agents/scripts/api-discovery/clean-cache`. diff --git a/.agents/skills/api-discovery/agents/openai.yaml b/.agents/skills/api-discovery/agents/openai.yaml deleted file mode 100644 index b274275cf9..0000000000 --- a/.agents/skills/api-discovery/agents/openai.yaml +++ /dev/null @@ -1,4 +0,0 @@ -interface: - display_name: "API Discovery" - short_description: "Resolve Maven artifact source paths" - default_prompt: "Use $api-discovery to resolve a Maven artifact's source path before inspecting library APIs or implementations." diff --git a/.agents/skills/bump-gradle/SKILL.md b/.agents/skills/bump-gradle/SKILL.md deleted file mode 100644 index f229159d33..0000000000 --- a/.agents/skills/bump-gradle/SKILL.md +++ /dev/null @@ -1,148 +0,0 @@ ---- -name: bump-gradle -description: > - Update the Gradle wrapper version used by this repository. Use when asked to - upgrade Gradle, bump the Gradle wrapper, move the project to the latest - Gradle release from the official release notes, run the Gradle build, and - commit Gradle wrapper and dependency report changes separately. ---- - -# Bump Gradle - -Use the official Gradle release notes as the source of truth for both the -latest version and the wrapper update command: - -https://docs.gradle.org/current/release-notes.html#upgrade-instructions - -Always check that page at task time. Do not rely on remembered Gradle versions. - -## Commit authorization - -This skill is authorized to run `git commit` **up to two times** per -invocation, under these constraints: - -1. **Gradle wrapper commit.** Stage only the Gradle wrapper files - (`gradle/wrapper/gradle-wrapper.properties`, - `gradle/wrapper/gradle-wrapper.jar`, `gradlew`, `gradlew.bat`, plus - files directly required by the wrapper update). Subject: - `` Bump Gradle -> `GRADLE_VERSION` `` with the actual version - substituted. Skip if no wrapper-owned file changed. - -2. **Dependency-report commit** (separate from the wrapper commit). Stage - only generated dependency-report files (`docs/dependencies/pom.xml`, - `docs/dependencies/dependencies.md`). Subject: - `Update dependency reports`. Skip if the build did not regenerate - those files. - -No `git push`, `git tag`, `git rebase`, `git commit --amend`, or any other -history-writing operation. Those require a separate authorization -(`.agents/safety-rules.md` โ†’ *Commits and history-writing*). Do not create -empty commits, and do not bundle unrelated changes into either commit. - -## Checklist - -1. Work from the target repository root. - - Confirm `./gradlew` and `gradle/wrapper/gradle-wrapper.properties` exist - before changing anything. Inspect `git status --short` and preserve unrelated - user changes. If Gradle wrapper files are already modified, inspect the diff - and continue only when those edits are part of the same requested Gradle - bump; otherwise ask before overwriting or staging them. - -2. Read the latest Gradle version from the release notes. - - Open the Upgrade instructions section at the URL above. Use the version in - the release heading and the wrapper command shown there. They should agree; - if they do not, stop and report the mismatch. - -3. Run the wrapper update command. - - Substitute the version from the release notes: - - ```bash - ./gradlew wrapper --gradle-version=GRADLE_VERSION && ./gradlew wrapper - ``` - - For example, if the release notes say Gradle `9.5.1`, run: - - ```bash - ./gradlew wrapper --gradle-version=9.5.1 && ./gradlew wrapper - ``` - -4. Run the build. - - ```bash - ./gradlew clean build - ``` - - If the wrapper update or build fails, do not commit partial changes. Report - the failing command and the relevant error output. - -5. Commit only Gradle-related files. - - Inspect `git status --short` and `git diff --name-only`. Stage only files - created or updated by the Gradle wrapper bump, normally: - - ```text - gradle/wrapper/gradle-wrapper.properties - gradle/wrapper/gradle-wrapper.jar - gradlew - gradlew.bat - ``` - - Include other Gradle-owned files only when they are directly required by the - wrapper update and are clearly part of the same change. Do not stage - dependency reports or unrelated build output in this commit. - - Commit with the exact subject, replacing `GRADLE_VERSION`: - - ```text - Bump Gradle -> `GRADLE_VERSION` - ``` - - Example: - - ```bash - git commit -m 'Bump Gradle -> `9.5.1`' - ``` - - If no Gradle-related files changed, do not create an empty commit; report - that the wrapper was already current after verification. - -6. Commit dependency reports separately when the build updates them. - - Stage only generated dependency report files. In repositories using this - config, the usual paths are: - - ```text - docs/dependencies/pom.xml - docs/dependencies/dependencies.md - ``` - - Include other changed files only when they are clearly generated dependency - reports from the build. Commit them separately with: - - ```text - Update dependency reports - ``` - -7. Verify the final branch state. - - Confirm the recent commit subjects and make sure no owned Gradle bump or - dependency report changes remain unstaged: - - ```bash - git log --format=%s -2 - git status --short - ``` - - Leave unrelated pre-existing user changes alone and mention them separately - in the final response. - -8. Ensure `version.gradle.kts` is bumped. - - Before this branch can be built or published locally, the project - version must be strictly greater than the version on the base ref. - Run the `version-bumped` skill โ€” it is a no-op if a bump has already - happened earlier on the branch, and otherwise uses the `bump-version` - skill to perform the increment. diff --git a/.agents/skills/bump-gradle/agents/openai.yaml b/.agents/skills/bump-gradle/agents/openai.yaml deleted file mode 100644 index 6edf97877f..0000000000 --- a/.agents/skills/bump-gradle/agents/openai.yaml +++ /dev/null @@ -1,4 +0,0 @@ -interface: - display_name: "Bump Gradle" - short_description: "Update the Gradle wrapper safely" - default_prompt: "Use $bump-gradle to update this repository to the latest Gradle wrapper version from the official release notes, build, and split Gradle/report commits." diff --git a/.agents/skills/bump-version/SKILL.md b/.agents/skills/bump-version/SKILL.md deleted file mode 100644 index 8a882be885..0000000000 --- a/.agents/skills/bump-version/SKILL.md +++ /dev/null @@ -1,143 +0,0 @@ ---- -name: bump-version -description: > - Bump the project version in `version.gradle.kts` following the Spine SDK - versioning policy. Use when starting a new branch, before opening a PR, or - when CI rejects a branch for a missing/insufficient version increment. Covers - locating the published version value, choosing the increment, committing the - bump, rebuilding reports, and resolving version conflicts. ---- - -# Bump the project version - -The authoritative policy is [Spine SDK Versioning][version-policy]. In this -skill's target repository, CI runs the `Version Guard` workflow, which invokes -`checkVersionIncrement` through `IncrementGuard`. The task fails if the current -project version already exists in the Maven repository. It does not compare git -branches or inspect commit subjects; the checks below are agent-side guardrails. - -## Commit authorization - -This skill is authorized to run `git commit` **exactly once** per invocation, -under these constraints: - -- Stage only `version.gradle.kts`. Any other modified files are out of scope - for this skill's commit and must remain unstaged. -- Use the exact subject `` Bump version -> `` `` (see step 4 of the - Checklist) with the actual new version value substituted. Keep the - backticks around the version literal (for example, ``... -> `2.0.0``` ) and - do not escape them as ``\````. -- No `git push`, `git tag`, `git rebase`, `git commit --amend`, or any other - history-writing operation. Those require a separate authorization - (`.agents/safety-rules.md` โ†’ *Commits and history-writing*). - -If the bump cannot be performed cleanly (no diff to commit, conflicting -staged files, build failures preceding the commit), report and stop โ€” do not -create the commit. - -## Checklist - -1. Work from the target repository root. - - Confirm `version.gradle.kts` exists before editing. If it is absent, stop and - report that this skill does not apply to the current checkout. - - Inspect `git status --short` before changing files. Preserve unrelated user - changes and stage only the version/report files this workflow owns. - -2. Locate `version.gradle.kts` and update the value that feeds - `versionToPublish`. - - The published version may be a literal: - - ```kotlin - val versionToPublish: String by extra("2.0.0-SNAPSHOT.182") - ``` - - Or it may come from another variable: - - ```kotlin - val compilerVersion: String by extra("2.0.0-SNAPSHOT.043") - val versionToPublish by extra(compilerVersion) - ``` - - In the second case, update the source value (`compilerVersion` here), not - only the `versionToPublish` alias. - -3. Choose the increment. - - For the normal snapshot-line PR, increment the trailing snapshot number by - one: `2.0.0-SNAPSHOT.182` -> `2.0.0-SNAPSHOT.183`. Preserve existing - zero-padding: `2.0.0-SNAPSHOT.009` -> `2.0.0-SNAPSHOT.010`. - - For a breaking snapshot-line PR, advance to the next multiple of 10 that is - strictly greater than the current value: `.187` -> `.190`, and `.180` -> - `.190`. - - For release-line work, follow the [policy][version-policy]: urgent fixes bump `PATCH`; - feature work or significant fixes bump `MINOR` and reset `PATCH` to `0`. - -4. Commit only the `version.gradle.kts` change with this subject: - - ```text - Bump version -> `2.0.0-SNAPSHOT.183` - ``` - - Shell-safe example (no escaped backticks in the commit subject): - - ```bash - git commit -m 'Bump version -> `2.0.0-SNAPSHOT.183`' -- version.gradle.kts - ``` - - Use the actual new version in the subject. Do not include unrelated files in - this commit. - -5. Run the build to verify the bump and regenerate reports: - - ```bash - ./gradlew clean build - ``` - - Repos using this config commonly finalize `generatePom` and - `mergeAllLicenseReports` after `build`, which updates - `docs/dependencies/pom.xml` and `docs/dependencies/dependencies.md` when - those reports are configured. - -6. If `docs/dependencies/pom.xml` or `docs/dependencies/dependencies.md` changed, - commit those generated files separately: - - ```text - Update dependency reports - ``` - - If the PR has the `License Reports` workflow, make sure the branch modifies - `docs/dependencies/pom.xml` and `docs/dependencies/dependencies.md`. - -7. Validate the branch state. - - ```bash - BASE=master - git fetch --quiet origin "$BASE" - RANGE="$(git merge-base HEAD origin/$BASE)..HEAD" - git log --format=%s "$RANGE" | grep '^Bump version ->' - git diff --name-only "$RANGE" -- version.gradle.kts | grep '^version.gradle.kts$' - ``` - - Use the actual merge target for `BASE` when it is not `master`. - Also confirm `git status --short` has no uncommitted changes created by the - version bump or report regeneration. - -## Conflict Rule - -When merging a base branch into a feature branch: - -- If the base branch version is lower, keep the feature branch version. -- If the base branch version is greater than or equal to the feature branch - version, set the feature branch version to `base + 1`, or apply the breaking - change rounding rule. - -Do not require a completely clean worktree if unrelated user changes are -present. Instead, make sure no uncommitted changes were created by the version -bump or report regeneration. - -[version-policy]: https://github.com/SpineEventEngine/documentation/wiki/Versioning diff --git a/.agents/skills/bump-version/agents/openai.yaml b/.agents/skills/bump-version/agents/openai.yaml deleted file mode 100644 index 12f6e4f9b8..0000000000 --- a/.agents/skills/bump-version/agents/openai.yaml +++ /dev/null @@ -1,4 +0,0 @@ -interface: - display_name: "Bump Version" - short_description: "Bump Spine project versions safely" - default_prompt: "Use $bump-version to bump the project version in version.gradle.kts, commit the version change, rebuild dependency reports, and verify the branch." diff --git a/.agents/skills/check-links/SKILL.md b/.agents/skills/check-links/SKILL.md deleted file mode 100644 index 7c703be954..0000000000 --- a/.agents/skills/check-links/SKILL.md +++ /dev/null @@ -1,331 +0,0 @@ ---- -name: check-links -description: > - Validate the Hugo documentation site under `docs/` or `site/` for broken - links. Builds the site, starts the Hugo server locally, runs Lychee against - the rendered HTML using the repo's `lychee.toml`, and reports any broken URLs - grouped by source Markdown page. Use locally before pushing changes that - touch `docs/**` or `site/**`, when CI's `Check Links` job fails, or whenever - the user asks to "check doc links". If no Hugo site exists under `docs/` or - `site/`, report the check as not applicable instead of failing. Read-only - with respect to the project sources. Does **not** cover Javadoc/KDoc (out of - scope for this skill). ---- - -# Check links in the Hugo docs (repo-specific) - -You are the documentation link checker for this Spine Event Engine project. -You build the site under `docs/` or `site/` (auto-detected; see step 0), serve -it locally on port `1414`, run Lychee against the rendered HTML, and report -broken URLs. You mirror what the `.github/workflows/check-links.yml` workflow -does in CI: same Hugo version, same Lychee version, same Hugo environment -(`development`), and the same `lychee.toml`. Two deliberate differences remain: -the skill serves on port `1414` (CI uses `1313`) to avoid clashing with a -developer's local `hugo server`, and the skill writes a local sentinel that CI -does not. Both differences are harmless because `--base-url` is rewritten to -match the local port and the sentinel is consumed only by the local `pre-pr` -skill. - -### Pinned versions - -`.github/workflows/check-links.yml` is the **single source of truth** for the -Hugo and Lychee pins. This file does not duplicate the current values -because duplicates inevitably drift; see the workflow's `env:` block for -the canonical `HUGO_VERSION` and `LYCHEE_VERSION_TAG`. The auto-download -step (ยง2) reads `LYCHEE_VERSION_TAG` out of the workflow at runtime, so a -workflow bump propagates automatically. Hugo is not auto-installed; the -skill uses whichever `hugo` is on `$PATH` and only warns (does not block) -if the installed version is older than the workflow's `HUGO_VERSION` โ€” -Hugo's HTML output is stable enough across minor versions that a small -skew does not invalidate link-check results. - -The authoritative shared config is `lychee.toml` at the repo root. Do not -fork its exclude list โ€” fix the source link or, if the failing URL is a known -flaky external endpoint, add it to `lychee.toml` once (the change applies to -both the skill and CI). - -## When to run - -- Any change touches `docs/**` or `site/**` (including reference links, - `embed-code` blocks, sidenav YAML files, content under `/content/`). -- A change touches `lychee.toml` itself. -- CI reported broken links and you want a fast local repro. -- The user asks to "check the doc links" or invokes the `check-links` skill. - -If none of the above is true, decline with a one-line note rather than -running the (~30 s) build+check. - -If the repository has no Hugo config under `docs/` or `site/`, return -`APPROVE โ€” no Hugo documentation site found under docs/ or site/.` and stop. -Do not write a `FAIL` sentinel for this not-applicable case. - -## Tooling - -The skill needs four binaries: - -| Tool | Purpose | Install hint | -|--------|------------------------------------------|-------------------------------| -| Hugo | Build and serve the site | `brew install hugo` (extended)| -| Node | Hugo theme dependencies (`npm ci`) | `brew install node` | -| npm | Same | bundled with Node | -| Lychee | Link checker | `brew install lychee` | - -For **Lychee**, prefer a pre-installed binary on `$PATH`. If none is found, -download the pinned release (see `LYCHEE_VERSION_TAG` in -`.github/workflows/check-links.yml` โ€” the dynamic-read pattern in step 2 below -keeps this version in lock-step with CI) into -`.agents/skills/check-links/.cache/lychee/` and use that path. The pinned -version matches what the CI workflow uses, so behavior is identical. - -`.agents/skills/check-links/.cache/` is git-ignored (see `.gitignore`). - -## Procedure - -Execute the steps in order. On the first failure, stop, write a `FAIL` -sentinel (step 8), and report the failure with the next action. - -### 0. Detect site root and work directory - -Before any other step, determine `SITE_DIR` (the Hugo site root) and `WORK_DIR` -(the directory where `npm ci` / `hugo` commands run โ€” mirrors `.github/workflows/check-links.yml`): - -```bash -SITE_DIR="" -for dir in docs site; do - for cfg in hugo.toml hugo.yaml \ - config/hugo.toml config/hugo.yaml \ - config/_default/hugo.toml config/_default/hugo.yaml; do - if [ -f "$dir/$cfg" ]; then - SITE_DIR="$dir" - break 2 - fi - done -done -if [ -z "$SITE_DIR" ]; then - echo "APPROVE โ€” no Hugo documentation site found under docs/ or site/." - exit 0 -fi - -if [ -f "${SITE_DIR}/_preview/package-lock.json" ]; then - WORK_DIR="${SITE_DIR}/_preview" -elif [ -f "${SITE_DIR}/package-lock.json" ]; then - WORK_DIR="${SITE_DIR}" -else - echo "ERROR: No package-lock.json found under ${SITE_DIR}/_preview/ or ${SITE_DIR}/." >&2 - exit 1 -fi -``` - -Use `$SITE_DIR` for content paths and `$WORK_DIR` for build/serve operations in the steps below. - -### 1. Scope check - -Run `git diff ...HEAD --name-only` (default `` = `master` unless -the user provides another). If the change set has **no** files under -`$SITE_DIR/**` and no changes to `lychee.toml`, and the user did not -explicitly ask, decline and exit cleanly. - -### 2. Preflight binaries - -- `hugo version` โ†’ must succeed; capture the version. If missing, stop with - Must-fix: "Install Hugo extended (`brew install hugo`)." If installed but - older than the workflow's `HUGO_VERSION` (parse with - `grep -E '^[[:space:]]+HUGO_VERSION:' .github/workflows/check-links.yml | sed -E 's/.*: *"?([^"]+)"?$/\1/'`), warn but - continue. -- `node -v` and `npm -v` โ†’ must succeed. If missing, stop with Must-fix: - "Install Node (`brew install node`) at the major version pinned by - `node-version:` in `.github/workflows/check-links.yml`." -- `lychee --version` โ†’ if it succeeds, record the path and version. -- If `lychee` is missing: - 1. Read the canonical pin from the workflow file so the skill cannot drift - from CI: - ```bash - LYCHEE_VERSION_TAG=$( - grep -E '^[[:space:]]+LYCHEE_VERSION_TAG:' .github/workflows/check-links.yml \ - | sed -E 's/.*: *"?([^"]+)"?$/\1/' - ) - ``` - Expected shape: `lychee-vX.Y.Z` (the leading `lychee-` is part of the - upstream release tag, not a typo). - 2. Determine platform via `uname -s` / `uname -m`. Map to the matching - Lychee asset (recent releases โ€” `v0.24.2` and later โ€” drop the - version from the asset filename): - - `Darwin` + `arm64` โ†’ `lychee-aarch64-apple-darwin.tar.gz` - - `Darwin` + `x86_64` โ†’ `lychee-x86_64-apple-darwin.tar.gz` - - `Linux` + `x86_64` โ†’ `lychee-x86_64-unknown-linux-gnu.tar.gz` - - `Linux` + `aarch64` โ†’ `lychee-aarch64-unknown-linux-gnu.tar.gz` - - any other combination (e.g. Windows, FreeBSD, 32-bit) โ†’ stop with - Must-fix: "Unsupported platform for Lychee auto-download โ€” install - Lychee manually (`brew install lychee` / `cargo install lychee`) - and rerun." - 3. Ensure the cache directory exists *before* the download โ€” - `mkdir -p .agents/skills/check-links/.cache/lychee/` โ€” - because the path is git-ignored and absent on a fresh clone, - and `tar -xzf โ€ฆ -C

` will fail with "no such file or - directory" if the target does not exist yet. This mirrors the - `mkdir -p lychee` that `check-links.yml` does before its own - extract step. - 4. Download from - `https://github.com/lycheeverse/lychee/releases/download/${LYCHEE_VERSION_TAG}/` - into `.agents/skills/check-links/.cache/lychee/` and extract - with `tar -xzf --strip-components=1 -C .agents/skills/check-links/.cache/lychee/` - so the binary lands at - `.agents/skills/check-links/.cache/lychee/lychee`. - 5. Use `.agents/skills/check-links/.cache/lychee/lychee` for the rest of this run. - 6. Print a one-line note: "Using auto-downloaded Lychee. For faster runs, - install with `brew install lychee`." - -### 3. Install Hugo deps - -Run `( cd ${WORK_DIR} && npm ci )`. We deliberately use `npm ci` -(matching the CI workflow's `Install Dependencies` step in `check-links.yml`) -rather than `npm install`: - -- `npm ci` installs exactly the versions pinned by `package-lock.json`; - `npm install` is allowed to update the lockfile and may resolve to - different transitive versions than CI, which defeats the "render - identical HTML to CI" goal. -- If `package.json` and `package-lock.json` drift out of sync, `npm ci` - fails fast with a clear error rather than silently healing the - lockfile โ€” a divergence we want to surface, not paper over. - -### 4. Build the site - -Run `( cd ${WORK_DIR} && hugo -e development )`. -This emits `${WORK_DIR}/public/**/*.html`. The `-e development` flag matches -what CI uses in `check-links.yml` so the two builds render identical HTML. -(The helper `${SITE_DIR}/_script/hugo-build` exists for interactive use but -defaults to `production`; we invoke `hugo` directly to keep the env in -lock-step with CI.) - -### 5. Start the Hugo server in the background - -The server must survive across multiple shell/tool calls (steps 5 โ†’ 6 โ†’ 8 -typically run in separate shells), so we rely on `nohup` alone โ€” a `trap โ€ฆ -EXIT` would fire when *this* shell exits and kill the server before Lychee -can query it. Teardown happens explicitly in step 8. - -Before launching, kill any leftover server from a previous crashed run so a -stale process does not hold port `1414`: - -```bash -pkill -F /tmp/check-links.hugo.pid 2>/dev/null || true -rm -f /tmp/check-links.hugo.pid - -( cd ${WORK_DIR} && nohup hugo server --environment development --port 1414 \ - > /tmp/check-links.hugo.out 2>&1 & echo $! > /tmp/check-links.hugo.pid ) -sleep 5 - -# Verify the captured PID is alive before relying on it. `$!` for -# `nohup foo &` is reliable on bash but not portable across shells; the -# pgrep check turns a silent "Lychee fetches an empty port" failure into -# a clear error. -if ! pgrep -F /tmp/check-links.hugo.pid > /dev/null 2>&1; then - echo "ERROR: Hugo server failed to start. Tail of log:" >&2 - tail -20 /tmp/check-links.hugo.out >&2 || true - exit 1 -fi -``` - -Port `1414` is chosen to avoid clashing with a developer's local `hugo server` -(default `1313`). The `--environment development` flag matches CI's build env. - -### 6. Run Lychee - -```bash - --config lychee.toml --timeout 60 \ - --base-url http://localhost:1414/ \ - "${WORK_DIR}/public/**/*.html" -``` - -Capture exit code. Any non-zero exit means at least one broken link. - -### 7. Report - -Group the broken URLs from Lychee's output by source page. To reverse-map -an HTML path to its Markdown source: - -`${WORK_DIR}/public/docs/
//index.html` -โ†” `${SITE_DIR}/content/docs/
/.md` (or `/_index.md`). - -Report in this shape: - -``` -## Doc link check ( vs ) - -Hugo: -Lychee: () -Pages scanned: -Broken URLs: - -### /content/docs/<...>/.md -- โ€” -- โ€” ... - -### /content/docs/<...>/.md -- ... -``` - -If `K == 0`, report a single line: "All links OK." - -### 8. Tear down and sentinel - -- Kill the Hugo server (and clean up its pid file): - - ```bash - pkill -F /tmp/check-links.hugo.pid 2>/dev/null || true - rm -f /tmp/check-links.hugo.pid /tmp/check-links.hugo.out - ``` - - Run this even if Lychee failed โ€” leaving a server on port `1414` would - poison the next invocation. -- Write `.git/check-links.ok` at the repo root: - - ``` - head= - branch= - status=PASS|FAIL - timestamp= - hugo= - lychee= - pages= - broken= - ``` - -The sentinel is consumed by the `pre-pr` skill's reviewer step: when it -sees a sentinel whose `head=` matches the current HEAD SHA and -`status=PASS`, it skips re-dispatching `check-links` and records it -as APPROVE with the note "cached from `.git/check-links.ok`". Any -HEAD advance (commit, amend, rebase) invalidates the cache automatically. - -## Notes - -- This skill does **not** modify tracked sources. It does, however, write - several git-ignored build artifacts during a run โ€” listed here so a future - reader does not mistake them for unrelated side-effects: - - `.agents/skills/check-links/.cache/lychee/` โ€” auto-downloaded - Lychee binary, when the system Lychee was unavailable. - - `${WORK_DIR}/node_modules/` โ€” installed by `npm ci` in step 3. - - `${WORK_DIR}/public/` โ€” Hugo's rendered HTML (the corpus Lychee scans). - - `${WORK_DIR}/resources/` โ€” Hugo's asset-pipeline cache. - - `.lycheecache` at the repo root โ€” Lychee's per-URL result cache - (honoured for `max_cache_age = "3d"` per `lychee.toml`). - - `/tmp/check-links.hugo.{pid,out}` โ€” server PID file and log, both - removed in step 8's teardown. - - Every path above is matched by an existing `.gitignore` entry; none is - committed. -- The `lychee.toml` exclude list is the single source of truth for flaky - external endpoints. If a real link must be excluded, add it there and - explain why in a comment so CI and local runs stay in sync. -- The skill assumes the docs build succeeds. A Hugo build error is treated - the same as a link failure โ€” surface it and stop. -- The `include_verbatim = false` setting in `lychee.toml` skips links inside - code blocks. That is intentional today; flip it on if you specifically need - to validate examples. - -## Related skills - -- `review-docs` โ€” prose, KDoc/Javadoc, and Markdown style review. Runs in - parallel with `check-links` when invoked by `pre-pr`. -- `pre-pr` โ€” composes the above and gates `gh pr create`. diff --git a/.agents/skills/check-links/agents/openai.yaml b/.agents/skills/check-links/agents/openai.yaml deleted file mode 100644 index 407bdae411..0000000000 --- a/.agents/skills/check-links/agents/openai.yaml +++ /dev/null @@ -1,4 +0,0 @@ -interface: - display_name: "Check Links" - short_description: "Validate rendered Hugo documentation links" - default_prompt: "Use $check-links to build the Hugo docs site, run Lychee against the rendered HTML, and report broken links." diff --git a/.agents/skills/dependency-audit/SKILL.md b/.agents/skills/dependency-audit/SKILL.md deleted file mode 100644 index af01f5d50b..0000000000 --- a/.agents/skills/dependency-audit/SKILL.md +++ /dev/null @@ -1,146 +0,0 @@ ---- -name: dependency-audit -description: > - Audit changes to dependency declarations under - `buildSrc/src/main/kotlin/io/spine/dependency/` โ€” catches accidental - version downgrades, BOM mismatches, missing deprecation markers when - artifacts are renamed or removed, copyright drift, and convention drift. - Use whenever a diff touches that directory, or when asked to "audit - this dependency bump". Read-only; does not run builds. ---- - -# Dependency audit (repo-specific) - -You are the dependency auditor for a Spine Event Engine repo. All managed -dependencies live under: - - buildSrc/src/main/kotlin/io/spine/dependency/ - -organized by sub-package: - -- `lib/` โ€” third-party runtime libraries (Kotlin, Guava, Protobuf, gRPC, โ€ฆ). -- `local/` โ€” Spine SDK artifacts (Base, CoreJvm, ModelCompiler, โ€ฆ). -- `test/` โ€” testing libraries (JUnit, Kotest, AssertK, Truth, Jacoco, Kover). -- `build/` โ€” static-analysis and build-time tools (Dokka, ErrorProne, Pmd, - CheckStyle, KSP, โ€ฆ). -- `kotlinx/` โ€” Kotlin-ecosystem libraries (Coroutines, Serialization, - DateTime, AtomicFu). -- `boms/` โ€” BOM declarations. - -Each file declares a Kotlin `object` extending `Dependency` or `DependencyWithBom` -(see `dependency/Dependency.kt`). The shape is: - - object Kotest { - const val version = "6.1.11" - const val group = "io.kotest" - const val assertions = "$group:kotest-assertions-core:$version" - // โ€ฆ - } - -## How to run an audit - -1. **Fetch the full diff once.** Default base is `origin/master`: - `git diff origin/master...HEAD -- 'buildSrc/src/main/kotlin/io/spine/dependency/**'` - (use `--staged` if the user is mid-commit, or a different base only if - the user names one). The unified diff already contains the old and new - lines you need for version-sanity and BOM checks โ€” do not call `--stat` - first and then re-read each file. If the diff is empty, ask the user - which files to audit. - -2. **Lean on the diff; read files on demand.** Version, BOM, copyright, and - deprecation deltas are all visible in the unified diff. Only read a - file when (a) it is newly added, or (b) a hunk references a - `version`/`group` constant defined outside the hunk and you need - surrounding context. **Budget:** if more than 5 files changed, do not - read individual files โ€” work from the diff and use targeted `rg` - for cross-cutting questions. - -3. **Batch independent work into one turn.** Issue the version-sanity (A), - convention-drift (D), and cross-cutting (E) tool calls *in parallel* - within a single response. Collect every finding and emit the report - once โ€” **do not stop at the first failure**. - -4. **Batch greps.** For deprecation/caller checks (C) and snapshot-pin - checks (A), build one ripgrep over the union of symbols instead of one - command per symbol. Examples: - - `rg -n '\b(name1|name2|name3)\b' --type kt` to find callers of any - removed `const val`. - - `rg -L 'Copyright \(c\) 2026' ` to flag every stale - header in one call. - - `rg -L '@Suppress\("unused", "ConstPropertyName"\)' ` - to flag missing object-level suppression in one call. - - `rg -n '(lib1:oldv1|lib2:oldv2)' --type kt --type gradle` โ€” one - alternation across libraries, not one command per library. - -5. **Fast path for pure version bumps.** If every hunk only modifies an - existing `version` (or `bom`) string literal โ€” no added/removed - `const val`, no new files, no renames โ€” run only Checks A and D. - Skip B, C, and E entirely. This is the dominant `dependency-update` - shape; do not waste tool calls re-validating naming or deprecation - discipline when nothing structural changed. - -## Checks - -### A. Version sanity -- **No silent downgrade.** Compare the old and new `version` value as semver. - A decrease (`2.0.0 -> 1.9.0`) or a snapshot regression (`-SNAPSHOT.183` -> - `.182`) is a Must-fix unless the commit message explicitly justifies it. -- **Snapshot vs. release consistency.** If `version` switches from a release - (`2.0.0`) to a snapshot (`2.0.1-SNAPSHOT.001`), confirm the consuming code - isn't pinned to the release elsewhere. Use the batched ripgrep recipe - in step 4 โ€” one alternation across all switched libraries, not one - command per library. -- **BOM โ†” component agreement.** For objects extending `DependencyWithBom`, - check that `bom` references the same version as `version` (e.g. Kotlin's - `kotlin-bom:$runtimeVersion`). - -### B. Naming and structure -- **Object name matches the upstream library name** (PascalCase). New files - must follow the convention of neighbors (e.g. `lib/Foo.kt` declares - `object Foo`). -- **No type names in property names** (`fooList`, `barObject`) โ€” this is in - `.agents/coding-guidelines.md`. -- **Module constants use `"$group::$version"`**, not hardcoded - Maven coordinates. Catch copy-paste like `"io.kotest:kotest-assertions-core:6.1.11"`. - -### C. Deprecation discipline -When an artifact is **renamed or removed**: -- The old `const val` must stay with `@Deprecated("โ€ฆ", ReplaceWith("โ€ฆ"))` - or `@Deprecated("โ€ฆ")` (see `Kotest.frameworkApi` and `Kotest.datatest` for - the established style). -- If the diff deletes one or more `const val`s outright, confirm no caller - is left behind. Use the batched ripgrep recipe in step 4 โ€” one - alternation over all removed symbol names, not one `git grep` per - name. If any caller survives, this is a Must-fix. - -### D. Convention drift -- **Copyright header year.** Every changed file should have a current-year - copyright line. If a file was edited but its copyright says `2024`, flag it - (the user can run the `update-copyright` skill to fix). -- **GitHub URL comment.** New `lib/` and `kotlinx/` files conventionally - start with `// https://github.com//` above the object. - Recommend it if missing. -- **`@Suppress("unused", "ConstPropertyName")` on the object.** This is the - established style for constant-heavy declarations. - -### E. Cross-cutting checks -- **`local/` deps don't leak.** Spine SDK artifacts in `local/` should not be - declared in `lib/` or `test/` (and vice versa). -- **No mixing Groovy and Kotlin DSL.** All Gradle code in `buildSrc/` must be - `.kt` or `.gradle.kts`. Catch any `.gradle` file slipping in. - -## Output format - -Three sections, in this order: - -- **Must fix** โ€” version downgrades, missing deprecation markers on removed - symbols, broken callers, BOM/version mismatches. -- **Should fix** โ€” convention drift, missing deprecation `ReplaceWith`, - missing copyright update, missing URL comment, naming oddities. -- **Nits** โ€” formatting, ordering, doc-comment polish. - -For each finding, cite the file and line, quote the offending lines, and -show the recommended fix. - -End with a one-line verdict: `APPROVE`, `APPROVE WITH CHANGES`, or -`REQUEST CHANGES`. diff --git a/.agents/skills/dependency-audit/agents/openai.yaml b/.agents/skills/dependency-audit/agents/openai.yaml deleted file mode 100644 index c3758f31ef..0000000000 --- a/.agents/skills/dependency-audit/agents/openai.yaml +++ /dev/null @@ -1,4 +0,0 @@ -interface: - display_name: "Dependency Audit" - short_description: "Review dependency declaration diffs" - default_prompt: "Use $dependency-audit to review dependency declaration changes for version sanity, BOM consistency, deprecations, and convention drift." diff --git a/.agents/skills/dependency-update/SKILL.md b/.agents/skills/dependency-update/SKILL.md deleted file mode 100644 index c9ddee4209..0000000000 --- a/.agents/skills/dependency-update/SKILL.md +++ /dev/null @@ -1,283 +0,0 @@ ---- -name: dependency-update -description: > - Walk every dependency declaration under - `buildSrc/src/main/kotlin/io/spine/dependency/`, discover the latest accepted - version of each artifact from the URL hinted in its file (or from Maven - metadata if no URL is present), and update the `version` constant in place. - External dependency scopes accept only released versions; the `local` scope - also accepts snapshots and pre-releases published from sibling Spine repos. - Use when asked to refresh dependency versions, bump libraries, run a - dependency audit, or "see what's stale". ---- - -# Update dependencies - -## Goal - -Bring every dependency object under -`buildSrc/src/main/kotlin/io/spine/dependency/` to its latest accepted version. -For every scope except `local/`, that means the latest **released** version: -snapshots, release candidates, milestones, alpha/beta, EAP, and `-dev` builds -are **excluded**. - -`local/` is the deliberate exception. It holds Spine SDK dependencies published -from sibling Spine repositories, and it may move to newer snapshots or -pre-releases such as `2.0.0-SNAPSHOT.388` or `2.1.0-RC1`. - -The authoritative version source for each artifact is the web page already -referenced in its file. When the file has no URL, use the Maven metadata -fallback described below. For non-`local/` artifacts, a discovered Maven -Central URL is **added back to the file** as a line comment so the next run has -a hint. - -## Inputs - -- No arguments โ†’ scan all of `buildSrc/src/main/kotlin/io/spine/dependency/`. -- One or more paths or sub-package names (`lib`, `local`, `test`, `build`, - `kotlinx`, `boms`) โ†’ restrict the scan to those. -- `--dry-run` โ†’ discover and report, but do not edit. - -## Pre-flight - -1. Run `git status --short`. If the worktree is dirty in files this skill will - touch, stop and ask the user. Otherwise preserve unrelated changes. -2. Confirm `buildSrc/src/main/kotlin/io/spine/dependency/` exists. -3. Note the current branch โ€” every change this skill makes is a candidate for - a single `chore(deps): refresh external versions` commit at the end; the - skill itself does NOT commit. The user decides. - -## Per-file workflow - -For each `*.kt` file in scope: - -### 1. Parse the file - -A dependency file declares one or more Kotlin `object`s, typically extending -`Dependency` or `DependencyWithBom`. The shape is: - - object Kotest { - const val version = "6.1.11" - const val group = "io.kotest" - const val assertions = "$group:kotest-assertions-core:$version" - // โ€ฆ - } - -Extract: - -- `objectName` โ€” the outer `object` identifier. -- `version` โ€” the literal version string. Some files have **multiple** version - constants (`runtimeVersion`, `embeddedVersion`, `annotationsVersion`); treat - each separately. The one driving the artifact is typically `override val - version = โ€ฆ` or the `const val version = โ€ฆ` declared at the top. -- `group` โ€” the Maven group. -- `module` artifact names โ€” each `const val foo = "$group:foo:$version"` line - contributes one artifact name. Use the first one to query Maven Central if - needed for non-`local/` artifacts, or Spine SDK Maven repositories for - `local/` artifacts. -- `versionUrl` โ€” a URL hint. Look in this order: - 1. Line comments above the object: `^//\s*(https?://\S+)`. - 2. KDoc `@see โ€ฆ` inside the object's KDoc. - 3. Plain `@see https?://โ€ฆ` inside the KDoc. - 4. If none: leave `versionUrl` empty and use the Maven metadata fallback - below. - -Skip files that contain only abstract base classes or helpers (`Dependency.kt`, -`DependencyWithBom.kt`, `BomsPlugin.kt`, anything without a concrete artifact -declaration). - -### 2. Find the latest accepted version - -The discovery rule depends on the URL shape. For files under -`dependency/local/`, check the Spine SDK Maven metadata before GitHub, even -when the file has a GitHub URL; snapshots are usually visible in Maven -metadata, not in GitHub's latest-release redirect. - -**A. GitHub repository URL** (`https://github.com//`): - -- Outside `local/`, resolve - `https://github.com///releases/latest`. GitHub redirects to the - latest non-prerelease tag. Read the redirected location or the rendered HTML - to extract the tag. -- In `local/`, do **not** rely on `/releases/latest`, because it hides - pre-releases. Use GitHub releases and tags only after checking Spine SDK - Maven metadata. When you do use GitHub, include pre-release entries and keep - version-like tags that match the artifact. -- Tags often have a `v` prefix. Strip it. -- If the repo publishes per-component tags (e.g. - `kotlinx-coroutines-1.10.2`), prefer the tag whose name matches the - artifact's module identifier. Otherwise take the topmost release. - -**B. Maven Central artifact URL** -(`https://search.maven.org/artifact//` or -`https://repo1.maven.org/maven2///`): - -- Hit Maven Central's REST API: - `https://search.maven.org/solrsearch/select?q=g:+AND+a:&rows=20&core=gav` -- Outside `local/`, filter the `response.docs[].v` values by the pre-release - rule (below). -- In `local/`, keep snapshots and pre-releases in the candidate list. -- Take the highest by semver comparison. - -**C. Spine SDK Maven repositories for `local/` artifacts**: - -- For files under `dependency/local/`, query Maven metadata in the current - Spine SDK Artifact Registry repositories before falling back elsewhere: - - `https://europe-maven.pkg.dev/spine-event-engine/releases` - - `https://europe-maven.pkg.dev/spine-event-engine/snapshots` -- Build the metadata URL as - `///maven-metadata.xml`, where `groupPath` is the - Maven group after first resolving symbolic aliases used in dependency files - (for example, `Spine.group` -> `io.spine` and `Spine.toolsGroup` -> - `io.spine.tools`) and then replacing dots with slashes. -- Read `...` entries. For `local/`, do not - reject `SNAPSHOT`, RC, milestone, alpha, beta, EAP, pre, or dev versions. -- If both release and snapshot repositories have candidates, compare all of - them together and take the highest version. - -**D. Project homepage** (e.g. `https://kotest.io/`, `https://junit.org/`, -`https://www.detekt.dev/`): - -- Try to find a "latest release" or "download" link on the page. If the page - is a thin landing page with no usable version data, fall through to E. - -**E. No URL or unusable URL โ€” Maven metadata fallback**: - -- Outside `local/`, query Maven Central as in B using the file's `group` and - the first module artifact name (the part after `$group:`). -- In `local/`, query the Spine SDK Maven metadata first. Use Maven Central only - if the artifact is absent from those repositories. -- If a non-`local/` Maven Central fallback query returns results, **also insert - a line comment** - `// https://search.maven.org/artifact//` above the object - declaration (after any existing copyright header). This back-fills the URL - hint for next time. Match the existing comment style (one line, no trailing - punctuation). -- If all fallback queries have no result, leave the file untouched and add it - to the `Manual review` section of the final report. - -### 3. Filter pre-releases outside `local/` - -Apply this filter only to files outside `dependency/local/`. - -For `local/` files, snapshots and pre-releases are accepted candidates. Do not -put them in `Filtered pre-releases`; put them in the `local/` confirmation -section of the final report instead. - -Reject any version string matching, case-insensitively: - - -SNAPSHOT$ - -RC[\d\-.]*$ e.g. -RC1, -RC.2 - -M\d+$ e.g. -M3 - -alpha[\d\-.]*$ - -beta[\d\-.]*$ - -EAP[\d\-.]*$ - -pre[\d\-.]*$ - -dev[\d\-.]*$ - \.Beta\d*$ Spring-style trailing tokens - \.Alpha\d*$ - \.RC\d*$ - \.M\d+$ - -Apply the regex to the **suffix after the numeric version**. The version -`2.0.0-SNAPSHOT.182` is a snapshot and must be rejected as a target outside -`local/`, but it is valid for `local/` dependency objects. This skill only -edits dependency files, never `version.gradle.kts` (that belongs to the -`bump-version` skill). - -### 4. Compare versions - -Use semver comparison: - -- Split on `.` and `-`. -- Numeric segments compare numerically; non-numeric segments compare - lexicographically. -- A version without any pre-release suffix is greater than one with the same - numeric prefix but a pre-release suffix. - -Only update when `latest > current`. Equal or lower โ†’ no change. - -### 5. Apply the edit - -- Replace the `version` literal with the new value. Use a precise replacement - anchored on the full line (`const val version = ""` โ†’ - `const val version = ""`). Do not blindly replace the version string, - because the same string can appear in module URLs constructed via - interpolation (`"$group:โ€ฆ:$version"`) โ€” those will pick up the new value - automatically. -- If the file uses a renamed version constant (`runtimeVersion`, - `compilerVersion`, etc.) that feeds `override val version = compilerVersion`, - update the **source** constant, not the alias. -- For `DependencyWithBom` objects, verify the `bom` line still resolves - correctly. The conventional shape is - `override val bom = "$group:-bom:$version"`, in which case no - separate edit is needed. If the BOM version is hard-coded, update it too. -- Preserve indentation, comment style, and surrounding blank lines exactly. - -### 6. Watch for `local/` artifacts - -`local/` holds Spine SDK dependencies (Base, CoreJvm, ModelCompiler, โ€ฆ) that -are published from sibling Spine repos. This scope accepts snapshots and -pre-releases because these artifacts often advance through internal snapshot -builds before a stable SDK release. - -Still **flag every `local/` update in the report**, and note whether the target -is a release, snapshot, or pre-release. The user can then decide whether to -bump the SDK in lockstep with the rest of the project. Spine SDK artifacts -often need to move together; one-off bumps can cause runtime ABI mismatches. - -## Report - -When the run completes, emit a Markdown report with these sections: - -- **Updated** โ€” table of `file | objectName | old โ†’ new | source URL`. -- **Already current** โ€” file/object pairs whose version was already the - newest accepted version. -- **Skipped (no URL, metadata empty)** โ€” manual review needed. -- **Filtered pre-releases** โ€” newer versions found but rejected because they - were RC/SNAPSHOT/alpha/etc. Applies only outside `local/`. -- **`local/` bumps to confirm** โ€” every `local/` change called out separately, - including snapshot and pre-release targets. - -End with the suggested next steps: - -1. Review the diff (`git diff buildSrc/src/main/kotlin/io/spine/dependency/`). -2. Run the `version-bumped` skill. Every feature branch must advance - `version.gradle.kts` strictly above the base before any - `./gradlew build` (which may transitively `publishToMavenLocal`). The - skill is a no-op when a bump already happened earlier on the branch - and otherwise uses the `bump-version` skill to perform the increment. -3. Run `./gradlew build` (or `./gradlew clean build` if `.proto` files - participate). -4. Commit. Match the shape of the actual change: - - Single `local/` bump (most common): `` Bump Spine Base -> `2.0.0-SNAPSHOT.190` `` - - Coordinated external set: `Bump Protobuf and gRPC` (one commit; - mention both). - - Bulk external refresh (rare): `Refresh external dependencies`. - -## Safety - -- Do not commit. Do not push. Editing files is the limit of this skill's - authority. -- Never edit `version.gradle.kts` โ€” that's the `bump-version` skill's - responsibility. -- Never auto-resolve a Maven Central query that returns multiple matching - artifacts with different groups (e.g. a library that exists under both - `io.netty` and `io.netty.incubator`). Ask the user. -- If a discovered "latest" version is more than one **major** ahead of the - current value (e.g. `1.x` โ†’ `3.x`), flag it as a major bump in the report - and apply the edit only if the user confirms, or only when running - non-interactively with `--include-majors`. Major bumps frequently break - ABI. - -## Failure modes to expect - -- **GitHub rate limit** on the unauthenticated REST API. The `/releases/latest` - HTML page does not require auth and is the preferred fallback. -- **Per-component tags** in a monorepo. Match by artifact name, don't take the - topmost tag blindly. -- **Repositories that publish to JCenter only** โ€” JCenter is sunset; if Maven - Central is empty, the dependency may need migration. Flag it. -- **Vendor-specific version schemes** (e.g. dates: `2025.10.01`) โ€” the - semver comparator above will still order these correctly; just don't - mis-classify them as pre-releases. diff --git a/.agents/skills/dependency-update/agents/openai.yaml b/.agents/skills/dependency-update/agents/openai.yaml deleted file mode 100644 index a61198d327..0000000000 --- a/.agents/skills/dependency-update/agents/openai.yaml +++ /dev/null @@ -1,4 +0,0 @@ -interface: - display_name: "Dependency Update" - short_description: "Refresh dependency versions, allowing snapshots only for local Spine SDK artifacts" - default_prompt: "Use $dependency-update to walk every dependency object under buildSrc/src/main/kotlin/io/spine/dependency/, find the latest accepted version for each, and update the version constants in place. External scopes use released non-snapshot versions only; dependency/local/ may use snapshots and pre-releases from sibling Spine repos. Use the URL referenced in each file as the source of truth; fall back to Maven metadata and back-fill missing hints when useful." diff --git a/.agents/skills/gradle-review/SKILL.md b/.agents/skills/gradle-review/SKILL.md deleted file mode 100644 index 1d4ada40e3..0000000000 --- a/.agents/skills/gradle-review/SKILL.md +++ /dev/null @@ -1,198 +0,0 @@ ---- -name: gradle-review -description: > - Review Gradle-related changes in this repo against Spine SDK conventions - and the upstream Gradle best-practices guides ingested under `practices/`. - Three scopes: (1) `buildSrc/` in the `config` repository only; - (2) Gradle build files in any project; (3) production code of Gradle - plugins exposed by Spine SDK tools. Use after any non-trivial change to - build logic, before opening a PR, or when asked for a Gradle review. - Read-only; does not run builds. ---- - -# Gradle review (repo-specific) - -You are the Gradle reviewer for a Spine Event Engine project. You review -Gradle build logic and plugin production code; you do **not** duplicate -`kotlin-review` (Kotlin idioms, safety rules, tests, version-gate) or -`dependency-audit` (artifact declarations under -`buildSrc/src/main/kotlin/io/spine/dependency/`). - -The authoritative standards live in two places: - -- **Spine-specific Gradle rules** โ€” - [`spine-task-conventions.md`](spine-task-conventions.md) in this - skill directory. Documents the `group = "spine"` mandate and the - `description` requirement on every custom task. -- **Upstream Gradle best practices** โ€” `practices/` in this skill - directory. One Markdown file per ingested Gradle docs page; each file - links back to the source URL and pins the Gradle version it was derived - from. The initial ingest is the "Tasks" best-practices page; more - pages are added over time. See `practices/README.md` for the ingest - procedure. - -## Scope - -This skill reviews three classes of files: - -1. **`buildSrc/` in the `config` repository only.** Detect via - - git remote -v - - The repo whose *any* remote URL matches the regex - `[:/]SpineEventEngine/config(\.git)?$` is `config`. The character - class `[:/]` covers both forms โ€” ssh - (`git@github.com:SpineEventEngine/config.git`) and https - (`https://github.com/SpineEventEngine/config.git`) โ€” and scanning - every remote (not just `origin`) handles forks where `origin` - points at a personal mirror and `upstream` points at the canonical - remote. - - In any other repo, treat `buildSrc/` as local scaffolding owned by - the consuming project and skip its files โ€” *except* - `buildSrc/src/main/kotlin/module.gradle.kts`, which `AGENTS.md ยง - Code review` carves out as consumer-owned and therefore in scope. - -2. **Gradle build files of the current project.** Anywhere: - - - `**/build.gradle.kts`, `**/settings.gradle.kts` - - `**/*.gradle.kts` precompiled scripts outside `buildSrc/` - (in `config`, precompiled scripts inside `buildSrc/` fall under - scope 1 instead) - -3. **Production code of Gradle plugins exposed by Spine SDK tools.** - Files under `src/main/kotlin/` or `src/main/java/` that are part of a - Gradle plugin. Detect by any of: - - - Class implements `org.gradle.api.Plugin` or - `org.gradle.api.Plugin`. - - Class extends `org.gradle.api.DefaultTask`, - `org.gradle.api.tasks.SourceTask`, `JavaExec`, `Exec`, `Copy`, etc. - - The owning module declares a `gradlePlugin { plugins { ... } }` - block in its `build.gradle.kts`, or ships a - `META-INF/gradle-plugins/*.properties` resource. - -If after filtering nothing in the diff falls in any scope, return -`APPROVE โ€” no Gradle-related changes.` and stop. - -## Review procedure - -1. **Scope the diff.** Obtain the change set via `git diff --staged` or - `git diff ...HEAD` depending on what the user describes - (default ` = origin/master`). Apply the scope rules above. - Then filter file paths against `AGENTS.md ยง Code review`: - - In **`config` itself** only `gradlew` and `gradlew.bat` are - skipped โ€” every other config-distributed path is owned by this - repo and stays in scope. - - In any **consumer repo**, honour the full config-distributed - skip list (with the `module.gradle.kts` carve-out from scope 1). - If filtering leaves the set empty in a consumer repo, return - `APPROVE โ€” all changes are config-distributed files.` and stop. - -2. **Read each affected file fully**, not just the hunks. Task - registration blocks span multiple lines; lazy-config and - cache-correctness issues only become visible with surrounding - context (e.g., a `Provider.get()` six lines above a - `tasks.register {}` call). - -3. **Check Spine-specific rules** (from - [`spine-task-conventions.md`](spine-task-conventions.md)): - - - Every custom task registered or configured in scope sets both - `group` and `description`. - - `group` equals `"spine"`. Once the shared constant exists (see - [`.agents/tasks/spine-task-group-constant.md`](../../tasks/spine-task-group-constant.md)), - a bare literal `"spine"` where the constant could have been used - becomes a Nit whose recommended replacement is the constant. - -4. **Check upstream Gradle best practices** (from `practices/`): - - - **Tasks** ([`practices/tasks.md`](practices/tasks.md), derived - from the Gradle Tasks best-practices page[^gradle-tasks]): - `dependsOn` vs. input/output wiring, cacheability annotations, - no `Provider.get()` in configuration outside an action, no eager - `FileCollection` / `Configuration` APIs, no early configuration - resolution, correct `@PathSensitivity`, unique outputs. - - Any additional `practices/*.md` files ingested since this skill - was written. Treat - [`practices/README.md`](practices/README.md)'s table as the - authoritative list of ingested pages. - -5. **Batch independent checks.** Issue the most common ripgrep recipes - in parallel within a single response โ€” examples: - - - `rg -n 'tasks\.create\(' --type kotlin` - โ€” eager registration (`--type kotlin` is ripgrep's built-in - type that covers both `*.kt` and `*.kts`; the short alias - `--type kt` is **not** recognised). - - `rg -n '\.files\b|\.getFiles\b|\.size\b|\.isEmpty\b|\.toList\b|\.asPath\b' --glob '*.gradle.kts' --glob '*.kt' --glob '*.java'` - โ€” eager file-collection APIs (covers Kotlin property access, - method invocation, and the Java `getFiles()` accessor in plugin - production code). - - `rg -n 'group\s*=\s*"spine"' --glob '*.gradle.kts' --glob '*.kt'` - โ€” confirm the Spine group is used; the absence in a `register` - block is the finding. - - `rg -n '@CacheableTask|@DisableCachingByDefault' --type kotlin` - โ€” locate plugin task classes that should carry an annotation. - - Collect every finding and emit the report once โ€” **do not stop at - the first failure**. - -## Output format - -Three sections, in this order, matching `kotlin-review`, -`review-docs`, and `dependency-audit`: - -- **Must fix** โ€” Spine mandate violations (missing `group` or - `description`; `group` not equal to `"spine"`); upstream - correctness-breaking patterns (`Provider.get()` outside a task - action; `Configuration` resolved during configuration; eager - `FileCollection` / `Configuration` APIs that discard implicit task - dependencies; overlapping task outputs); mixing Groovy and Kotlin - DSL in build logic. -- **Should fix** โ€” upstream Gradle recommendations whose failure mode - is cache-miss performance or idiomatic concern: `dependsOn` where - input/output wiring would express the link; missing `@CacheableTask` - / `@DisableCachingByDefault` on a plugin task class; missing or - wrong `@PathSensitivity`; `tasks.create(...)` instead of - `tasks.register(...)`. -- **Nits** โ€” task name not action-oriented camelCase; `description` - not in the imperative form documented by - [`spine-task-conventions.md`](spine-task-conventions.md); - the literal `"spine"` written where the shared constant exists; - missing KDoc back-link to the Gradle docs anchor that motivated a - rule. - -For each finding, cite the file and line, quote the offending lines, -and show the recommended fix. If a section is empty, write "None." - -End with a one-line verdict: `APPROVE`, `APPROVE WITH CHANGES`, or -`REQUEST CHANGES`. - -## Extending this skill - -This skill is self-extensible. Two triggers, both **user-initiated**: - -1. **Gradle release.** When the project upgrades the Gradle wrapper - (`gradle/wrapper/gradle-wrapper.properties`), reread each - `practices/*.md` against the matching - `docs.gradle.org//userguide/...` page and refresh content - that has changed. Bump the `gradle-version` and `ingested` fields - and the table in `practices/README.md`. - -2. **New page or rule.** When a maintainer asks to add a practice from - another Gradle docs page (or a new Spine rule), follow - `practices/README.md`: - - 1. Fetch the target Gradle docs page. - 2. Add a new Markdown file under `practices/` (slug from the page - anchor). - 3. Update the table in `practices/README.md`. - 4. Update this `SKILL.md`'s "Check upstream Gradle best practices" - list if the new page introduces categories the procedure did - not enumerate before. - -The skill never auto-fetches. The user runs the `gradle-review` skill for a -review, and explicitly asks for an ingest/refresh when one is wanted. - -[^gradle-tasks]: https://docs.gradle.org/9.5.1/userguide/best_practices_tasks.html diff --git a/.agents/skills/gradle-review/agents/openai.yaml b/.agents/skills/gradle-review/agents/openai.yaml deleted file mode 100644 index 5fc2e6097f..0000000000 --- a/.agents/skills/gradle-review/agents/openai.yaml +++ /dev/null @@ -1,4 +0,0 @@ -interface: - display_name: "Gradle Review" - short_description: "Review Gradle build logic changes" - default_prompt: "Use $gradle-review to review Gradle build logic and plugin changes against Spine conventions and ingested Gradle best practices." diff --git a/.agents/skills/gradle-review/practices/README.md b/.agents/skills/gradle-review/practices/README.md deleted file mode 100644 index ac92bd3588..0000000000 --- a/.agents/skills/gradle-review/practices/README.md +++ /dev/null @@ -1,68 +0,0 @@ -# Gradle best-practices index - -This directory mirrors selected pages of the upstream Gradle "Best -practices" user guide. Each file is derived from one Gradle docs page -and links back to its source URL. The `gradle-review` skill references -these files when reviewing changes. - -## Gradle version pin - -The notes here track Gradle **9.5.1** โ€” the version pinned by -`gradle/wrapper/gradle-wrapper.properties` in this repository at the -time of ingest. When the wrapper is bumped, refresh each `*.md` below -against the matching `docs.gradle.org//userguide/...` page and -update this section. - -## Ingested pages - -| File | Source | Last reviewed | -|------|--------|---------------| -| [tasks.md](tasks.md) | | 2026-05-29 | - -## Ingest procedure - -Ingests are **user-initiated only.** This procedure runs when a -maintainer explicitly asks for a new practice page or for a refresh -(typically after a Gradle wrapper bump). The skill never auto-fetches -Gradle docs. - -1. Identify the Gradle docs page URL. -2. Pick a slug from the page's anchor (e.g. `tasks`, `dependencies`, - `configurations`). Keep slugs short and kebab-case. -3. Create `practices/.md` with this frontmatter: - - --- - source: - gradle-version: - ingested: - --- - -4. For each best practice on the page, write a short section with: - - **The rule.** One sentence. - - **Why it matters.** One sentence โ€” the rationale Gradle cites. - - **Spine review level.** One of `Must fix`, `Should fix`, `Nit`. - Map upstream "recommended" items by the failure mode they - prevent: build-correctness failures or lost task dependencies โ†’ - `Must fix`; cache-miss performance and idiomatic concerns โ†’ - `Should fix`; style and naming โ†’ `Nit`. - -5. If the page introduces a category not covered by the current - `SKILL.md` "Check upstream Gradle best practices" list, edit that - list. - -6. Add a row to the table above. Bump the `Last reviewed` date. - -## Spine additions - -Some `gradle-review` checks have no direct upstream counterpart but -follow from existing Spine guidelines: - -- **`tasks.create(...)` vs. `tasks.register(...)`** โ€” Spine prefers - lazy registration. The rule cross-references the `@since 4.9` - Gradle documentation on lazy configuration but is enforced as a - Spine review item. -- **Mixing Groovy and Kotlin DSL** โ€” Spine projects use Kotlin DSL - exclusively (`*.gradle.kts`, `*.kt`). - -These are documented inside the relevant `practices/*.md` "Spine -additions" sections so reviewers see them alongside the upstream rules. diff --git a/.agents/skills/gradle-review/practices/tasks.md b/.agents/skills/gradle-review/practices/tasks.md deleted file mode 100644 index f1536b59e1..0000000000 --- a/.agents/skills/gradle-review/practices/tasks.md +++ /dev/null @@ -1,147 +0,0 @@ ---- -source: https://docs.gradle.org/9.5.1/userguide/best_practices_tasks.html -gradle-version: 9.5.1 -ingested: 2026-05-29 ---- - -# Tasks โ€” Gradle best practices - -Source: the Gradle "Best practices for tasks" user-guide page[^src]. - -The Gradle user guide enumerates a set of best practices for tasks. -Each is mapped below to a Spine review level used by the -`gradle-review` skill. - -## Spine-specific must-fix - -From [`spine-task-conventions.md`](../spine-task-conventions.md): - -- Every custom task must set `group`. The value must equal `"spine"` - (use the shared constant once introduced โ€” see - [`.agents/tasks/spine-task-group-constant.md`](../../../tasks/spine-task-group-constant.md)). -- Every custom task must set `description`. - -These are **Must fix** findings in `gradle-review`. - -## Upstream practices - -### 1. Avoid `dependsOn` โ€” *Should fix* - -Use input/output wiring (`Provider`-typed `inputs`/`outputs` and -producer-task references) instead of explicit `dependsOn(...)` for the -*action* graph. Wiring tells Gradle *why* one task needs another, -which in turn enables incremental builds and accurate task selection. - -`dependsOn` remains correct for lifecycle tasks โ€” tasks without task -actions โ€” per the upstream guidance. (Finalizer relations are wired -with `finalizedBy(...)`, not `dependsOn(...)`.) - -### 2. Favor `@CacheableTask` / `@DisableCachingByDefault` โ€” *Should fix* - -Annotate task classes for cacheability instead of calling -`outputs.cacheIf {}` at registration time. The annotation documents -the contract in source and avoids re-evaluating the predicate on -every configuration. - -### 3. Don't call `get()` on a `Provider` outside a task action โ€” *Must fix* - -`Provider.get()` during configuration forces immediate evaluation, -breaks the configuration cache, and serialises work that Gradle would -otherwise run in parallel. Compose providers with `map(...)` / -`flatMap(...)` and defer `get()` to the `@TaskAction` method. - -### 4. Group and describe custom tasks โ€” *Must fix* - -Set `group` and `description` on every custom task. Tasks without a -group are hidden from `./gradlew tasks` unless `--all` is passed. -They are also excluded from the default IntelliJ IDEA Gradle -tool-window listing (Spine addendum from -[`spine-task-conventions.md`](../spine-task-conventions.md)). - -**Spine addendum:** `group` must equal `"spine"`. - -### 5. Avoid eager APIs on `FileCollection` / `Configuration` โ€” *Must fix* - -`.size()`, `.isEmpty()`, `.files` / `getFiles()`, `asPath()`, and -`.toList()` on a `Configuration` or `FileCollection` trigger -dependency resolution during the configuration phase **and discard -any implicit task dependencies the collection carried** โ€” the latter -is a wrong-outputs failure mode, not a performance one. Consume the -collection lazily via `@InputFiles` / `@Classpath` and -`Provider<...>` chains. - -### 6. Don't resolve `Configuration`s before task execution โ€” *Must fix* - -Resolving a `Configuration` during configuration (e.g., calling -`configuration.resolve()`, `configuration.resolvedConfiguration`, or -reading `.files` from one) loses task-dependency tracking and slows -unrelated tasks because every build path triggers resolution. Resolve -inside the `@TaskAction` only. - -### 7. Use the right `@PathSensitivity` โ€” *Should fix* - -Pick the sensitivity that matches what the task's output actually -depends on: - -- **`@PathSensitivity.NONE`** โ€” content-only inputs where the file - name and location do not affect outputs: classpath JAR entries, - binary blobs, signed/checksummed bundles, etc. -- **`@PathSensitivity.RELATIVE`** โ€” inputs whose relative path is - part of the task's contract: source-tree files such as `.proto`, - `.kt`, `.java`, or templated resources, where the relative path - encodes the package/module/output location. -- **`@PathSensitivity.NAME_ONLY`** โ€” when only the file name (not - the directory) matters; rare but applicable to per-name lookup - tables and similar. -- **`@PathSensitivity.ABSOLUTE`** โ€” almost never correct; defeats - cache portability and should appear with a justifying comment. - -Mismatches show up as cache misses (over-strict sensitivity) or -incorrect cache hits (under-strict sensitivity โ€” the more dangerous -direction). Annotating proto-compilation source inputs with `NONE`, -for example, will cause incremental builds to miss renames that -change package structure. - -### 8. Use unique output files and directories โ€” *Must fix* - -Two tasks must not write to overlapping outputs (either inside one -project or across projects). Overlap causes unnecessary reruns, can -mask stale outputs, and may corrupt incremental builds. Each task -writes to its own deterministic location, typically under -`layout.buildDirectory.dir("โ€ฆ")`. - -## Spine additions (not on the upstream page) - -- **`tasks.create(...)` vs. `tasks.register(...)` โ€” *Should fix*.** - `register` is lazy and aligns with every other recommendation on - this page. New code should always use `register`. Configuring an - existing task with `tasks.named(...)` is also lazy and preferred - over `tasks.getByName(...)`. - -- **Mixing Groovy and Kotlin DSL โ€” *Must fix*.** Spine projects use - Kotlin DSL exclusively (`*.gradle.kts`, `*.kt`). Catch any - `.gradle` Groovy script slipping into `buildSrc/` or the project - root. - -## Nits - -- **Task names** should be action-oriented camelCase - (`generateSpineModel`, not `spine_model_generator` or - `spineModelGen`). -- **`description`** should read as an imperative sentence - (`"Generates Spine model classes from .proto definitions"`). - [`spine-task-conventions.md`](../spine-task-conventions.md) is the - canonical source; this Nit tracks whatever convention that file - establishes. -- **`"spine"` as a string literal.** Once the shared constant exists - (see - [`.agents/tasks/spine-task-group-constant.md`](../../../tasks/spine-task-group-constant.md)), - the literal `"spine"` in `buildSrc/` code, build files, or plugin - production code is a Nit unless wrapped in a comment with a TODO - referencing the migration. -- **KDoc back-link.** A public custom task class should link the - Gradle docs anchor that motivated its design (the relevant rule - above in this file, or the upstream page[^src]) so future readers - know which best practice the class implements. - -[^src]: https://docs.gradle.org/9.5.1/userguide/best_practices_tasks.html diff --git a/.agents/skills/gradle-review/spine-task-conventions.md b/.agents/skills/gradle-review/spine-task-conventions.md deleted file mode 100644 index a8278c0d6f..0000000000 --- a/.agents/skills/gradle-review/spine-task-conventions.md +++ /dev/null @@ -1,81 +0,0 @@ -# Spine task conventions - -This file is the authoritative source for Spine SDK rules on Gradle -custom tasks. The `gradle-review` skill enforces them, and -`practices/tasks.md` cross-references the rule alongside the upstream -Gradle "Best practices for tasks" page. - -## Background: `group` and `description` are metadata - -The `group` and `description` properties on a Gradle `Task` are -**metadata only**. They control how tasks are organised and displayed -in: - -- `./gradlew tasks` -- The IntelliJ IDEA Gradle tool window -- Other build tools - -They have **no impact** on task execution or task-dependency wiring. - -Gradle and the Kotlin Gradle plugin intentionally place core tasks -(`compileJava`, `compileKotlin`, `processResources`, โ€ฆ) into the -**`other`** group to keep the default task list clean. High-level -tasks use the conventional groups `build`, `verification`, -`documentation`, and `publishing`. - -## Rule - -Every custom task registered or configured by Spine SDK code must set -both: - -- **`group`** equal to the string `"spine"`. Use the shared constant - once it exists โ€” see - [`../../tasks/spine-task-group-constant.md`](../../tasks/spine-task-group-constant.md). -- **`description`** as a short imperative sentence describing what - the task does (no trailing period). - -The rule applies to: - -- `tasks.register(...) { โ€ฆ }` and `tasks.create(...) { โ€ฆ }`. -- `tasks.withType<โ€ฆ>().configureEach { โ€ฆ }`. -- Plugin production code that programmatically registers or - configures tasks (`Plugin` implementations under - `tool-base` and similar repos). - -Both examples below reference the shared constant -`io.spine.gradle.SpineTaskGroup.name`, which holds the value -`"spine"` and is visible to every `build.gradle.kts` because it -lives in `buildSrc/`. - -### Example โ€” registering a new task - -```kotlin -import io.spine.gradle.SpineTaskGroup - -tasks.register("generateSpineModel") { - group = SpineTaskGroup.name - description = "Generates Spine model classes from .proto definitions" - // ... -} -``` - -### Example โ€” configuring an existing task type - -```kotlin -import io.spine.gradle.SpineTaskGroup - -tasks.withType().configureEach { - group = SpineTaskGroup.name - description = "Compiles Spine-specific module sources" -} -``` - -## Why this matters - -- Makes Spine-specific tasks easy to discover in the IDE and on the - command line, especially in large multi-plugin projects. -- Mirrors the convention established by Dokka, Ktlint, Shadow, and - similar third-party plugins โ€” each places its tasks in a single - named group. -- Lets the `gradle-review` skill cross-check task registration code - against one consistent rule. diff --git a/.agents/skills/java-to-kotlin/SKILL.md b/.agents/skills/java-to-kotlin/SKILL.md deleted file mode 100644 index 7b603ab5b2..0000000000 --- a/.agents/skills/java-to-kotlin/SKILL.md +++ /dev/null @@ -1,59 +0,0 @@ ---- -name: java-to-kotlin -description: > - Convert Java code to Kotlin, including Java API comments from Javadoc to KDoc. - Use when asked to migrate Java files, classes, methods, nullability semantics, - or common Java patterns into idiomatic Kotlin while preserving behavior. ---- - -# ๐Ÿช„ Converting Java code to Kotlin - -* Java code API comments are Javadoc format. -* Kotlin code API comments are in KDoc format. - -## Javadoc to KDoc conversion - -* The wording of original Javadoc comments must be preserved. - -## Treating nullability - -* Use nullable Kotlin type only if the type in Java is annotated as `@Nullable`. - -## Efficient Conversion Workflow - -* First, analyze the entire Java file structure before beginning conversion to understand dependencies and class relationships. -* Convert Java code to Kotlin systematically: imports first, followed by class definitions, methods, and finally expressions. -* Preserve all existing functionality and behavior during conversion. -* Maintain original code structure and organization to ensure readability. - -## Common Java to Kotlin Patterns - -* Convert Java getters/setters to Kotlin properties with appropriate visibility modifiers. -* Transform Java static methods to companion object functions or top-level functions as appropriate. -* Replace Java anonymous classes with Kotlin lambda expressions when possible. -* Convert Java interfaces with default methods to Kotlin interfaces with implementations. -* Transform Java builders to Kotlin DSL patterns when appropriate. - -## Error Prevention - -* Pay special attention to Java's checked exceptions versus Kotlin's unchecked exceptions. -* Be cautious with Java wildcards (`? extends`, `? super`) conversion to Kotlin's `out` and `in` type parameters. -* Ensure proper handling of Java static initialization blocks in Kotlin companion objects. -* Verify that Java overloaded methods convert correctly with appropriate default parameter values in Kotlin. -* Remember that Kotlin has smart casts which can eliminate explicit type casting needed in Java. - -## Documentation Conversion - -* Convert `@param` to `@param` with the same description. -* Convert `@return` to `@return` with the same description. -* Convert `@throws` to `@throws` with the same description. -* Convert `{@link}` to `[name][fully.qualified.Name]` format. -* Convert `{@code}` to inline code with backticks (`). - -## Final step: ensure the version is bumped - -After the conversion is verified, run the `version-bumped` skill so the branch -carries a strictly greater `version.gradle.kts` than the base ref before -any `./gradlew build` (which may transitively `publishToMavenLocal` and -overwrite the previously published snapshot consumer repos depend on). -The skill is a no-op when a bump already happened earlier on the branch. diff --git a/.agents/skills/java-to-kotlin/agents/openai.yaml b/.agents/skills/java-to-kotlin/agents/openai.yaml deleted file mode 100644 index 252920fedc..0000000000 --- a/.agents/skills/java-to-kotlin/agents/openai.yaml +++ /dev/null @@ -1,4 +0,0 @@ -interface: - display_name: "Java to Kotlin" - short_description: "Convert Java code to idiomatic Kotlin" - default_prompt: "Use $java-to-kotlin to convert Java code to Kotlin while preserving behavior, nullability, and API documentation wording." diff --git a/.agents/skills/kotlin-engineer/SKILL.md b/.agents/skills/kotlin-engineer/SKILL.md deleted file mode 100644 index aa0d6ca56e..0000000000 --- a/.agents/skills/kotlin-engineer/SKILL.md +++ /dev/null @@ -1,65 +0,0 @@ ---- -name: kotlin-engineer -description: > - Kotlin 2.x policy and pitfalls. Use when writing, reviewing, or refactoring - Kotlin code โ€” enforces coroutine-safety, Flow correctness, null-safety, and - API-design rules that LLMs frequently get wrong. ---- - -# Kotlin โ€” policy & pitfalls - -Baseline Kotlin knowledge (data/sealed/value classes, scope functions, null-safety operators, extension functions, `suspend`, `Flow`, `when` exhaustiveness) is assumed. This skill does not teach the language โ€” it encodes the project policy and the traps that keep appearing in code review. - -## Setup Check (run first) - -Before writing non-trivial code: - -1. **Kotlin version** โ€” target 2.x when possible. Check `build.gradle(.kts)` (`kotlin("jvm") version "2.x"`) or `libs.versions.toml`. -2. **JDK target** โ€” `kotlin { jvmToolchain(21) }` or `compileOptions { targetCompatibility = JavaVersion.VERSION_21 }`. Matters for virtual threads (21+) and records interop (17+). -3. **Compiler plugins** โ€” `kotlin("plugin.spring")`, `kotlin("plugin.jpa")`, `kotlinx-serialization`, `kotlin("kapt")` vs `com.google.devtools.ksp`. Missing `plugin.spring` โ†’ final Spring classes can't be proxied. Missing `plugin.jpa` โ†’ `InstantiationException: No default constructor`. -4. **Lint** โ€” `detekt` / `ktlint` configured? Follow the existing rules; don't introduce new violations. -5. **Build wrapper** โ€” use `./gradlew` - -## MUST DO - -- **Null-safety via `?`, `?.`, `?:`, `let`, `requireNotNull`.** Use `!!` only when null is a true contract violation โ€” document why on the same line. -- **Sealed hierarchies** for closed result / state types (`sealed interface Result { data class Success(...); data class Failure(...) }`) + exhaustive `when` without `else`. -- **Value classes (`@JvmInline value class`)** for domain identifiers (`UserId`, `Email`) โ€” zero-overhead type-safety. -- **`data class` only for pure value types.** Not for entities, services, or anything with behavior / lifecycle. -- **Structured concurrency** โ€” inject `CoroutineScope`, use `coroutineScope { }` / framework scopes (`viewModelScope`). Never `GlobalScope.launch`. -- **Always rethrow `CancellationException`** in generic `catch (e: Exception)` blocks โ€” swallowing it disables cancellation. -- **Expose read-only Flow types** โ€” `val state: StateFlow = _state.asStateFlow()`. Never leak `MutableStateFlow` / `MutableSharedFlow` from an API. -- **`withContext(Dispatchers.IO)` / `Default`** for blocking / CPU work inside suspend. Encapsulate dispatcher choice in the repository / data-source layer โ€” not at call sites. -- **Immutability by default** โ€” `val` over `var`, `List` over `MutableList` in public API, `copy()` on data classes instead of mutation. -- **Named arguments for 3+ parameters** โ€” prevents silent argument swaps at call sites. - -## MUST NOT DO - -- **No `!!`** without a commented reason. Refactor to `?.let { }` / `requireNotNull(x) { "why" }`. -- **No `runBlocking` in production** โ€” only in `main` and tests. Inside a suspend function it's always a bug. -- **No `GlobalScope.launch` / `GlobalScope.async`** โ€” leaks, no structured cancellation. -- **No swallowing `CancellationException`.** `try/catch(Exception)` without a cancellation rethrow silently disables cancellation. -- **No `.first()` / `.single()` on a hot `Flow`** without a timeout โ€” a source that never emits hangs the coroutine forever. -- **No `async { }.await()` sequentially** when you want parallelism โ€” it's the same as calling `suspend` directly. Use `coroutineScope { val a = async { .. }; val b = async { .. }; a.await() + b.await() }`. -- **No `Dispatchers.Main` / `Dispatchers.IO` references from common / multiplatform code** unless the module is JVM-only. -- **No platform-type leaks (`String!`)** in public API โ€” annotate Java interop returns with `@NotNull` / `@Nullable` on the Java side, or cast explicitly. -- **No catching `Throwable`** โ€” you'll catch `OutOfMemoryError`, `StackOverflowError`, and cancellation. Use `Exception` and rethrow cancellation. -- **No `lateinit var` on primitives or nullable types** โ€” compile error. Use `Delegates.notNull()` for primitives. - -## Reference Guide - -| Load when | File | -|---|---| -| Async / reactive code โ€” coroutines, Flow, StateFlow/SharedFlow, cancellation, testing | `references/coroutines.md` | -| API design โ€” scope functions, value/data/sealed classes, extension functions, inline/reified, delegates, `Result` | `references/idioms.md` | -| Gradle / tooling โ€” Kotlin DSL, version catalogs, KSP vs kapt, multi-module layout, compiler plugins | `references/build-setup.md` | - -## Output Format - -When producing code: - -1. A short plan (1โ€“3 bullets) of what's changing. -2. The code. -3. A checklist of the non-obvious MUST rules applied. - -When reviewing code: call out MUST-DO / MUST-NOT violations explicitly and suggest the minimal fix. diff --git a/.agents/skills/kotlin-engineer/agents/openai.yaml b/.agents/skills/kotlin-engineer/agents/openai.yaml deleted file mode 100644 index ed64b4efe2..0000000000 --- a/.agents/skills/kotlin-engineer/agents/openai.yaml +++ /dev/null @@ -1,4 +0,0 @@ -interface: - display_name: "Kotlin Engineer" - short_description: "Kotlin 2.x policy and pitfalls" - default_prompt: "Use $kotlin-engineer when writing, reviewing, or refactoring Kotlin code to enforce coroutine-safety, Flow correctness, null-safety, and API-design rules." diff --git a/.agents/skills/kotlin-engineer/references/build-setup.md b/.agents/skills/kotlin-engineer/references/build-setup.md deleted file mode 100644 index 0097d1fce6..0000000000 --- a/.agents/skills/kotlin-engineer/references/build-setup.md +++ /dev/null @@ -1,92 +0,0 @@ -# Kotlin build setup โ€” policy & pitfalls - -Assumes basic Gradle knowledge. This file is the setup policy and the compiler-plugin traps. - -## Build script style - -- **Kotlin DSL (`build.gradle.kts`)** over Groovy. Type-safe, better IDE support, same syntax as the rest of the codebase. -- **Version catalogs (`gradle/libs.versions.toml`)** for dependency versions. Don't hard-code versions in module scripts. - ```toml - [versions] - # Check latest stable at: https://kotlinlang.org/docs/releases.html - kotlin = "" - # Check latest stable at: https://github.com/Kotlin/kotlinx.coroutines/releases - coroutines = "" - [libraries] - coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" } - [plugins] - kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } - ``` -- **`./gradlew`** always. If the wrapper is missing, generate it (`gradle wrapper --gradle-version X`) before anything else. - -## Kotlin compiler plugins โ€” which are mandatory, which break builds if missing - -| Plugin | Needed when | Failure mode if missing | -|---|---|---| -| `kotlin("plugin.spring")` | Any Spring project | Final classes can't be CGLIB-proxied โ†’ `@Transactional` / `@Async` silently don't work | -| `kotlin("plugin.jpa")` | JPA entities | `InstantiationException: No default constructor for entity` at runtime | -| `kotlinx-serialization` | `@Serializable` data classes | `SerializationException: Serializer for class not found` | -| `kotlin("kapt")` / KSP | Annotation processors (Dagger, Room, Moshi codegen) | Processor simply doesn't run | -| `org.jlleitschuh.gradle.ktlint` / `io.gitlab.arturbosch.detekt` | If the project uses them | Lint noise in PRs | - -- `all-open` plugin is the underlying mechanism of `plugin.spring` / `plugin.jpa`. Don't apply `all-open` directly unless you know exactly which annotations to mark open. -- `no-arg` plugin generates a synthetic no-arg constructor for annotated classes โ€” that's how `plugin.jpa` works. - -## KSP vs kapt - -- **Prefer KSP (`com.google.devtools.ksp`)** โ€” significantly faster than kapt (2โ€“7ร— depending on the processor), avoids the stub generation round-trip. -- **Fall back to kapt only when** the processor doesn't have a KSP implementation (still a few legacy ones). Migrate when possible: Room, Moshi, Hilt all have KSP now. -- Don't mix KSP and kapt for the same processor in the same module โ€” conflicts, build slowdown. -- kapt pitfall: `kapt` tasks ignore incremental compilation by default. Large multi-module projects waste minutes per build. - -## JVM toolchain - -- **Set `jvmToolchain(n)`** โ€” Gradle downloads the right JDK if missing: - ```kotlin - kotlin { - jvmToolchain(21) - } - ``` -- This replaces `sourceCompatibility` / `targetCompatibility` / `kotlinOptions.jvmTarget`. Setting all three is redundant and error-prone (they drift). -- Virtual threads require JDK 21+. Records interop requires JDK 17+. - -## `kotlinOptions` / `compilerOptions` - -- In Kotlin 2.x use the DSL: - ```kotlin - kotlin { - compilerOptions { - jvmTarget = JvmTarget.JVM_21 - freeCompilerArgs.addAll( - "-Xjsr305=strict", // treat JSR-305 annotations as strict - "-Xcontext-receivers", // if you use context receivers - ) - } - } - ``` -- `-Xjsr305=strict` is strongly recommended when consuming Java APIs with `@Nullable` / `@NotNull` โ€” turns platform types into proper Kotlin nullable types. -- Don't enable `-Werror` in library modules without a plan โ€” breaks CI on benign warnings (deprecations you don't control). - -## Multi-module layout - -- **One-way dependencies.** `app` โ†’ `feature-*` โ†’ `core`. No `core` โ†’ `feature`. -- **Public API module boundary** โ€” use `api(...)` in Gradle only when consumers need the type transitively; otherwise `implementation(...)`. `api` leaks the dependency and slows up compilation. -- Convention plugins (`buildSrc` or composite build `build-logic`) for shared config โ€” don't duplicate `compilerOptions` / plugin ids across modules. - -## Common failure modes - -| Symptom | Cause | Fix | -|---|---|---| -| `@Transactional` silently doesn't start a transaction in Kotlin code | Class is `final` (Kotlin default) | Add `kotlin("plugin.spring")` โ€” marks annotated classes `open` | -| `InstantiationException: No default constructor` on JPA entity | Kotlin class has no no-arg constructor | Add `kotlin("plugin.jpa")` | -| `SerializationException: Serializer for class X not found` | Missing `kotlinx-serialization` plugin or `@Serializable` on the class | Apply the plugin + annotation | -| `Duplicate class` from two versions of the same library | Transitive dependency conflict | `./gradlew :app:dependencies` โ†’ align via `constraints` block or version catalog | -| KSP / kapt not running | Plugin not applied OR task excluded in CI | Check `./gradlew :module:kspKotlin` runs standalone | -| Build suddenly slow after adding a library | kapt pulled in transitively | Check `./gradlew :module:dependencies` for `kapt` configuration; migrate to KSP | - -## What NOT to put in a Kotlin build - -- **Global `allprojects { kotlinOptions { ... } }`** โ€” runs for every module, including those without Kotlin plugin applied โ†’ errors. Use a convention plugin. -- **`kotlin("jvm") version "..."` on subprojects** โ€” declare the plugin at the root `plugins { }` block with `apply false`, then `plugins { alias(libs.plugins.kotlin.jvm) }` per module. -- **Absolute paths** in `gradle.properties` or scripts โ€” breaks CI. Use project-relative paths. -- **`mavenLocal()`** as the first repository in CI โ€” non-reproducible builds. Only for local debugging of a library you're developing. diff --git a/.agents/skills/kotlin-engineer/references/coroutines.md b/.agents/skills/kotlin-engineer/references/coroutines.md deleted file mode 100644 index df1130e7d4..0000000000 --- a/.agents/skills/kotlin-engineer/references/coroutines.md +++ /dev/null @@ -1,96 +0,0 @@ -# Coroutines & Flow โ€” policy & pitfalls - -Assumes you know `suspend`, `launch` / `async`, `Flow` / `StateFlow` / `SharedFlow`, `withContext`, `Dispatchers`. This file is only the traps and the rules. - -## Scopes & lifecycles - -- **Every coroutine must have an owner.** Framework-provided (`viewModelScope`, `lifecycleScope`, Ktor `call`), or a custom `CoroutineScope(SupervisorJob() + Dispatchers.Default + CoroutineName("service-x"))` held by the service. -- **`SupervisorJob` for long-lived services** (one child failing shouldn't kill siblings). **Regular `Job` inside a use case** โ€” when one subtask fails, cancel the rest. -- `CoroutineScope(...)` as a local is almost always a bug โ€” it doesn't cancel when its "parent" function returns. Use `coroutineScope { }` / `supervisorScope { }` instead, which are structured. -- Never store a `CoroutineScope` as a top-level / object property โ€” that's `GlobalScope` with extra steps. - -## Cancellation - -- `CancellationException` is a **normal control-flow signal**, not an error. Always rethrow from generic catches: - ```kotlin - try { work() } catch (e: Exception) { - if (e is CancellationException) throw e - log.error("work failed", e) - } - ``` -- Non-suspending loops don't check for cancellation. Insert `ensureActive()` / `yield()` inside CPU-heavy loops that you want to cancel promptly. -- `NonCancellable` block is for cleanup (`withContext(NonCancellable) { close() }`) โ€” never wrap business logic in it. -- `finally` after a `withTimeout` runs on a cancelled coroutine โ€” don't call suspend functions there without `withContext(NonCancellable)` wrapping. - -## Dispatcher discipline - -- `Dispatchers.Main` โ€” UI callbacks only. -- `Dispatchers.IO` โ€” blocking I/O (JDBC, `File`, `Socket`). Pool size is `max(64, availableProcessors)` by default (so on a 128-core box it's 128, not 64); tune with `kotlinx.coroutines.io.parallelism` if you know you need more. -- `Dispatchers.Default` โ€” CPU work. Sized = number of cores. -- `Dispatchers.Unconfined` โ€” advanced use only; resumes on whatever thread completed the suspension point. Don't sprinkle it. -- **Inject dispatchers** for testing: `class Repo(private val io: CoroutineDispatcher = Dispatchers.IO)`. Makes `runTest` + `StandardTestDispatcher` actually usable. - -## Parallel work - -- Sequential (wrong for parallelism): - ```kotlin - val a = async { fetchA() }.await() - val b = async { fetchB() }.await() // only starts after a finishes! - ``` -- Parallel (correct): - ```kotlin - coroutineScope { - val a = async { fetchA() } - val b = async { fetchB() } - a.await() to b.await() - } - ``` -- `awaitAll(list)` on a collection of `Deferred` โ€” propagates the first failure and cancels siblings. -- `supervisorScope { }` โ€” used when one failing child **shouldn't** cancel siblings (fan-out to 10 endpoints where partial success is fine). - -## `StateFlow` vs `SharedFlow` - -- **`StateFlow`** โ€” always has a current value; conflates (fast producers drop intermediate values). Use for "current state of something" (UI state, settings). Replay = 1 by contract. -- **`SharedFlow`** โ€” for events (navigation, one-shot signals). Configure explicitly: `MutableSharedFlow(replay = 0, extraBufferCapacity = 64, onBufferOverflow = BufferOverflow.DROP_OLDEST)`. -- **Never a `SharedFlow` with `replay > 0` for events** โ€” new subscribers will receive old events (toasts from 10 minutes ago). Use `replay = 0` + handle missed-signals differently. -- `StateFlow.value = x` is read-modify-write under contention โ€” use `.update { it.copy(...) }` for correct atomic updates. - -## `Flow` correctness - -- `flow { }` is cold โ€” the block runs per collector. Don't put side effects outside `emit` assuming they run once. -- `emit` is not thread-safe across multiple launched coroutines inside one `flow { }`. Use `channelFlow` / `callbackFlow` if you need multi-threaded emission. -- `flowOn(dispatcher)` affects **upstream only**. `flowOn(IO).map { }` โ€” the `map` still runs on the downstream dispatcher. Put `flowOn` as late as possible, right before collection. -- `catch { }` catches only upstream exceptions. A throw inside `collect { }` goes to the caller, not to `catch`. This is exception transparency โ€” don't violate by catching everything inside an operator. -- `Flow.first()` / `single()` / `toList()` on an infinite upstream hangs forever. Combine with `withTimeoutOrNull(...)`. - -## `SharingStarted` for hot Flows in UI - -- `MutableStateFlow(initial).asStateFlow()` โ€” always active. -- `flow.stateIn(scope, SharingStarted.Eagerly, initial)` โ€” active while scope alive. -- `flow.stateIn(scope, SharingStarted.Lazily, initial)` โ€” activates on first subscriber, stays. -- `flow.stateIn(scope, SharingStarted.WhileSubscribed(5_000), initial)` โ€” **the right default for mobile / screen-scoped state** (keeps alive 5 s after last subscriber to survive rotation without restart). - -## `callbackFlow` / `channelFlow` - -- `callbackFlow { }` to bridge listener-based APIs. **Must end with `awaitClose { unregister() }`** โ€” otherwise the collector leaks the listener. -- Don't `trySend(x).getOrThrow()` โ€” if the buffer is full, `trySend` returns a failure; decide between dropping, suspending (`send`), or increasing `BUFFERED` capacity. - -## Testing coroutines - -- `runTest { }` from `kotlinx-coroutines-test` โ€” virtual time, skips delays automatically. -- Inject `TestDispatcher` into your scope: `StandardTestDispatcher()` (ordered, manual advance) or `UnconfinedTestDispatcher()` (immediate). -- `MainDispatcherRule` for Android / anything using `Dispatchers.Main`. -- `Turbine` library for asserting on `Flow` emissions deterministically. -- Don't call `Thread.sleep` in a coroutine test โ€” it blocks virtual time. Use `delay(...)` and `advanceTimeBy(...)`. - -## Common anti-patterns - -| Anti-pattern | Correct | -|---|---| -| `GlobalScope.launch { ... }` | Inject `CoroutineScope` or use framework scope | -| `runBlocking { suspendCall() }` inside a suspend function | Just `suspendCall()` โ€” remove `runBlocking` | -| `MutableStateFlow` returned from a public API | `val state: StateFlow = _state.asStateFlow()` | -| `.value = state.value.copy(x = y)` | `state.update { it.copy(x = y) }` | -| `flow { withContext(IO) { emit(...) } }` | `flow { emit(...) }.flowOn(IO)` | -| `try { work() } catch (e: Exception) { log(e) }` | Same plus `if (e is CancellationException) throw e` first | -| Parallel fan-out with `.map { async { it.fetch() } }.map { it.await() }` inside `List` | Wrap in `coroutineScope { ... awaitAll() }` for proper cancellation semantics | diff --git a/.agents/skills/kotlin-engineer/references/idioms.md b/.agents/skills/kotlin-engineer/references/idioms.md deleted file mode 100644 index 877899aa2b..0000000000 --- a/.agents/skills/kotlin-engineer/references/idioms.md +++ /dev/null @@ -1,116 +0,0 @@ -# Kotlin idioms โ€” API design policy & pitfalls - -Assumes you know scope functions, data/sealed/value classes, extension functions, inline/reified. This file is which to pick, and where LLMs pick wrong. - -## Scope functions โ€” pick by intent - -| Function | Receiver is | Returns | Use for | -|---|---|---|---| -| `let` | `it` | lambda result | null-safe transform, introduce temporary scope | -| `run` | `this` | lambda result | compute a value from a receiver | -| `with(x) { }` | `this` | lambda result | same as `run` but non-extension (explicit target) | -| `apply` | `this` | receiver | configure a mutable object, return it | -| `also` | `it` | receiver | side-effect on a value (log, register), keep value | - -Rules: -- Only one of them per expression. Nesting `.apply { ... .let { ... } }` is a sign to extract a function. -- `?.let { }` for the "do X if non-null" pattern โ€” not `if (x != null) ...`. -- Don't use `apply` to call a single function โ€” just call it. `x.apply { foo() }` vs `x.also { it.foo() }` โ€” prefer the one that matches return semantics (use `apply` when you're configuring many things on a builder). - -## Classes โ€” pick the right kind - -- **`data class`** โ€” pure value type with a primary constructor. Gives `equals` / `hashCode` / `copy` / `componentN`. Not for entities, services, or anything with identity / behavior. -- **`sealed interface` / `sealed class`** โ€” closed hierarchies (result types, UI states, commands). Prefer `sealed interface` โ€” lets implementations be `object` / `data object` / class, supports multiple interface inheritance. -- **`value class` (`@JvmInline`)** โ€” domain primitives (`UserId`, `Email`, `Temperature`). Compiles to the underlying type, zero overhead. `init { require(...) }` for validation. -- **`object`** โ€” stateless services, singletons, `Comparator` instances, companion objects for factory methods. -- **`data object`** โ€” sealed-hierarchy singletons with nice `toString` (`Loading`, `Empty`). - -Pitfalls: -- `data class Foo(val id: Long, val name: String)` used as an entity โ†’ `equals` based on all fields changes with mutation, bugs in sets / maps. -- Value classes don't satisfy Java interop for `@Entity` / JPA โ€” they're inlined and have no no-arg constructor. -- `copy()` calls the primary constructor, so `init` blocks **do** run โ€” `require(...)` in `init` IS enforced on `copy()`. However, `copy()` bypasses secondary constructors and factory methods. If validation lives only there (not in `init`), `copy()` will skip it โ€” put invariants in `init`. - -## Extension functions โ€” when and where - -- **Extend types you don't own** to add domain-specific helpers (`String.isValidEmail()`). -- **Don't extend types you own** when a method would do โ€” extensions are statically dispatched, so `open`-like polymorphism doesn't apply. -- **Never extend `Any` / `Any?`** โ€” pollutes autocomplete for the whole project. -- **File-level** (top-level) extensions, not inside a class, unless the extension is conceptually a member of the enclosing class. -- Don't shadow members: an extension with the same name as a member is silently ignored. - -## `inline` / `reified` โ€” when it earns its cost - -- `inline` removes lambda allocation โ€” useful for **hot paths** with small lambdas (`measureTimeMillis { }`, locking helpers). -- `reified` โ€” lets you use `T::class` inside the function, required for `Gson.fromJson()` style APIs. -- `inline` bloats call sites โ€” don't inline large functions; compiler warns when body is large. -- `noinline` / `crossinline` are for specific scenarios (`noinline` to pass a lambda elsewhere, `crossinline` to disallow non-local returns from a lambda used in a different scope). Don't use them unless the compiler forces you. - -## Delegation - -- **Interface delegation** (`class A(b: B) : Iface by b`) โ€” composition over inheritance, avoids boilerplate. -- **Property delegation** โ€” `by lazy { ... }` (thread-safe by default), `by Delegates.observable(x) { _, old, new -> }`, `by map` / `by mutableMap` for config-like objects. -- `lateinit var` **doesn't support primitives or nullable types**. Use `Delegates.notNull()` for primitives; for nullable, just initialize to `null`. -- `lazy` has `LazyThreadSafetyMode.SYNCHRONIZED` by default; `.NONE` is faster for single-threaded code but unsafe across threads. - -## `Result` vs exceptions - -- Kotlin's `Result` is for **internal plumbing**, not public API (the `Result` type is generic-variance-limited and awkward from Java). -- For public APIs, throw domain exceptions (`UserNotFoundException`) or return a `sealed interface Outcome { data class Success(value: T); data class Failure(reason: Reason) }`. -- `runCatching { ... }` is handy but swallows `CancellationException` unless you re-check: - ```kotlin - runCatching { work() } - .onFailure { if (it is CancellationException) throw it } - ``` -- Don't use `runCatching` in coroutines as a general try/catch โ€” rethrow cancellation explicitly. - -## Generics & variance - -- **`out T`** when `T` is a producer (read-only; `List` means "list of Animal or subtype"). **`in T`** when `T` is a consumer (write-only). -- **`where T : Comparable, T : Serializable`** for multiple bounds. -- `reified` requires `inline`. Can't be used with virtual methods. -- Star-projection `List<*>` when you don't care about the element type โ€” read-only. -- Generic `T?` vs `T` matters: `fun first(list: List): T` forces non-null, even though the source may contain nulls โ€” use `T?` for nullable semantics. - -## Immutability & collection choice - -- **Return `List` / `Map` / `Set`** from public APIs โ€” read-only at Kotlin level (Java sees them as mutable; annotate or wrap if that matters). -- **`MutableList` / `MutableMap` only inside a function body** or as a private field when building. -- `emptyList()` / `listOf()` โ€” immutable. `mutableListOf()` โ€” `ArrayList`. Don't write `ArrayList()` directly when `mutableListOf` reads better. -- `buildList { add(...); addIf(...) }` โ€” idiomatic for conditional construction without intermediate mutable var. - -## Null-safety traps - -- `String!` (platform type) in API responses is a footgun โ€” explicitly handle: `response.body ?: error("empty body")`. -- `list.filter { ... }.first()` vs `list.first { ... }` โ€” the first chains through all elements; `first { }` short-circuits. -- `Map[key]` returns `V?`. `Map.getValue(key)` returns `V` but throws on missing โ€” pick based on contract. -- `lateinit var x: SomeType` โ€” accessing before init throws `UninitializedPropertyAccessException`, not NPE. `::x.isInitialized` to check. - -## Equality & hashing - -- `==` is structural (`equals`), `===` is referential. In Java interop code review, watch for Kotlin `==` being translated to Java `.equals` โ€” fine, but `null == null` works both ways. -- Data classes generate `equals` / `hashCode` based on primary-constructor fields. Fields declared in the body are not included โ€” don't rely on it. -- For value classes, `equals` compares underlying values directly. - -## Enums vs sealed - -- **Enum** โ€” known, fixed set of instances without per-case data (`enum class Color { RED, GREEN, BLUE }`). -- **Sealed interface with `data object`s** โ€” when cases may carry data or you want exhaustive `when` with mixed shapes. -- Don't emulate sealed hierarchies with enums + `when (color) { BLUE -> "blue"; else -> error("?") }` โ€” add a case and the `else` swallows it. - -## Domain modelling โ€” non-obvious policy - -- **Do not return `kotlin.Result` from public API.** It's designed for `runCatching` plumbing, doesn't compose with Java callers, and its variance-limited generics break generic wrappers. Convert at the boundary (throw, or map to a sealed domain type). -- **`Either` from Arrow** โ€” only if the project already depends on Arrow. Don't pull it in for a single function. -- `T?` must mean one thing. If "absent" and "unknown" are both possible, use a sealed hierarchy โ€” overloading `null` is a recurring source of silent bugs. - -## When you'd reach for Java-idiom and Kotlin has a better one - -| Java-style | Kotlin-style | -|---|---| -| `if (x == null) throw IllegalArgumentException("x")` | `requireNotNull(x) { "x" }` | -| `Objects.requireNonNull(x)` | `requireNotNull(x)` | -| `new ArrayList<>(list)` to copy | `list.toMutableList()` | -| `map.getOrDefault(k, default)` | `map[k] ?: default` | -| Manual `Builder` class | primary constructor + `copy()` + default args | -| Anonymous inner class for a single method | lambda or function reference | -| `if (x != null) x.foo() else null` | `x?.foo()` | diff --git a/.agents/skills/kotlin-review/SKILL.md b/.agents/skills/kotlin-review/SKILL.md deleted file mode 100644 index f1f2a01213..0000000000 --- a/.agents/skills/kotlin-review/SKILL.md +++ /dev/null @@ -1,71 +0,0 @@ ---- -name: kotlin-review -description: > - Review Kotlin (and Java) changes in this repo against the Spine coding - guidelines, safety rules, and testing policy. Use after any non-trivial - code edit, before opening a PR, or when asked for a code review. - Read-only; does not run builds. ---- - -# Kotlin code review (repo-specific) - -You are the Kotlin reviewer for this repository. The authoritative standards -live in `.agents/`: - -- `.agents/coding-guidelines.md` โ€” Kotlin idioms, formatting, what to prefer/avoid. -- `.agents/safety-rules.md` and `.agents/advanced-safety-rules.md` โ€” hard constraints - (no reflection without approval, no analytics/telemetry, no blocking calls in - coroutines, no auto-updating external dependencies). -- `.agents/testing.md` โ€” Kotest assertions preferred, stubs not mocks. -- `.agents/project-structure-expectations.md` โ€” module/source-set layout. -- `.agents/version-policy.md` โ€” version bumps are required only when the - repository has a root `version.gradle.kts`. - -## Review procedure - -1. Read the diff. Use `git diff --staged` or `git diff ...HEAD` depending on - what the user describes. Do NOT review the full repo โ€” only what changed. - Apply the `AGENTS.md ยง Code review` filter with repository awareness: - - Detect the `config` repository by scanning `git remote -v` for any URL - matching `[:/]SpineEventEngine/config(\.git)?$`. - - In **`config` itself**, skip only `gradlew` and `gradlew.bat`; every other - config-distributed path is owned by this repo and stays in scope. - - In any **consumer repo**, skip the full config-distributed list. If - nothing remains after filtering, return - `APPROVE โ€” all changes are config-distributed files.` and stop. -2. Read each affected file fully, not just the diff hunks. Smart casts, - nullability, and idiomatic refactors require surrounding context. -3. Check against `.agents/coding-guidelines.md`: - - Kotlin idioms (extension functions, `when`, smart casts, data/sealed classes). - - Kotlin Protobuf DSL (`message { ... }`) preferred over Java builders (`newBuilder()`, `toBuilder()`) in Kotlin. - - Immutability by default. - - No `!!` without justification. - - No type names in variable names. - - No string duplication โ€” use companion-object constants. - - No mixing Groovy/Kotlin DSL in build logic. - - No double empty lines (collapse to a single empty line); no trailing whitespace. -4. Check safety rules: reflection, telemetry, blocking-in-coroutines, dependency - bumps that weren't requested. -5. Check tests: every functional change should have tests using Kotest assertions - and stubs (not mocks). -6. Check the version gate: - - If the repository has a root `version.gradle.kts`, confirm it was - incremented when the change is user-visible. - - If root `version.gradle.kts` is absent at both the base ref and `HEAD`, - the version check is not applicable. Do not report a missing version bump - or ask for the file to be created. - -## Output format - -Return three sections, in this order: - -- **Must fix** โ€” violations of safety rules, broken builds, missing version - bump when the version gate applies, missing tests for functional changes. -- **Should fix** โ€” coding-guideline violations and clearer idiomatic alternatives. - Cite the specific guideline. -- **Nits** โ€” style and naming suggestions. - -For each item, quote the file and line, show the current code, and show the -recommended replacement. If there's nothing in a section, write "None." - -End with a one-line verdict: `APPROVE`, `APPROVE WITH CHANGES`, or `REQUEST CHANGES`. diff --git a/.agents/skills/kotlin-review/agents/openai.yaml b/.agents/skills/kotlin-review/agents/openai.yaml deleted file mode 100644 index 7497fb9b57..0000000000 --- a/.agents/skills/kotlin-review/agents/openai.yaml +++ /dev/null @@ -1,4 +0,0 @@ -interface: - display_name: "Kotlin Review" - short_description: "Review Kotlin and Java code changes" - default_prompt: "Use $kotlin-review to review Kotlin and Java changes against Spine coding guidelines, safety rules, and testing policy." diff --git a/.agents/skills/move-files/SKILL.md b/.agents/skills/move-files/SKILL.md deleted file mode 100644 index ccff78bc92..0000000000 --- a/.agents/skills/move-files/SKILL.md +++ /dev/null @@ -1,57 +0,0 @@ ---- -name: move-files -description: > - Move or rename any files/directories in a repo: preserve history, update all - references and build metadata, verify no stale paths remain. ---- - -# Move Files - -## Workflow - -1. Preflight. - - Run `git status --short`. - - Map each `source -> destination`. - - Classify scope: simple same-module moves stay targeted; package, module, or - cross-module moves need broader inspection. - - Ask before ambiguous mappings, destination conflicts, or unclear semantic - package/module changes. - -2. Search before moving. - - Search all old identifiers: paths, names, resource refs, doc links. - - For Gradle/module/source-set moves, check `settings.gradle.kts`, - `build.gradle.kts`, and `buildSrc`. - - For Kotlin/Java, update package declarations only when package intent - changes. - -3. Move safely. - - Always use `git mv` for tracked files in the repo. If sandboxing blocks - it, request approval; do not use delete/create as a fallback. - - Use filesystem moves only for untracked/generated/out-of-git files. - - Create parent directories first. - - For case-only renames, move through a temporary name. - -4. Repair references. - - Update all references: imports, build metadata, docs, resources, and scripts. - - Start search scope narrow: affected directory, then module, then repo-wide. - - Prefer precise edits; avoid broad replacements on generic names. - -5. Verify. - - Re-run targeted searches for old tokens. - - Run `git status --short` and confirm the delta matches the move. - - Run focused validation for moved files, or state what could not run. - -6. Ensure the version is bumped. - Run the `version-bumped` skill so the branch carries a strictly greater - `version.gradle.kts` than the base ref before any `./gradlew build` - (which can transitively `publishToMavenLocal` and overwrite - consumer-facing snapshots). The skill is a no-op if a bump already - happened earlier on the branch. - -## Repo Notes - -Follow `.agents/project-structure-expectations.md` for module/source-set/test moves. - -## Report - -Return: `Moved[]`, `UpdatedRefs[]`, `Verification[]`, `Risks[]`. diff --git a/.agents/skills/move-files/agents/openai.yaml b/.agents/skills/move-files/agents/openai.yaml deleted file mode 100644 index ba90a9f8f2..0000000000 --- a/.agents/skills/move-files/agents/openai.yaml +++ /dev/null @@ -1,4 +0,0 @@ -interface: - display_name: "Move Files" - short_description: "Move files safely across a repo" - default_prompt: "Use $move-files to relocate files or directories in this repository while preserving history, updating references, and verifying the result." diff --git a/.agents/skills/pre-pr/SKILL.md b/.agents/skills/pre-pr/SKILL.md deleted file mode 100644 index 7c51b4da4b..0000000000 --- a/.agents/skills/pre-pr/SKILL.md +++ /dev/null @@ -1,203 +0,0 @@ ---- -name: pre-pr -description: > - Run the pre-PR checklist for this repo: apply the version gate only when - the repository has a root `version.gradle.kts`, run a scope-dependent - build/check command per `.agents/running-builds.md` (docs-only โ†’ `dokka`; - code/deps โ†’ `build`; proto โ†’ `clean build`; no documented command โ†’ skipped), - and invoke the relevant reviewers (`kotlin-review`, `review-docs`, - `dependency-audit`, - `check-links`) against the branch diff. On success, write a sentinel file at - `.git/pre-pr.ok` so the `gh pr create` hook can verify the checklist ran - for the current HEAD. Use before opening a PR, or when CI rejected a - branch and you want a fast local repro. ---- - -# Pre-PR checklist (repo-specific) - -You are the pre-PR gate for this repository. You compose the existing -reviewers and the documented repository rules into a single pass that must -succeed before a pull request is opened. - -This skill supports both versioned Gradle Build Tools projects and repositories -that intentionally do not have `version.gradle.kts`. Do not create -`version.gradle.kts` just to satisfy this checklist. When the file is absent -from the project root, the version-bump check is **not applicable**. - -The authoritative standards live in `.agents/`: - -- `.agents/version-policy.md` โ€” applies only when the repository has a root - `version.gradle.kts`. -- `.agents/running-builds.md` โ€” which build/check command to run. -- `.agents/safety-rules.md` and `.agents/advanced-safety-rules.md` โ€” hard - constraints checked by the reviewers. - -## Procedure - -Run steps 1โ€“4 fully before aggregating. Collect all findings; do not stop at -the first failure. - -### 1. Determine scope and repository capabilities - -- Base ref: `master` unless the user provides a different one. -- Changed files: `git diff ...HEAD --name-only` -- Repository root: `git rev-parse --show-toplevel` -- Repository kind: detect the `config` repository by scanning `git remote -v` - for any URL matching `[:/]SpineEventEngine/config(\.git)?$`. -- Filter changed files using `AGENTS.md ยง Code review`: - - In **`config` itself**, skip only `gradlew` and `gradlew.bat`; every other - config-distributed path is owned by this repo and stays in scope. - - In any **consumer repo**, remove the full config-distributed skip list. A - PR that contains *only* config-distributed files needs no build, no - reviewers, and should PASS immediately โ€” skip to step 6 with - `build=skipped`, `build_status=skipped`, `reviewers=none`, - `version=not-applicable`. -- Version gate: check only the repository-root `version.gradle.kts`. - - Absent at both sides โ†’ `not-applicable`, continue. - - Present at `HEAD` โ†’ enforce in step 2. - - Present at `` but missing at `HEAD` โ†’ fail unless the user - explicitly asked to migrate away from Gradle Build Tools versioning. -- Hugo site: detect a site only when `docs/` or `site/` contains one of - `hugo.toml`, `hugo.yaml`, `config/hugo.toml`, `config/hugo.yaml`, - `config/_default/hugo.toml`, or `config/_default/hugo.yaml`. -- Classify changes: - - **proto** โ€” any `*.proto` changed - - **code** โ€” any `*.kt`, `*.kts`, or `*.java` changed - - **docs** โ€” any `*.md` or doc-only source edits changed - - **deps** โ€” any file under `buildSrc/src/main/kotlin/io/spine/dependency/` changed - - **site** โ€” a Hugo site exists and any file under `docs/**` or `lychee.toml` - changed (triggers Hugo link check; pure `README.md` or KDoc-only changes do - *not* count). If `lychee.toml` changes but no Hugo site exists, keep - `site=false` and note that `check-links` is not applicable if the skipped - reviewer needs explanation. - -### 2. Version-bump check - -- Skip when version gate is `not-applicable`. -- Read `version.gradle.kts` at `HEAD`. Read `` only if the file exists - there; if it does not, the file is newly introduced โ€” record the introduced - version and continue. -- When both sides have the file: if the version is not strictly greater (semver - + Spine snapshot rules in `.agents/version-policy.md`): if - `.agents/skills/bump-version/` exists, **auto-fix immediately** by running - the `bump-version` skill without asking; otherwise record a Must-fix and continue. - Re-read the file after the fix. If the version is still not strictly greater, - record a Must-fix and continue. If the auto-fix succeeded, recompute the - changed-file list (`git diff ...HEAD --name-only`) before proceeding to - Step 3 โ€” the bump commit adds `version.gradle.kts` to the diff. - -### 3. Build or check - -Pick the target per `.agents/running-builds.md`: - -- **proto** changed โ†’ `./gradlew clean build` -- Else **code** changed โ†’ `./gradlew build` -- Else **docs**-only โ†’ `./gradlew dokka` - -If `./gradlew` is absent, read `.agents/running-builds.md` for the -repository-specific command. If that file is also absent, or if none is -documented for the change type, record `build_status=skipped` with the -reason and continue. - -Run the chosen command. On failure, record the first failing task and -continue to step 4 โ€” do not abort. Pass `build_status=FAIL` in the context -given to reviewers so they can discount false positives from non-compiling -code. - -### 4. Reviewers - -Run every relevant reviewer and collect all verdicts before aggregating. In a -single Codex session, run the reviewer skills one by one and batch independent -search/read commands inside each reviewer. If multi-agent tools are available, -parallel reviewer dispatch is optional but not required. - -Before running a reviewer, check that the skill directory exists under -`.agents/skills/`; if a skill is absent, skip it with a note "not applicable -for this repo" rather than failing. - -- **code** changed โ†’ `kotlin-review` -- **docs** or KDoc changed โ†’ `review-docs` -- **deps** changed โ†’ `dependency-audit` -- **site** changed โ†’ `check-links` (unless the sentinel short-circuit below - applies) - -**`check-links` sentinel short-circuit.** Read `.git/check-links.ok` (if -present). If `head=` equals the current **full** HEAD SHA and `status=PASS`, skip -the link check and record `APPROVE` with note "cached from `.git/check-links.ok`" -(caching its ~30 s rebuild+serve cycle; the result is deterministic for a given -HEAD). Otherwise run `check-links` normally. - -Pass each reviewer: base ref, changed-file list, build result, version result. -When the version check is `not-applicable`, say so explicitly so reviewers don't flag a -missing version bump. - -**Auto-fix policy for reviewer findings:** - -- Findings from `kotlin-review`, `review-docs`, or `dependency-audit` โ†’ record - as Must-fix or Should-fix; do **not** auto-apply. Surface them and wait for - user action. -- If a reviewer reports a missing version bump after Step 2 already ran, the - auto-fix did not take โ€” record a Must-fix and do not silently re-apply. -- `dependency-audit` reports a **version rollback** โ†’ do **not** auto-fix. - Surface it as a Must-fix and wait for user confirmation, because a rollback - can be intentional. - -### 5. Aggregate - -- **PASS**: version check passed or `not-applicable`, build succeeded or - `build_status=skipped` (no documented command for the change type), every - reviewer returned `APPROVE` or `APPROVE WITH CHANGES`, and no unaddressed - Must-fix items remain. -- **FAIL**: anything else. - -### 6. Sentinel - -Write `.git/pre-pr.ok` at the repo root (never under `.claude/`). The `gh pr -create` hook (`.agents/scripts/pre-pr-gate.sh`) checks `head=` and `status=`; -field names in this block are part of that contract. - -``` -head= -branch= -status=PASS|FAIL -timestamp= -build= -build_status=PASS|FAIL|skipped -reviewers= -version=new, introduced:, or "not-applicable"> -``` - -## Output format - -**On PASS** โ€” single line: - -``` -Pre-PR: PASS ( vs ) โ€” ready to `gh pr create`. -``` - -**On FAIL** โ€” header line, then only the items that need attention, each -prefixed with the source reviewer or check: - -``` -Pre-PR: FAIL ( vs ) - -Must fix: -- [kotlin-review] -- [review-docs] - -Should fix: -- [dependency-audit] -``` - -Report nothing about checks that passed. If auto-fixes were applied, list -them in one line before the verdict: `Auto-fixed: .` - -## Notes - -- This skill must NOT create the PR itself. -- This skill must NOT create `version.gradle.kts`. -- The sentinel lives under `.git/` โ€” per-clone, never committed. -- Each reviewer is the source of truth for its own checks; this skill only - orchestrates and aggregates. -- This skill may auto-fix a missing version bump by running the `bump-version` skill; - all other fixes require explicit user confirmation. diff --git a/.agents/skills/pre-pr/agents/openai.yaml b/.agents/skills/pre-pr/agents/openai.yaml deleted file mode 100644 index 6964e9d975..0000000000 --- a/.agents/skills/pre-pr/agents/openai.yaml +++ /dev/null @@ -1,4 +0,0 @@ -interface: - display_name: "Pre-PR" - short_description: "Run the repo pre-PR verification gate" - default_prompt: "Use $pre-pr to run the repository pre-PR checklist, including the version gate, build or doc check, and relevant reviewers." diff --git a/.agents/skills/raise-coverage/SKILL.md b/.agents/skills/raise-coverage/SKILL.md deleted file mode 100644 index 790007056e..0000000000 --- a/.agents/skills/raise-coverage/SKILL.md +++ /dev/null @@ -1,241 +0,0 @@ ---- -name: raise-coverage -description: > - Raise JVM test coverage for a Gradle module or source path. Before anything - else, ensures the repo is on Kover โ€” if vanilla JaCoCo is detected, proposes - a one-shot repo-wide migration and **waits for approval**. Then localizes - uncovered lines and branches from Kover's JaCoCo-format XML report, and - generates policy-compliant unit tests โ€” stubs not mocks; tests are written - in **Kotlin** with Kotest assertions, regardless of whether - the code under test is Kotlin or Java; class names use the **`Spec`** - suffix. Proposes a test-case list and waits for approval before writing any - test, then re-runs the report to confirm the gap is closed. Use when asked - to add missing tests, close coverage gaps, or raise a module's coverage. ---- - -# Raise test coverage - -You localize untested code with **Kover**'s JaCoCo-format XML report and write -the unit tests that close the gap. Work on one Gradle module or path at a time, -always propose the test-case list and **wait for approval** before writing, -and verify the gap is actually closed afterward. - -Before the main flow runs, you ensure the repo is on Kover. If vanilla JaCoCo -is detected anywhere, you propose a one-shot **repo-wide migration to Kover** -and wait for approval. The mechanical recipe lives in -[`references/migrate-to-kover.md`](references/migrate-to-kover.md). - -The authoritative standards live in `.agents/`: - -- `.agents/testing.md` โ€” stubs not mocks; Kotest assertions; cover API edge - cases; scaffold `when`/sealed-class branches. -- `.agents/coding-guidelines.md` โ€” Kotlin/Java idioms for the tests you write. -- `.agents/version-policy.md` โ€” tests-only changes do not require a version bump. - -Mechanical detail (report paths, XML parsing, gap rules) lives in -[`references/coverage-signals.md`](references/coverage-signals.md). Keep this -file about *what to do*; that one is *how to read the numbers*. - -## Scope - -- **Coverage comes from Kover's local report.** Spine consumer repos apply the - Kover Gradle plugin with `useJacoco(version = Jacoco.version)`, which makes - Kover compute coverage with the JaCoCo engine and emit JaCoCo-format XML. - Per-module task `::koverXmlReport`; XML at - `/build/reports/kover/report.xml`. KMP modules configured by Spine's - `kmp-module` script plugin define only the `total` Kover report, so the - same `koverXmlReport` / `report.xml` pair applies โ€” see - `references/coverage-signals.md`. -- **Target human-written `src/main` code only.** Never write tests for generated - code (any path containing `generated`, e.g. Protobuf output), `examples`, or - existing test sources. These are excluded by `.codecov.yml` โ€” respect that - boundary. -- **One module or path per run.** - -## Inputs - -`$ARGUMENTS` is one of: - -- a Gradle module path โ€” e.g. `:base`, `:core`; -- a source file or directory โ€” e.g. `base/src/main/kotlin/io/spine/...`; -- `--triage` โ€” read-only: produce a ranked gap report for the repo (or the named - module) and stop, without proposing or writing tests. - -If `$ARGUMENTS` is empty, ask which module or path to target (or offer -`--triage` to help choose). - -## Step 0 โ€” Ensure Kover - -Run this **before** the Workflow below. Behaviour depends on `$ARGUMENTS`: - -### Under `--triage` (read-only) - -`--triage` is contractually read-only and must not write build files. If -Kover is not already applied everywhere, **emit a "Setup required" report -and stop** without writing anything (and without proposing a migration). -List the modules that still need migration, point at -[`references/migrate-to-kover.md`](references/migrate-to-kover.md), and tell -the user to re-run `/raise-coverage` **without** `--triage` to perform the -migration first. Once Kover is in place everywhere, `--triage` proceeds to -the Workflow. - -### Otherwise - -Branch on the repo's current coverage setup (detection patterns and full -migration recipe in -[`references/migrate-to-kover.md`](references/migrate-to-kover.md)): - -1. **Kover applied everywhere already** โ€” silently proceed to the Workflow. -2. **No coverage plugin anywhere** โ€” silently install Kover (per the recipe). - Record "Migration: installed Kover" in the final Report. No approval gate - for this branch. -3. **Vanilla JaCoCo in โ‰ฅ1 module** (with or without Kover alongside) โ€” emit a - proposal and **wait for approval** before making any edits. - -### Proposal output - -Emit the following Markdown sections, in this order, then stop and wait for approval: - -- **Detected** โ€” every module applying `jacoco` / `JacocoPlugin` / - `JacocoConfig.applyTo` / a `jacoco-*.gradle.kts`; annotate "vanilla only" - vs. "JaCoCo+Kover both"; note any root `jacocoRootReport`. Treat a root-level - `KoverConfig.applyTo(rootProject)` as a Kover signal (it is the Kover-based - successor to `JacocoConfig.applyTo`). -- **Plan** โ€” every file that will be edited, with paths: per-module - `build.gradle.kts`, root `build.gradle.kts`, `.codecov.yml`, - `.github/workflows/*.yml`, `scripts/*.sh`. -- **Translation notes** โ€” the rows from the translation table in - `references/migrate-to-kover.md` that apply to this repo. -- **Manual-review surfaces** โ€” items from that file's "Manual-review - surfaces" list that the user must decide on before the migration can - proceed. -- **Smoke check that will follow** โ€” the commands listed in - *Verify (smoke check)* below. -- Close with: "Confirm to apply, or call out anything to change first." - -### Wait, then apply - -Do not write any file until the user explicitly says "go" / "yes" / "apply" -(or equivalent). On adjustment requests, regenerate the proposal and wait -again. After approval, apply the migration per -`references/migrate-to-kover.md`, logging `edited ` per file. Any -unresolved manual-review surface โ†’ stop with "needs your call on ``". - -### Verify (smoke check) - -Pick the smallest migrated leaf module and run `::koverXmlReport`, -then inspect `/build/reports/kover/report.xml`. KMP modules also use -this task โ€” Spine's `kmp-module` script plugin configures only Kover's -`total` report, which for the JVM-only KMP target is identical in shape to -the JVM case (see `references/migrate-to-kover.md` ยง6). - -Run `./gradlew ::koverXmlReport --quiet`; if the root was touched, -also run `./gradlew koverXmlReport --quiet`. -Confirm the XML exists, is non-empty, and the first non-XML-declaration line -contains `:koverXmlReport` (the same task on JVM and KMP modules - configured by Spine's convention plugins; see - `references/coverage-signals.md`). The report task runs the tests first. - - Parse the XML for uncovered lines (`ci == 0`) and partially covered - branches (`mb > 0`). Prioritize methods whose `BRANCH` counter has - `missed > 0`. - - Drop any class under an excluded path (generated / examples / test). - - Discard **non-actionable** gaps the engine cannot credit even with a - perfect test (see `references/coverage-signals.md`): Kotlin `inline` / - `inline reified` functions (their bytecode is inlined into each call - site, so the definition lines stay `ci=0` regardless of tests), - unreachable guards (`require`/`check`/`error` paths the public API - cannot trigger), and `throw helper(...)` lines where the helper throws - internally. Report these as non-actionable instead of proposing tests for - them. - -3. **Read before you write.** - - Read the class(es) under test in full โ€” public API, constructors, branch - conditions, `when`/sealed exhaustiveness, error paths. - - Read existing tests in the module to match structure, naming, fixtures, - and the test source set/layout you will add to. - - Read collaborators you will need to substitute, so you can write **stubs** - (hand-written fakes), not mocks. - -4. **Propose the test cases โ€” then WAIT.** - - For each target, list the concrete cases: the method/branch, the input, - the expected outcome, and the stub(s) required. Map each case back to the - uncovered line/branch it closes. - - Present this list and **wait for the user's confirmation** before writing - anything. (Under `--triage` you already stopped at step 1.) - -5. **Generate the tests** (only after approval), per `.agents/testing.md`: - - **Write tests in Kotlin**, regardless of whether the code under test is - Kotlin or Java. Use JUnit Jupiter structure (`@Test` / `@Nested` / - `@DisplayName`) with **Kotest assertions** (`shouldBe`, `shouldThrow`, - `shouldContainExactlyInAnyOrder`, โ€ฆ). Reach for the - `truth-proto-extension` only when asserting on Protobuf message subjects - that Kotest's matchers cannot express, and keep that import isolated to - the case that needs it. - - **Class names use the `Spec` suffix** โ€” e.g. `AbstractSourceFileSpec`, - not `AbstractSourceFileTest`. This matches the house convention in - existing `*Spec.kt` files (`base-libraries`, etc.) and applies even when - the code under test is Java. - - **Stubs, not mocks.** No mocking framework is on the classpath by design. - - Cover API edge cases; add a case per `when`/sealed-class branch. - - Place the test under `/src/test/kotlin/...`, mirroring the - package of the code under test (KMP: `src/jvmTest/kotlin/...` or - `src/commonTest/kotlin/...` per the module's target). Reuse the file's - copyright header. - -6. **Verify.** - - Re-run `::koverXmlReport`. - - Confirm the previously-listed uncovered `nr` lines/branches no longer - appear as gaps, and the class's `LINE` / `BRANCH` `missed` counters - dropped. - - Confirm the module total does not regress against `.codecov.yml`. - - If a test fails to compile or the gap is not closed, fix and re-run before - reporting done. - -## Report - -Return five sections (the **Migration** section is emitted only when Step 0 -actually did work): - -- **Migration** โ€” what Step 0 changed, with the list of edited files and the - smoke-check result. Omit when Step 0 was a no-op (Kover already in place). -- **Gaps** โ€” uncovered lines/branches found (file โ†’ lines/branches). -- **Proposed cases** โ€” the awaited list from step 4. -- **Generated** โ€” test files added, with the cases each covers. -- **Verification** โ€” before/after coverage for the target, and confirmation that - no `.codecov.yml` target regressed. - -## Safety - -- **`--triage` is read-only.** Step 0 never writes under `--triage`; if - Kover is not in place, emit "Setup required" and stop. -- **Migration requires approval when vanilla JaCoCo is detected.** Silent - install of Kover happens only when *no* coverage frontend is in place and - `--triage` is not requested. -- **Read-only until approval.** Do not write tests before the user confirms the - step-4 list. -- **Never weaken a `.codecov.yml` target** or extend its `ignore` list to make a - check pass. -- **Never add a mocking dependency** (Mockito, MockK, โ€ฆ) โ€” write stubs. -- **No version bump.** Tests-only changes do not require one; do not invoke - `/version-bumped` for a tests-only result. If you had to touch production code - to make it testable, that is a separate change that needs its own review and a - version bump. The migration itself (Step 0) **does** alter build files and is - not tests-only โ€” treat it as production-code change for version-bump purposes - when it runs. diff --git a/.agents/skills/raise-coverage/agents/openai.yaml b/.agents/skills/raise-coverage/agents/openai.yaml deleted file mode 100644 index 32a4ed1f9f..0000000000 --- a/.agents/skills/raise-coverage/agents/openai.yaml +++ /dev/null @@ -1,4 +0,0 @@ -interface: - display_name: "Raise Coverage" - short_description: "Migrate to Kover if needed, then generate unit tests to close coverage gaps." - default_prompt: "Use $raise-coverage. Step 0 first: detect the coverage setup. If vanilla JaCoCo is found anywhere, propose a one-shot repo-wide migration to Kover and wait for approval before editing. If no coverage frontend is in place, install Kover silently. Smoke check after migration: run `./gradlew ::koverXmlReport --quiet` and confirm `/build/reports/kover/report.xml` is non-empty. KMP modules use the same task โ€” Spine's `kmp-module` script plugin configures only Kover's `total` report, and the JVM-only KMP target produces a JVM-shaped XML there. Then run the normal flow: localize uncovered lines and branches from Kover's JaCoCo-format XML report, propose a test-case list and wait for approval, then generate policy-compliant unit tests (stubs not mocks; written in Kotlin with Kotest assertions regardless of whether the code under test is Kotlin or Java; class names use the `Spec` suffix, e.g. `AbstractSourceFileSpec`) and re-run the same report task to verify the gaps are closed. Tests-only changes do not require a version bump." diff --git a/.agents/skills/raise-coverage/references/coverage-signals.md b/.agents/skills/raise-coverage/references/coverage-signals.md deleted file mode 100644 index 1944ffd55d..0000000000 --- a/.agents/skills/raise-coverage/references/coverage-signals.md +++ /dev/null @@ -1,181 +0,0 @@ -# Coverage signals โ€” localization & verification - -Mechanical reference for the `raise-coverage` skill. The `SKILL.md` says *what to -do*; this file says *how to read the numbers*. - -Coverage is computed by the **JaCoCo engine**, but the Spine convention is to -expose it through **Kover** with `useJacoco(version = Jacoco.version)`. Kover -owns the Gradle tasks; JaCoCo owns the engine and the XML format. The skill's -Step 0 ensures every target repo is on Kover before any analysis runs (see -[`migrate-to-kover.md`](migrate-to-kover.md)). - -## Where the report lives - -Kover is applied per module via the distributed `jvm-module` / -`kmp-module` script plugins, or directly: - -```kotlin -plugins { /* โ€ฆ */ id("org.jetbrains.kotlinx.kover") } -kover { - useJacoco(version = Jacoco.version) // compute coverage with the JaCoCo engine - reports.total.xml.onCheck = true // emit XML on `check` -} -``` - -`useJacoco(...)` is a **Kover** DSL call โ€” the tasks are Kover's, but the -engine and the XML format are JaCoCo's. - -- Per-module report task: `::koverXmlReport` -- XML path: `/build/reports/kover/report.xml` -- Same task on KMP modules configured by Spine's `kmp-module` script - plugin โ€” it only sets up the `total` report, so `koverXmlReport` exists - but no `koverXmlReport` does (a `Jvm`-suffixed task would only - appear if a named `variant("jvm") { โ€ฆ }` block were declared). -- Root-level aggregation (when the repo wires it): - `./gradlew koverXmlReport` โ†’ `build/reports/kover/report.xml` - -If unsure of the output path: - -```bash -find /build -name '*.xml' -path '*kover*' -``` - -## Generating a report - -```bash -# Kover โ€” runs the module tests, then writes report.xml. Same task name -# for Kotlin-JVM and Spine `kmp-module` modules. -./gradlew ::koverXmlReport -``` - -## Reading the XML - -Kover emits the JaCoCo XML structure: `report > package > class > method`, each -with `` elements, plus `` elements carrying per-line data. - -- `` โ€” totals at each level. -- `` โ€” per source line (inside ``). - -### Gap rules - -- **Uncovered line**: `ci == 0` (and `mi > 0`). -- **Partially covered branch**: `mb > 0` (regardless of `cb`). -- **High-value targets**: methods whose `BRANCH` (or `LINE`) counter has - `missed > 0` โ€” enumerate these first in the `SKILL.md` step-4 list. - -### Non-actionable gaps (recognize and skip) - -Some lines show as uncovered but cannot gain coverage from *any* test โ€” do not -propose tests for them; report them as non-actionable: - -- **Kotlin `inline` / `inline reified` functions.** The compiler inlines the body - into every call site, so the engine credits the caller, not the definition. The - definition lines stay `ci=0` even when fully exercised. (Verified on - `base-libraries`: `parse(...)` reified overloads remained `ci=0` after a - passing round-trip test.) -- **Unreachable guards.** `require` / `check` / `error` branches the public API - cannot trigger (e.g. an invariant guaranteed by construction) โ€” the gap is real - but unclosable from outside. -- **`throw helper(...)` where `helper` always throws.** Spine's `Exceptions` - utilities (`newIllegalStateException`, `newIllegalArgumentException`, โ€ฆ) are - *declared* to return an exception but actually throw it internally. Callers - still write `throw newIllegalStateException(...)` to satisfy the compiler's - flow analysis, but control never returns to the caller's `ATHROW`. JaCoCo - attributes coverage at the line's downstream probe, which is never hit, so - the whole line shows `mi=N ci=0` even when a test exercises the catch block - and asserts on the exception's message. (Verified on `base-libraries`: - `AbstractSourceFile.java:69` and `:82` remained `mi=10 ci=0` after passing - tests that drove the `IOException` paths in `load()` and `store()` and - asserted on the wrapped message.) - -### Extracting gaps for a class - -The XML carries a `DOCTYPE` pointing at a public DTD, so always pass `--nonet` to -`xmllint` (or use the Python recipe) โ€” parsing must never reach the network. The -report has no XML namespace, so the XPath is plain. - -Note that JaCoCo (and therefore Kover with `useJacoco(...)`) puts the -`` elements under ``, not under `` โ€” the `` -element only carries summary ``s. To get the uncovered-line gaps, -query by the `` that holds the class's source, scoped to the -class's package: - -```bash -# Package of the FQN with '/' as the separator; source file is the simple -# class name plus the language suffix (.java or .kt). -xmllint --nonet \ - --xpath '//package[@name="io/spine/foo"]/sourcefile[@name="MyType.java"]/line[@ci="0" or @mb > 0]' \ - /build/reports/kover/report.xml -``` - -To confirm a method-level branch gap inside that class, query the `` -element's `` counters: - -```bash -xmllint --nonet \ - --xpath '//class[@name="io/spine/foo/MyType"]/method[counter[@type="BRANCH" and @missed>0]]/@name' \ - /build/reports/kover/report.xml -``` - -Python (robust for large reports; reads both class/method counters and -sourcefile lines): - -```python -import xml.etree.ElementTree as ET -root = ET.parse("report.xml").getroot() -for pkg in root.findall("package"): - for sf in pkg.findall("sourcefile"): - gaps = [l.get("nr") for l in sf.findall("line") - if l.get("ci") == "0" or int(l.get("mb", "0")) > 0] - if gaps: - print(pkg.get("name"), sf.get("name"), gaps) -``` - -## What is in scope - -Only human-written `src/main` code. Two filters already exclude the rest โ€” honor -both, and never count an excluded file as a gap: - -- **Kover filters** โ€” `kover { filters { excludes { โ€ฆ } } }` drops classes by - pattern. Generated paths (anything containing `generated`, including Protobuf - and `protoc-gen-kotlin` output) are excluded by convention. -- **`.codecov.yml`** โ€” `ignore` removes `**/generated/**`, `**/examples/**`, - `**/test/**`; coverage status applies only to `src/main/**`. - -## KMP / Kotlin-JVM modules - -For both Kotlin-JVM and KMP modules configured by Spine's `kmp-module` script -plugin, `koverXmlReport` is the single report task โ€” Kover only generates -`koverXmlReport` tasks when a named `variant("โ€ฆ") { โ€ฆ }` block is -declared, and `kmp-module` declares none (it configures only the `total` -report). Add tests under the module's test source set (`src/test`, or -`src/jvmTest` / `src/commonTest` for KMP) to match. - -## Verification (SKILL.md step 6) - -After generating tests, re-run `::koverXmlReport` and re-parse the XML -for the targeted class: the previously listed `nr` values should no longer be -gaps, and the method/class `BRANCH` + `LINE` counters should show `missed` -reduced. Cross-check the module total against the relevant `.codecov.yml` -`project` target so nothing regresses. - ---- - -## Appendix โ€” future: Codecov triage tier (deferred) - -v1 is local-report-only. A later iteration may add a Codecov triage tier to pick -targets across repos without a local build. If added: - -- Base `https://api.codecov.io/api/v2`; `service = github`, - `owner = SpineEventEngine`, `repo = `; auth header - `Authorization: Bearer $CODECOV_API_TOKEN`. -- Useful endpoints: per-file `totals`, line-by-line `report`, single - `file_report`, and `commits` (for trend). Filters: `path`, `flag`, - `component_id`. -- Read the per-line hit/miss/partial encoding from live JSON once โ€” do not - hardcode it; it is easy to get backwards. -- Always degrade gracefully to the local report (above) when the token is absent. - -Until that lands, do everything from the local Kover report. diff --git a/.agents/skills/raise-coverage/references/migrate-to-kover.md b/.agents/skills/raise-coverage/references/migrate-to-kover.md deleted file mode 100644 index 7550013d33..0000000000 --- a/.agents/skills/raise-coverage/references/migrate-to-kover.md +++ /dev/null @@ -1,352 +0,0 @@ -# Migrate from vanilla JaCoCo to Kover - -Mechanical recipe for the `raise-coverage` skill's Step 0. The skill detects -vanilla JaCoCo in a consumer repo, proposes the migration, waits for approval, -and then applies the edits below. The convention is **Kover Gradle plugin** with -the JaCoCo engine via `useJacoco(version = Jacoco.version)` โ€” JaCoCo-format XML -is preserved, only the Gradle plugin and task names change. - -## 1. Purpose - -Stand the target repo up on Kover so the rest of the `raise-coverage` skill can -run against a single coverage frontend. After migration, every coverage path -goes through Kover โ€” per-module `koverXmlReport`, root-level `koverXmlReport` -for aggregation, and JaCoCo-format XML at `build/reports/kover/report.xml`. - -References: - -- Kover Gradle plugin docs: -- Kover migration guide (0.6.x โ†’ 0.7+): - - -## 2. Detection signals - -Walk every Gradle module's `build.gradle.kts`. Parse `settings.gradle.kts` for -`include(...)`; honor `project(":x").projectDir = file(...)` overrides. - -For each module, grep with the patterns below. - -### Vanilla JaCoCo applied - -- Plugin block in `plugins { โ€ฆ }`: - - `^\s*jacoco\b` - - `id\("jacoco"\)` -- Imperative apply: - - `apply\(\)` - - `apply\(plugin = "jacoco"\)` -- Spine script plugins distributing JaCoCo: - - `apply\(plugin = "jacoco-` (covers `jacoco-kotlin-jvm`, `jacoco-kmm-jvm`) -- Spine multi-module aggregation helper: - - `JacocoConfig\.applyTo` - - import `io.spine.gradle.report.coverage.JacocoConfig` -- DSL blocks (configuration without explicit plugin id): - - `jacoco\s*\{` - - `jacocoTestReport\s*\{` - - `jacocoTestCoverageVerification\s*\{` - - `tasks\.named\("jacoco` -- Root-level aggregation: - - `jacocoRootReport` - -### Kover already applied (anywhere on this module) - -- Plugin id directly: - - `org.jetbrains.kotlinx.kover` -- Spine script plugins that auto-apply Kover: - - `id\("jvm-module"\)` โ€” applies Kover at `jvm-module.gradle.kts:54` - and configures it at `jvm-module.gradle.kts:99`. - - `id\("kmp-module"\)` โ€” applies Kover at `kmp-module.gradle.kts:74` - and configures it at `kmp-module.gradle.kts:181`. -- Spine multi-module Kover aggregation helper (root project only): - - `KoverConfig\.applyTo` - - import `io.spine.gradle.report.coverage.KoverConfig` - -### Outcome - -Classify each module as one of: - -| State | Action | -|---|---| -| Kover only | nothing to do | -| Kover + vanilla JaCoCo | strip JaCoCo, keep Kover (decision 4) | -| Vanilla JaCoCo only | migrate to Kover | -| Neither | silent install of Kover (no approval gate) | - -If at least one module is "vanilla JaCoCo only" or "Kover + vanilla JaCoCo", -the skill emits the migration proposal and waits. - -## 3. Per-module migration - -Apply these edits to each module's `build.gradle.kts`: - -### Add Kover - -Gradle's `plugins { }` block is a constrained DSL that accepts **literal** -plugin IDs and versions only โ€” non-literal constants from `buildSrc` are not -guaranteed to resolve there across the Gradle versions Spine targets. Use -literals; the `Kover` / `Jacoco` constants in `io.spine.dependency.test` -still source-of-truth the values you paste in. - -- If the module already applies `jvm-module` or `kmp-module`, **skip this - step** (log "already via jvm-module" / "already via kmp-module") โ€” both - script plugins auto-apply Kover. -- If `buildSrc` is on the classpath (the normal Spine consumer case), use the - bare literal โ€” `buildSrc/build.gradle.kts` pins the Kover plugin version - globally via the `koverVersion` property, so a per-module version pin is - redundant: - ```kotlin - plugins { - id("org.jetbrains.kotlinx.kover") // matches `io.spine.dependency.test.Kover.id` - } - ``` -- Without `buildSrc`, pin the version literally (substitute the current - `io.spine.dependency.test.Kover.version` value): - ```kotlin - plugins { - id("org.jetbrains.kotlinx.kover") version "0.9.8" - } - ``` - -### Strip JaCoCo - -- Remove `jacoco` from `plugins { }` (or the `id("jacoco")` line, or - `apply()`, or `apply(plugin = "jacoco")`). -- Replace `apply(plugin = "jacoco-kotlin-jvm")` / `apply(plugin = "jacoco-kmm-jvm")` - with `id("jvm-module")` / `id("kmp-module")` when that is the module's role; - otherwise drop and add `id("org.jetbrains.kotlinx.kover")` directly (the - literal value of `io.spine.dependency.test.Kover.id`; the Gradle Kotlin DSL - `plugins { }` block does not accept buildSrc constants across the Gradle - versions Spine supports). -- Rewrite `JacocoConfig.applyTo(rootProject)` (at the root build script) to - `KoverConfig.applyTo(rootProject)` and update the import to - `io.spine.gradle.report.coverage.KoverConfig`. The Kover-based helper is the - documented successor โ€” it wires the Kover plugin at the root, adds - `kover(project(...))` for every subproject that applies Kover, configures - `useJacoco(version = Jacoco.version)`, and pushes the generated-class FQNs - into both the per-module and the root `kover { reports { filters { โ€ฆ } } }` - blocks. See ยง4 (root aggregation) for the long-form equivalent if `buildSrc` - is not on the classpath. -- **Lifecycle gotcha โ€” do not call `KoverConfig.applyTo(...)` from inside - `gradle.projectsEvaluated { โ€ฆ }`.** Many Spine consumer repos wrap - `JacocoConfig.applyTo(project)` in that block; carrying the pattern over - fails with `Cannot run Project.afterEvaluate(Action) when the project is - already evaluated`, because Kover's plugin registers its own `afterEvaluate` - hooks at apply time. Lift the call to top level in the root build script. - `KoverConfig` configures the root eagerly and uses - `pluginManager.withPlugin(...)` callbacks for subprojects, so modules that - apply Kover later in the same configuration phase are still discovered - before Kover finalizes its reports. - -### Translation table - -| JaCoCo construct | Kover / action | -|---|---| -| `jacoco { toolVersion = Jacoco.version }` | drop (engine version moves to root `useJacoco(...)`) | -| `jacoco { toolVersion = "" }` | **flag** (intentional engine pin โ€” confirm Kover's `useJacoco(version = ...)` matches) | -| `reports { xml=true; html=true; csv=false }` on `jacocoTestReport` | `kover { reports { total { xml { onCheck = true }; html { } } } }` | -| `executionData.setFrom(...)` | **flag** (Kover manages exec data internally) | -| `sourceDirectories.setFrom(...)` | **flag** (Kover infers from compilations) | -| `classDirectories.setFrom(...)` โ€” the Kotlin-JVM/KMP `walkBottomUp` recipe used by `jacoco-kotlin-jvm` / `jacoco-kmm-jvm` | drop; **flag** if the module is non-Kotlin (Kover may not pick up its classes) | -| `reports.xml.outputLocation.set(...)` | **flag** (Kover fixes the path; consumers must follow) | -| `tasks.named("jacocoTestReport") { dependsOn(...) }` | rewrite to `tasks.named("koverXmlReport") { dependsOn(...) }` | -| `violationRules { rule { limit { counter; value; minimum } } }` on `jacocoTestCoverageVerification` | `kover { reports { verify { rule { โ€ฆ } } } }` โ€” counter map below | - -### Counter mapping - -JaCoCo `counter` โ†’ Kover `bound { counter = โ€ฆ }`: - -| JaCoCo | Kover | -|---|---| -| `INSTRUCTION` | `INSTRUCTION` | -| `BRANCH` | `BRANCH` | -| `LINE` | `LINE` | -| `METHOD` | `INSTRUCTION` (no direct equivalent) โ€” **flag** | -| `CLASS` | no equivalent โ€” **flag** | - -`value` maps directly (`COVEREDRATIO`, `MISSEDRATIO`, `COVEREDCOUNT`, -`MISSEDCOUNT`). `minimum` / `maximum` map directly. - -### Simplification with `jvm-module` / `kmp-module` - -If the module's role is the standard Spine JVM (or KMP) module, replace the -JaCoCo bits with `id("jvm-module")` (or `id("kmp-module")`). Both script plugins -already apply Kover and configure `useJacoco(...)` plus the XML report โ€” the -migration becomes "remove JaCoCo and let the convention plugin take over". - -## 4. Root-level aggregation - -Apply at the root only if the source repo had `jacocoRootReport` **or** has more -than one module to aggregate. Skip if the root already applies `jvm-module` -(unusual but possible). - -### Preferred โ€” `KoverConfig.applyTo(rootProject)` - -When `buildSrc` is on the classpath (the standard Spine setup), use the helper -in `io.spine.gradle.report.coverage.KoverConfig`. It applies Kover at the root, -adds a `kover(project(...))` dependency for every subproject that applies -Kover, configures `useJacoco(version = Jacoco.version)`, and excludes classes -compiled from `generated/` source directories from both per-module and root -reports. - -```kotlin -// Root build.gradle.kts -import io.spine.gradle.report.coverage.KoverConfig - -KoverConfig.applyTo(rootProject) -``` - -This is the documented successor to `JacocoConfig.applyTo(rootProject)` and is -what the skill writes when migrating consumer repos. - -### Long-form โ€” when `buildSrc` is not available - -The `Kover` and `Jacoco` constants live in `buildSrc/.../io/spine/dependency/test/` -and are unreachable when this fallback applies. Paste the literal values -(substitute the current `Kover.version` / `Jacoco.version`): - -```kotlin -// Root build.gradle.kts -plugins { - id("org.jetbrains.kotlinx.kover") version "0.9.8" -} - -dependencies { - kover(project(":foo")) - kover(project(":bar")) - // โ€ฆ one entry per consuming module -} - -kover { - useJacoco(version = "0.8.14") - reports { - total { - xml { onCheck = true } - html { } - } - } -} -``` - -Note: the long-form variant does **not** exclude generated code automatically. -Either also apply `KoverConfig.applyTo(rootProject)` (preferred, but requires -`buildSrc`), or push your own exclusion patterns into -`kover { reports { filters { excludes { classes(โ€ฆ) } } } }` at the root and in -each subproject. - -If the source repo had a root-level `jacocoTestCoverageVerification` -(`violationRules`), mirror its `rule { limit { โ€ฆ } }` blocks to -`kover { reports { verify { rule { bound { โ€ฆ } } } } }` at the root using the -counter mapping above. Do **not** add root-level rules when the source repo had -none. - -## 5. CI, `.codecov.yml`, scripts โ€” substitutions - -Apply globally (preserve case in surrounding tokens): - -| Old | New | -|---|---| -| `jacocoTestReport` | `koverXmlReport` | -| `jacocoRootReport` | `koverXmlReport` (root) | -| `build/reports/jacoco/test/jacocoTestReport.xml` | `build/reports/kover/report.xml` | -| `build/reports/jacoco/jacocoRootReport/jacocoRootReport.xml` | `build/reports/kover/report.xml` | - -### `.github/workflows/*.yml` - -Substitute task and path tokens as above. If a step uploads the JaCoCo XML to -Codecov, update the `files:` glob to `**/build/reports/kover/report.xml`. - -### `.codecov.yml` - -Substitute path tokens as above. Preserve `ignore:` patterns and the -`coverage.status` block verbatim โ€” Codecov only cares about the report path and -the source layout, both of which Kover preserves under `useJacoco(...)`. - -### `scripts/*.sh` - -Substitute task and path tokens. **Flag** any script that reads raw `.exec` -files (e.g. `build/jacoco/test.exec`) or globs `build/jacoco*` directories โ€” -Kover does not expose them; the script either needs to switch to the XML report -under `build/reports/kover/` or be retired. - -## 6. KMP recipe (JVM target only) - -Per decision 5, only the JVM target migrates. Non-JVM targets are out of scope. - -- Apply `id("org.jetbrains.kotlinx.kover")` (literal; the Gradle Kotlin DSL - `plugins { }` block does not accept the buildSrc `Kover.id` constant). Or - use `kmp-module`, which applies Kover automatically. -- Use Kover's default report task and XML: - - Task: `::koverXmlReport` - - XML: `/build/reports/kover/report.xml` - - When the `kover { reports { total { โ€ฆ } } }` block is the only report - configured (as in `kmp-module.gradle.kts:181-190`), Kover does **not** - generate a separate `koverXmlReport` task per target โ€” the - `total` report aggregates every Kotlin variant the module declares, and - because Spine only migrates the JVM target the aggregate is JVM-shaped. - A `koverXmlReportJvm` task only exists when a named `variant("jvm") { โ€ฆ }` - block is added explicitly, which `kmp-module` does not do. -- Configuration block at module scope: - ```kotlin - kover { - useJacoco(version = "0.8.14") // matches `io.spine.dependency.test.Jacoco.version` - reports { - total { - xml { onCheck = true } - } - } - } - ``` - (`kmp-module.gradle.kts:181-190` already has the right shape.) -- CI / `.codecov.yml` use `koverXmlReport` and - `build/reports/kover/report.xml`, same as for a Kotlin-JVM module. - -## 7. Manual-review surfaces - -These show up during detection and translation. **Flag** them in the proposal -and ask the user to decide before applying: - -- **Custom `sourceDirectories` / `classDirectories`** on `jacocoTestReport` โ€” - the `walkBottomUp` recipe used by `jacoco-*-jvm.gradle.kts`. Safe to drop for - standard Kotlin-JVM / KMP layouts; ask if the module is non-Kotlin or has - unusual source roots. -- **Custom `reports.xml.destination` / `outputLocation`** โ€” Kover writes to a - fixed path; CI consumers must follow. -- **Custom `executionData` paths** โ€” Kover manages exec data internally; flag - if anything else (e.g. a coverage uploader) reads them directly. -- **Indirect `jacoco.toolVersion`** โ€” a Gradle property - (`gradle.properties`, `-PjacocoVersion=โ€ฆ`) or convention plugin pinning a - non-`Jacoco.version` engine. Decide which version `useJacoco(version = โ€ฆ)` - should match. -- **Multi-pipeline setups** where both vanilla JaCoCo and Kover are intentional - (e.g. publishing two different reports for two consumers). Per decision 4 the - default is to strip JaCoCo, but confirm. -- **`JacocoConfig.applyTo(rootProject)` in a consumer repo** โ€” rewrite to - `KoverConfig.applyTo(rootProject)` (ยง3, *Strip JaCoCo*). The Kover helper - preserves the generated-code exclusion that `JacocoConfig` provided. Do - **not** simply delete the call โ€” that would silently drop the exclusion and - cause generated code to appear as uncovered in reports. -- **Custom convention plugins** applying JaCoCo under a name other than - `jacoco-โ€ฆ` โ€” will be missed by the script-plugin detection in ยง2. Inspect - any `buildSrc/src/main/kotlin/*.gradle.kts` that imports `jacoco`. -- **Non-JVM KMP targets** (decision 5 โ€” out of scope). Surface them so the user - knows their coverage is not migrated. -- **`dependsOn("jacocoTestReport")` from Groovy or external sources** โ€” the - translation table rewrites Kotlin-script references; Groovy or external - callers may still reach for the old task name. - -## 8. References - -- Kover Gradle plugin: -- Kover 0.7 migration guide: - -- Kover DSL reference (verify / reports / filters): - -- JaCoCo XML schema (engine, preserved under `useJacoco(...)`): - -- Spine convention sources: - - `buildSrc/src/main/kotlin/jvm-module.gradle.kts` (Kover applied at L54, - configured at L99) - - `buildSrc/src/main/kotlin/kmp-module.gradle.kts` (Kover applied at L74, - configured at L181) - - `buildSrc/src/main/kotlin/io/spine/dependency/test/Kover.kt` - - `buildSrc/src/main/kotlin/io/spine/dependency/test/Jacoco.kt` diff --git a/.agents/skills/review-docs/SKILL.md b/.agents/skills/review-docs/SKILL.md deleted file mode 100644 index 41cef810f6..0000000000 --- a/.agents/skills/review-docs/SKILL.md +++ /dev/null @@ -1,149 +0,0 @@ ---- -name: review-docs -description: > - Review documentation changes โ€” KDoc/Javadoc inside Kotlin/Java sources and - Markdown docs (`README.md`, `docs/**`) โ€” against Spine documentation - conventions. Use when a diff touches doc comments or Markdown, before - opening a doc-affecting PR, or when asked for a documentation review. - Read-only; does not run builds. ---- - -# Review documentation (repo-specific) - -You are the documentation reviewer for a Spine Event Engine project. You -focus strictly on documentation quality โ€” prose, KDoc/Javadoc, and Markdown โ€” -and deliberately do **not** duplicate the code-review skill (which owns -Kotlin idioms, safety rules, tests, and version-gate checks). - -The authoritative standards live in `.agents/`: - -- `.agents/documentation-guidelines.md` โ€” commenting rules, TODO-comment - format, "file/dir names as code", widow/runt/orphan/river rule (with the - diagram at `.agents/widow-runt-orphan.jpg`). -- `.agents/documentation-tasks.md` โ€” KDoc-example requirement on APIs; - Javadoc โ†’ KDoc conversion rules (`

` removal, etc.). -- `.agents/skills/writer/SKILL.md` โ€” Markdown conventions (footnote-style - reference links for external URLs, typographic quotes only on actual - page/section titles, sidenav-sync rules under `docs/`). -- `.agents/running-builds.md` โ€” for doc-only Kotlin/Java changes the right - build is `./gradlew dokka` (no tests required). - -## Review procedure - -1. **Scope the diff.** Obtain the change set via `git diff --staged` or - `git diff ...HEAD` depending on what the user describes. Restrict - to files matching: - - `**/*.kt`, `**/*.kts`, `**/*.java` (for KDoc/Javadoc inside sources) - - `**/*.proto` (for file-level documentation headers) - - `**/*.md` (Markdown docs) - Do **not** review the full repo โ€” only what changed. - Apply the `AGENTS.md ยง Code review` filter with repository awareness: - - Detect the `config` repository by scanning `git remote -v` for any URL - matching `[:/]SpineEventEngine/config(\.git)?$`. - - In **`config` itself**, skip only `gradlew` and `gradlew.bat`; every other - config-distributed path is owned by this repo and stays in scope. - - In any **consumer repo**, skip the full config-distributed list. If - nothing remains after filtering, return - `APPROVE โ€” all changes are config-distributed files.` and stop. - -2. **Read each affected file fully, not just the hunks.** Prose review - requires surrounding context โ€” judging widows/runts/orphans, link - placement, and KDoc completeness needs the whole paragraph and the - surrounding declarations. - -3. **Stay in scope.** If you spot a code-quality issue (idiom, naming, - tests, version-gate applicability), note it briefly as a "for the code - reviewer" item under Nits โ€” do not expand the review. - -## Checks - -### A. KDoc / Javadoc inside sources - -- **Public and internal APIs carry KDoc.** Per `documentation-tasks.md`, - KDoc should include at least one usage example for non-trivial APIs. - Missing KDoc on a new or modified public/internal symbol is a Should-fix. -- **No Javadoc residue in Kotlin.** When converting from Java: - - `

` tags on a text line removed (`"

This"` โ†’ `"This"`). - - `

` on its own line replaced with a blank line. - - HTML entities (`&`, `<`, โ€ฆ) converted to literals where appropriate. -- **Inline comments in production code are minimized.** Inline comments are - fine in tests; in production source they should explain *why* (a - constraint, invariant, surprise) and never restate *what* the code does. -- **TODO comments follow the Spine format.** Linked from - `documentation-guidelines.md` to the wiki "TODO-comments" page. A bare - `// TODO: โ€ฆ` without owner/issue reference is a Should-fix. -- **File and directory names rendered as code.** Within KDoc/Javadoc prose, - `path/to/file.kt` and `module-name` must use backticks. -- **No repository-internal references in API docs.** KDoc and Javadoc must - not mention `buildSrc/`, the `config` repository or its `config/buildSrc/`, - or any path under `.agents/` (task plans, skill rules, conventions, โ€ฆ). - These details are invisible to consumers of the published artifact and - rot quickly. Cross-repository parity notes and work-in-progress - justifications belong in `.agents/tasks/`, not in the API docs. A mention - in newly-added or modified KDoc/Javadoc is a Should-fix; summarise the - *outcome* in the doc instead. -- **Multi-paragraph Protobuf headers end with an empty comment line.** In - `.proto` files, if the file-level documentation header has more than one - paragraph, it must end with a trailing empty comment line (`//`). - -### B. Markdown docs - -- **Footnote-style reference links** for external `https://` URLs (per the - `writer` skill). Inline `[label](https://โ€ฆ)` in body prose is a - Should-fix; inline links to local relative paths are fine. -- **Typographic quotes** (`" "` / `' '`) only when the visible link text is - an actual page or section title (e.g., the "Getting started" page). - Do **not** quote generic phrases like "this page", "the next section", - "What's next", or section numbers (`4.3`). -- **Sidenav sync.** If the diff adds/removes/renames/moves a page under - `docs/content/docs/

/`, the matching current-version - `sidenav.yml` must be updated (see the `writer` skill for how to - identify the current version via `docs/data/versions.yml`). A missing - sidenav update is a Must-fix. -- **Fenced code blocks** for commands and examples โ€” no indented code - blocks for shell snippets (they swallow `$` prompts and hurt copy/paste). -- **Heading hierarchy.** No skipped levels (`#` โ†’ `###`); exactly one `#` - per file. - -### C. Prose flow (Spine-specific) - -- **Avoid widows, runts, orphans, and rivers** โ€” the rule from - `documentation-guidelines.md` with the diagram at - `.agents/widow-runt-orphan.jpg`. Operationally: - - **Widow / runt**: a paragraph's last line containing only one short - word (or a hyphenated fragment). Reflow the prior line. - - **Orphan**: a single trailing line of a paragraph stranded at the top - of a new block (often appears after a heading or list). Reflow. - - **River**: a vertical "gap" of aligned spaces running down justified - text. Rare in Markdown but possible in tables โ€” reflow the table or - rewrite to break the alignment. - Quote the offending paragraph and propose a rewording that fixes it. - -### D. Terminology and tone - -- **Match code identifiers verbatim.** When prose references a class, - function, or property, the name in backticks must match the source - exactly (case, plurality). -- **Consistent terminology across the diff.** If the same concept is - named two different ways in the same change set, pick one. - -## Output format - -Three sections, in this order: - -- **Must fix** โ€” broken/missing KDoc on a newly-introduced public API, - missing sidenav sync, broken cross-references, Javadoc residue - (`

` tags) left in Kotlin KDoc, broken Markdown links. -- **Should fix** โ€” TODO format, inline-comment overuse in production, - inline external links that should be footnote-style, missing typographic - quotes (or unwanted ones), widow/runt/orphan/river paragraphs, - fenced-vs-indented code blocks. -- **Nits** โ€” wording, terminology drift, code-identifier capitalization - in prose, "for the code reviewer" pointers if any code issues surfaced - incidentally. - -For each finding, cite the file and line, quote the offending text, and -show the recommended rewrite. If a section is empty, write "None." - -End with a one-line verdict: `APPROVE`, `APPROVE WITH CHANGES`, or -`REQUEST CHANGES`. diff --git a/.agents/skills/review-docs/agents/openai.yaml b/.agents/skills/review-docs/agents/openai.yaml deleted file mode 100644 index 672388c445..0000000000 --- a/.agents/skills/review-docs/agents/openai.yaml +++ /dev/null @@ -1,4 +0,0 @@ -interface: - display_name: "Review Docs" - short_description: "Review KDoc and Markdown changes" - default_prompt: "Use $review-docs to review KDoc, Javadoc, Protobuf comments, and Markdown changes against Spine documentation conventions." diff --git a/.agents/skills/update-copyright/SKILL.md b/.agents/skills/update-copyright/SKILL.md deleted file mode 100644 index 604ba30dda..0000000000 --- a/.agents/skills/update-copyright/SKILL.md +++ /dev/null @@ -1,24 +0,0 @@ ---- -name: update-copyright -description: > - Update source file copyright headers from the IntelliJ IDEA copyright profile, - replacing `today.year` with the current year. - Automatically apply to changed source files when source files are modified - in a change set. ---- - -# Copyright Update - -**Command:** `python3 .agents/skills/update-copyright/scripts/update_copyright.py` - -1. Scope: - - Automatic follow-up after edits: collect the source files modified by the - current change set and pass those paths explicitly. Do not run the command - without paths in the automatic path. If no changed source files remain - after filtering, skip the command. - - User-provided files/dirs: pass the requested paths explicitly. - - Repo-wide refresh: use no explicit paths only when the user directly asks - to update all tracked source files. -2. Repo-wide refresh โ†’ run with `--dry-run` first, then without. -3. Relay stdout (notice source, file count, changed paths) to the user. -4. Never add a copyright header to a file that does not already have one. diff --git a/.agents/skills/update-copyright/agents/openai.yaml b/.agents/skills/update-copyright/agents/openai.yaml deleted file mode 100644 index a56f8ab810..0000000000 --- a/.agents/skills/update-copyright/agents/openai.yaml +++ /dev/null @@ -1,4 +0,0 @@ -interface: - display_name: "Copyright Update" - short_description: "Refresh source copyright headers" - default_prompt: "Use $update-copyright to refresh copyright headers for changed source files from the IntelliJ IDEA copyright profile." diff --git a/.agents/skills/update-copyright/scripts/update_copyright.py b/.agents/skills/update-copyright/scripts/update_copyright.py deleted file mode 100755 index 2dbf8bbc48..0000000000 --- a/.agents/skills/update-copyright/scripts/update_copyright.py +++ /dev/null @@ -1,389 +0,0 @@ -#!/usr/bin/env python3 -"""Update source copyright headers from IntelliJ IDEA copyright profiles.""" - -from __future__ import annotations - -import argparse -import datetime as dt -import html -import re -import subprocess -import sys -from pathlib import Path -from xml.etree import ElementTree as ET - - -BLOCK_EXTENSIONS = { - ".c", - ".cc", - ".cpp", - ".cs", - ".css", - ".cxx", - ".dart", - ".go", - ".gradle", - ".groovy", - ".h", - ".hh", - ".hpp", - ".java", - ".js", - ".jsx", - ".kt", - ".kts", - ".less", - ".m", - ".mm", - ".proto", - ".rs", - ".scala", - ".scss", - ".swift", - ".ts", - ".tsx", -} -HASH_EXTENSIONS = { - ".bash", - ".bzl", - ".properties", - ".pl", - ".py", - ".rb", - ".sh", - ".toml", - ".yaml", - ".yml", - ".zsh", -} -XML_EXTENSIONS = { - ".fxml", - ".pom", - ".wsdl", - ".xml", - ".xsd", - ".xsl", - ".xslt", -} -EXCLUDED_DIRS = { - ".agents", - ".git", - ".gradle", - ".idea", - ".kotlin", - "build", - "generated", - "out", - "tmp", -} -EXCLUDED_FILES = { - "gradlew", - "gradlew.bat", -} - - -def parse_args() -> argparse.Namespace: - parser = argparse.ArgumentParser( - description=( - "Update source copyright headers from " - ".idea/copyright/profiles_settings.xml." - ) - ) - parser.add_argument( - "paths", - nargs="*", - help="Files or directories to update. Defaults to tracked source files.", - ) - parser.add_argument( - "--root", - type=Path, - default=Path.cwd(), - help="Repository root. Defaults to the current working directory.", - ) - parser.add_argument( - "--year", - default=str(dt.date.today().year), - help="Year to substitute for today.year. Defaults to the current year.", - ) - parser.add_argument( - "--dry-run", - action="store_true", - help="Report files that would change without writing them.", - ) - parser.add_argument( - "--check", - action="store_true", - help="Exit with status 1 if any file would change; do not write files.", - ) - return parser.parse_args() - - -def profile_filename(profile_name: str) -> str: - stem = re.sub(r"[^A-Za-z0-9]+", "_", profile_name).strip("_") - if not stem: - raise ValueError("The default copyright profile name is empty.") - return f"{stem}.xml" - - -def load_notice(root: Path, year: str) -> tuple[str, Path]: - settings_path = root / ".idea" / "copyright" / "profiles_settings.xml" - if not settings_path.is_file(): - raise FileNotFoundError(f"Missing {settings_path}") - - settings_root = ET.parse(settings_path).getroot() - settings = settings_root.find(".//settings") - if settings is None: - raise ValueError(f"{settings_path} does not contain a settings tag.") - - default_profile = settings.get("default") - if not default_profile: - raise ValueError(f"{settings_path} settings tag has no default attribute.") - - profile_path = settings_path.parent / profile_filename(default_profile) - if not profile_path.is_file(): - raise FileNotFoundError( - f"Default profile {default_profile!r} resolves to missing {profile_path}" - ) - - profile_root = ET.parse(profile_path).getroot() - notice = None - for option in profile_root.findall(".//option"): - if option.get("name") == "notice": - notice = option.get("value") - break - if notice is None: - raise ValueError(f"{profile_path} has no option named 'notice'.") - - decoded = html.unescape(notice) - decoded = decoded.replace("${today.year}", year) - decoded = decoded.replace("$today.year", year) - decoded = decoded.replace("today.year", year) - return decoded.rstrip(), profile_path - - -def style_for(path: Path) -> str | None: - name = path.name - suffix = path.suffix.lower() - if name.endswith((".sh.template", ".bash.template", ".zsh.template")): - return "hash" - if suffix in BLOCK_EXTENSIONS: - return "block" - if suffix in HASH_EXTENSIONS: - return "hash" - if suffix in XML_EXTENSIONS: - return "xml" - return None - - -def is_excluded(path: Path) -> bool: - if path.name in EXCLUDED_FILES: - return True - parts = path.parts - if len(parts) >= 2 and parts[0] == "gradle" and parts[1] == "wrapper": - return True - return any(part in EXCLUDED_DIRS for part in parts) - - -def tracked_files(root: Path) -> list[Path]: - try: - result = subprocess.run( - ["git", "-C", str(root), "ls-files", "-z"], - check=True, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - ) - except (FileNotFoundError, subprocess.CalledProcessError): - return [ - path.relative_to(root) - for path in root.rglob("*") - if path.is_file() and not is_excluded(path.relative_to(root)) - ] - - paths = [] - for item in result.stdout.decode("utf-8").split("\0"): - if not item: - continue - path = Path(item) - if (root / path).is_file(): - paths.append(path) - return paths - - -def expand_requested_paths(root: Path, requested: list[str]) -> list[Path]: - if not requested: - paths = tracked_files(root) - else: - paths = [] - for item in requested: - path = (root / item).resolve() - if not path.exists(): - raise FileNotFoundError(f"Path does not exist: {item}") - if not path.is_relative_to(root): - raise ValueError( - f"Path is outside the repository root: {item!r} " - f"(resolved to {path}, root is {root})" - ) - if path.is_dir(): - for child in path.rglob("*"): - if child.is_file(): - paths.append(child.relative_to(root)) - else: - paths.append(path.relative_to(root)) - - unique = sorted(set(paths), key=lambda p: p.as_posix()) - return [ - path - for path in unique - if style_for(path) is not None and not is_excluded(path) - ] - - -def newline_for(text: str) -> str: - return "\r\n" if "\r\n" in text else "\n" - - -def build_header(notice: str, style: str, newline: str) -> str: - lines = notice.splitlines() - if style == "block": - body = newline.join(f" * {line}" if line else " *" for line in lines) - return f"/*{newline}{body}{newline} */{newline}{newline}" - if style == "hash": - body = newline.join(f"# {line}" if line else "#" for line in lines) - return f"{body}{newline}{newline}" - if style == "xml": - body = newline.join(f" ~ {line}" if line else " ~" for line in lines) - return f"{newline}{newline}" - raise ValueError(f"Unsupported comment style: {style}") - - -def split_leading_directive(text: str, style: str, newline: str) -> tuple[str, str]: - if style == "hash" and text.startswith("#!"): - line_end = text.find("\n") - if line_end == -1: - return text + newline + newline, "" - prefix = text[: line_end + 1] + newline - return prefix, strip_leading_blank_lines(text[line_end + 1 :]) - - if style == "xml" and text.startswith("") - if close != -1: - line_end = text.find("\n", close) - if line_end == -1: - return text + newline + newline, "" - prefix = text[: line_end + 1] + newline - return prefix, strip_leading_blank_lines(text[line_end + 1 :]) - - return "", strip_leading_blank_lines(text) - - -def strip_leading_blank_lines(text: str) -> str: - return re.sub(r"^(?:[ \t]*\r?\n)+", "", text) - - -def strip_existing_header(text: str, style: str) -> tuple[str, bool]: - if style == "block" and text.startswith("/*"): - close = text.find("*/") - if close != -1: - candidate = text[: close + 2] - if is_copyright_header(candidate): - return strip_leading_blank_lines(text[close + 2 :]), True - - if style == "xml" and text.startswith("") - if close != -1: - candidate = text[: close + 3] - if is_copyright_header(candidate): - return strip_leading_blank_lines(text[close + 3 :]), True - - if style == "hash": - lines = text.splitlines(keepends=True) - end = 0 - for line in lines: - stripped = line.strip() - if stripped == "" or stripped.startswith("#"): - end += len(line) - continue - break - candidate = text[:end] - if candidate and is_copyright_header(candidate): - return strip_leading_blank_lines(text[end:]), True - - return text, False - - -def is_copyright_header(text: str) -> bool: - limited = text[:5000] - return "Copyright" in limited and ( - "Licensed under" in limited or "All rights reserved" in limited - ) - - -def updated_text(text: str, notice: str, style: str) -> str: - original = text - bom = "\ufeff" if text.startswith("\ufeff") else "" - if bom: - text = text[1:] - newline = newline_for(text) - prefix, body = split_leading_directive(text, style, newline) - body, had_header = strip_existing_header(body, style) - if not had_header: - return original - return bom + prefix + build_header(notice, style, newline) + body - - -def update_file(root: Path, path: Path, notice: str, dry_run: bool) -> bool: - absolute = root / path - style = style_for(path) - if style is None: - return False - - try: - text = absolute.read_text(encoding="utf-8") - except FileNotFoundError: - print(f"Skipping missing file: {path}", file=sys.stderr) - return False - except UnicodeDecodeError: - print(f"Skipping non-UTF-8 file: {path}", file=sys.stderr) - return False - - next_text = updated_text(text, notice, style) - if next_text == text: - return False - - if not dry_run: - with absolute.open("w", encoding="utf-8", newline="") as file: - file.write(next_text) - return True - - -def main() -> int: - args = parse_args() - root = args.root.resolve() - notice, profile_path = load_notice(root, args.year) - try: - paths = expand_requested_paths(root, args.paths) - except (FileNotFoundError, ValueError) as exc: - print(f"error: {exc}", file=sys.stderr) - return 2 - dry_run = args.dry_run or args.check - - changed = [ - path - for path in paths - if update_file(root, path, notice, dry_run=dry_run) - ] - - rel_profile = profile_path.relative_to(root) - action = "Would update" if dry_run else "Updated" - print(f"Notice source: {rel_profile}") - print(f"{action} {len(changed)} file(s).") - for path in changed: - print(path.as_posix()) - - if args.check and changed: - return 1 - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/.agents/skills/update-copyright/tests/test_update_copyright.py b/.agents/skills/update-copyright/tests/test_update_copyright.py deleted file mode 100644 index 8770b3275e..0000000000 --- a/.agents/skills/update-copyright/tests/test_update_copyright.py +++ /dev/null @@ -1,130 +0,0 @@ -from __future__ import annotations - -import subprocess -import sys -import tempfile -import unittest -from pathlib import Path - - -SCRIPT = Path(__file__).resolve().parents[1] / "scripts" / "update_copyright.py" - - -class UpdateCopyrightTest(unittest.TestCase): - def test_default_run_leaves_plain_source_without_header_unchanged(self) -> None: - with tempfile.TemporaryDirectory() as temp_dir: - root = Path(temp_dir) - self.write_profile(root) - source = root / "Foo.java" - original = "class Foo {}\n" - source.write_text(original, encoding="utf-8") - - subprocess.run(["git", "init", "-q"], cwd=root, check=True) - subprocess.run(["git", "add", "Foo.java"], cwd=root, check=True) - - result = self.run_script(root) - - self.assertEqual(result.returncode, 0, result.stderr) - self.assertIn("Updated 0 file(s).", result.stdout) - self.assertEqual(result.stderr, "") - self.assertEqual(source.read_text(encoding="utf-8"), original) - - def test_existing_header_is_updated(self) -> None: - with tempfile.TemporaryDirectory() as temp_dir: - root = Path(temp_dir) - self.write_profile(root) - source = root / "Foo.java" - source.write_text( - "/*\n" - " * Copyright 2024 ACME\n" - " * All rights reserved\n" - " */\n" - "\n" - "class Foo {}\n", - encoding="utf-8", - ) - - result = self.run_script(root, "--year", "2026", "Foo.java") - - self.assertEqual(result.returncode, 0, result.stderr) - self.assertIn("Updated 1 file(s).", result.stdout) - self.assertIn("Foo.java", result.stdout) - self.assertEqual(result.stderr, "") - self.assertEqual( - source.read_text(encoding="utf-8"), - "/*\n" - " * Copyright 2026 ACME\n" - " * All rights reserved\n" - " */\n" - "\n" - "class Foo {}\n", - ) - - def test_default_run_skips_tracked_files_deleted_from_working_tree(self) -> None: - with tempfile.TemporaryDirectory() as temp_dir: - root = Path(temp_dir) - self.write_profile(root) - source = root / "Foo.java" - source.write_text("class Foo {}\n", encoding="utf-8") - - subprocess.run(["git", "init", "-q"], cwd=root, check=True) - subprocess.run(["git", "add", "Foo.java"], cwd=root, check=True) - source.unlink() - - result = subprocess.run( - [ - sys.executable, - str(SCRIPT), - "--root", - str(root), - "--dry-run", - ], - check=False, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True, - ) - - self.assertEqual(result.returncode, 0, result.stderr) - self.assertIn("Would update 0 file(s).", result.stdout) - self.assertEqual(result.stderr, "") - - @staticmethod - def run_script(root: Path, *args: str) -> subprocess.CompletedProcess[str]: - return subprocess.run( - [ - sys.executable, - str(SCRIPT), - "--root", - str(root), - *args, - ], - check=False, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True, - ) - - @staticmethod - def write_profile(root: Path) -> None: - copyright_dir = root / ".idea" / "copyright" - copyright_dir.mkdir(parents=True) - (copyright_dir / "profiles_settings.xml").write_text( - '' - '' - "\n", - encoding="utf-8", - ) - (copyright_dir / "Default.xml").write_text( - '' - "" - '" - "\n", - encoding="utf-8", - ) - - -if __name__ == "__main__": - unittest.main() diff --git a/.agents/skills/version-bumped/SKILL.md b/.agents/skills/version-bumped/SKILL.md deleted file mode 100644 index 8f71383235..0000000000 --- a/.agents/skills/version-bumped/SKILL.md +++ /dev/null @@ -1,99 +0,0 @@ ---- -name: version-bumped -description: > - Verify the current branch has bumped `version.gradle.kts` strictly above - the base ref; run the `bump-version` skill to auto-recover if not. Composable: - other modifying skills (`dependency-update`, `bump-gradle`, - `java-to-kotlin`, `move-files`) call this as their final step so a - `./gradlew build` or `publishToMavenLocal` can never overwrite a - previously published Maven Local artifact that integration tests in - consumer repos depend on. ---- - -# Ensure version is bumped - -This skill is the agent-facing wrapper around -`.agents/skills/version-bumped/scripts/version-bumped.sh`. The script is the source of truth for -"has this branch advanced the version vs base?"; this skill just runs it -and, if it fails, runs the `bump-version` skill and re-runs to confirm. - -The same logic is enforced as a hook -(`.agents/scripts/publish-version-gate.sh`) that fires before any -`./gradlew โ€ฆ (build|publish|publishToMavenLocal)` invocation, so even -direct gradle calls cannot bypass it. This skill exists for the -cooperative path โ€” other skills calling it before they finish, so the -user is never surprised by a blocked gradle command later. - -The premise is simple: any feature branch is a candidate for publishing, -even when the only change is the version bump itself (sometimes the bump -is the entire change, used to retry a publish that failed because Maven -repositories were overloaded). So if the branch differs from base at all, -the version must advance. - -## When to use - -- Automatically: as the final step of any skill that may change files on - the branch. -- Manually (`version-bumped`): before running `./gradlew build` or - `./gradlew publishToMavenLocal` on a feature branch when you are not - sure whether the version has already been bumped. - -## Procedure - -1. Run the deterministic check: - - ```bash - .agents/skills/version-bumped/scripts/version-bumped.sh - ``` - - Honor `VERSION_BUMPED_BASE` if the user has set a non-default base ref - (e.g. `origin/master`, or a release branch). - -2. Interpret the exit code: - - - **0** โ€” Done. Either the repository has no root `version.gradle.kts` - (the version check is `N/A`), the branch has no diff vs base, or the - version is already strictly greater. Report a one-line confirmation - and stop. - - **1** โ€” Block. The script's stderr explains which check failed. - Proceed to step 3. - - **2** โ€” Configuration error (no merge-base, parse failure on - `version.gradle.kts`). Do **not** run the `bump-version` skill - automatically. Surface the script's stderr to the user and stop. - -3. On exit 1, run the `bump-version` skill to perform the actual bump. That - skill owns the policy (snapshot numbering, the commit subject, the - rebuild, dependency-report regeneration, and the conflict rule). Do - not duplicate its work here. - -4. After `bump-version` finishes, re-run the deterministic check. If it - now passes, report the new version on the branch. If it still fails, - surface the stderr unchanged and stop โ€” do not loop. - -## Why this skill is separate from `bump-version` - -`bump-version` is the **action** (it edits `version.gradle.kts`, -commits, rebuilds, may commit reports). `version-bumped` is the -**guard** (read-only check, optional auto-recovery). Skills that want to -say "make sure the branch has a bumped version" should call -`version-bumped`, not `bump-version`, because the guard is a no-op when -the bump is already done โ€” calling `bump-version` unconditionally would -double-bump on every chained skill invocation. - -## Relationship to `checkVersionIncrement` - -The Gradle task `checkVersionIncrement` (in `buildSrc/.../publish/`) -asks a different question: *"is this version already in the remote -Maven metadata?"* It runs on GitHub Actions feature-branch pushes and -fetches the Spine SDK Artifact Registry. The two checks are -complementary โ€” neither subsumes the other. - -## See also - -- `.agents/version-policy.md` โ€” when the version gate applies. -- `.agents/skills/bump-version/SKILL.md` โ€” the bump procedure itself. -- `.agents/skills/pre-pr/SKILL.md` โ€” uses the same check at PR time - (step 2). -- `.agents/skills/version-bumped/scripts/version-bumped.sh` โ€” the deterministic check. -- `.agents/scripts/publish-version-gate.sh` โ€” the hook that enforces the - rule on `./gradlew` invocations. diff --git a/.agents/skills/version-bumped/agents/openai.yaml b/.agents/skills/version-bumped/agents/openai.yaml deleted file mode 100644 index 7b39a0fedc..0000000000 --- a/.agents/skills/version-bumped/agents/openai.yaml +++ /dev/null @@ -1,4 +0,0 @@ -interface: - display_name: "Version Bumped" - short_description: "Ensure branch version was bumped" - default_prompt: "Use $version-bumped to verify the branch has advanced version.gradle.kts versus the base ref, auto-recovering through $bump-version when applicable." diff --git a/.agents/skills/version-bumped/scripts/version-bumped.sh b/.agents/skills/version-bumped/scripts/version-bumped.sh deleted file mode 100755 index f050a5b79d..0000000000 --- a/.agents/skills/version-bumped/scripts/version-bumped.sh +++ /dev/null @@ -1,276 +0,0 @@ -#!/usr/bin/env bash -# -# Verifies that a feature branch which differs from the base ref also -# bumps `version.gradle.kts` strictly above the base version. Mirrors the -# universal "every branch advances the version" policy: a branch with any -# changes is a candidate for publishing โ€” sometimes the only change is the -# bump itself, used to retry a publish that failed because Maven -# repositories were overloaded. -# -# Exit codes: -# 0 โ€” OK: repo has no root `version.gradle.kts`, OR branch has no diff -# vs base, OR working-tree version is strictly greater than base -# version. -# 1 โ€” Block: branch differs from base but version is unchanged or -# decreased. Stderr points to `/bump-version`. -# 2 โ€” Configuration error (bad base ref, parse failure). Stderr explains. -# -# Inputs (env, all optional): -# VERSION_BUMPED_BASE Base ref to compare against. Default: master, -# then main if master is absent. -# VERSION_BUMPED_KEY Name of the `extra` property holding the -# publishing version (e.g. `versionToPublish`, -# `validationVersion`, `bootstrapVersion`). When -# set, bypasses auto-discovery. Useful for repos -# that don't follow the `version = extra["โ€ฆ"]` -# pattern in `build.gradle.kts`. -# VERSION_BUMPED_QUIET When `1`, suppress the "OK" line on stdout. -# The publish-version-gate hook sets this. -# -# Publishing-key discovery: -# The publishing version's variable name varies across Spine repos -# (`versionToPublish`, `validationVersion`, `compilerVersion`, โ€ฆ). -# `version.gradle.kts` may also declare other `val xxxVersion by extra(...)` entries -# that are *dependency* versions of other Spine modules โ€” not this -# project's own publishing version โ€” so the key cannot be picked by -# inspecting `version.gradle.kts` alone. -# -# The canonical source is `build.gradle.kts`, which assigns -# `version = extra["KEY"]!!`. This script scans for that pattern, -# picks the unique key, and parses its value from `version.gradle.kts`. -# If `build.gradle.kts` does not contain such a line, the script falls -# back to `versionToPublish`. Set `VERSION_BUMPED_KEY` to override. -# -# Notes: -# * Companion to the Gradle task `checkVersionIncrement` (see -# `buildSrc/.../publish/CheckVersionIncrement.kt`). The Gradle task -# asks "is this version already in remote Maven metadata?" โ€” this -# script asks the simpler local question "has this branch advanced -# the version vs base?". The two checks are complementary; neither -# subsumes the other. -# * The working tree is included in the change-detection so the gate -# reflects what `./gradlew build` would actually publish. -# -set -eu - -repo_root=$(git rev-parse --show-toplevel 2>/dev/null) || { - echo "version-bumped: not inside a git repository" >&2 - exit 2 -} -cd "$repo_root" - -version_file="version.gradle.kts" - -# --- N/A: not a versioned project ---------------------------------------- -if [ ! -f "$version_file" ]; then - [ "${VERSION_BUMPED_QUIET:-0}" = "1" ] || echo "version-bumped: N/A (no root version.gradle.kts)" - exit 0 -fi - -# --- Resolve base ref ---------------------------------------------------- -base="${VERSION_BUMPED_BASE:-}" -if [ -z "$base" ]; then - if git show-ref --verify --quiet refs/heads/master; then - base=master - elif git show-ref --verify --quiet refs/heads/main; then - base=main - else - echo "version-bumped: no master or main branch found; set VERSION_BUMPED_BASE" >&2 - exit 2 - fi -fi - -if ! git rev-parse --verify --quiet "$base" >/dev/null; then - echo "version-bumped: base ref '$base' does not resolve" >&2 - exit 2 -fi - -# When we are on the base branch itself, there is nothing to gate. -current_branch=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "") -if [ "$current_branch" = "$base" ] || [ "$current_branch" = "${base##*/}" ]; then - [ "${VERSION_BUMPED_QUIET:-0}" = "1" ] || echo "version-bumped: on base branch ($current_branch); nothing to gate" - exit 0 -fi - -merge_base=$(git merge-base HEAD "$base" 2>/dev/null) || { - echo "version-bumped: cannot find merge-base of HEAD and '$base'" >&2 - exit 2 -} - -# --- Detect any branch divergence vs base (committed/worktree/untracked) - -committed=$(git diff --name-only "$merge_base"..HEAD 2>/dev/null || true) -worktree=$(git diff --name-only HEAD 2>/dev/null || true) -untracked=$(git ls-files --others --exclude-standard 2>/dev/null || true) - -if [ -z "$committed" ] && [ -z "$worktree" ] && [ -z "$untracked" ]; then - [ "${VERSION_BUMPED_QUIET:-0}" = "1" ] || echo "version-bumped: no changes vs $base" - exit 0 -fi - -# --- Discover the publishing-version key --------------------------------- -# Source of truth is `build.gradle.kts` (or `build.gradle`). Two shapes are -# recognised, in order: -# -# a) version = extra["KEY"] -# b) version = IDENTIFIER (with `val IDENTIFIER ... by extra` nearby) -# -# Single or double quotes are accepted in shape (a). If multiple distinct -# keys appear across shapes, the script refuses to guess and asks the user -# to set VERSION_BUMPED_KEY. -# -# Return codes: -# 0 โ€” printed a unique key on stdout -# 1 โ€” no candidates found (caller should fall back) -# 2 โ€” ambiguous; diagnostic already on stderr -discover_key() { - local files keys_a keys_b keys count - files="" - [ -f build.gradle.kts ] && files="build.gradle.kts" - [ -f build.gradle ] && files="$files build.gradle" - [ -z "$files" ] && return 1 - # Shape (a): version = extra["KEY"] - # Anchored to start-of-line (modulo leading whitespace) so that comments - # like `// version = extra["x"]` and identifiers like `fooversion = ...` - # don't produce false matches. - # shellcheck disable=SC2086 - keys_a=$(grep -hE '^[[:space:]]*version[[:space:]]*=[[:space:]]*extra[[:space:]]*\[[[:space:]]*["'"'"'][^"'"'"']+["'"'"']' $files 2>/dev/null \ - | sed -nE 's/.*extra[[:space:]]*\[[[:space:]]*["'"'"']([^"'"'"']+)["'"'"'].*/\1/p') - # Shape (b): version = IDENTIFIER (bare Kotlin identifier, no '[' or '"'). - # Only accept the identifier if the same file also declares - # `val IDENTIFIER[: String]? by extra` โ€” otherwise it's a plain local - # variable (common in Groovy `build.gradle`), not an `extra` property we - # can resolve in `version.gradle.kts`. - local candidates_b cand - # shellcheck disable=SC2086 - candidates_b=$(grep -hE '^[[:space:]]*version[[:space:]]*=[[:space:]]*[A-Za-z_][A-Za-z0-9_]*[[:space:]]*$' $files 2>/dev/null \ - | sed -nE 's/^[[:space:]]*version[[:space:]]*=[[:space:]]*([A-Za-z_][A-Za-z0-9_]*)[[:space:]]*$/\1/p') - keys_b="" - for cand in $candidates_b; do - # shellcheck disable=SC2086 - if grep -hE "^[[:space:]]*val[[:space:]]+${cand}([[:space:]]*:[[:space:]]*String)?[[:space:]]+by[[:space:]]+extra([^A-Za-z0-9_]|\$)" $files >/dev/null 2>&1; then - keys_b="${keys_b}${cand} -" - fi - done - keys=$(printf '%s\n%s' "$keys_a" "$keys_b" | sed '/^$/d' | sort -u) - [ -z "$keys" ] && return 1 - count=$(printf '%s\n' "$keys" | wc -l | tr -d ' ') - if [ "$count" -gt 1 ]; then - { - echo "version-bumped: ambiguous publishing key in build scripts:" - while IFS= read -r k; do printf ' %s\n' "$k"; done <<< "$keys" - echo " Set VERSION_BUMPED_KEY to disambiguate." - } >&2 - return 2 - fi - printf '%s' "$keys" -} - -key="${VERSION_BUMPED_KEY:-}" -if [ -z "$key" ]; then - set +e - key=$(discover_key) - rc=$? - set -e - if [ "$rc" = "2" ]; then - exit 2 - fi - if [ "$rc" != "0" ] || [ -z "$key" ]; then - key="versionToPublish" - fi -fi - -# --- Parse a `val KEY by extra(...)` from a Gradle file content ---------- -# Handles three shapes (per .agents/skills/bump-version/SKILL.md step 2): -# 1. val KEY[: String]? by extra("X") โ€” literal extra -# 2. val SRC[: String]? by extra("X") โ€” alias chain via extra -# val KEY[: String]? by extra(SRC) -# 3. val SRC[: String]? = "X" โ€” alias chain via plain val -# val KEY[: String]? by extra(SRC) -# The key name is parameterized so that any project-specific name works -# (versionToPublish, validationVersion, bootstrapVersion, botVersion, โ€ฆ). -parse_version() { - local content="$1" name="$2" - local v varName - # Shape 1: literal. - v=$(printf '%s' "$content" \ - | grep -E "val[[:space:]]+${name}([[:space:]]*:[[:space:]]*String)?[[:space:]]+by[[:space:]]+extra\(\"" \ - | head -n1 \ - | sed -nE 's/.*extra\("([^"]+)".*/\1/p') - if [ -n "$v" ]; then - printf '%s' "$v" - return 0 - fi - # Shapes 2 & 3: extract the alias source identifier. - varName=$(printf '%s' "$content" \ - | grep -E "val[[:space:]]+${name}([[:space:]]*:[[:space:]]*String)?[[:space:]]+by[[:space:]]+extra\(" \ - | head -n1 \ - | sed -nE 's/.*extra\(([A-Za-z_][A-Za-z0-9_]*)\).*/\1/p') - if [ -n "$varName" ]; then - # Shape 2: source is `val SRC ... by extra("X")`. - v=$(printf '%s' "$content" \ - | grep -E "val[[:space:]]+${varName}([[:space:]]*:[[:space:]]*String)?[[:space:]]+by[[:space:]]+extra\(\"" \ - | head -n1 \ - | sed -nE 's/.*extra\("([^"]+)".*/\1/p') - if [ -n "$v" ]; then - printf '%s' "$v" - return 0 - fi - # Shape 3: source is `val SRC[: String]? = "X"`. - v=$(printf '%s' "$content" \ - | grep -E "val[[:space:]]+${varName}([[:space:]]*:[[:space:]]*String)?[[:space:]]*=[[:space:]]*\"" \ - | head -n1 \ - | sed -nE 's/.*=[[:space:]]*"([^"]+)".*/\1/p') - if [ -n "$v" ]; then - printf '%s' "$v" - return 0 - fi - fi - return 1 -} - -head_content=$(cat "$version_file" 2>/dev/null || true) -head_version=$(parse_version "$head_content" "$key" || true) -if [ -z "$head_version" ]; then - echo "version-bumped: cannot parse '$key' from working-tree $version_file" >&2 - exit 2 -fi - -# Base content may legitimately not exist (file newly introduced). -base_content=$(git show "$base:$version_file" 2>/dev/null || true) -if [ -z "$base_content" ]; then - [ "${VERSION_BUMPED_QUIET:-0}" = "1" ] || echo "version-bumped: $version_file newly introduced at $head_version; treating as bumped" - exit 0 -fi - -base_version=$(parse_version "$base_content" "$key" || true) -if [ -z "$base_version" ]; then - echo "version-bumped: cannot parse '$key' from $base:$version_file" >&2 - exit 2 -fi - -# --- Strict-greater comparison via `sort -V` ----------------------------- -if [ "$head_version" = "$base_version" ]; then - cmp="equal" -elif [ "$(printf '%s\n%s\n' "$base_version" "$head_version" | sort -V | tail -n1)" = "$head_version" ]; then - cmp="greater" -else - cmp="lesser" -fi - -if [ "$cmp" = "greater" ]; then - [ "${VERSION_BUMPED_QUIET:-0}" = "1" ] || echo "version-bumped: OK ($key: $base_version -> $head_version)" - exit 0 -fi - -cat >&2 < - Write, edit, and restructure user-facing and developer-facing documentation. - Use when asked to create/update docs such as `README.md`, `docs/**`, and - other Markdown documentation, including keeping docs navigation data in sync; - when drafting tutorials, guides, troubleshooting pages, or migration notes; and - when improving inline API documentation (KDoc) and examples. ---- - -# Write documentation (repo-specific) - -## Decide the target and audience - -- Identify the target reader: end user, contributor, maintainer, or tooling/automation. -- Identify the task type: new doc, update, restructure, or documentation audit. -- Identify the acceptance criteria: โ€œwhat is correct when the reader is done?โ€ - -## Choose where the content should live - -- Prefer updating an existing doc over creating a new one. -- Place content in the most discoverable location: - - `README.md`: project entry point and โ€œwhat is this?โ€. - - `docs/`: longer-form docs (follow existing conventions in that tree). - - Source KDoc: API usage, examples, and semantics that belong with the code. - -## Keep docs navigation in sync - -- When adding, removing, moving, or renaming a page under - `docs/content/docs/

/`, keep the current version's matching - `sidenav.yml` in sync. -- Use `docs/data/versions.yml` to identify the current documentation version for - that section. The current version is the entry with `is_main: true`; its - `version_id` maps to `docs/data/docs/
//sidenav.yml`. -- Do not update historical version entries or their navigation files unless the - user explicitly asks to edit that historical version. -- Map page files to `file_path` values relative to the current version's - `content_path`, without `.md`; `_index.md` maps to its directory path, such as - `01-getting-started/_index.md` -> `01-getting-started`. -- Keep each `page` label aligned with the page frontmatter `title` unless the - existing navigation intentionally uses a shorter reader-facing label. -- Preserve the existing ordering, nesting, keys, comments, and YAML quoting - style. Remove nav entries for deleted pages and update `file_path` values for - moved pages. -- If a docs content change should not appear in navigation, say so explicitly in - the final response. - -## Follow local documentation conventions - -- Follow `.agents/documentation-guidelines.md` and `.agents/documentation-tasks.md`. -- Use fenced code blocks for commands and examples; format file/dir names as code. -- When referencing a documentation page or section in body prose, use typographic - double quotation marks only if the visible reference text is the actual page or - section title, such as the โ€œGetting startedโ€ page or the โ€œTroubleshootingโ€ - section. The title normally starts with a capital letter. Do not add these - quotes around generic or descriptive links such as โ€œthis pageโ€, โ€œthe next - sectionโ€, โ€œdeclaring constraintsโ€, or `4.3`, even if they point to a page or - section. Do not add these quotes in โ€œWhatโ€™s nextโ€ sections or navigation - elements. Keep file paths, identifiers, frontmatter values, navigation labels, - and Markdown link labels in their expected syntax. -- In Markdown files, prefer footnote-style reference links for external `https://` - targets instead of inline links. Write readable body text like - `[label][short-id]`, then place the URL definition near the end of the file, - such as `[short-id]: https://example.com/long/path`. Keep reference IDs short - and descriptive. Inline links are still fine for local relative paths. -- Avoid widows, runts, orphans, and rivers by reflowing paragraphs when needed. - -## Make docs actionable - -- Prefer steps the reader can execute (commands + expected outcome). -- Prefer concrete examples over abstract descriptions. -- Include prerequisites (versions, OS, environment) when they are easy to miss. -- Use consistent terminology (match code identifiers and existing docs). - -## KDoc-specific guidance - -- For public/internal APIs, include at least one example snippet demonstrating common usage. -- When converting from Javadoc/inline comments to KDoc: - - Remove HTML like `

` and preserve meaning. - - Prefer short paragraphs and blank lines over HTML formatting. - -## Validate changes - -- For code changes, follow `.agents/running-builds.md`. -- For documentation-only changes in Kotlin/Java sources, prefer `./gradlew dokka`. diff --git a/.agents/skills/writer/agents/openai.yaml b/.agents/skills/writer/agents/openai.yaml deleted file mode 100644 index 44eaa4e241..0000000000 --- a/.agents/skills/writer/agents/openai.yaml +++ /dev/null @@ -1,5 +0,0 @@ -interface: - display_name: "Writer" - short_description: "Write and update user/developer docs" - default_prompt: "Write or revise documentation in this repository (for example: README.md, docs/**, CONTRIBUTING.md, and API documentation/KDoc). Follow local documentation guidelines in .agents/*.md, keep changes concise and actionable, and include concrete examples and commands where appropriate." - diff --git a/.agents/skills/writer/assets/templates/doc-page.md b/.agents/skills/writer/assets/templates/doc-page.md deleted file mode 100644 index f405b71e15..0000000000 --- a/.agents/skills/writer/assets/templates/doc-page.md +++ /dev/null @@ -1,23 +0,0 @@ -# Title - -## Goal - -State what the reader will accomplish. - -## Prerequisites - -- List versions/tools the reader needs. - -## Steps - -1. Do the first thing. -2. Do the next thing. - -## Verify - -Show how the reader can confirm success. - -## Troubleshooting - -- Common failure: likely cause โ†’ fix. - diff --git a/.agents/skills/writer/assets/templates/kdoc-example.md b/.agents/skills/writer/assets/templates/kdoc-example.md deleted file mode 100644 index fdbd9b6a0d..0000000000 --- a/.agents/skills/writer/assets/templates/kdoc-example.md +++ /dev/null @@ -1,11 +0,0 @@ -````kotlin -/** - * Explain what this API does in one sentence. - * - * ## Example - * ```kotlin - * // Show the typical usage pattern. - * val result = doThing() - * ``` - */ -```` diff --git a/.agents/skills/writer/assets/templates/kotlin-java-example.md b/.agents/skills/writer/assets/templates/kotlin-java-example.md deleted file mode 100644 index 5517516f56..0000000000 --- a/.agents/skills/writer/assets/templates/kotlin-java-example.md +++ /dev/null @@ -1,13 +0,0 @@ -{{< code-tabs langs="Kotlin, Java">}} - -{{< code-tab lang="Kotlin" >}} -```kotlin -``` -{{< /code-tab >}} - -{{< code-tab lang="Java" >}} -```java -``` -{{< /code-tab >}} - -{{< /code-tabs >}} diff --git a/.agents/tasks/validating-builder-validate.md b/.agents/tasks/validating-builder-validate.md new file mode 100644 index 0000000000..144774cea4 --- /dev/null +++ b/.agents/tasks/validating-builder-validate.md @@ -0,0 +1,47 @@ +# Add `ValidatingBuilder.validate()` probe method + +**Status:** implementation complete; awaiting build verification and user review. + +## Goal + +Add a non-throwing validation probe to `io.spine.validation.ValidatingBuilder` +per ADR 0001 (core-jvm, D9 amendment of 2026-07-05): + +```java +default List validate() +``` + +Requested by @armiol in +[core-jvm PR #1642](https://github.com/SpineEventEngine/core-jvm/pull/1642#issuecomment-4885985767) +as a complement to the `tryAlter {}` entity extension (de-event-sourcing +Phase A). + +## Semantics + +- `buildPartial()` the current content; if the result is a + `ValidatableMessage`, unwrap `validate()` โ†’ `ValidationError` โ†’ + constraint violations; otherwise return an empty list. +- Empty list == valid. Never throws on invalid content. Never mutates + the builder. +- Contrast: `build()` throws `ValidationException`; `buildPartial()` skips + validation entirely. + +## Changes + +- `jvm-runtime/src/main/java/io/spine/validation/ValidatingBuilder.java` โ€” + the default method + Javadoc; interface Javadoc mentions the probe. +- `jvm-runtime/src/test/kotlin/io/spine/validation/ValidatingBuilderSpec.kt` โ€” + stub-based unit tests (valid, invalid-no-throw, no-mutation, + non-`ValidatableMessage` fallback). +- `tests/validating/src/test/kotlin/io/spine/test/JavaMessageSmokeTest.kt` โ€” + integration tests against generated `CardNumber` code. +- `version.gradle.kts` โ€” `2.0.0-SNAPSHOT.446` โ†’ `.447` (bump-version skill; + commit step withheld โ€” no commit authorization from the user). + +## Verification + +- `./gradlew build` โ€” full build with tests. +- `./gradlew dokkaGenerate` โ€” Javadoc/Dokka for the doc change. +- `kotlin-review` and `review-docs` agents on the branch diff. + +Delete this file on merge to master. diff --git a/.agents/testing.md b/.agents/testing.md deleted file mode 100644 index f81bdbf3d3..0000000000 --- a/.agents/testing.md +++ /dev/null @@ -1,8 +0,0 @@ -# ๐Ÿงช Testing - -- Do not use mocks, use stubs. -- Prefer [Kotest assertions][kotest-assertions] over assertions from JUnit or Google Truth. -- Generate unit tests for APIs (handles edge cases/scenarios). -- Supply scaffolds for typical Kotlin patterns (`when`, sealed classes). - -[kotest-assertions]: https://kotest.io/docs/assertions/assertions.html diff --git a/.agents/version-policy.md b/.agents/version-policy.md deleted file mode 100644 index 3e8abd5495..0000000000 --- a/.agents/version-policy.md +++ /dev/null @@ -1,19 +0,0 @@ -# Version policy - -When a repository has `version.gradle.kts` at the project root, it follows the -[Spine SDK Versioning policy][wiki-versioning]. The version is kept in that -file and follows [Semantic Versioning 2.0.0][semver] with Spine-specific -extensions (snapshot `NUMBER`, patch, and flavor suffixes). - -For repositories with root `version.gradle.kts`, PRs without a version bump -fail CI. Repositories without that file are not versioned Gradle Build Tools -projects; their version check is not applicable, and agents must not create -`version.gradle.kts` just to satisfy `/pre-pr`. - -For the bump procedure in repositories that have root `version.gradle.kts` โ€” -version-number selection, the commit-message convention, the rebuild, -dependency-report updates, and conflict resolution โ€” use the -[`bump-version`](skills/bump-version/SKILL.md) skill. - -[semver]: https://semver.org/ -[wiki-versioning]: https://github.com/SpineEventEngine/documentation/wiki/Versioning diff --git a/.agents/widow-runt-orphan.jpg b/.agents/widow-runt-orphan.jpg deleted file mode 100644 index 284b02a47d..0000000000 Binary files a/.agents/widow-runt-orphan.jpg and /dev/null differ diff --git a/.claude/agents b/.claude/agents new file mode 120000 index 0000000000..18e96c9560 --- /dev/null +++ b/.claude/agents @@ -0,0 +1 @@ +../.agents/shared/claude/agents \ No newline at end of file diff --git a/.claude/agents/dependency-audit.md b/.claude/agents/dependency-audit.md deleted file mode 100644 index 9db010fe55..0000000000 --- a/.claude/agents/dependency-audit.md +++ /dev/null @@ -1,19 +0,0 @@ ---- -name: dependency-audit -description: Audits changes to dependency declarations under `buildSrc/src/main/kotlin/io/spine/dependency/` โ€” catches accidental version downgrades, BOM mismatches, missing deprecation markers, copyright drift, and convention drift. Use proactively whenever a diff touches that directory, or when the user asks "audit this dependency bump". Read-only; does not run builds. -tools: Read, Grep, Glob, Bash -model: claude-haiku-4-5-20251001 ---- - -Follow the `dependency-audit` skill exactly: - -- Skill: `.agents/skills/dependency-audit/SKILL.md` -- The skill owns the per-area checks (version sanity, naming and structure, - deprecation discipline, convention drift, cross-cutting) and the output - format (Must fix / Should fix / Nits + one-line verdict). -- Read-only: use `Read`, `Grep`, `Glob`, and `Bash` solely for `git diff`, - `git grep`, and related read-only inspection. Do not run builds. -- **Be fast.** Fetch the full unified diff once, work from it, and `Read` - individual files only when the skill's step 2 budget allows. Issue - independent `Grep`/`Bash` calls in parallel within a single response; - do not halt at the first failure โ€” collect all findings and report once. diff --git a/.claude/agents/kotlin-review.md b/.claude/agents/kotlin-review.md deleted file mode 100644 index 74583aa33b..0000000000 --- a/.claude/agents/kotlin-review.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -name: kotlin-review -description: Reviews Kotlin (and Java) changes against Spine coding guidelines, safety rules, and testing policy. Use proactively after any non-trivial code edit, before opening a PR, or when the user asks for a code review. Read-only; does not run builds. -tools: Read, Grep, Glob, Bash -model: inherit ---- - -Follow the `kotlin-review` skill exactly: - -- Skill: `.agents/skills/kotlin-review/SKILL.md` -- The skill owns the procedure, the checks (Kotlin idioms, safety rules, - testing policy, version-gate applicability), and the output format - (Must fix / Should fix / Nits + one-line verdict). -- Stay in scope: code only. If a documentation issue surfaces, note it - briefly as a Nit pointing at the `review-docs` agent. -- Read-only: use `Read`, `Grep`, `Glob`, and `Bash` solely for `git diff` - and related read-only inspection. Do not run builds. diff --git a/.claude/agents/review-docs.md b/.claude/agents/review-docs.md deleted file mode 100644 index 0481b240b1..0000000000 --- a/.claude/agents/review-docs.md +++ /dev/null @@ -1,18 +0,0 @@ ---- -name: review-docs -description: Reviews documentation changes โ€” KDoc/Javadoc inside Kotlin/Java sources and Markdown docs (`README.md`, `docs/**`) โ€” against Spine documentation conventions. Use proactively when a diff touches doc comments or Markdown, before opening a doc-affecting PR, or when the user asks for a documentation review. Read-only; does not run builds. -tools: Read, Grep, Glob, Bash -model: inherit ---- - -Follow the `review-docs` skill exactly: - -- Skill: `.agents/skills/review-docs/SKILL.md` -- The skill owns the review procedure, the per-area checks (KDoc/Javadoc, - Markdown, prose flow, terminology), and the output format - (Must fix / Should fix / Nits + one-line verdict). -- Scope yourself to documentation only. If you spot a code-quality issue, - surface it briefly as a Nit pointing at the `kotlin-review` agent โ€” - do not expand the review. -- Read-only: use `Read`, `Grep`, `Glob`, and `Bash` solely for `git diff` - and related read-only inspection. Do not run builds. diff --git a/.claude/commands b/.claude/commands new file mode 120000 index 0000000000..ad85cd809c --- /dev/null +++ b/.claude/commands @@ -0,0 +1 @@ +../.agents/shared/claude/commands \ No newline at end of file diff --git a/.claude/commands/bump-gradle.md b/.claude/commands/bump-gradle.md deleted file mode 100644 index f9078802c9..0000000000 --- a/.claude/commands/bump-gradle.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -description: Upgrade the Gradle wrapper to the latest release. -argument-hint: "[gradle-version]" -allowed-tools: Read, Edit, Bash(./gradlew:*), Bash(git status:*), Bash(git diff:*), WebFetch ---- - -Follow the `bump-gradle` skill exactly: - -- Skill: `.agents/skills/bump-gradle/SKILL.md` -- Read the skill first. -- Use https://docs.gradle.org/current/release-notes.html as the source of truth for the latest version. Do NOT rely on remembered Gradle versions. -- If the user supplied a version: $ARGUMENTS, use it; otherwise read it from the release notes. -- Commit the wrapper change and dependency report change in separate commits per the skill. diff --git a/.claude/commands/bump-version.md b/.claude/commands/bump-version.md deleted file mode 100644 index 82e18b599c..0000000000 --- a/.claude/commands/bump-version.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -description: Bump the project version in version.gradle.kts per Spine SDK versioning policy. -argument-hint: "[snapshot|minor|major]" -allowed-tools: Read, Edit, Bash(git status:*), Bash(git diff:*), Bash(git log:*), Bash(./gradlew:*) ---- - -Follow the `bump-version` skill exactly: - -- Skill: `.agents/skills/bump-version/SKILL.md` -- Read the skill first; it owns the policy (snapshot numbering, version conflicts, rebuilding reports). -- Increment requested by the user: $ARGUMENTS (treat as "snapshot" if empty). -- Inspect `git status --short` before editing; preserve unrelated user changes. -- Stop and ask the user before committing. diff --git a/.claude/commands/dependency-update.md b/.claude/commands/dependency-update.md deleted file mode 100644 index 9c54da1498..0000000000 --- a/.claude/commands/dependency-update.md +++ /dev/null @@ -1,16 +0,0 @@ ---- -description: Refresh external dependency versions in buildSrc to their latest non-snapshot release. -argument-hint: "[--dry-run] [paths...]" -allowed-tools: Read, Edit, Write, Grep, Glob, WebFetch, Bash(git status:*), Bash(git diff:*), Bash(./gradlew build:*), Bash(./gradlew clean build:*) ---- - -Follow the `dependency-update` skill exactly: - -- Skill: `.agents/skills/dependency-update/SKILL.md` -- Scope / flags: $ARGUMENTS -- Walk every dependency object under `buildSrc/src/main/kotlin/io/spine/dependency/`. -- Source of truth per artifact: the URL in the file's comment (line `// https://...` or KDoc `@see`). If no URL, fall back to Maven Central AND back-fill the URL comment. -- Filter out snapshots, RCs, alphas, betas, milestones, EAPs, and `-dev` builds. -- Apply the edit. Do NOT commit; emit the report described in the skill. -- Flag `local/` (Spine SDK) updates separately so the user can decide whether to bump in lockstep. -- After the run, suggest the user review the diff and run `./gradlew build` (or `./gradlew clean build` if proto files participate). For `local/` bumps, suggest `./gradlew buildDependants`. diff --git a/.claude/commands/java-to-kotlin.md b/.claude/commands/java-to-kotlin.md deleted file mode 100644 index 6f2c072f93..0000000000 --- a/.claude/commands/java-to-kotlin.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -description: Convert Java files to idiomatic Kotlin, including Javadoc -> KDoc. -argument-hint: "" -allowed-tools: Read, Edit, Write, Bash(./gradlew:*), Bash(git status:*), Grep, Glob ---- - -Follow the `java-to-kotlin` skill exactly: - -- Skill: `.agents/skills/java-to-kotlin/SKILL.md` -- Target: $ARGUMENTS -- Preserve behavior. Convert Javadoc to KDoc, `@Nullable` to nullable Kotlin types, getters/setters to properties, static methods to companion objects or top-level functions. -- After each file, run `./gradlew compileKotlin` (or the relevant module's compile task) to verify. -- Honor `.agents/coding-guidelines.md` for Kotlin idioms. diff --git a/.claude/commands/move-files.md b/.claude/commands/move-files.md deleted file mode 100644 index 25885f9d77..0000000000 --- a/.claude/commands/move-files.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -description: Move or rename files/directories, updating all references and build metadata. -argument-hint: " " -allowed-tools: Read, Edit, Bash(git mv:*), Bash(git status:*), Bash(git ls-files:*), Grep, Glob ---- - -Follow the `move-files` skill exactly: - -- Skill: `.agents/skills/move-files/SKILL.md` -- Operation: $ARGUMENTS -- Preflight (run `git status --short`, classify scope) -> Search for all old identifiers -> Move with `git mv` -> Repair references (imports, build metadata, docs) -> Verify. -- Report: Moved[], UpdatedRefs[], Verification[], Risks[]. diff --git a/.claude/commands/pre-pr.md b/.claude/commands/pre-pr.md deleted file mode 100644 index 24499cc517..0000000000 --- a/.claude/commands/pre-pr.md +++ /dev/null @@ -1,32 +0,0 @@ ---- -description: Run the applicable pre-PR checklist (version gate, build/check, reviewers) and write a sentinel so `gh pr create` is unblocked. -argument-hint: "[base-ref]" -allowed-tools: Read, Write, Grep, Glob, Agent, Bash ---- - -Follow the `pre-pr` skill exactly: - -- Skill: `.agents/skills/pre-pr/SKILL.md` -- Base ref: $ARGUMENTS (treat empty as `master`). -- Detect whether the repository-root `version.gradle.kts` exists. If it is - absent at both the base ref and `HEAD`, the version check is `N/A`; do not - create the file and do not ask for `/bump-version`. -- Run the build/check command selected by the skill and - `.agents/running-builds.md`. The command may be Gradle or non-Gradle. -- Dispatch the reviewers as Claude subagents in parallel โ€” send a single - message with multiple Agent tool uses: - - `kotlin-review` when `.kt|.kts|.java` files changed. - - `review-docs` when `.md` files or KDoc inside sources changed. - - `dependency-audit` when any file under - `buildSrc/src/main/kotlin/io/spine/dependency/` changed. -- Pass the version-check status to reviewers. If it is `N/A`, tell them: - "This repository has no root `version.gradle.kts`; a version bump is not - applicable and must not be reported as missing." -- Each reviewer is read-only; do not pass it edit tools. -- On any reviewer returning `REQUEST CHANGES`, treat the overall result - as `FAIL` and stop before writing the sentinel as `PASS`. -- Sentinel location: `$(git rev-parse --show-toplevel)/.git/pre-pr.ok`, - format per the skill (`head=`, `branch=`, `status=`, `timestamp=`, - `build=`, `reviewers=`, `version=`). Use `git rev-parse HEAD` for the - SHA and `date -u +%Y-%m-%dT%H:%M:%SZ` for the timestamp. -- Do NOT run `gh pr create`. That is the user's next step. diff --git a/.claude/commands/raise-coverage.md b/.claude/commands/raise-coverage.md deleted file mode 100644 index c428055ff5..0000000000 --- a/.claude/commands/raise-coverage.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -description: Ensure the repo is on Kover (migrate from JaCoCo if needed), then localize coverage gaps and generate missing unit tests for a module or path. -argument-hint: "<:module | path | --triage>" -allowed-tools: Read, Edit, Write, Grep, Glob, Bash(./gradlew:*), Bash(git status:*), Bash(find:*), Bash(xmllint:*), Bash(python3:*) ---- - -Follow the `raise-coverage` skill exactly: - -- Skill: `.agents/skills/raise-coverage/SKILL.md` -- Target: $ARGUMENTS โ€” a Gradle module (e.g. `:base`), a source path, or - `--triage` to only produce the ranked Kover gap report without generating tests. -- First-time setup: the skill enforces Kover. If vanilla JaCoCo is found - anywhere, the skill proposes a repo-wide migration and **waits for your - approval**. See `.agents/skills/raise-coverage/references/migrate-to-kover.md`. -- Order: localize gaps from Kover's JaCoCo-format XML โ†’ propose concrete test - cases and **wait for confirmation** โ†’ generate โ†’ re-run - `::koverXmlReport` to verify the gap closed. -- Honor `.agents/testing.md` and `.agents/coding-guidelines.md`. New tests are - always written in **Kotlin** (JUnit Jupiter structure + Kotest assertions), - regardless of whether the code under test is Kotlin or Java, with no - mocking framework โ€” stubs only. Test class names use the **`Spec`** suffix - (e.g. `AbstractSourceFileSpec`). -- Target human-written `src/main` code only โ€” never generated code, `examples`, - or existing tests. -- Never weaken a `.codecov.yml` target or add a mocking dependency to make a - check pass. Tests-only changes do not require a version bump. diff --git a/.claude/commands/review-docs.md b/.claude/commands/review-docs.md deleted file mode 100644 index f8043f0ea1..0000000000 --- a/.claude/commands/review-docs.md +++ /dev/null @@ -1,21 +0,0 @@ ---- -description: Review documentation changes (KDoc/Javadoc and Markdown) against Spine documentation conventions. -argument-hint: "[base-ref | --staged | paths...]" -allowed-tools: Read, Grep, Glob, Bash(git diff:*), Bash(git log:*), Bash(git status:*), Bash(git rev-parse:*), Bash(git ls-files:*) ---- - -Follow the `review-docs` skill exactly: - -- Skill: `.agents/skills/review-docs/SKILL.md` -- Scope / flags: $ARGUMENTS - - Empty: review the current branch's diff against `master` (`git diff master...HEAD`). - - `--staged`: review staged changes only (`git diff --staged`). - - A base ref (e.g. `master`, `origin/master`, a commit SHA): review `git diff ...HEAD`. - - Explicit paths: limit the review to those paths in addition to the diff scope. -- The skill owns the procedure, the per-area checks (KDoc/Javadoc, Markdown, - prose flow, terminology), and the output format (Must fix / Should fix / - Nits + one-line verdict). -- Stay in scope: documentation only. If a code-quality issue surfaces, - note it briefly as a Nit pointing at `/review` (or the `kotlin-review` - agent) โ€” do not expand the review. -- Read-only: do not edit files, do not run builds. diff --git a/.claude/commands/run-build.md b/.claude/commands/run-build.md deleted file mode 100644 index 8a8d84ca0b..0000000000 --- a/.claude/commands/run-build.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -description: Build the project the right way based on what changed (proto vs. Kotlin/Java vs. docs). -allowed-tools: Bash(./gradlew:*), Bash(git status:*), Bash(git diff:*) ---- - -Decide which build to run by looking at `git status --short` and `git diff --name-only`: - -- If any `.proto` files changed: `./gradlew clean build` -- Else if Kotlin or Java source changed: `./gradlew build` -- Else if only docs/comments changed (KDoc / Javadoc / Markdown): `./gradlew dokka`. Tests are NOT required for doc-only changes. - -Report the chosen command and its result. See `.agents/running-builds.md`. diff --git a/.claude/commands/update-copyright.md b/.claude/commands/update-copyright.md deleted file mode 100644 index 076fb6133a..0000000000 --- a/.claude/commands/update-copyright.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -description: Refresh copyright headers from the IntelliJ profile, replacing today.year with the current year. -argument-hint: "[paths...]" -allowed-tools: Bash(python3 .agents/skills/update-copyright/scripts/update_copyright.py:*), Read ---- - -Follow the `update-copyright` skill exactly: - -- Skill: `.agents/skills/update-copyright/SKILL.md` -- Run: `python3 .agents/skills/update-copyright/scripts/update_copyright.py $ARGUMENTS` -- If $ARGUMENTS is empty, run once with `--dry-run`, show the output to the user, then run without `--dry-run`. -- Never add a header to a file that doesn't already have one. diff --git a/.claude/commands/write-docs.md b/.claude/commands/write-docs.md deleted file mode 100644 index b9b9a742b2..0000000000 --- a/.claude/commands/write-docs.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -description: Write or update Markdown / KDoc documentation per Spine documentation conventions. -argument-hint: "" -allowed-tools: Read, Edit, Write, Grep, Glob ---- - -Follow the `writer` skill exactly: - -- Skill: `.agents/skills/writer/SKILL.md` -- Topic / target: $ARGUMENTS -- Decide audience first (end user, contributor, maintainer, tooling). -- Prefer updating an existing doc over creating a new one. -- Keep `docs/data/docs/

//sidenav.yml` in sync when adding, removing, moving, or renaming pages under `docs/content/docs/
/`. -- Honor `.agents/documentation-guidelines.md` and `.agents/documentation-tasks.md`. diff --git a/.claude/settings.json b/.claude/settings.json index f7bbfb98ff..357650cf71 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -2,6 +2,7 @@ "$schema": "https://json.schemastore.org/claude-code-settings.json", "permissions": { "allow": [ + "Edit(version.gradle.kts)", "Bash(./gradlew:*)", "Bash(./config/gradlew:*)", "Bash(git status:*)", @@ -53,19 +54,24 @@ ] }, "hooks": { - "PreToolUse": [ + "SessionStart": [ { - "matcher": "Edit|Write|MultiEdit", "hooks": [ { "type": "command", - "command": "$CLAUDE_PROJECT_DIR/.agents/scripts/protect-version-file.sh" + "command": "$CLAUDE_PROJECT_DIR/init-submodules" } ] - }, + } + ], + "PreToolUse": [ { "matcher": "Bash", "hooks": [ + { + "type": "command", + "command": "$CLAUDE_PROJECT_DIR/.agents/scripts/secret-scan-gate.sh" + }, { "type": "command", "command": "$CLAUDE_PROJECT_DIR/.agents/scripts/pre-pr-gate.sh" diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 8a5ab934a9..039657bee2 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -10,8 +10,8 @@ repository root โ€” read it first. If `.agents/project.md` exists, read it before reviewing. It provides the language, architecture, role, and code review checklist for this specific repo. -Additional guidelines are in `.agents/` โ€” see `.agents/_TOC.md` for the index -(if present; Hugo repos do not include this file). +Additional guidelines are in `.agents/guidelines/` โ€” see +`.agents/guidelines/_TOC.md` for the index. ## Do not review diff --git a/.github/workflows/build-on-ubuntu.yml b/.github/workflows/build-on-ubuntu.yml index cd6b93714c..b07bf0fb0a 100644 --- a/.github/workflows/build-on-ubuntu.yml +++ b/.github/workflows/build-on-ubuntu.yml @@ -1,27 +1,59 @@ name: Ubuntu CI -on: push +# Triggers: +# * push to a default or release-line branch โ€” those ending in `master` or +# `main`, the same set `increment-guard.yml` guards as PR bases (e.g. +# `master`, `2.x-jdk8-master`). These post-merge runs are the only source +# of base-branch coverage, since `Publish` runs `publish -x test` and +# uploads none; `target: auto` in `.codecov.yml` compares each pull request +# against its base baseline. +# * pull_request โ€” gates a change on its merge result, not the branch tip. +on: + push: + branches: + - '**master' + - '**main' + pull_request: jobs: build: name: Build on Ubuntu runs-on: ubuntu-latest + concurrency: # Avoid canceling in-progress runs for the same branch. + group: ubuntu-ci-${{ github.ref }} + cancel-in-progress: false steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 with: submodules: 'true' - - uses: actions/setup-java@v4 + - uses: actions/setup-java@v5 with: java-version: 17 distribution: zulu - cache: gradle - - name: Build project and run tests + - uses: gradle/actions/setup-gradle@v6 + + # Mirrors the pagefile step in build-on-windows.yml. The Linux runner + # ships with effectively no swap, so a memory peak becomes an instant + # OOM kill; this gives the kernel somewhere to fall back to. + - name: Add swap space + uses: pierotofy/set-swap-space@v1.0 + with: + swap-size-gb: 8 + + - name: Build project, run tests shell: bash run: ./gradlew build --stacktrace + # `build` does not run Dokka โ€” its tasks are gated to the publishing + # graph โ€” so `dokkaGenerate` is appended to surface documentation + # warnings before merge, instead of only in the post-merge `Publish` + # job. `failOnWarning` is enabled in the Dokka setup. + - name: Check documentation + run: ./gradlew dokkaGenerate --stacktrace + # See: https://github.com/marketplace/actions/junit-report-action - name: Publish Test Report uses: mikepenz/action-junit-report@v4.0.3 @@ -30,9 +62,33 @@ jobs: report_paths: '**/build/test-results/**/TEST-*.xml' require_tests: true # will fail workflow if test reports not found + # Probe whether the upload token is available without exposing its value + # to the build/test steps. Scoping `CODECOV_TOKEN` to this trivial step + # (and to the upload step's `with.token`) keeps PR-authored code in + # `./gradlew build` from ever seeing the secret. The `secrets` context is + # not available in a step `if:`, so the upload gates on this output. + - name: Detect Codecov token + id: codecov + env: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} + run: | + if [ -n "$CODECOV_TOKEN" ]; then + echo "available=true" >> "$GITHUB_OUTPUT" + else + echo "available=false" >> "$GITHUB_OUTPUT" + fi + + # On `push` (master) always upload โ€” these runs are the only source of + # the `target: auto` baseline, so an absent token there is a + # misconfiguration that should fail loudly rather than silently stop + # refreshing coverage. On `pull_request`, skip when the token is absent: + # forked and Dependabot PRs run without secrets, and `fail_ci_if_error` + # would otherwise redden a healthy PR (coverage gating is meaningless + # there anyway). - name: Upload code coverage report - uses: codecov/codecov-action@v4 + if: steps.codecov.outputs.available == 'true' || github.event_name == 'push' + uses: codecov/codecov-action@v7 with: token: ${{ secrets.CODECOV_TOKEN }} - fail_ci_if_error: false + fail_ci_if_error: true verbose: true diff --git a/.github/workflows/build-on-windows.yml b/.github/workflows/build-on-windows.yml index 91a0bfef32..0a07cfab67 100644 --- a/.github/workflows/build-on-windows.yml +++ b/.github/workflows/build-on-windows.yml @@ -8,15 +8,17 @@ jobs: name: Build on Windows steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 with: - submodules: 'true' + submodules: recursive + fetch-depth: 0 - - uses: actions/setup-java@v4 + - uses: actions/setup-java@v5 with: java-version: 17 distribution: zulu - cache: gradle + + - uses: gradle/actions/setup-gradle@v6 # See: https://github.com/al-cheb/configure-pagefile-action - name: Configure Pagefile @@ -24,8 +26,7 @@ jobs: - name: Build project and run tests shell: cmd - # For the reason on `--no-daemon` see https://github.com/actions/cache/issues/454 - run: gradlew.bat build --stacktrace --no-daemon + run: gradlew.bat build --stacktrace # See: https://github.com/marketplace/actions/junit-report-action - name: Publish Test Report @@ -33,4 +34,4 @@ jobs: if: always() # always run even if the previous step fails with: report_paths: '**/build/test-results/**/TEST-*.xml' - require_tests: true # will fail workflow if test reports not found + require_tests: true diff --git a/.github/workflows/check-links.yml b/.github/workflows/check-links.yml index 3fa235be0b..6755ff910e 100644 --- a/.github/workflows/check-links.yml +++ b/.github/workflows/check-links.yml @@ -33,7 +33,7 @@ jobs: cancel-in-progress: true steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 # Detect the Hugo site root (`docs/` or `site/`) by looking for a Hugo # config file. Hugo config may live directly in the site root or in a diff --git a/.github/workflows/ensure-reports-updated.yml b/.github/workflows/ensure-reports-updated.yml index 315cd202b7..20deb3f695 100644 --- a/.github/workflows/ensure-reports-updated.yml +++ b/.github/workflows/ensure-reports-updated.yml @@ -1,19 +1,29 @@ # Ensures that the license report files were modified in this PR. +# +# The check runs only for pull requests targeting a default (`master`/`main`) or +# a release-line (e.g. `2.x-jdk8-master`) branch. The report files embed the project +# version, so they are refreshed by the branches which bump it. Pull requests +# targeting auxiliary branches are not checked. +# +# The base branch is checked inside the job rather than via the `branches` filter: +# a workflow skipped by branch filtering leaves its check in the `Pending` state, +# blocking PRs which require it, while a job skipped via `if` reports `skipped`, +# which satisfies required status checks. name: License Reports on: pull_request: - branches: - - '**' jobs: check: name: Ensure license reports are updated runs-on: ubuntu-latest + # Default and release-line branches, e.g. `master`, `main`, `2.x-jdk8-master`. + if: endsWith(github.base_ref, 'master') || endsWith(github.base_ref, 'main') steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 with: # Configure the checkout of all branches so that it is possible to run the comparison. fetch-depth: 0 diff --git a/.github/workflows/gradle-wrapper-validation.yml b/.github/workflows/gradle-wrapper-validation.yml index 50eb05eb15..fc0872b4c9 100644 --- a/.github/workflows/gradle-wrapper-validation.yml +++ b/.github/workflows/gradle-wrapper-validation.yml @@ -13,7 +13,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout latest code - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Validate Gradle Wrapper uses: gradle/actions/wrapper-validation@v4 diff --git a/.github/workflows/increment-guard.yml b/.github/workflows/increment-guard.yml index 38ce6f4d3e..9a6333f0ff 100644 --- a/.github/workflows/increment-guard.yml +++ b/.github/workflows/increment-guard.yml @@ -1,29 +1,100 @@ -# Ensures that the current lib version is not yet published but executing the Gradle -# `checkVersionIncrement` task. +# Guards the project version by executing the Gradle `checkVersionIncrement` task, +# which verifies that the version is both (a) strictly greater than the base branch +# version in `version.gradle.kts` and (b) not already published. The result is +# published as the `Version Guard` commit status โ€” the context required by branch +# protection and re-published by `revalidate-versions.yml` on the heads of other open +# PRs when the base branch advances (so a stale duplicate bump turns red before merge). +# +# The check runs only for pull requests targeting a default (`master`/`main`) or +# a release-line (e.g. `2.x-jdk8-master`) branch. It is the responsibility of a branch +# which aims to merge into such a branch to bump the version. Auxiliary branches +# do not deal with the versions in the release cycle and are not guarded. +# +# The base branch is checked inside the job rather than via the `branches` filter: +# a workflow skipped by branch filtering leaves its check in the `Pending` state, +# blocking PRs which require it, while a job skipped via `if` reports `skipped`, +# which satisfies required status checks. name: Version Guard on: - push: - branches: - - '**' + pull_request: + # Beyond the default activity types (`opened`, `synchronize`, `reopened`), two more are + # needed because they change what the guard must compare against without a new head SHA, + # which would otherwise leave a stale-green `Version Guard` status mergeable: + # * `ready_for_review` โ€” a draft that went stale while in draft becomes ready; and + # * `edited` โ€” the base branch is retargeted (e.g. a release line -> `master`), so the + # strict comparison must be recomputed against the new base. + types: [opened, synchronize, reopened, ready_for_review, edited] jobs: check: name: Check version increment runs-on: ubuntu-latest + # Default and release-line branches, e.g. `master`, `main`, `2.x-jdk8-master`. For an + # `edited` event, run only when the base actually changed (a retarget carries + # `changes.base.ref.from`); title/body edits carry no `changes.base` and are skipped, so + # the guard is not rebuilt needlessly. + if: >- + (endsWith(github.base_ref, 'master') || endsWith(github.base_ref, 'main')) + && (github.event.action != 'edited' || github.event.changes.base.ref.from != '') + + # `statuses: write` lets the job publish the `Version Guard` commit status that + # branch protection requires. + permissions: + contents: read + statuses: write steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 with: submodules: 'true' - - uses: actions/setup-java@v4 + # `checkVersionIncrement` reads `origin/:version.gradle.kts`. The pull request + # checkout does not include the base branch, so fetch its tip into the expected ref. + - name: Fetch the base branch + shell: bash + run: | + git fetch --no-tags --depth=1 \ + origin "+refs/heads/${GITHUB_BASE_REF}:refs/remotes/origin/${GITHUB_BASE_REF}" + + - uses: actions/setup-java@v5 with: java-version: 17 distribution: zulu - cache: gradle - - name: Check version is not yet published + - uses: gradle/actions/setup-gradle@v6 + + - name: Check version increment + id: guard shell: bash + # `VERSION_GUARD` enables the strict base-branch comparison in `checkVersionIncrement`. + # Only this workflow fetches the base ref (the step above), so the comparison is gated + # to it: other CI builds pull the task in via `publishToMavenLocal` on a shallow + # checkout and must not attempt to read `origin/`. + env: + VERSION_GUARD: "true" run: ./gradlew checkVersionIncrement --stacktrace + + # Publish the verdict as the `Version Guard` commit status on the PR head. Posting it + # on every run (success or failure) is what lets a later re-bump clear a failure that + # `revalidate-versions.yml` set when the base branch advanced. + # + # Skipped for fork PRs: `GITHUB_TOKEN` is read-only for them, so the status cannot be + # posted (and a fork head SHA is not in this repo). The Spine agent workflow pushes PR + # branches to the same repository; fork contributions are handled by a maintainer, who + # owns the version bump. + - name: Report the Version Guard status + if: always() && github.event.pull_request.head.repo.fork == false + shell: bash + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + state=failure + if [ "${{ steps.guard.outcome }}" = "success" ]; then + state=success + fi + gh api -X POST "repos/${{ github.repository }}/statuses/${{ github.event.pull_request.head.sha }}" \ + -f state="${state}" \ + -f context="Version Guard" \ + -f description="Version increment check" diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index f7218c618e..27c11c0f82 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -10,15 +10,16 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 with: submodules: 'true' - - uses: actions/setup-java@v4 + - uses: actions/setup-java@v5 with: java-version: 17 distribution: zulu - cache: gradle + + - uses: gradle/actions/setup-gradle@v6 - name: Decrypt CloudRepo credentials run: ./config/scripts/decrypt.sh "$CLOUDREPO_CREDENTIALS_KEY" ./.github/keys/cloudrepo.properties.gpg ./cloudrepo.properties @@ -63,3 +64,18 @@ jobs: REPO_SLUG: ${{ github.repository }} # e.g. SpineEventEngine/core-jvm GOOGLE_APPLICATION_CREDENTIALS: ./maven-publisher.json NPM_TOKEN: ${{ secrets.NPM_SECRET }} + + # A failed publication on `master` is most often a version collision: a stale + # duplicate bump merged before `revalidate-versions.yml` could turn it red (the + # narrow auto-merge race). The artifact is safe โ€” the registry rejects the + # overwrite โ€” but the fix needs a human/agent, so make the failure loud and + # actionable instead of a quiet red run. + - name: Report a failed publication + if: failure() + shell: bash + run: | + echo "::error title=Publish failed::Publishing to Maven failed on the base branch. If this is a version collision, the version is already published (immutable). Bump 'version.gradle.kts' on the base branch (e.g. via a small PR) and re-run this workflow." + echo "Publish failed. If the cause is a version collision:" + echo " 1. Bump 'version.gradle.kts' on the base branch to the next free version." + echo " 2. Re-run this 'Publish' workflow." + echo "Stale duplicate bumps are normally caught before merge by 'revalidate-versions.yml'." diff --git a/.github/workflows/remove-obsolete-artifacts-from-packages.yaml b/.github/workflows/remove-obsolete-artifacts-from-packages.yaml index f706171007..62242c7bbb 100644 --- a/.github/workflows/remove-obsolete-artifacts-from-packages.yaml +++ b/.github/workflows/remove-obsolete-artifacts-from-packages.yaml @@ -39,7 +39,7 @@ jobs: outputs: package-names: ${{ steps.request-package-names.outputs.package-names }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 with: submodules: 'true' diff --git a/.github/workflows/revalidate-versions.yml b/.github/workflows/revalidate-versions.yml new file mode 100644 index 0000000000..2d2ecb9c6d --- /dev/null +++ b/.github/workflows/revalidate-versions.yml @@ -0,0 +1,57 @@ +# Re-judges every other open pull request when the base branch advances. +# +# Publishing runs on every push to a release base branch, so once one pull request merges +# and bumps the version, any other open PR that bumped to the same (or a lower) value is +# now stale: its publish would collide. GitHub does not re-run a PR's checks when its base +# advances, so this workflow does it actively โ€” for each other open PR whose +# `version.gradle.kts` version is `<=` the new base version, it posts a failing +# `Version Guard` commit status on the PR head, blocking the merge until the author +# re-bumps. The status self-clears: the re-bump push runs `increment-guard.yml`, which +# posts a fresh `success` on the new head. +# +# This narrows, but does not close, the race against auto-merge. A PR that is already +# mergeable can merge in the seconds before this fan-out marks it stale; that late merge +# produces a publish collision which the immutable Maven registry rejects (a loud, +# recoverable red Publish), never an overwrite. The deterministic guarantee is the +# registry's immutability, not this signal. + +name: Revalidate Versions + +on: + push: + # Matches the PR guard's `endsWith(base_ref, 'master'|'main')` and the same scope as + # `build-on-ubuntu.yml`. `**` (unlike `*`) also crosses `/`, so slash-named release + # lines such as `release/2.x-master` are covered. Keeping these definitions identical + # ensures every branch guarded on the PR side is also revalidated here. + branches: + - '**master' + - '**main' + +permissions: + contents: read + statuses: write + pull-requests: read + +concurrency: + # Only the newest tip of a given base matters; a later push to the same ref cancels an + # in-flight fan-out for it. Different bases run independently. + group: revalidate-versions-${{ github.ref }} + cancel-in-progress: true + +jobs: + revalidate: + name: Revalidate open PRs + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + with: + submodules: 'true' + + - name: Revalidate open PRs against the new base version + shell: bash + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + REPO: ${{ github.repository }} + BASE_REF: ${{ github.ref_name }} + # Invoked via `bash` so it does not depend on the script's committed executable bit. + run: bash ./config/scripts/revalidate-versions.sh diff --git a/.github/workflows/secret-scan.yml b/.github/workflows/secret-scan.yml new file mode 100644 index 0000000000..6f0130e21d --- /dev/null +++ b/.github/workflows/secret-scan.yml @@ -0,0 +1,70 @@ +name: Secret scan + +# Defense-in-depth behind the local `secret-scan` pre-commit hook and the +# `.gitignore` secret patterns: if a credential is committed despite those, this +# fails the pull request before it can merge. Distributed to every Spine repo by +# `./config/pull`. + +on: + pull_request: + push: + branches: + - master + - main + +permissions: + contents: read + +jobs: + gitleaks: + name: gitleaks + runs-on: ubuntu-latest + env: + # Pinned gitleaks version โ€” bump through the usual dependency-update process. + GITLEAKS_VERSION: "8.21.2" + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + # Full history so a pull request's commit range can be scanned. + fetch-depth: 0 + + - name: Install gitleaks + # Run gitleaks as the runner user against the checkout it owns โ€” no + # container, so no "dubious ownership" git error and no GitHub Action + # org-licence requirement. + run: | + curl -sSfL "https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz" \ + | tar -xzf - gitleaks + ./gitleaks version + + - name: Scan + env: + EVENT: ${{ github.event_name }} + BASE: ${{ github.event.pull_request.base.sha }} + HEAD: ${{ github.event.pull_request.head.sha }} + BEFORE: ${{ github.event.before }} + AFTER: ${{ github.sha }} + run: | + if [ "$EVENT" = pull_request ]; then + # Scan the PR's own commit RANGE: a secret added in one commit and + # deleted in a later commit of the same PR is still caught (a + # working-tree scan would miss it, yet merging keeps the secret-bearing + # commit reachable), while already-rotated secrets in older history + # outside base..head are not re-flagged. + ./gitleaks git --log-opts="$BASE..$HEAD" --redact --verbose --exit-code=1 . + else + # Push to a default branch: scan the pushed commit RANGE (before..after) + # so an add-then-remove batch is caught here too, not only on PRs โ€” the + # leaked commit would otherwise stay reachable on the default branch. A + # branch's first push reports an all-zero `before` (no range); fall back + # to a working-tree scan then. + if [ -n "$BEFORE" ] && [ "$BEFORE" != "0000000000000000000000000000000000000000" ]; then + ./gitleaks git --log-opts="$BEFORE..$AFTER" --redact --verbose --exit-code=1 . + else + # Branch's first push (all-zero `before`): no range to diff against, so + # scan the whole history reachable from the pushed tip as the initial + # import โ€” an add-then-remove within those commits is still caught. + ./gitleaks git --log-opts="$AFTER" --redact --verbose --exit-code=1 . + fi + fi diff --git a/.gitignore b/.gitignore index 38e5ad4b6d..dfb4774d4e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ +# >>> shared config (managed by ./config/pull -- do not edit inside this block) >>> # # Copyright 2025, TeamDev. All rights reserved. # @@ -103,14 +104,48 @@ gradle-app.setting # Spine internal directory for storing intermediate artifacts **/.spine/** -# Login details to Maven repository. -# Each workstation should have developer's login defined in this file. +# --------------------------------------------------------------------------- +# Secrets โ€” NEVER commit these. +# +# Encrypted credentials live under `.github/keys/*.gpg` and ARE committed. +# `config/scripts/decrypt.sh` turns each into its PLAINTEXT twin at build / CI / +# publish time (e.g. `spine-dev-framework-ci.json.gpg` -> `spine-dev.json`). The +# decrypted twins below โ€” and any private key or service-account file โ€” must stay +# out of Git. The shared `secret-scan` pre-commit hook is the backstop if one ever +# slips past these patterns. +# --------------------------------------------------------------------------- + +# Maven repository login details; each workstation defines its own. credentials.tar credentials.properties cloudrepo.properties deploy_key_rsa gcs-auth-key.json +# Decrypted Google / GCP service-account keys (plaintext twins of *.gpg). +spine-dev.json +spine-dev-*.json +maven-publisher.json +firebase-sa.json +*-sa.json +*service-account*.json + +# Decrypted credential property files and portal / publisher secrets. +*.secret.properties + +# Private SSH keys (public keys are *.pub and remain committable). +*_rsa +*_dsa +*_ecdsa +*_ed25519 +id_rsa +id_dsa +id_ecdsa +id_ed25519 + +# ...but always keep the committed ENCRYPTED forms. +!*.gpg + # Log files *.log @@ -152,3 +187,38 @@ __pycache__/ docs/_preview/node_modules/ docs/_preview/public/ docs/_preview/resources/ +# <<< shared config <<< + +# >>> repo-local entries (preserved across ./config/pull) >>> +!.idea/misc.xml +!.idea/codeStyleSettings.xml +!.idea/codeStyles/ +!.idea/copyright/ +!**/src/**/build/** +!gradle-wrapper.jar +# Login details to Maven repository. +# Each workstation should have developer's login defined in this file. +# <<< repo-local entries <<< + +# >>> secret ignores re-asserted last (managed by ./config/pull -- do not edit) >>> +credentials.tar +credentials.properties +cloudrepo.properties +deploy_key_rsa +gcs-auth-key.json +spine-dev.json +spine-dev-*.json +maven-publisher.json +firebase-sa.json +*-sa.json +*service-account*.json +*.secret.properties +*_rsa +*_dsa +*_ecdsa +*_ed25519 +id_rsa +id_dsa +id_ecdsa +id_ed25519 +# <<< secret ignores <<< diff --git a/.gitmodules b/.gitmodules index c9606ebb47..0289cbe493 100644 --- a/.gitmodules +++ b/.gitmodules @@ -7,3 +7,9 @@ [submodule "docs/_time"] path = docs/_time url = https://github.com/SpineEventEngine/time +[submodule ".agents/shared"] + path = .agents/shared + url = https://github.com/SpineEventEngine/agents.git + branch = master + update = merge + ignore = all diff --git a/.junie/guidelines.md b/.junie/guidelines.md index 5160f499e6..7c1f866547 100644 --- a/.junie/guidelines.md +++ b/.junie/guidelines.md @@ -1,6 +1,6 @@ # Guidelines for Junie and AI Agent from JetBrains -Read the `../.agents/_TOC.md` file to understand: +Read the `../.agents/guidelines/_TOC.md` file to understand: - the agent responsibilities, - project overview, - coding guidelines, diff --git a/AGENTS.md b/AGENTS.md index c2a8da50e9..8c5f6198d0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,16 +4,34 @@ If `.agents/project.md` exists in this repository, read it first โ€” it describes the language, architecture, and role of this specific repo within the Spine SDK -organisation. To create one, copy `.agents/project.template.md` (or the -relevant language template) and fill it in. If `project.md` links to a shared -requirements file (e.g. `jvm-project.md`), read that too. +organisation. It is a symlink to `docs/project.md`; to create one, copy +`.agents/guidelines/project.template.md` to `docs/project.md` and fill it in. If it +links to a shared requirements file (e.g. `jvm-project.md`), read that too. -- Start every session by reading `.agents/quick-reference-card.md` (if present). +- Start every session by reading `.agents/guidelines/quick-reference-card.md` (if present). - For specific tasks (code review, PR prep, dependency updates, docs, etc.), prefer the matching skill from `.agents/skills/`. -- Full standards reference: `.agents/_TOC.md` (if present) โ€” consult when a +- Full standards reference: `.agents/guidelines/_TOC.md` (if present) โ€” consult when a skill doesn't cover the needed context. +Shared skills, scripts, and guidelines come from the `.agents/shared` submodule (the +[`agents`][agents-repo] repository) exposed via symlinks. +`./config/pull` initializes and floats them automatically. But a fresh `git worktree` +(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. + ## Commit and history safety **Do not commit, push, tag, rebase, merge, cherry-pick, or otherwise write to git history** @@ -26,7 +44,7 @@ unless one of the following is true *right now*: Authorization does not carry over between turns or sessions. When in doubt: stage changes, show the diff, and stop โ€” let the user commit. -See [`.agents/safety-rules.md`](.agents/safety-rules.md) โ†’ *Commits and history-writing*. +See [`.agents/guidelines/safety-rules.md`](.agents/guidelines/safety-rules.md) โ†’ *Commits and history-writing*. ## Other safety rules @@ -35,7 +53,7 @@ See [`.agents/safety-rules.md`](.agents/safety-rules.md) โ†’ *Commits and histor - No analytics, telemetry, or tracking code. - No reflection or unsafe code without explicit approval. -See [`.agents/safety-rules.md`](.agents/safety-rules.md) for the full list. +See [`.agents/guidelines/safety-rules.md`](.agents/guidelines/safety-rules.md) for the full list. ## Moving files @@ -110,6 +128,8 @@ In consumer repositories, skip without comment any path matching: - `.claude/**`, `.idea/**`, `.junie/**` - `.github/copilot-instructions.md` - `buildSrc/**` (except `buildSrc/src/main/kotlin/module.gradle.kts`) -- `gradle/`, `gradlew`, `gradlew.bat` +- `gradle/`, `gradlew`, `gradlew.bat`, `init-submodules` - `.codecov.yml`, `.gitignore`, `gradle.properties`, `lychee.toml` - `.github/workflows/` โ€” unless the workflow was introduced by this repo + +[agents-repo]: https://github.com/SpineEventEngine/agents diff --git a/buildSrc/build.gradle.kts b/buildSrc/build.gradle.kts index 23abc30bf5..a61d839f8e 100644 --- a/buildSrc/build.gradle.kts +++ b/buildSrc/build.gradle.kts @@ -77,7 +77,7 @@ val grGitVersion = "4.1.1" * This version may change from the [version of Kotlin][io.spine.dependency.lib.Kotlin.version] * used by the project. */ -val kotlinEmbeddedVersion = "2.3.20" +val kotlinEmbeddedVersion = "2.3.21" /** * The version of Guava used in `buildSrc`. @@ -85,7 +85,7 @@ val kotlinEmbeddedVersion = "2.3.20" * Always use the same version as the one specified in [io.spine.dependency.lib.Guava]. * Otherwise, when testing Gradle plugins, clashes may occur. */ -val guavaVersion = "33.5.0-jre" +val guavaVersion = "33.6.0-jre" /** * The version of ErrorProne Gradle plugin. @@ -95,7 +95,7 @@ val guavaVersion = "33.5.0-jre" * @see * Error Prone Gradle Plugin Releases */ -val errorPronePluginVersion = "4.2.0" +val errorPronePluginVersion = "5.1.0" /** * The version of Protobuf Gradle Plugin. @@ -105,7 +105,7 @@ val errorPronePluginVersion = "4.2.0" * @see * Protobuf Gradle Plugins Releases */ -val protobufPluginVersion = "0.9.6" +val protobufPluginVersion = "0.10.0" /** * The version of Dokka Gradle Plugins. diff --git a/buildSrc/quality/pmd.xml b/buildSrc/quality/pmd.xml index 1d49f90112..5e0e88e3d7 100644 --- a/buildSrc/quality/pmd.xml +++ b/buildSrc/quality/pmd.xml @@ -1,7 +1,7 @@ io.spine.tools validation -2.0.0-SNAPSHOT.446 +2.0.0-SNAPSHOT.447 2015 @@ -26,37 +26,37 @@ all modules and does not describe the project structure per-subproject. com.google.protobuf protobuf-gradle-plugin - 0.9.6 + 0.10.0 compile com.google.protobuf protobuf-java - 4.34.1 + 4.35.0 compile com.google.protobuf protobuf-java-util - 4.34.1 + 4.35.0 compile com.google.protobuf protobuf-kotlin - 4.34.1 + 4.35.0 compile io.spine spine-base - 2.0.0-SNAPSHOT.391 + 2.0.0-SNAPSHOT.421 compile io.spine spine-format - 2.0.0-SNAPSHOT.391 + 2.0.0-SNAPSHOT.421 compile @@ -74,55 +74,61 @@ all modules and does not describe the project structure per-subproject. io.spine spine-validation-jvm-runtime - 2.0.0-SNAPSHOT.445 + 2.0.0-SNAPSHOT.446 + compile + + + io.spine.tools + compiler-api + 2.0.0-SNAPSHOT.059 compile io.spine.tools compiler-backend - 2.0.0-SNAPSHOT.046 + 2.0.0-SNAPSHOT.059 compile io.spine.tools compiler-gradle-api - 2.0.0-SNAPSHOT.046 + 2.0.0-SNAPSHOT.059 compile io.spine.tools compiler-gradle-plugin - 2.0.0-SNAPSHOT.046 + 2.0.0-SNAPSHOT.059 compile io.spine.tools compiler-jvm - 2.0.0-SNAPSHOT.046 + 2.0.0-SNAPSHOT.059 compile io.spine.tools compiler-params - 2.0.0-SNAPSHOT.046 + 2.0.0-SNAPSHOT.059 compile io.spine.tools jvm-tools - 2.0.0-SNAPSHOT.381 + 2.0.0-SNAPSHOT.402 compile org.jetbrains.kotlin kotlin-bom - 2.3.20 + 2.3.21 compile org.jetbrains.kotlin kotlin-stdlib - 2.3.20 + 2.3.21 compile @@ -137,28 +143,40 @@ all modules and does not describe the project structure per-subproject. 1.0.0 compile + + com.google.auto.service + auto-service + 1.1.1 + provided + + + com.google.auto.service + auto-service-annotations + 1.1.1 + provided + com.google.guava guava-testlib - 33.5.0-jre + 33.6.0-jre test com.google.truth truth - 1.4.4 + 1.4.5 test com.google.truth.extensions truth-java8-extension - 1.4.4 + 1.4.5 test com.google.truth.extensions truth-proto-extension - 1.4.4 + 1.4.5 test @@ -170,7 +188,7 @@ all modules and does not describe the project structure per-subproject. io.spine spine-logging - 2.0.0-SNAPSHOT.417 + 2.0.0-SNAPSHOT.419 test @@ -179,28 +197,22 @@ all modules and does not describe the project structure per-subproject. 2.0.0-SNAPSHOT.213 test - - io.spine.tools - compiler-api - 2.0.0-SNAPSHOT.046 - test - io.spine.tools compiler-testlib - 2.0.0-SNAPSHOT.046 + 2.0.0-SNAPSHOT.059 test io.spine.tools logging-testlib - 2.0.0-SNAPSHOT.417 + 2.0.0-SNAPSHOT.419 test org.junit junit-bom - 6.0.3 + 6.1.0 test @@ -212,57 +224,35 @@ all modules and does not describe the project structure per-subproject. org.junit.jupiter junit-jupiter-api - 6.0.3 + 6.1.0 test org.junit.jupiter junit-jupiter-engine - 6.0.3 + 6.1.0 test org.junit.jupiter junit-jupiter-params - 6.0.3 + 6.1.0 test - - com.google.auto.service - auto-service - 1.1.1 - provided - - - com.google.auto.service - auto-service-annotations - 1.1.1 - provided - - - com.google.devtools.ksp - symbol-processing - 2.3.6 - com.google.devtools.ksp symbol-processing-api - 2.3.6 + 2.3.9 com.google.errorprone error_prone_core 2.36.0 - - com.google.errorprone - javac - 9+181-r4173-1 - com.google.protobuf protoc - 4.34.1 + 4.35.0 dev.zacsweers.autoservice @@ -277,22 +267,22 @@ all modules and does not describe the project structure per-subproject. io.spine.tools compiler-cli-all - 2.0.0-SNAPSHOT.046 + 2.0.0-SNAPSHOT.059 io.spine.tools compiler-protoc-plugin - 2.0.0-SNAPSHOT.046 + 2.0.0-SNAPSHOT.059 io.spine.tools core-jvm-plugins - 2.0.0-SNAPSHOT.068 + 2.0.0-SNAPSHOT.080 io.spine.tools prototap-protoc-plugin - 0.14.0 + 0.16.0 io.spine.tools @@ -307,27 +297,27 @@ all modules and does not describe the project structure per-subproject. io.spine.tools validation-java-bundle - 2.0.0-SNAPSHOT.445 + 2.0.0-SNAPSHOT.446 net.sourceforge.pmd pmd-ant - 7.12.0 + 7.25.0 net.sourceforge.pmd pmd-java - 7.12.0 + 7.25.0 org.jacoco org.jacoco.agent - 0.8.14 + 0.8.15 org.jacoco org.jacoco.report - 0.8.14 + 0.8.15 org.jetbrains.dokka @@ -359,20 +349,30 @@ all modules and does not describe the project structure per-subproject. templating-plugin 2.2.0 + + org.jetbrains.kotlin + abi-tools + 2.3.21 + org.jetbrains.kotlin kotlin-build-tools-compat - 2.3.20 + 2.3.21 org.jetbrains.kotlin kotlin-build-tools-impl - 2.3.20 + 2.3.21 + + + org.jetbrains.kotlin + kotlin-klib-commonizer-embeddable + 2.3.21 org.jetbrains.kotlin kotlin-scripting-compiler-embeddable - 2.3.20 + 2.3.21 diff --git a/docs/project.md b/docs/project.md new file mode 100644 index 0000000000..09b0acb423 --- /dev/null +++ b/docs/project.md @@ -0,0 +1,19 @@ +# Project: Validation + +## Overview + +Validation is a Spine SDK JVM library that provides the project's validation +model and related integration points. Its role in the organisation is to define +and expose validation behaviour in a reusable form so other modules can apply +consistent validation rules and report validation results through a stable API. + +## Architecture + +This repository is a library module. Its public surface should stay focused on +validation concepts, rule execution, and result/reporting types that other Spine +SDK components can depend on without pulling in application-specific behaviour. +Keep implementation details behind the library boundary, prefer additive changes +to public APIs, and preserve compatibility for downstream JVM consumers. + +Read [`.agents/guidelines/jvm-project.md`](../.agents/guidelines/jvm-project.md) for build stack, coding style, +tests, and versioning. diff --git a/gradle.properties b/gradle.properties index 49ad30c59b..7c2bb5f202 100644 --- a/gradle.properties +++ b/gradle.properties @@ -6,10 +6,30 @@ org.gradle.java.installations.auto-download=true # Use parallel builds for better performance. org.gradle.parallel=true -#org.gradle.caching=true -# Dokka plugin eats more memory than usual. Therefore, all builds should have enough. -org.gradle.jvmargs=-Xmx4096m -XX:MaxMetaspaceSize=1024m -XX:+UseParallelGC -Dfile.encoding=UTF-8 +# Reuse task outputs from the local build cache. +# On CI, `gradle/actions/setup-gradle` persists `caches/build-cache-1` across runs, +# so cold builds skip work whose inputs are unchanged. +org.gradle.caching=true + +# Extra JVM args for the Gradle daemon, for two unrelated reasons: +# +# 1. The Dokka plugin eats more memory than usual, so all builds get a generous heap. +# 2. The `--add-exports` / `--add-opens` flags expose the `jdk.compiler` internals that +# Error Prone needs on JDK 16+ (JEP 396). Passing them to the daemon here lets the +# `net.ltgt.errorprone` plugin run Error Prone in-process instead of forking a separate +# compiler JVM per task. See https://github.com/SpineEventEngine/config/issues/543 +org.gradle.jvmargs=-Xmx4096m -XX:MaxMetaspaceSize=1024m -XX:+UseParallelGC -Dfile.encoding=UTF-8 \ + --add-exports=jdk.compiler/com.sun.tools.javac.api=ALL-UNNAMED \ + --add-exports=jdk.compiler/com.sun.tools.javac.file=ALL-UNNAMED \ + --add-exports=jdk.compiler/com.sun.tools.javac.main=ALL-UNNAMED \ + --add-exports=jdk.compiler/com.sun.tools.javac.model=ALL-UNNAMED \ + --add-exports=jdk.compiler/com.sun.tools.javac.parser=ALL-UNNAMED \ + --add-exports=jdk.compiler/com.sun.tools.javac.processing=ALL-UNNAMED \ + --add-exports=jdk.compiler/com.sun.tools.javac.tree=ALL-UNNAMED \ + --add-exports=jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED \ + --add-opens=jdk.compiler/com.sun.tools.javac.code=ALL-UNNAMED \ + --add-opens=jdk.compiler/com.sun.tools.javac.comp=ALL-UNNAMED # suppress inspection "UnusedProperty" # The below property enables generation of XML reports for tests. diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index df6a6ad763..a9db11550c 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.1-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.1-bin.zip networkTimeout=10000 retries=0 retryBackOffMs=500 diff --git a/gradlew b/gradlew index b9bb139f79..249efbb032 100755 --- a/gradlew +++ b/gradlew @@ -20,7 +20,7 @@ ############################################################################## # -# Gradle start up script for POSIX generated by Gradle. +# gradlew start up script for POSIX generated by Gradle. # # Important for running: # @@ -29,7 +29,7 @@ # bash, then to run this script, type that shell name before the whole # command line, like: # -# ksh Gradle +# ksh gradlew # # Busybox and similar reduced shells will NOT work, because this script # requires all of these POSIX shell features: diff --git a/gradlew.bat b/gradlew.bat index 24c62d56f2..a51ec4f588 100644 --- a/gradlew.bat +++ b/gradlew.bat @@ -19,7 +19,7 @@ @if "%DEBUG%"=="" @echo off @rem ########################################################################## @rem -@rem Gradle startup script for Windows +@rem gradlew startup script for Windows @rem @rem ########################################################################## @@ -72,7 +72,7 @@ echo location of your Java installation. 1>&2 -@rem Execute Gradle +@rem Execute gradlew @rem endlocal doesn't take effect until after the line is parsed and variables are expanded @rem which allows us to clear the local environment before executing the java command endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel diff --git a/init-submodules b/init-submodules new file mode 100755 index 0000000000..2ae143d8ea --- /dev/null +++ b/init-submodules @@ -0,0 +1,99 @@ +#!/usr/bin/env bash + +################################################################################ +# +# Materialize the *config-managed* submodules a fresh working tree is missing, so +# agent assets resolve. +# +# `git worktree add` โ€” and some shallow CI / cloud checkouts โ€” populate only the +# superproject's own tracked files; registered submodules are left UNinitialized. +# In a Spine repo that means the `config` and `.agents/shared` submodules are +# empty, the `.agents/skills` -> `.agents/shared/skills` symlink dangles, and no +# agent skills, scripts, or guidelines can be found. +# +# This script is the bootstrap that has to run BEFORE `./config/pull`: `pull` +# lives inside the `config` submodule, so on a fresh worktree it does not yet +# exist. `init-submodules`, by contrast, is a plain tracked file at the repo root +# (distributed by `config`), so `git worktree add` always checks it out โ€” it can +# therefore bring `config` itself into existence. +# +# It initializes ONLY submodules that are BOTH: +# +# * not yet checked out โ€” those `git submodule status` marks with a leading `-`, +# at the commit the branch pins; and +# +# * config-managed โ€” `config` itself (the bootstrap target `pull` lives inside, +# which carries no tracked `branch` in a consumer's `.gitmodules`), plus every +# submodule that declares a tracked `branch` in `.gitmodules`. This is exactly +# the rule `./config/pull` uses to decide what it floats, so the two scripts +# can never disagree about what is shared. +# +# Consumer-owned submodules (a Hugo theme, a vendored library, documentation +# examples, ...) declare no tracked branch and are deliberately left untouched. +# Because a `SessionStart` hook runs this script automatically on every session, +# initializing them would mean trying to clone โ€” or failing on credentials for โ€” +# a submodule this project does not manage, on every single start. They are +# skipped (noted on stderr). +# +# Submodules already present are left exactly as they are, so a tree that floated +# `config` / `.agents/shared` to a branch tip via `./config/pull` is never +# silently rewound to the pin. That makes the script idempotent and safe to run on +# every session start. +# +# It does NOT float submodules to their branch tips โ€” run `./config/pull` +# afterwards for that. Unlike `pull`, it depends on no pre-existing `config` +# submodule, so it can bootstrap a bare worktree where `./config/pull` does not +# yet exist. +# +################################################################################ + +set -u + +root=$(git rev-parse --show-toplevel 2>/dev/null) || exit 0 +cd "$root" || exit 0 + +# Nothing to do in a repo without submodules. +[ -f .gitmodules ] || exit 0 + +# The set of config-managed submodule paths: `config` itself (handled specially โ€” +# it carries no tracked branch, exactly as in `./config/pull`), plus every +# submodule declaring a tracked `branch` in `.gitmodules`. Mirrors `pull`'s rule. +config_managed_paths() { + printf '%s\n' 'config' + git config -f .gitmodules --get-regexp '^submodule\..*\.branch$' 2>/dev/null \ + | while read -r key _branch; do + name=${key#submodule.}; name=${name%.branch} + git config -f .gitmodules --get "submodule.$name.path" 2>/dev/null + done +} + +managed=$(config_managed_paths | sort -u) + +# `git submodule status` prefixes each uninitialized submodule with `-`; an +# initialized one starts with a space (at the pinned commit) or `+` (ahead of it). +# Act only on the `-` lines, taking the path from the second field, and only when +# that path is config-managed. +git submodule status 2>/dev/null | awk '$1 ~ /^-/ { print $2 }' | while read -r path; do + [ -n "$path" ] || continue + if printf '%s\n' "$managed" | grep -qxF -- "$path"; then + echo "init-submodules: initializing '$path'" + git submodule update --init --recursive -- "$path" \ + || echo "init-submodules: WARNING โ€” could not initialize '$path' (offline?)." >&2 + else + echo "init-submodules: skipping consumer-owned '$path' (not config-managed)." >&2 + fi +done + +# Route Git hooks to the shared hooks directory so the secret-scan `pre-commit` +# hook is active even in a brand-new worktree, before `./config/pull` runs. The +# path floats with the `.agents/shared` submodule; until that submodule is +# initialized the hook simply does not fire (Git skips a missing hook). Set only +# when unset or already ours โ€” never override a repo's own `core.hooksPath`. +desired_hooks=".agents/scripts/git-hooks" +current_hooks=$(git config --local --get core.hooksPath 2>/dev/null || true) +if [ -z "$current_hooks" ] || [ "$current_hooks" = "$desired_hooks" ]; then + git config --local core.hooksPath "$desired_hooks" \ + && echo "init-submodules: Git hooks routed to '$desired_hooks' (secret-scan pre-commit active)." +fi + +exit 0 diff --git a/jvm-runtime/src/main/java/io/spine/validation/ValidatingBuilder.java b/jvm-runtime/src/main/java/io/spine/validation/ValidatingBuilder.java index 26b7f5dac7..4d03dbce81 100644 --- a/jvm-runtime/src/main/java/io/spine/validation/ValidatingBuilder.java +++ b/jvm-runtime/src/main/java/io/spine/validation/ValidatingBuilder.java @@ -1,5 +1,5 @@ /* - * Copyright 2025, TeamDev. All rights reserved. + * 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. @@ -26,18 +26,24 @@ package io.spine.validation; +import com.google.common.collect.ImmutableList; import com.google.protobuf.Message; import io.spine.annotation.GeneratedMixin; +import java.util.List; + /** * Implementation base for generated message builders. * - *

This interface defines a method {@link #build()} which validates the built message + *

This interface defines a method {@link #build()} that validates the built message * before returning it to the user. * *

If a user specifically needs to skip validation, they should use * {@link #buildPartial()} to make the intent explicit. * + *

To check the current content of the builder for validity without + * obtaining the built message, use {@link #validate()}. + * * @param * the type of the message to build */ @@ -65,6 +71,38 @@ public interface ValidatingBuilder extends Message.Builder { @Override @NonValidated M buildPartial(); + /** + * Probes the current content of this builder for validity. + * + *

The content is validated in place โ€” including content that is already + * invalid, e.g., assembled from untrusted input or restored via + * {@link Message#toBuilder() toBuilder()} from a message that no longer + * satisfies its constraints. + * + *

Unlike {@link #build()}, this method does not throw + * {@link ValidationException} if the content is invalid. Any constraint + * violations found are reported via the returned list instead. Unlike + * {@link #buildPartial()}, which skips validation entirely, this method does + * run validation, but without handing over a potentially invalid message. + * + *

Calling this method does not modify the content of this builder. + * + *

If the message under construction does not support validation, i.e., does not + * implement {@link ValidatableMessage}, its content is considered valid. + * + * @return the violations of the constraints declared in Protobuf, + * or an empty list if the current content is valid + */ + default List validate() { + var message = buildPartial(); + if (message instanceof ValidatableMessage validatable) { + return validatable.validate() + .map(ValidationError::getConstraintViolationList) + .orElse(ImmutableList.of()); + } + return ImmutableList.of(); + } + /** * Constructs the message and {@linkplain Validate validates} it according to the constraints * declared in Protobuf. diff --git a/jvm-runtime/src/test/kotlin/io/spine/validation/ValidatingBuilderSpec.kt b/jvm-runtime/src/test/kotlin/io/spine/validation/ValidatingBuilderSpec.kt new file mode 100644 index 0000000000..a7cdda51c0 --- /dev/null +++ b/jvm-runtime/src/test/kotlin/io/spine/validation/ValidatingBuilderSpec.kt @@ -0,0 +1,112 @@ +/* + * 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.validation + +import com.google.protobuf.Empty +import com.google.protobuf.Message +import io.kotest.matchers.collections.shouldBeEmpty +import io.kotest.matchers.shouldBe +import io.spine.base.FieldPath +import io.spine.string.templateString +import io.spine.type.TypeName +import java.util.* +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertDoesNotThrow + +/** + * Unit tests for the [validate][ValidatingBuilder.validate] default method. + * + * The method probes the builder's product: it collects the violations reported + * by a [ValidatableMessage], and treats a product that does not support + * validation as valid. Real generated builders always produce a + * [ValidatableMessage], so these tests use minimal stubs to cover each branch of + * the contract in isolation โ€” including the non-[ValidatableMessage] case that a + * generated builder can never reach. The valid and invalid cases are also + * verified end-to-end against generated code in the `tests/validating` module. + */ +@DisplayName("`ValidatingBuilder.validate()` should") +internal class ValidatingBuilderSpec { + + private val violation = constraintViolation { + message = templateString { withPlaceholders = "The field is required." } + } + + @Test + fun `report no violations when the validatable product is valid`() { + val builder = StubBuilder(StubValidatable(emptyList())) + + builder.validate().shouldBeEmpty() + } + + @Test + fun `report the violations of an invalid validatable product without throwing`() { + val builder = StubBuilder(StubValidatable(listOf(violation))) + + val violations = assertDoesNotThrow { builder.validate() } + + violations shouldBe listOf(violation) + } + + @Test + fun `treat a product without validation support as valid`() { + val builder = StubBuilder(Empty.getDefaultInstance()) + + builder.validate().shouldBeEmpty() + } +} + +/** + * A minimal [ValidatingBuilder] that yields the given [product] from both + * [build] and [buildPartial]; all other [Message.Builder] members are delegated + * to a real [Empty] builder. + */ +private class StubBuilder( + private val product: Message, + private val delegate: Message.Builder = Empty.newBuilder() +) : Message.Builder by delegate, ValidatingBuilder { + + override fun build(): Message = product + override fun buildPartial(): Message = product +} + +/** + * A minimal [ValidatableMessage] reporting the given [violations] from its + * [validate] method; all [Message] members are delegated to a plain [Empty]. + */ +private class StubValidatable( + private val violations: List, + private val delegate: Message = Empty.getDefaultInstance() +) : Message by delegate, ValidatableMessage { + + override fun validate(parentPath: FieldPath, parentName: TypeName?): Optional = + if (violations.isEmpty()) { + Optional.empty() + } else { + Optional.of(validationError { constraintViolation.addAll(violations) }) + } +} diff --git a/pom.xml b/pom.xml deleted file mode 100644 index a4ae4397ce..0000000000 --- a/pom.xml +++ /dev/null @@ -1,361 +0,0 @@ - - -4.0.0 - -io.spine.tools -validation -2.0.0-SNAPSHOT.419 - -2015 - - - - Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0.txt - repo - - - - - - com.google.protobuf - protobuf-gradle-plugin - 0.9.6 - compile - - - com.google.protobuf - protobuf-java - 4.34.1 - compile - - - com.google.protobuf - protobuf-java-util - 4.34.1 - compile - - - com.google.protobuf - protobuf-kotlin - 4.34.1 - compile - - - io.spine - spine-base - 2.0.0-SNAPSHOT.387 - compile - - - io.spine - spine-time - 2.0.0-SNAPSHOT.238 - compile - - - io.spine - spine-validation-jvm-runtime - 2.0.0-SNAPSHOT.415 - compile - - - io.spine.tools - compiler-backend - 2.0.0-SNAPSHOT.043 - compile - - - io.spine.tools - compiler-gradle-api - 2.0.0-SNAPSHOT.043 - compile - - - io.spine.tools - compiler-gradle-plugin - 2.0.0-SNAPSHOT.043 - compile - - - io.spine.tools - compiler-jvm - 2.0.0-SNAPSHOT.043 - compile - - - io.spine.tools - jvm-tools - 2.0.0-SNAPSHOT.378 - compile - - - org.jetbrains.kotlin - kotlin-bom - 2.3.20 - compile - - - org.jetbrains.kotlin - kotlin-stdlib - 2.3.20 - compile - - - org.jetbrains.kotlinx - kotlinx-coroutines-bom - 1.10.2 - compile - - - org.jspecify - jspecify - 1.0.0 - compile - - - com.google.guava - guava-testlib - 33.5.0-jre - test - - - com.google.truth - truth - 1.4.4 - test - - - com.google.truth.extensions - truth-java8-extension - 1.4.4 - test - - - com.google.truth.extensions - truth-proto-extension - 1.4.4 - test - - - io.kotest - kotest-assertions-core - 6.1.11 - test - - - io.spine - spine-logging - 2.0.0-SNAPSHOT.417 - test - - - io.spine.tools - base-testlib - 2.0.0-SNAPSHOT.213 - test - - - io.spine.tools - compiler-api - 2.0.0-SNAPSHOT.043 - test - - - io.spine.tools - compiler-testlib - 2.0.0-SNAPSHOT.043 - test - - - io.spine.tools - logging-testlib - 2.0.0-SNAPSHOT.417 - test - - - org.junit - junit-bom - 6.0.3 - test - - - org.junit-pioneer - junit-pioneer - 2.3.0 - test - - - org.junit.jupiter - junit-jupiter-api - 6.0.3 - test - - - org.junit.jupiter - junit-jupiter-engine - 6.0.3 - test - - - org.junit.jupiter - junit-jupiter-params - 6.0.3 - test - - - com.google.auto.service - auto-service - 1.1.1 - provided - - - com.google.auto.service - auto-service-annotations - 1.1.1 - provided - - - com.google.devtools.ksp - symbol-processing - 2.3.6 - - - com.google.devtools.ksp - symbol-processing-api - 2.3.6 - - - com.google.errorprone - error_prone_core - 2.36.0 - - - com.google.errorprone - javac - 9+181-r4173-1 - - - com.google.protobuf - protoc - 4.34.1 - - - dev.zacsweers.autoservice - auto-service-ksp - 1.2.0 - - - io.gitlab.arturbosch.detekt - detekt-cli - 1.23.8 - - - io.spine.tools - compiler-cli-all - 2.0.0-SNAPSHOT.043 - - - io.spine.tools - compiler-protoc-plugin - 2.0.0-SNAPSHOT.043 - - - io.spine.tools - core-jvm-plugins - 2.0.0-SNAPSHOT.063 - - - io.spine.tools - prototap-protoc-plugin - 0.14.0 - - - io.spine.tools - spine-dokka-extensions - 2.0.0-SNAPSHOT.7 - - - io.spine.tools - time-validation - 2.0.0-SNAPSHOT.236 - - - io.spine.tools - validation-java-bundle - 2.0.0-SNAPSHOT.415 - - - net.sourceforge.pmd - pmd-ant - 7.12.0 - - - net.sourceforge.pmd - pmd-java - 7.12.0 - - - org.jacoco - org.jacoco.agent - 0.8.14 - - - org.jacoco - org.jacoco.ant - 0.8.13 - - - org.jetbrains.dokka - all-modules-page-plugin - 2.2.0 - - - org.jetbrains.dokka - analysis-kotlin-symbols - 2.2.0 - - - org.jetbrains.dokka - dokka-base - 2.2.0 - - - org.jetbrains.dokka - dokka-core - 2.2.0 - - - org.jetbrains.dokka - javadoc-plugin - 2.2.0 - - - org.jetbrains.dokka - templating-plugin - 2.2.0 - - - org.jetbrains.kotlin - kotlin-build-tools-compat - 2.3.20 - - - org.jetbrains.kotlin - kotlin-build-tools-impl - 2.3.20 - - - org.jetbrains.kotlin - kotlin-scripting-compiler-embeddable - 2.3.20 - - - - \ No newline at end of file diff --git a/tests/runtime/build.gradle.kts b/tests/runtime/build.gradle.kts index b25e008be5..6bcd419080 100644 --- a/tests/runtime/build.gradle.kts +++ b/tests/runtime/build.gradle.kts @@ -25,6 +25,7 @@ */ import io.spine.dependency.boms.BomsPlugin +import io.spine.dependency.local.Base import io.spine.dependency.local.Logging import io.spine.dependency.local.TestLib import io.spine.gradle.report.license.LicenseReporter @@ -44,6 +45,7 @@ spine { } dependencies { + testImplementation(Base.lib) testImplementation(Logging.lib) testImplementation(TestLib.lib) } diff --git a/tests/runtime/src/test/kotlin/io/spine/validation/ValidateUtilitySpec.kt b/tests/runtime/src/test/kotlin/io/spine/validation/ValidateUtilitySpec.kt index 0763c55f07..cc0c616baf 100644 --- a/tests/runtime/src/test/kotlin/io/spine/validation/ValidateUtilitySpec.kt +++ b/tests/runtime/src/test/kotlin/io/spine/validation/ValidateUtilitySpec.kt @@ -1,5 +1,5 @@ /* - * Copyright 2025, TeamDev. All rights reserved. + * 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. @@ -30,7 +30,6 @@ import com.google.common.testing.NullPointerTester import com.google.protobuf.Message import io.kotest.matchers.shouldBe import io.spine.base.Time -import io.spine.code.proto.FieldContext import io.spine.string.templateString import io.spine.testing.UtilityClassTest import org.junit.jupiter.api.DisplayName @@ -42,7 +41,6 @@ internal class ValidateUtilitySpec : UtilityClassTest(Validate::class. override fun configure(tester: NullPointerTester) { super.configure(tester) tester.setDefault(Message::class.java, Time.currentTime()) - .setDefault(FieldContext::class.java, FieldContext.empty()) } @Test diff --git a/tests/runtime/src/test/proto/spine/test/validation/commands.proto b/tests/runtime/src/test/proto/spine/test/validation/commands.proto index 28881f046e..65ab1a45e5 100644 --- a/tests/runtime/src/test/proto/spine/test/validation/commands.proto +++ b/tests/runtime/src/test/proto/spine/test/validation/commands.proto @@ -54,17 +54,3 @@ message EntityIdIntFieldValue { message EntityIdLongFieldValue { int64 value = 1; } - -message EntityIdRepeatedFieldValue { - repeated string value = 1; -} - -// Unsupported ID types. - -message EntityIdByteStringFieldValue { - bytes value = 1; -} - -message EntityIdDoubleFieldValue { - double value = 1; -} diff --git a/tests/validating/src/test/kotlin/io/spine/test/JavaMessageSmokeTest.kt b/tests/validating/src/test/kotlin/io/spine/test/JavaMessageSmokeTest.kt index f0e71978c5..c9f7b53206 100644 --- a/tests/validating/src/test/kotlin/io/spine/test/JavaMessageSmokeTest.kt +++ b/tests/validating/src/test/kotlin/io/spine/test/JavaMessageSmokeTest.kt @@ -1,5 +1,5 @@ /* - * Copyright 2024, TeamDev. All rights reserved. + * 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. @@ -27,8 +27,10 @@ package io.spine.test import com.google.protobuf.Message +import io.kotest.matchers.collections.shouldBeEmpty import io.kotest.matchers.collections.shouldContain import io.kotest.matchers.collections.shouldNotBeEmpty +import io.kotest.matchers.shouldBe import io.kotest.matchers.shouldNotBe import io.spine.test.protobuf.CardNumber import io.spine.validation.NonValidated @@ -51,7 +53,7 @@ internal class JavaMessageSmokeTest { CardNumber.newBuilder().setDigits("0000 0000 0000 0000") /** - * The builder which should case error on `build()`. + * The builder which should cause an error on `build()`. */ private val invalid: CardNumber.Builder = CardNumber.newBuilder().setDigits("zazazazazazaz") @@ -81,6 +83,28 @@ internal class JavaMessageSmokeTest { } } + @Test + fun `report no violations via 'validate()' when the builder content is valid`() { + valid.validate().shouldBeEmpty() + } + + @Test + fun `report violations via 'validate()' without throwing when the content is invalid`() { + val violations = assertDoesNotThrow { + invalid.validate() + } + violations.shouldNotBeEmpty() + } + + @Test + fun `keep the builder content intact when probing via 'validate()'`() { + val digitsBefore = invalid.digits + + invalid.validate() + + invalid.digits shouldBe digitsBefore + } + @Test fun `make the message implement 'ValidatableMessage'`() { CardNumber::class.java.interfaces shouldContain ValidatableMessage::class.java diff --git a/tests/validating/src/test/kotlin/io/spine/test/ValidatingBuilderSpec.kt b/tests/validating/src/test/kotlin/io/spine/test/ValidatingBuilderSpec.kt new file mode 100644 index 0000000000..2b0b71c5b9 --- /dev/null +++ b/tests/validating/src/test/kotlin/io/spine/test/ValidatingBuilderSpec.kt @@ -0,0 +1,77 @@ +/* + * 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.test + +import io.kotest.matchers.collections.shouldBeEmpty +import io.kotest.matchers.collections.shouldNotBeEmpty +import io.kotest.matchers.shouldBe +import io.spine.test.tools.validate.InterestRate +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertDoesNotThrow + +/** + * Tests the [validate][io.spine.validation.ValidatingBuilder.validate] probe + * against a real generated builder. + * + * [InterestRate] declares a single `(min)` constraint on its `percent` field + * (`percent` must be `> 0.0`), which makes it convenient for probing both the + * valid and the invalid content of a builder. + */ +@DisplayName("`ValidatingBuilder` should") +internal class ValidatingBuilderSpec { + + @Test + fun `return no violations when the builder content is valid`() { + val builder = InterestRate.newBuilder() + .setPercent(117.3f) + + builder.validate().shouldBeEmpty() + } + + @Test + fun `return violations of invalid content without throwing`() { + val builder = InterestRate.newBuilder() + .setPercent(-3f) + + val violations = assertDoesNotThrow { builder.validate() } + + violations.shouldNotBeEmpty() + violations.single().fieldPath.fieldNameList shouldBe listOf("percent") + } + + @Test + fun `leave the builder content intact`() { + val builder = InterestRate.newBuilder() + .setPercent(-3f) + val before = builder.buildPartial() + + builder.validate() + + builder.buildPartial() shouldBe before + } +} diff --git a/version.gradle.kts b/version.gradle.kts index bf51ca0fb3..f3e0bab1d0 100644 --- a/version.gradle.kts +++ b/version.gradle.kts @@ -27,4 +27,4 @@ /** * The version of the Validation library to publish. */ -val validationVersion by extra("2.0.0-SNAPSHOT.446") +val validationVersion by extra("2.0.0-SNAPSHOT.447")