From 4445a73e6b184babee454d8a5f59b770ca1ec60b Mon Sep 17 00:00:00 2001 From: alexander-yevsyukov Date: Fri, 22 May 2026 14:30:11 +0100 Subject: [PATCH 01/12] Bump version -> `2.0.0-SNAPSHOT.441` --- version.gradle.kts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/version.gradle.kts b/version.gradle.kts index b7f00490c8..78d3ca01bd 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.440") +val validationVersion by extra("2.0.0-SNAPSHOT.441") From 6506e659f4b20200f8d55b60812fbef75e8fe9ed Mon Sep 17 00:00:00 2001 From: alexander-yevsyukov Date: Fri, 22 May 2026 15:15:27 +0100 Subject: [PATCH 02/12] Make warning text indented for better readability Resolves #220. --- .../generate/option/bound/BoundedFieldGenerator.kt | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/java/src/main/kotlin/io/spine/tools/validation/java/generate/option/bound/BoundedFieldGenerator.kt b/java/src/main/kotlin/io/spine/tools/validation/java/generate/option/bound/BoundedFieldGenerator.kt index 59d3971ed8..1094ca7bbc 100644 --- a/java/src/main/kotlin/io/spine/tools/validation/java/generate/option/bound/BoundedFieldGenerator.kt +++ b/java/src/main/kotlin/io/spine/tools/validation/java/generate/option/bound/BoundedFieldGenerator.kt @@ -227,12 +227,14 @@ private fun unsignedIntegerWarning( } val unsignedApi = Docs.unsignedApi(javaClass) Compilation.warning(file, span) { - "Unsigned integer types are not supported in Java. The Protobuf compiler uses" + - " signed integers to represent unsigned types in Java ($SCALAR_TYPES)." + - " Operations on unsigned values rely on static utility methods from" + - " `${javaClass.name}` ($unsignedApi). Be cautious when dealing with" + - " unsigned values outside of these methods, as Java treats all primitive" + - " integers as signed." + """ + Unsigned integer types are not supported in Java. + The Protobuf compiler uses signed integers to represent unsigned types in Java. + See: $SCALAR_TYPES + Operations on unsigned values rely on static utility methods from `${javaClass.name}`. + See: $unsignedApi + Be cautious when dealing with unsigned values outside of these methods, as Java treats all primitive integers as signed. + """.trimIndent() } } From db3b4d1c5aef76c69ecca4811c5a390dfd3757d1 Mon Sep 17 00:00:00 2001 From: alexander-yevsyukov Date: Fri, 22 May 2026 15:53:43 +0100 Subject: [PATCH 03/12] Update `config` --- .agents/scripts/api-discovery/.gitignore | 3 + .agents/scripts/api-discovery/README.md | 158 ++++++++ .agents/scripts/api-discovery/clean-cache | 143 +++++++ .agents/scripts/api-discovery/discover | 153 ++++++++ .agents/scripts/api-discovery/extract-sources | 118 ++++++ .agents/scripts/api-discovery/lib/common.sh | 364 ++++++++++++++++++ .agents/scripts/api-discovery/update-sibling | 159 ++++++++ .agents/skills/api-discovery/SKILL.md | 288 ++++++++++++++ .agents/tasks/api-discovery.md | 156 ++++++++ .agents/tasks/prohibit-automatic-commits.md | 92 +++++ CLAUDE.md | 1 + config | 2 +- 12 files changed, 1636 insertions(+), 1 deletion(-) create mode 100644 .agents/scripts/api-discovery/.gitignore create mode 100644 .agents/scripts/api-discovery/README.md create mode 100755 .agents/scripts/api-discovery/clean-cache create mode 100755 .agents/scripts/api-discovery/discover create mode 100755 .agents/scripts/api-discovery/extract-sources create mode 100644 .agents/scripts/api-discovery/lib/common.sh create mode 100755 .agents/scripts/api-discovery/update-sibling create mode 100644 .agents/skills/api-discovery/SKILL.md create mode 100644 .agents/tasks/api-discovery.md create mode 100644 .agents/tasks/prohibit-automatic-commits.md diff --git a/.agents/scripts/api-discovery/.gitignore b/.agents/scripts/api-discovery/.gitignore new file mode 100644 index 0000000000..c824ff1d5b --- /dev/null +++ b/.agents/scripts/api-discovery/.gitignore @@ -0,0 +1,3 @@ +# 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 new file mode 100644 index 0000000000..de4c631a11 --- /dev/null +++ b/.agents/scripts/api-discovery/README.md @@ -0,0 +1,158 @@ +# `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 new file mode 100755 index 0000000000..30f6049202 --- /dev/null +++ b/.agents/scripts/api-discovery/clean-cache @@ -0,0 +1,143 @@ +#!/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 new file mode 100755 index 0000000000..2b69d36bac --- /dev/null +++ b/.agents/scripts/api-discovery/discover @@ -0,0 +1,153 @@ +#!/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 new file mode 100755 index 0000000000..a8456680a7 --- /dev/null +++ b/.agents/scripts/api-discovery/extract-sources @@ -0,0 +1,118 @@ +#!/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 new file mode 100644 index 0000000000..fa3e9c833e --- /dev/null +++ b/.agents/scripts/api-discovery/lib/common.sh @@ -0,0 +1,364 @@ +#!/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 new file mode 100755 index 0000000000..e145aaee6a --- /dev/null +++ b/.agents/scripts/api-discovery/update-sibling @@ -0,0 +1,159 @@ +#!/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/skills/api-discovery/SKILL.md b/.agents/skills/api-discovery/SKILL.md new file mode 100644 index 0000000000..b1622ffd10 --- /dev/null +++ b/.agents/skills/api-discovery/SKILL.md @@ -0,0 +1,288 @@ +--- +name: api-discovery +description: > + Resolve the on-disk location of a Maven artifact's source code, + so you can `Grep`/`Read` 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 `Grep`, `Read`, or `Glob`. + +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. | Pass it to `Grep`/`Read`/`Glob`. 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 `Grep`/`Read`/`Glob` 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 +``` + +Tool calls then look like: + +- `Glob` pattern `**/*.kt`, path + `/Users//Projects/Spine/base-libraries/base`. +- `Grep` pattern `class Identifier`, path the same. + +**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/tasks/api-discovery.md b/.agents/tasks/api-discovery.md new file mode 100644 index 0000000000..1671f5eac1 --- /dev/null +++ b/.agents/tasks/api-discovery.md @@ -0,0 +1,156 @@ +--- +slug: api-discovery +branch: improve-api-discovery +owner: claude +status: in-review +started: 2026-05-21 +--- + +## Goal + +Make Spine API discovery fast and token-efficient by directing agents +to read library sources from local sibling clones first, and from a +one-time-extracted sources-JAR cache otherwise — never via repeated +`unzip` against Gradle-cache JARs. + +## Context + +Investigation transcripts (see +`~/Desktop/gradle-caches-scanning-by-claude.png`) show agents running +dozens of `find ~/.gradle/caches` + `unzip -l` + `unzip -p` calls per +query. Each call decompresses the JAR; token usage is dominated by +path noise and JAR listings. The user keeps every Spine repo cloned +as a sibling under `/Users/sanders/Projects/Spine/`, so the raw +sources are already on disk for ~14 of the 16 Spine local deps and +just need to be reached directly. + +Detailed design in `~/.claude/plans/mellow-juggling-yeti.md`. + +## Plan + +- [x] Draft plan + design review (Plan agent) +- [x] Write task file +- [x] Implement `lib/common.sh` (shared bash helpers) +- [x] Implement `discover` (main entry) +- [x] Implement `extract-sources` (one-shot JAR extraction, race-safe) +- [x] Implement `clean-cache` (manual pruning) +- [x] Write `README.md` + `.gitignore` for `.agents/scripts/api-discovery/` +- [x] Write `SKILL.md` for `.agents/skills/api-discovery/` +- [x] Add one bullet to `CLAUDE.md` under Workflow Rules +- [x] Smoke tests (#1–#7 from plan) +- [x] Code-review fixes (six findings, see Log) +- [x] **Follow-up:** `update-sibling` script + skill workflow for STALE +- [ ] Human review / merge; delete file on merge to master + +## Follow-up: sibling auto-update on STALE + +Originally deferred under "Out of scope" in the plan. User asked for +it: when STALE fires, the agent should offer to refresh the sibling +clone so api-discovery returns up-to-date sources. Constraints from +the user's reply: + +- Pull only when the sibling is on its default branch (`master` or + `main`) — they explicitly use checked-out feature branches as a + staging area for "advancing multiple subprojects at the same time", + so a feature branch is *intentional* local state and must be left + alone. +- The action must be confirmed by the user, never autonomous. + +Design: + +- New script `update-sibling`: + - Resolves a sibling by bare name (under ``) or by + absolute path. + - Branch ∈ {`master`,`main`} + clean tree + tracked upstream + → `git pull --ff-only`. + - Any other branch → no-op, exit 0 with "using local code as-is". + - Detached HEAD / dirty tree / no upstream → distinct exit codes + (3 / 4 / 5) with descriptive stderr. + - Pull failure → exit 6. + - Never switches branches, never `--rebase`, never `--force`, never + fetches a branch the user does not track. +- SKILL.md gains a "When STALE fires" section: surface the warning, + ask the user, run `update-sibling` on consent, re-run `discover` + if a pull happened. +- README.md documents the new script + adds it to the Layout table. + +## Log + +- 2026-05-21 — drafted plan; Plan-agent reviewed and pushed back on + over-engineering (manifest, sibling repo, exit codes). Adopted + simplifications. +- 2026-05-21 — cache location: `/.agents/caches/api-discovery/` + with first-use bootstrap prompt (approve / alt root / non-cached). + Scripts and skill live in the consumer repo's `.agents/`. +- 2026-05-21 — implementation begins. +- 2026-05-21 — implementation complete. All 7 smoke tests pass. + Resolved all 24 Spine artifacts end-to-end (Base/Change/Logging KMP/ + multi-module ProtoData/Validation/Tool-base/Mc-java). Extension-cache + path tested with Jackson and Guava — concurrent extractions race + safely on atomic `mv` with no `.tmp.*` leftovers. `STALE` warning + fires for validation-java (declared .433 vs sibling .440). KMP + source sets (`src/commonMain`, `src/jvmMain`, …) recognized in + addition to plain `src/main`. Status flipped to `in-review` for + human merge; task file will be deleted on merge to `master`. +- 2026-05-21 — added `update-sibling` follow-up: a guarded + `git pull --ff-only` for stale Spine siblings. Branch ∈ + {`master`,`main`} + clean tracked tree + tracked upstream → pull; + any other branch → no-op exit 0 ("intentional local state"); + detached / dirty / no-upstream → distinct refusals (exit 3/4/5); + pull failure → exit 6. Uses `--untracked-files=no` so build + artifacts and editor scratch don't block pulls. SKILL.md and + scripts/README.md document the workflow; the agent must ask the + user before invoking. Verified all 8 paths: successful FF on a + synthetic master + upstream, no-op on `validation` (`address-issues` + branch), exit 4 on `base-libraries` (dirty), exit 1 on missing, + exit 2 on non-repo, exit 3 on detached HEAD, exit 5 on no upstream, + exit 1 on missing args. The `main` default branch is also accepted. +- 2026-05-21 — `update-sibling` code-review pass applied five fixes: + (a) README exit-0 row was missing the `already up-to-date` outcome. + (b) `usage()` exited `EX_FAIL` (1), conflating "bad invocation" with + "sibling not on disk". Added `EX_USAGE=64` (BSD `sysexits(3)`) + and routed `usage()` to it; `sibling not on disk` keeps exit 1. + (c) Reworded the dirty-tree guard comment: untracked files don't + block FF on their own, but a genuine overwrite conflict (upstream + adds a path that exists untracked locally) still surfaces via + git's own check as `EX_PULL_FAILED`. Original "no effect on + semantics" wording was misleading. + (d) Exit 0 conflated three outcomes (pulled / up-to-date / + skipped-branch) and the skill had to parse free-form English log + lines to tell them apart. Now each success path emits a single + stable stdout token (`pulled`, `up-to-date`, `skipped-branch`); + failure paths emit empty stdout. Stderr keeps the human text. + (e) `SKILL.md` rewritten around the token contract: the exit-code + table splits exit 0 into three token-keyed sub-rows, procedure + step 4 branches on the token (not stderr), and a new + `up-to-date` example was added. `README.md` exit-code table got + the same split plus an `EX_USAGE=64` row. + Smoke-tested all eight paths end-to-end: synthetic upstream FF emits + `pulled`, re-run on the same clone emits `up-to-date`, `validation` + on `address-issues` emits `skipped-branch`, `base-libraries` (dirty) + exits 4, missing path exits 1, non-repo exits 2, no-args/too-many + exit 64. All failure paths produce empty stdout — agent can never + misread an error message as a result token. +- 2026-05-21 — earlier code-review pass applied six fixes: + (1) `extract-sources`: pre-test `[ -e "$target" ]` plus post-mv + nested-debris cleanup. Previous version was unsafe because + `mv tmp target` into an existing directory silently moves tmp + INSIDE target on macOS/Linux instead of failing. + (2) `discover`: replaced `target=$(...); status=$?` with + `target=$(...) || exit $?` so `set -e` cannot terminate + between assignment and status check. + (3) `clean-cache`: added `prune_empty_parents()` and call it on + both removal and "no entries match" paths (skipped under + `--dry-run`). Empty `//` dirs are now + reclaimed. + (4) `read_declared_version`: anchored regex at line start (with + optional access modifier) to avoid matching `const val version` + strings in KDoc / comments / nested code. + (5) Removed dead `find_dep_file_for_artifact` (callers all use + `find_local_dep_file_for_artifact`). + (6) `find_local_dep_file_for_artifact`: validate artifact name + against `[A-Za-z0-9._-]` and ERE-escape it via new + `escape_ere()` before grep, blocking regex-metachar injection. + All seven smoke tests still pass; race-safety verified by parallel + extractions of `guava-testlib:33.5.0-jre` (both exit 0, no `.tmp.*` + remnants, no nested `target/v.tmp.PID/` debris). diff --git a/.agents/tasks/prohibit-automatic-commits.md b/.agents/tasks/prohibit-automatic-commits.md new file mode 100644 index 0000000000..ff067c505a --- /dev/null +++ b/.agents/tasks/prohibit-automatic-commits.md @@ -0,0 +1,92 @@ +--- +slug: prohibit-automatic-commits +branch: prohibit-automatic-commits +owner: claude +status: in-review +started: 2026-05-20 +--- + +## Goal + +Make it a durable, team-wide rule that AI agents (Claude Code main thread, +every subagent, every skill) MUST NOT run `git commit` (or other +history-writing git/gh operations) unless authorization is *explicit and +current*. Authorization comes from one of two sources only: + +1. The currently active skill's `SKILL.md` contains an explicit + `## Commit authorization` section. +2. The user's current prompt explicitly instructs the operation + (e.g. "commit this", "push the branch"). + +Agents must otherwise stage changes and stop, letting the user review and +decide. This preserves today's auto-commit behavior for `bump-version` +and `bump-gradle`, which will declare authorization in their SKILL.md. + +## Context + +- Today's pain: Claude Code commits routinely, even when the user wants + to review diffs locally first. +- The project's `.claude/settings.json` already has `Bash(git commit:*)` + in `permissions.ask`. That asks the user per-commit but does not + redirect agent behavior — the agent still proposes commits constantly. + The fix is at the *instruction* layer, not the permission layer. +- Skills that legitimately commit today: `bump-version`, `bump-gradle`. +- Skills that do not commit but prescribe commit messages for the human: + `dependency-update` (already says "Do not commit. Do not push."). +- The user accepted removal of the global `~/.claude/settings.json` hook + added earlier this session. Enforcement lives in `.agents/` instructions + only. + +## Plan + +- [x] **1. Add the canonical rule to `.agents/safety-rules.md`.** + Added section *Commits and history-writing*. Lists default (no + history writes), two authorization sources, the fallback behavior + (stage + show diff + stop), and the operations covered. Names the + `## Commit authorization` marker. + +- [x] **2. Surface the rule in `.agents/quick-reference-card.md`.** + Added one-line pointer to `safety-rules.md` → *Commits and + history-writing*. + +- [x] **3. Add a workflow rule to `CLAUDE.md`.** + Added bullet under *Workflow Rules* referencing + `.agents/safety-rules.md`. + +- [x] **4. Declare authorization in `bump-version/SKILL.md`.** + Added a top-level `## Commit authorization` section above the + Checklist: exactly one commit, stage only `version.gradle.kts`, + subject `` Bump version -> `` ``, no push/tag/amend. + +- [x] **5. Declare authorization in `bump-gradle/SKILL.md`.** + Added a top-level `## Commit authorization` section above the + Checklist: up to two commits (wrapper + dependency reports), exact + subjects, no push/tag/amend. + +- [x] **6. Cross-check the non-authorizing skills.** + `dependency-update/SKILL.md` already explicit ("Do not commit. Do + not push.") — left as is. `pre-pr/SKILL.md` does not commit — left + as is. Other skills scanned (see Log). + +- [x] **7. Verification.** See Log entry — all three grep checks pass. + +## Out of scope + +- Project `.claude/settings.json` `ask` rule for `Bash(git commit:*)`: + leave as defense-in-depth (zero cost when the agent obeys the rule). +- `~/.claude/settings.json` global hook: already reverted earlier this + session per user direction. + +## Log + +- 2026-05-20 — drafted, awaiting plan approval. +- 2026-05-20 — approved by user. Executed steps 1–6. +- 2026-05-20 — verification: + - `grep -RIn '^## Commit authorization' .agents/skills/` returns exactly + `bump-gradle/SKILL.md` and `bump-version/SKILL.md` ✓ + - `safety-rules.md` referenced from `CLAUDE.md`, `quick-reference-card.md`, + `bump-version/SKILL.md`, `bump-gradle/SKILL.md` ✓ + - Literal `git commit` strings live only in the two authorizing skills ✓ + - `dependency-update/SKILL.md` still says "Do not commit. Do not push."; + `pre-pr/SKILL.md` still writes a sentinel and does not commit ✓ +- Status: `in-review` — awaiting user sign-off, then delete on merge to master. diff --git a/CLAUDE.md b/CLAUDE.md index 57963ba148..59a8b71372 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -14,6 +14,7 @@ - Write the plan to `.agents/tasks/.md` before coding. See `.agents/tasks/README.md` for format and lifecycle. - If something goes wrong — STOP and re-plan immediately. - One focused task per subagent. +- Before reading library source code from `~/.gradle/caches`, follow the `api-discovery` skill — never `unzip` JARs directly. - **Never `git commit`, `git push`, `git tag`, or otherwise rewrite git history** unless the active skill's `SKILL.md` has a `## Commit authorization` section, or the *current* user prompt explicitly tells you to. Authorization does not carry over between turns. diff --git a/config b/config index 9eef139451..fb1122901e 160000 --- a/config +++ b/config @@ -1 +1 @@ -Subproject commit 9eef1394513063eb9e3c0ebeb49c29abc650a814 +Subproject commit fb1122901e040cbcbf7dc370311c0ab9d83fbf64 From cc2a3fc470296202b8bab78d21389192b5fa96bd Mon Sep 17 00:00:00 2001 From: alexander-yevsyukov Date: Fri, 22 May 2026 18:08:36 +0100 Subject: [PATCH 04/12] Update dependency reports --- docs/dependencies/dependencies.md | 64 +++++++++++++++---------------- docs/dependencies/pom.xml | 2 +- 2 files changed, 33 insertions(+), 33 deletions(-) diff --git a/docs/dependencies/dependencies.md b/docs/dependencies/dependencies.md index 2f4667526e..6344285bb8 100644 --- a/docs/dependencies/dependencies.md +++ b/docs/dependencies/dependencies.md @@ -1,6 +1,6 @@ -# Dependencies of `io.spine.tools:validation-context:2.0.0-SNAPSHOT.440` +# Dependencies of `io.spine.tools:validation-context:2.0.0-SNAPSHOT.441` ## Runtime 1. **Group** : com.fasterxml.jackson. **Name** : jackson-bom. **Version** : 2.21.3. @@ -1090,14 +1090,14 @@ The dependencies distributed under several licenses, are used according their commercial-use-friendly license. -This report was generated on **Thu May 21 21:42:37 WEST 2026** using +This report was generated on **Fri May 22 18:04:51 WEST 2026** using [Gradle-License-Report plugin](https://github.com/jk1/Gradle-License-Report) by Evgeny Naumenko, licensed under [Apache 2.0 License](https://github.com/jk1/Gradle-License-Report/blob/master/LICENSE). -# Dependencies of `io.spine.tools:validation-context-tests:2.0.0-SNAPSHOT.440` +# Dependencies of `io.spine.tools:validation-context-tests:2.0.0-SNAPSHOT.441` ## Runtime 1. **Group** : com.fasterxml.jackson. **Name** : jackson-bom. **Version** : 2.20.0. @@ -1791,7 +1791,7 @@ This report was generated on **Thu May 21 21:42:37 WEST 2026** using The dependencies distributed under several licenses, are used according their commercial-use-friendly license. -This report was generated on **Thu May 21 21:42:36 WEST 2026** using +This report was generated on **Fri May 22 18:04:50 WEST 2026** using [Gradle-License-Report plugin](https://github.com/jk1/Gradle-License-Report) by Evgeny Naumenko, licensed under [Apache 2.0 License](https://github.com/jk1/Gradle-License-Report/blob/master/LICENSE). @@ -1812,7 +1812,7 @@ This report was generated on **Thu May 21 18:44:23 WEST 2026** using -# Dependencies of `io.spine.tools:validation-gradle-plugin:2.0.0-SNAPSHOT.440` +# Dependencies of `io.spine.tools:validation-gradle-plugin:2.0.0-SNAPSHOT.441` ## Runtime 1. **Group** : com.fasterxml.jackson. **Name** : jackson-bom. **Version** : 2.21.3. @@ -2864,14 +2864,14 @@ This report was generated on **Thu May 21 18:44:23 WEST 2026** using The dependencies distributed under several licenses, are used according their commercial-use-friendly license. -This report was generated on **Thu May 21 21:42:36 WEST 2026** using +This report was generated on **Fri May 22 18:04:50 WEST 2026** using [Gradle-License-Report plugin](https://github.com/jk1/Gradle-License-Report) by Evgeny Naumenko, licensed under [Apache 2.0 License](https://github.com/jk1/Gradle-License-Report/blob/master/LICENSE). -# Dependencies of `io.spine.tools:validation-java:2.0.0-SNAPSHOT.440` +# Dependencies of `io.spine.tools:validation-java:2.0.0-SNAPSHOT.441` ## Runtime 1. **Group** : com.fasterxml.jackson. **Name** : jackson-bom. **Version** : 2.21.3. @@ -3961,14 +3961,14 @@ This report was generated on **Thu May 21 21:42:36 WEST 2026** using The dependencies distributed under several licenses, are used according their commercial-use-friendly license. -This report was generated on **Thu May 21 21:42:37 WEST 2026** using +This report was generated on **Fri May 22 18:04:51 WEST 2026** using [Gradle-License-Report plugin](https://github.com/jk1/Gradle-License-Report) by Evgeny Naumenko, licensed under [Apache 2.0 License](https://github.com/jk1/Gradle-License-Report/blob/master/LICENSE). -# Dependencies of `io.spine.tools:validation-java-bundle:2.0.0-SNAPSHOT.440` +# Dependencies of `io.spine.tools:validation-java-bundle:2.0.0-SNAPSHOT.441` ## Runtime 1. **Group** : org.jetbrains. **Name** : annotations. **Version** : 13.0. @@ -4015,14 +4015,14 @@ This report was generated on **Thu May 21 21:42:37 WEST 2026** using The dependencies distributed under several licenses, are used according their commercial-use-friendly license. -This report was generated on **Thu May 21 21:42:35 WEST 2026** using +This report was generated on **Fri May 22 18:04:49 WEST 2026** using [Gradle-License-Report plugin](https://github.com/jk1/Gradle-License-Report) by Evgeny Naumenko, licensed under [Apache 2.0 License](https://github.com/jk1/Gradle-License-Report/blob/master/LICENSE). -# Dependencies of `io.spine.tools:validation-java-settings:2.0.0-SNAPSHOT.440` +# Dependencies of `io.spine.tools:validation-java-settings:2.0.0-SNAPSHOT.441` ## Runtime 1. **Group** : com.google.code.findbugs. **Name** : jsr305. **Version** : 3.0.2. @@ -4798,14 +4798,14 @@ This report was generated on **Thu May 21 21:42:35 WEST 2026** using The dependencies distributed under several licenses, are used according their commercial-use-friendly license. -This report was generated on **Thu May 21 21:42:36 WEST 2026** using +This report was generated on **Fri May 22 18:04:50 WEST 2026** using [Gradle-License-Report plugin](https://github.com/jk1/Gradle-License-Report) by Evgeny Naumenko, licensed under [Apache 2.0 License](https://github.com/jk1/Gradle-License-Report/blob/master/LICENSE). -# Dependencies of `io.spine:spine-validation-jvm-runtime:2.0.0-SNAPSHOT.440` +# Dependencies of `io.spine:spine-validation-jvm-runtime:2.0.0-SNAPSHOT.441` ## Runtime 1. **Group** : com.google.code.findbugs. **Name** : jsr305. **Version** : 3.0.2. @@ -5605,14 +5605,14 @@ This report was generated on **Thu May 21 21:42:36 WEST 2026** using The dependencies distributed under several licenses, are used according their commercial-use-friendly license. -This report was generated on **Thu May 21 21:42:36 WEST 2026** using +This report was generated on **Fri May 22 18:04:50 WEST 2026** using [Gradle-License-Report plugin](https://github.com/jk1/Gradle-License-Report) by Evgeny Naumenko, licensed under [Apache 2.0 License](https://github.com/jk1/Gradle-License-Report/blob/master/LICENSE). -# Dependencies of `io.spine.tools:validation-consumer:2.0.0-SNAPSHOT.440` +# Dependencies of `io.spine.tools:validation-consumer:2.0.0-SNAPSHOT.441` ## Runtime 1. **Group** : com.fasterxml.jackson. **Name** : jackson-bom. **Version** : 2.21.3. @@ -6294,14 +6294,14 @@ This report was generated on **Thu May 21 21:42:36 WEST 2026** using The dependencies distributed under several licenses, are used according their commercial-use-friendly license. -This report was generated on **Thu May 21 21:42:36 WEST 2026** using +This report was generated on **Fri May 22 18:04:50 WEST 2026** using [Gradle-License-Report plugin](https://github.com/jk1/Gradle-License-Report) by Evgeny Naumenko, licensed under [Apache 2.0 License](https://github.com/jk1/Gradle-License-Report/blob/master/LICENSE). -# Dependencies of `io.spine.tools:validation-consumer-dependency:2.0.0-SNAPSHOT.440` +# Dependencies of `io.spine.tools:validation-consumer-dependency:2.0.0-SNAPSHOT.441` ## Runtime 1. **Group** : com.google.code.findbugs. **Name** : jsr305. **Version** : 3.0.2. @@ -6759,14 +6759,14 @@ This report was generated on **Thu May 21 21:42:36 WEST 2026** using The dependencies distributed under several licenses, are used according their commercial-use-friendly license. -This report was generated on **Thu May 21 21:42:36 WEST 2026** using +This report was generated on **Fri May 22 18:04:50 WEST 2026** using [Gradle-License-Report plugin](https://github.com/jk1/Gradle-License-Report) by Evgeny Naumenko, licensed under [Apache 2.0 License](https://github.com/jk1/Gradle-License-Report/blob/master/LICENSE). -# Dependencies of `io.spine.tools:validation-extensions:2.0.0-SNAPSHOT.440` +# Dependencies of `io.spine.tools:validation-extensions:2.0.0-SNAPSHOT.441` ## Runtime 1. **Group** : com.fasterxml.jackson. **Name** : jackson-bom. **Version** : 2.21.3. @@ -7385,14 +7385,14 @@ This report was generated on **Thu May 21 21:42:36 WEST 2026** using The dependencies distributed under several licenses, are used according their commercial-use-friendly license. -This report was generated on **Thu May 21 21:42:36 WEST 2026** using +This report was generated on **Fri May 22 18:04:50 WEST 2026** using [Gradle-License-Report plugin](https://github.com/jk1/Gradle-License-Report) by Evgeny Naumenko, licensed under [Apache 2.0 License](https://github.com/jk1/Gradle-License-Report/blob/master/LICENSE). -# Dependencies of `io.spine.tools:validation-runtime:2.0.0-SNAPSHOT.440` +# Dependencies of `io.spine.tools:validation-runtime:2.0.0-SNAPSHOT.441` ## Runtime 1. **Group** : com.google.code.findbugs. **Name** : jsr305. **Version** : 3.0.2. @@ -7953,14 +7953,14 @@ This report was generated on **Thu May 21 21:42:36 WEST 2026** using The dependencies distributed under several licenses, are used according their commercial-use-friendly license. -This report was generated on **Thu May 21 21:42:36 WEST 2026** using +This report was generated on **Fri May 22 18:04:50 WEST 2026** using [Gradle-License-Report plugin](https://github.com/jk1/Gradle-License-Report) by Evgeny Naumenko, licensed under [Apache 2.0 License](https://github.com/jk1/Gradle-License-Report/blob/master/LICENSE). -# Dependencies of `io.spine.tools:validation-time:2.0.0-SNAPSHOT.440` +# Dependencies of `io.spine.tools:validation-time:2.0.0-SNAPSHOT.441` ## Runtime 1. **Group** : com.google.code.findbugs. **Name** : jsr305. **Version** : 3.0.2. @@ -8382,14 +8382,14 @@ This report was generated on **Thu May 21 21:42:36 WEST 2026** using The dependencies distributed under several licenses, are used according their commercial-use-friendly license. -This report was generated on **Thu May 21 21:42:36 WEST 2026** using +This report was generated on **Fri May 22 18:04:50 WEST 2026** using [Gradle-License-Report plugin](https://github.com/jk1/Gradle-License-Report) by Evgeny Naumenko, licensed under [Apache 2.0 License](https://github.com/jk1/Gradle-License-Report/blob/master/LICENSE). -# Dependencies of `io.spine.tools:validation-validating:2.0.0-SNAPSHOT.440` +# Dependencies of `io.spine.tools:validation-validating:2.0.0-SNAPSHOT.441` ## Runtime 1. **Group** : com.google.code.findbugs. **Name** : jsr305. **Version** : 3.0.2. @@ -8993,14 +8993,14 @@ This report was generated on **Thu May 21 21:42:36 WEST 2026** using The dependencies distributed under several licenses, are used according their commercial-use-friendly license. -This report was generated on **Thu May 21 21:42:36 WEST 2026** using +This report was generated on **Fri May 22 18:04:50 WEST 2026** using [Gradle-License-Report plugin](https://github.com/jk1/Gradle-License-Report) by Evgeny Naumenko, licensed under [Apache 2.0 License](https://github.com/jk1/Gradle-License-Report/blob/master/LICENSE). -# Dependencies of `io.spine.tools:validation-validator:2.0.0-SNAPSHOT.440` +# Dependencies of `io.spine.tools:validation-validator:2.0.0-SNAPSHOT.441` ## Runtime 1. **Group** : com.fasterxml.jackson. **Name** : jackson-bom. **Version** : 2.21.3. @@ -9738,14 +9738,14 @@ This report was generated on **Thu May 21 21:42:36 WEST 2026** using The dependencies distributed under several licenses, are used according their commercial-use-friendly license. -This report was generated on **Thu May 21 21:42:36 WEST 2026** using +This report was generated on **Fri May 22 18:04:50 WEST 2026** using [Gradle-License-Report plugin](https://github.com/jk1/Gradle-License-Report) by Evgeny Naumenko, licensed under [Apache 2.0 License](https://github.com/jk1/Gradle-License-Report/blob/master/LICENSE). -# Dependencies of `io.spine.tools:validation-validator-dependency:2.0.0-SNAPSHOT.440` +# Dependencies of `io.spine.tools:validation-validator-dependency:2.0.0-SNAPSHOT.441` ## Runtime 1. **Group** : com.google.code.findbugs. **Name** : jsr305. **Version** : 3.0.2. @@ -9978,14 +9978,14 @@ This report was generated on **Thu May 21 21:42:36 WEST 2026** using The dependencies distributed under several licenses, are used according their commercial-use-friendly license. -This report was generated on **Thu May 21 21:42:35 WEST 2026** using +This report was generated on **Fri May 22 18:04:50 WEST 2026** using [Gradle-License-Report plugin](https://github.com/jk1/Gradle-License-Report) by Evgeny Naumenko, licensed under [Apache 2.0 License](https://github.com/jk1/Gradle-License-Report/blob/master/LICENSE). -# Dependencies of `io.spine.tools:validation-vanilla:2.0.0-SNAPSHOT.440` +# Dependencies of `io.spine.tools:validation-vanilla:2.0.0-SNAPSHOT.441` ## Runtime 1. **Group** : com.google.code.findbugs. **Name** : jsr305. **Version** : 3.0.2. @@ -10328,6 +10328,6 @@ This report was generated on **Thu May 21 21:42:35 WEST 2026** using The dependencies distributed under several licenses, are used according their commercial-use-friendly license. -This report was generated on **Thu May 21 21:42:36 WEST 2026** using +This report was generated on **Fri May 22 18:04:50 WEST 2026** using [Gradle-License-Report plugin](https://github.com/jk1/Gradle-License-Report) by Evgeny Naumenko, licensed under [Apache 2.0 License](https://github.com/jk1/Gradle-License-Report/blob/master/LICENSE). \ No newline at end of file diff --git a/docs/dependencies/pom.xml b/docs/dependencies/pom.xml index 4d90463d3b..66c5415121 100644 --- a/docs/dependencies/pom.xml +++ b/docs/dependencies/pom.xml @@ -10,7 +10,7 @@ all modules and does not describe the project structure per-subproject. --> io.spine.tools validation -2.0.0-SNAPSHOT.440 +2.0.0-SNAPSHOT.441 2015 From a7a1a3e03350698540e91fddcb24c85896b8742d Mon Sep 17 00:00:00 2001 From: alexander-yevsyukov Date: Fri, 22 May 2026 18:13:17 +0100 Subject: [PATCH 05/12] Bump version -> `2.0.0-SNAPSHOT.442` --- version.gradle.kts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/version.gradle.kts b/version.gradle.kts index 78d3ca01bd..02fe50ac1a 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.441") +val validationVersion by extra("2.0.0-SNAPSHOT.442") From 67cee6ce1b01b3590151b1000c8aa145177e6e46 Mon Sep 17 00:00:00 2001 From: alexander-yevsyukov Date: Fri, 22 May 2026 18:21:52 +0100 Subject: [PATCH 06/12] Reject `(required)` for fields with `Empty` --- .../tools/validation/RequiredOptionSpec.kt | 38 ++++++++++++++++++- .../validation/required_option_spec.proto | 21 +++++++++- .../option/required/RequiredOption.kt | 36 ++++++++++++++++-- .../options/required/RequiredMessageITest.kt | 23 +---------- .../spine/test/tools/validate/required.proto | 6 --- 5 files changed, 90 insertions(+), 34 deletions(-) diff --git a/context-tests/src/test/kotlin/io/spine/tools/validation/RequiredOptionSpec.kt b/context-tests/src/test/kotlin/io/spine/tools/validation/RequiredOptionSpec.kt index 81f5adebc7..1bd0cf4c92 100644 --- a/context-tests/src/test/kotlin/io/spine/tools/validation/RequiredOptionSpec.kt +++ b/context-tests/src/test/kotlin/io/spine/tools/validation/RequiredOptionSpec.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. @@ -33,10 +33,14 @@ import io.spine.tools.compiler.ast.qualifiedName import io.spine.tools.compiler.protobuf.field import io.spine.tools.validation.given.RequiredBoolField import io.spine.tools.validation.given.RequiredDoubleField +import io.spine.tools.validation.given.RequiredEmptyField import io.spine.tools.validation.given.RequiredIntField +import io.spine.tools.validation.given.RequiredMapWithEmptyValue +import io.spine.tools.validation.given.RequiredRepeatedEmpty import io.spine.tools.validation.given.RequiredSignedInt import io.spine.tools.validation.option.REQUIRED import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Nested import org.junit.jupiter.api.Test @DisplayName("`RequiredReaction` should") @@ -73,8 +77,40 @@ internal class RequiredReactionSpec : CompilationErrorTest() { val field = message.field("temperature") error.message shouldContain unsupportedFieldType(field) } + + @Nested inner class + `reject option on 'google_protobuf_Empty' fields` { + + @Test + fun `when singular`() { + val message = RequiredEmptyField.getDescriptor() + val error = assertCompilationFails(message) + val field = message.field("value") + error.message shouldContain rejectsEmpty(field) + } + + @Test + fun `when 'repeated'`() { + val message = RequiredRepeatedEmpty.getDescriptor() + val error = assertCompilationFails(message) + val field = message.field("value") + error.message shouldContain rejectsEmpty(field) + } + + @Test + fun `when used as a 'map' value`() { + val message = RequiredMapWithEmptyValue.getDescriptor() + val error = assertCompilationFails(message) + val field = message.field("value") + error.message shouldContain rejectsEmpty(field) + } + } } private fun unsupportedFieldType(field: Field) = "The field type `${field.type.name}` of the `${field.qualifiedName}` is not supported" + " by the `($REQUIRED)` option." + +private fun rejectsEmpty(field: Field) = + "The field `${field.qualifiedName}` of type `${field.type.name}` cannot be marked" + + " as `($REQUIRED)` because `google.protobuf.Empty` has no fields" diff --git a/context-tests/src/testFixtures/proto/spine/validation/required_option_spec.proto b/context-tests/src/testFixtures/proto/spine/validation/required_option_spec.proto index 856647bb7d..98cc87f01e 100644 --- a/context-tests/src/testFixtures/proto/spine/validation/required_option_spec.proto +++ b/context-tests/src/testFixtures/proto/spine/validation/required_option_spec.proto @@ -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. @@ -28,6 +28,7 @@ syntax = "proto3"; package spine.validation.stubs; +import "google/protobuf/empty.proto"; import "spine/options.proto"; option (type_url_prefix) = "type.spine.io"; @@ -68,3 +69,21 @@ message IfMissingWithInvalidPlaceholders { message IfMissingWithoutRequired { string value = 1 [(if_missing).error_msg = "The `value` must be set."]; } + +// Provides a singular `google.protobuf.Empty` field with the inapplicable +// `(required)` option. +message RequiredEmptyField { + google.protobuf.Empty value = 1 [(.required) = true]; +} + +// Provides a `repeated google.protobuf.Empty` field with the inapplicable +// `(required)` option. +message RequiredRepeatedEmpty { + repeated google.protobuf.Empty value = 1 [(.required) = true]; +} + +// Provides a `map<_, google.protobuf.Empty>` field with the inapplicable +// `(required)` option. +message RequiredMapWithEmptyValue { + map value = 1 [(.required) = true]; +} diff --git a/context/src/main/kotlin/io/spine/tools/validation/option/required/RequiredOption.kt b/context/src/main/kotlin/io/spine/tools/validation/option/required/RequiredOption.kt index 2924ee248e..24e47c4a1e 100644 --- a/context/src/main/kotlin/io/spine/tools/validation/option/required/RequiredOption.kt +++ b/context/src/main/kotlin/io/spine/tools/validation/option/required/RequiredOption.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. @@ -42,9 +42,13 @@ import io.spine.server.tuple.EitherOf2 import io.spine.tools.compiler.Compilation import io.spine.tools.compiler.ast.Field import io.spine.tools.compiler.ast.FieldRef +import io.spine.tools.compiler.ast.FieldType import io.spine.tools.compiler.ast.File +import io.spine.tools.compiler.ast.TypeName import io.spine.tools.compiler.ast.boolValue import io.spine.tools.compiler.ast.event.FieldOptionDiscovered +import io.spine.tools.compiler.ast.isList +import io.spine.tools.compiler.ast.isMap import io.spine.tools.compiler.ast.name import io.spine.tools.compiler.ast.qualifiedName import io.spine.tools.compiler.ast.ref @@ -75,11 +79,13 @@ import io.spine.validation.StandardPlaceholder.PARENT_TYPE * [RequiredFieldDiscovered] event if the following conditions are met: * * 1. The field type is supported by the option. - * 2. The option value is `true`. + * 2. The field type is not `google.protobuf.Empty`, nor a `repeated` of `Empty`, + * nor a `map` with `Empty` values. + * 3. The option value is `true`. * - * If (1) is violated, the reaction reports a compilation error. + * If (1) or (2) is violated, the reaction reports a compilation error. * - * Violation of (2) means that the `(required)` option is applied correctly, + * Violation of (3) means that the `(required)` option is applied correctly, * but effectively disabled. [RequiredFieldDiscovered] is not emitted for * disabled options. In this case, the reaction emits [NoReaction] meaning * that the option is ignored. @@ -98,6 +104,7 @@ internal class RequiredReaction : Reaction() { val field = event.subject val file = event.file checkFieldType(field, file) + checkFieldIsNotEmpty(field, file) if (!event.option.boolValue) { return ignore() @@ -153,6 +160,27 @@ private fun checkFieldType(field: Field, file: File) = " strings, bytes, repeated, and maps." } +private fun checkFieldIsNotEmpty(field: Field, file: File) = + Compilation.check(!field.type.refersToEmpty(), file, field.span) { + "The field `${field.qualifiedName}` of type `${field.type.name}` cannot be marked" + + " as `($REQUIRED)` because `google.protobuf.Empty` has no fields and its" + + " instances are always equal to the default value." + } + +/** + * Tells if this [FieldType] is `google.protobuf.Empty`, a `repeated` of `Empty`, + * or a `map` with `Empty` values. + */ +private fun FieldType.refersToEmpty(): Boolean = when { + isMessage -> message.isProtobufEmpty + isList -> list.isMessage && list.message.isProtobufEmpty + isMap -> map.valueType.isMessage && map.valueType.message.isProtobufEmpty + else -> false +} + +private val TypeName.isProtobufEmpty: Boolean + get() = packageName == "google.protobuf" && simpleName == "Empty" + private val SUPPORTED_PLACEHOLDERS = setOf( FIELD_PATH.value, FIELD_TYPE.value, diff --git a/tests/validating/src/test/kotlin/io/spine/test/options/required/RequiredMessageITest.kt b/tests/validating/src/test/kotlin/io/spine/test/options/required/RequiredMessageITest.kt index 8aaf78bc89..a809e75fb3 100644 --- a/tests/validating/src/test/kotlin/io/spine/test/options/required/RequiredMessageITest.kt +++ b/tests/validating/src/test/kotlin/io/spine/test/options/required/RequiredMessageITest.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,9 +27,7 @@ package io.spine.test.options.required import com.google.protobuf.ByteString -import com.google.protobuf.Empty import io.spine.base.Identifier -import io.spine.test.tools.validate.AlwaysInvalid import io.spine.test.tools.validate.Enclosed import io.spine.test.tools.validate.Singulars import io.spine.test.tools.validate.UltimateChoice @@ -56,23 +54,4 @@ internal class RequiredMessageITest { .setNotEmptyString(" ") assertValid(singulars) } - - /** - * This function tests that the validation fails on the option applied - * to the field with the type [Empty]. - * - * We should do more and - * [raise compile-time error](https://github.com/SpineEventEngine/validation/issues/146). - */ - @Test - fun `not be applied to the type 'Empty'`() { - val fieldName = "impossible" - - val unset = AlwaysInvalid.newBuilder() - assertViolation(unset, fieldName) - - val set = AlwaysInvalid.newBuilder() - .setImpossible(Empty.getDefaultInstance()) - assertViolation(set, fieldName) - } } diff --git a/tests/validating/src/testFixtures/proto/spine/test/tools/validate/required.proto b/tests/validating/src/testFixtures/proto/spine/test/tools/validate/required.proto index 9428c053fd..48174c8d71 100644 --- a/tests/validating/src/testFixtures/proto/spine/test/tools/validate/required.proto +++ b/tests/validating/src/testFixtures/proto/spine/test/tools/validate/required.proto @@ -35,7 +35,6 @@ option java_package = "io.spine.test.tools.validate"; option java_outer_classname = "RequiredProto"; option java_multiple_files = true; -import "google/protobuf/empty.proto"; import "google/protobuf/timestamp.proto"; message Singulars { @@ -60,11 +59,6 @@ message Collections { repeated UltimateChoice at_least_one_piece_of_meat = 4 [(required) = true]; } -message AlwaysInvalid { - - google.protobuf.Empty impossible = 1 [(required) = true]; -} - message MapWithMessageValues { map timestamp = 1 [(required) = true]; From e1a40df592a5d31569f3b434f03a79e006358856 Mon Sep 17 00:00:00 2001 From: alexander-yevsyukov Date: Fri, 22 May 2026 18:22:10 +0100 Subject: [PATCH 07/12] Improve tests for `(set_once)` constraint --- .../options/setonce/SetOnceFieldsITest.kt | 119 +++++++++++++++++- .../test/options/setonce/SetOnceTestEnv.kt | 7 +- .../test/tools/validate/set_once_fields.proto | 10 +- 3 files changed, 133 insertions(+), 3 deletions(-) diff --git a/tests/validating/src/test/kotlin/io/spine/test/options/setonce/SetOnceFieldsITest.kt b/tests/validating/src/test/kotlin/io/spine/test/options/setonce/SetOnceFieldsITest.kt index 82364cc7f1..30c2dc34db 100644 --- a/tests/validating/src/test/kotlin/io/spine/test/options/setonce/SetOnceFieldsITest.kt +++ b/tests/validating/src/test/kotlin/io/spine/test/options/setonce/SetOnceFieldsITest.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,6 +27,8 @@ package io.spine.test.options.setonce import com.google.protobuf.ByteString.copyFromUtf8 +import io.spine.test.options.setonce.SetOnceTestEnv.BIRTHDAY1 +import io.spine.test.options.setonce.SetOnceTestEnv.BIRTHDAY2 import io.spine.test.options.setonce.SetOnceTestEnv.DONALD import io.spine.test.options.setonce.SetOnceTestEnv.EIGHTY_KG import io.spine.test.options.setonce.SetOnceTestEnv.FIFTY_KG @@ -41,7 +43,9 @@ import io.spine.test.options.setonce.SetOnceTestEnv.STUDENT1 import io.spine.test.options.setonce.SetOnceTestEnv.STUDENT2 import io.spine.test.options.setonce.SetOnceTestEnv.THIRD_YEAR import io.spine.test.options.setonce.SetOnceTestEnv.YES +import io.spine.test.tools.validate.Measurement import io.spine.test.tools.validate.StudentSetOnce +import io.spine.test.tools.validate.measurement import io.spine.test.tools.validate.studentSetOnce import io.spine.tools.validation.assertions.assertValidationFails import io.spine.tools.validation.assertions.assertValidationPasses @@ -659,6 +663,119 @@ internal class SetOnceFieldsITest { .build() } } + + /** + * Tests `(set_once)` constraint when the field type is an external message + * imported from a dependency, e.g. `google.protobuf.Timestamp`. + */ + @Nested inner class + `prohibit overriding non-default external message` { + + private val measurementBefore = measurement { timestamp = BIRTHDAY1 } + private val measurementAfter = measurement { timestamp = BIRTHDAY2 } + + @Test + fun `by value`() = assertValidationFails { + measurementBefore.toBuilder() + .setTimestamp(BIRTHDAY2) + } + + @Test + fun `by builder`() = assertValidationFails { + measurementBefore.toBuilder() + .setTimestamp(BIRTHDAY2.toBuilder()) + } + + @Test + fun `by reflection`() = assertValidationFails { + measurementBefore.toBuilder() + .setField(measurementField("timestamp"), BIRTHDAY2) + } + + @Test + fun `by field merge`() = assertValidationFails { + measurementBefore.toBuilder() + .mergeTimestamp(BIRTHDAY2) + } + + @Test + fun `by message merge`() = assertValidationFails { + measurementBefore.toBuilder() + .mergeFrom(measurementAfter) + } + + @Test + fun `by bytes merge`() = assertValidationFails { + measurementBefore.toBuilder() + .mergeFrom(measurementAfter.toByteArray()) + } + } + + @Nested inner class + `allow overriding default and same-value external 'Timestamp'` { + + private val emptyMeasurement = measurement { } + private val measurementAfter = measurement { timestamp = BIRTHDAY2 } + + @Test + fun `by value`() = assertValidationPasses { + emptyMeasurement.toBuilder() + .setTimestamp(BIRTHDAY2) + .setTimestamp(BIRTHDAY2) + .build() + } + + @Test + fun `by builder`() = assertValidationPasses { + emptyMeasurement.toBuilder() + .setTimestamp(BIRTHDAY2.toBuilder()) + .setTimestamp(BIRTHDAY2.toBuilder()) + .build() + } + + @Test + fun `by reflection`() = assertValidationPasses { + emptyMeasurement.toBuilder() + .setField(measurementField("timestamp"), BIRTHDAY2) + .setField(measurementField("timestamp"), BIRTHDAY2) + .build() + } + + @Test + fun `by field merge`() = assertValidationPasses { + emptyMeasurement.toBuilder() + .mergeTimestamp(BIRTHDAY2) + .mergeTimestamp(BIRTHDAY2) + .build() + } + + @Test + fun `by message merge`() = assertValidationPasses { + emptyMeasurement.toBuilder() + .mergeFrom(measurementAfter) + .mergeFrom(measurementAfter) + .build() + } + + @Test + fun `by bytes merge`() = assertValidationPasses { + emptyMeasurement.toBuilder() + .mergeFrom(measurementAfter.toByteArray()) + .mergeFrom(measurementAfter.toByteArray()) + .build() + } + + @Test + fun `after clearing`() = assertValidationPasses { + measurementAfter.toBuilder() + .clearTimestamp() + .setTimestamp(BIRTHDAY2) + .build() + } + } } private fun field(fieldName: String) = StudentSetOnce.getDescriptor().findFieldByName(fieldName) + +private fun measurementField(fieldName: String) = + Measurement.getDescriptor().findFieldByName(fieldName) diff --git a/tests/validating/src/testFixtures/kotlin/io/spine/test/options/setonce/SetOnceTestEnv.kt b/tests/validating/src/testFixtures/kotlin/io/spine/test/options/setonce/SetOnceTestEnv.kt index 86c7d38138..b05e438b39 100644 --- a/tests/validating/src/testFixtures/kotlin/io/spine/test/options/setonce/SetOnceTestEnv.kt +++ b/tests/validating/src/testFixtures/kotlin/io/spine/test/options/setonce/SetOnceTestEnv.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. @@ -27,6 +27,8 @@ package io.spine.test.options.setonce import com.google.protobuf.ByteString +import com.google.protobuf.Timestamp +import com.google.protobuf.util.Timestamps import io.spine.test.tools.validate.Name import io.spine.test.tools.validate.YearOfStudy import io.spine.test.tools.validate.name @@ -50,6 +52,9 @@ object SetOnceTestEnv { val JACK: Name = name { value = "Jack" } val DONALD: Name = name { value = "Donald" } + val BIRTHDAY1: Timestamp = Timestamps.fromSeconds(1_000_000_000L) + val BIRTHDAY2: Timestamp = Timestamps.fromSeconds(1_700_000_000L) + const val SHORT_HEIGHT: Double = 1.55 const val TALL_HEIGHT: Double = 1.88 diff --git a/tests/validating/src/testFixtures/proto/spine/test/tools/validate/set_once_fields.proto b/tests/validating/src/testFixtures/proto/spine/test/tools/validate/set_once_fields.proto index bec8a2b95e..b93f6c415f 100644 --- a/tests/validating/src/testFixtures/proto/spine/test/tools/validate/set_once_fields.proto +++ b/tests/validating/src/testFixtures/proto/spine/test/tools/validate/set_once_fields.proto @@ -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. @@ -23,6 +23,7 @@ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. */ + syntax = "proto3"; package spine.test.tools.validate; @@ -35,6 +36,7 @@ option java_outer_classname = "SetOnceFieldsProto"; option java_multiple_files = true; import "spine/test/tools/validate/set_once_values.proto"; +import "google/protobuf/timestamp.proto"; // Tests that the constraint works with all supported field types. message StudentSetOnce { @@ -98,3 +100,9 @@ message StudentSetOnceFalse { bytes signature = 16 [(set_once) = false]; YearOfStudy year_of_study = 17 [(set_once) = false]; } + +// Tests that the constraint works with an external message type imported +// from a dependency (`google.protobuf.Timestamp`).` +message Measurement { + google.protobuf.Timestamp timestamp = 1 [(set_once) = true]; +} From fc2a1f33526c64e6f0b8c3d95a2b87c039510a44 Mon Sep 17 00:00:00 2001 From: alexander-yevsyukov Date: Fri, 22 May 2026 18:22:20 +0100 Subject: [PATCH 08/12] Update dependency reports --- docs/dependencies/dependencies.md | 64 +++++++++++++++---------------- docs/dependencies/pom.xml | 2 +- 2 files changed, 33 insertions(+), 33 deletions(-) diff --git a/docs/dependencies/dependencies.md b/docs/dependencies/dependencies.md index 6344285bb8..bd3c9cf554 100644 --- a/docs/dependencies/dependencies.md +++ b/docs/dependencies/dependencies.md @@ -1,6 +1,6 @@ -# Dependencies of `io.spine.tools:validation-context:2.0.0-SNAPSHOT.441` +# Dependencies of `io.spine.tools:validation-context:2.0.0-SNAPSHOT.442` ## Runtime 1. **Group** : com.fasterxml.jackson. **Name** : jackson-bom. **Version** : 2.21.3. @@ -1090,14 +1090,14 @@ The dependencies distributed under several licenses, are used according their commercial-use-friendly license. -This report was generated on **Fri May 22 18:04:51 WEST 2026** using +This report was generated on **Fri May 22 18:20:29 WEST 2026** using [Gradle-License-Report plugin](https://github.com/jk1/Gradle-License-Report) by Evgeny Naumenko, licensed under [Apache 2.0 License](https://github.com/jk1/Gradle-License-Report/blob/master/LICENSE). -# Dependencies of `io.spine.tools:validation-context-tests:2.0.0-SNAPSHOT.441` +# Dependencies of `io.spine.tools:validation-context-tests:2.0.0-SNAPSHOT.442` ## Runtime 1. **Group** : com.fasterxml.jackson. **Name** : jackson-bom. **Version** : 2.20.0. @@ -1791,7 +1791,7 @@ This report was generated on **Fri May 22 18:04:51 WEST 2026** using The dependencies distributed under several licenses, are used according their commercial-use-friendly license. -This report was generated on **Fri May 22 18:04:50 WEST 2026** using +This report was generated on **Fri May 22 18:20:28 WEST 2026** using [Gradle-License-Report plugin](https://github.com/jk1/Gradle-License-Report) by Evgeny Naumenko, licensed under [Apache 2.0 License](https://github.com/jk1/Gradle-License-Report/blob/master/LICENSE). @@ -1812,7 +1812,7 @@ This report was generated on **Thu May 21 18:44:23 WEST 2026** using -# Dependencies of `io.spine.tools:validation-gradle-plugin:2.0.0-SNAPSHOT.441` +# Dependencies of `io.spine.tools:validation-gradle-plugin:2.0.0-SNAPSHOT.442` ## Runtime 1. **Group** : com.fasterxml.jackson. **Name** : jackson-bom. **Version** : 2.21.3. @@ -2864,14 +2864,14 @@ This report was generated on **Thu May 21 18:44:23 WEST 2026** using The dependencies distributed under several licenses, are used according their commercial-use-friendly license. -This report was generated on **Fri May 22 18:04:50 WEST 2026** using +This report was generated on **Fri May 22 18:20:29 WEST 2026** using [Gradle-License-Report plugin](https://github.com/jk1/Gradle-License-Report) by Evgeny Naumenko, licensed under [Apache 2.0 License](https://github.com/jk1/Gradle-License-Report/blob/master/LICENSE). -# Dependencies of `io.spine.tools:validation-java:2.0.0-SNAPSHOT.441` +# Dependencies of `io.spine.tools:validation-java:2.0.0-SNAPSHOT.442` ## Runtime 1. **Group** : com.fasterxml.jackson. **Name** : jackson-bom. **Version** : 2.21.3. @@ -3961,14 +3961,14 @@ This report was generated on **Fri May 22 18:04:50 WEST 2026** using The dependencies distributed under several licenses, are used according their commercial-use-friendly license. -This report was generated on **Fri May 22 18:04:51 WEST 2026** using +This report was generated on **Fri May 22 18:20:29 WEST 2026** using [Gradle-License-Report plugin](https://github.com/jk1/Gradle-License-Report) by Evgeny Naumenko, licensed under [Apache 2.0 License](https://github.com/jk1/Gradle-License-Report/blob/master/LICENSE). -# Dependencies of `io.spine.tools:validation-java-bundle:2.0.0-SNAPSHOT.441` +# Dependencies of `io.spine.tools:validation-java-bundle:2.0.0-SNAPSHOT.442` ## Runtime 1. **Group** : org.jetbrains. **Name** : annotations. **Version** : 13.0. @@ -4015,14 +4015,14 @@ This report was generated on **Fri May 22 18:04:51 WEST 2026** using The dependencies distributed under several licenses, are used according their commercial-use-friendly license. -This report was generated on **Fri May 22 18:04:49 WEST 2026** using +This report was generated on **Fri May 22 18:20:27 WEST 2026** using [Gradle-License-Report plugin](https://github.com/jk1/Gradle-License-Report) by Evgeny Naumenko, licensed under [Apache 2.0 License](https://github.com/jk1/Gradle-License-Report/blob/master/LICENSE). -# Dependencies of `io.spine.tools:validation-java-settings:2.0.0-SNAPSHOT.441` +# Dependencies of `io.spine.tools:validation-java-settings:2.0.0-SNAPSHOT.442` ## Runtime 1. **Group** : com.google.code.findbugs. **Name** : jsr305. **Version** : 3.0.2. @@ -4798,14 +4798,14 @@ This report was generated on **Fri May 22 18:04:49 WEST 2026** using The dependencies distributed under several licenses, are used according their commercial-use-friendly license. -This report was generated on **Fri May 22 18:04:50 WEST 2026** using +This report was generated on **Fri May 22 18:20:29 WEST 2026** using [Gradle-License-Report plugin](https://github.com/jk1/Gradle-License-Report) by Evgeny Naumenko, licensed under [Apache 2.0 License](https://github.com/jk1/Gradle-License-Report/blob/master/LICENSE). -# Dependencies of `io.spine:spine-validation-jvm-runtime:2.0.0-SNAPSHOT.441` +# Dependencies of `io.spine:spine-validation-jvm-runtime:2.0.0-SNAPSHOT.442` ## Runtime 1. **Group** : com.google.code.findbugs. **Name** : jsr305. **Version** : 3.0.2. @@ -5605,14 +5605,14 @@ This report was generated on **Fri May 22 18:04:50 WEST 2026** using The dependencies distributed under several licenses, are used according their commercial-use-friendly license. -This report was generated on **Fri May 22 18:04:50 WEST 2026** using +This report was generated on **Fri May 22 18:20:28 WEST 2026** using [Gradle-License-Report plugin](https://github.com/jk1/Gradle-License-Report) by Evgeny Naumenko, licensed under [Apache 2.0 License](https://github.com/jk1/Gradle-License-Report/blob/master/LICENSE). -# Dependencies of `io.spine.tools:validation-consumer:2.0.0-SNAPSHOT.441` +# Dependencies of `io.spine.tools:validation-consumer:2.0.0-SNAPSHOT.442` ## Runtime 1. **Group** : com.fasterxml.jackson. **Name** : jackson-bom. **Version** : 2.21.3. @@ -6294,14 +6294,14 @@ This report was generated on **Fri May 22 18:04:50 WEST 2026** using The dependencies distributed under several licenses, are used according their commercial-use-friendly license. -This report was generated on **Fri May 22 18:04:50 WEST 2026** using +This report was generated on **Fri May 22 18:20:28 WEST 2026** using [Gradle-License-Report plugin](https://github.com/jk1/Gradle-License-Report) by Evgeny Naumenko, licensed under [Apache 2.0 License](https://github.com/jk1/Gradle-License-Report/blob/master/LICENSE). -# Dependencies of `io.spine.tools:validation-consumer-dependency:2.0.0-SNAPSHOT.441` +# Dependencies of `io.spine.tools:validation-consumer-dependency:2.0.0-SNAPSHOT.442` ## Runtime 1. **Group** : com.google.code.findbugs. **Name** : jsr305. **Version** : 3.0.2. @@ -6759,14 +6759,14 @@ This report was generated on **Fri May 22 18:04:50 WEST 2026** using The dependencies distributed under several licenses, are used according their commercial-use-friendly license. -This report was generated on **Fri May 22 18:04:50 WEST 2026** using +This report was generated on **Fri May 22 18:20:28 WEST 2026** using [Gradle-License-Report plugin](https://github.com/jk1/Gradle-License-Report) by Evgeny Naumenko, licensed under [Apache 2.0 License](https://github.com/jk1/Gradle-License-Report/blob/master/LICENSE). -# Dependencies of `io.spine.tools:validation-extensions:2.0.0-SNAPSHOT.441` +# Dependencies of `io.spine.tools:validation-extensions:2.0.0-SNAPSHOT.442` ## Runtime 1. **Group** : com.fasterxml.jackson. **Name** : jackson-bom. **Version** : 2.21.3. @@ -7385,14 +7385,14 @@ This report was generated on **Fri May 22 18:04:50 WEST 2026** using The dependencies distributed under several licenses, are used according their commercial-use-friendly license. -This report was generated on **Fri May 22 18:04:50 WEST 2026** using +This report was generated on **Fri May 22 18:20:28 WEST 2026** using [Gradle-License-Report plugin](https://github.com/jk1/Gradle-License-Report) by Evgeny Naumenko, licensed under [Apache 2.0 License](https://github.com/jk1/Gradle-License-Report/blob/master/LICENSE). -# Dependencies of `io.spine.tools:validation-runtime:2.0.0-SNAPSHOT.441` +# Dependencies of `io.spine.tools:validation-runtime:2.0.0-SNAPSHOT.442` ## Runtime 1. **Group** : com.google.code.findbugs. **Name** : jsr305. **Version** : 3.0.2. @@ -7953,14 +7953,14 @@ This report was generated on **Fri May 22 18:04:50 WEST 2026** using The dependencies distributed under several licenses, are used according their commercial-use-friendly license. -This report was generated on **Fri May 22 18:04:50 WEST 2026** using +This report was generated on **Fri May 22 18:20:28 WEST 2026** using [Gradle-License-Report plugin](https://github.com/jk1/Gradle-License-Report) by Evgeny Naumenko, licensed under [Apache 2.0 License](https://github.com/jk1/Gradle-License-Report/blob/master/LICENSE). -# Dependencies of `io.spine.tools:validation-time:2.0.0-SNAPSHOT.441` +# Dependencies of `io.spine.tools:validation-time:2.0.0-SNAPSHOT.442` ## Runtime 1. **Group** : com.google.code.findbugs. **Name** : jsr305. **Version** : 3.0.2. @@ -8382,14 +8382,14 @@ This report was generated on **Fri May 22 18:04:50 WEST 2026** using The dependencies distributed under several licenses, are used according their commercial-use-friendly license. -This report was generated on **Fri May 22 18:04:50 WEST 2026** using +This report was generated on **Fri May 22 18:20:28 WEST 2026** using [Gradle-License-Report plugin](https://github.com/jk1/Gradle-License-Report) by Evgeny Naumenko, licensed under [Apache 2.0 License](https://github.com/jk1/Gradle-License-Report/blob/master/LICENSE). -# Dependencies of `io.spine.tools:validation-validating:2.0.0-SNAPSHOT.441` +# Dependencies of `io.spine.tools:validation-validating:2.0.0-SNAPSHOT.442` ## Runtime 1. **Group** : com.google.code.findbugs. **Name** : jsr305. **Version** : 3.0.2. @@ -8993,14 +8993,14 @@ This report was generated on **Fri May 22 18:04:50 WEST 2026** using The dependencies distributed under several licenses, are used according their commercial-use-friendly license. -This report was generated on **Fri May 22 18:04:50 WEST 2026** using +This report was generated on **Fri May 22 18:20:28 WEST 2026** using [Gradle-License-Report plugin](https://github.com/jk1/Gradle-License-Report) by Evgeny Naumenko, licensed under [Apache 2.0 License](https://github.com/jk1/Gradle-License-Report/blob/master/LICENSE). -# Dependencies of `io.spine.tools:validation-validator:2.0.0-SNAPSHOT.441` +# Dependencies of `io.spine.tools:validation-validator:2.0.0-SNAPSHOT.442` ## Runtime 1. **Group** : com.fasterxml.jackson. **Name** : jackson-bom. **Version** : 2.21.3. @@ -9738,14 +9738,14 @@ This report was generated on **Fri May 22 18:04:50 WEST 2026** using The dependencies distributed under several licenses, are used according their commercial-use-friendly license. -This report was generated on **Fri May 22 18:04:50 WEST 2026** using +This report was generated on **Fri May 22 18:20:28 WEST 2026** using [Gradle-License-Report plugin](https://github.com/jk1/Gradle-License-Report) by Evgeny Naumenko, licensed under [Apache 2.0 License](https://github.com/jk1/Gradle-License-Report/blob/master/LICENSE). -# Dependencies of `io.spine.tools:validation-validator-dependency:2.0.0-SNAPSHOT.441` +# Dependencies of `io.spine.tools:validation-validator-dependency:2.0.0-SNAPSHOT.442` ## Runtime 1. **Group** : com.google.code.findbugs. **Name** : jsr305. **Version** : 3.0.2. @@ -9978,14 +9978,14 @@ This report was generated on **Fri May 22 18:04:50 WEST 2026** using The dependencies distributed under several licenses, are used according their commercial-use-friendly license. -This report was generated on **Fri May 22 18:04:50 WEST 2026** using +This report was generated on **Fri May 22 18:20:28 WEST 2026** using [Gradle-License-Report plugin](https://github.com/jk1/Gradle-License-Report) by Evgeny Naumenko, licensed under [Apache 2.0 License](https://github.com/jk1/Gradle-License-Report/blob/master/LICENSE). -# Dependencies of `io.spine.tools:validation-vanilla:2.0.0-SNAPSHOT.441` +# Dependencies of `io.spine.tools:validation-vanilla:2.0.0-SNAPSHOT.442` ## Runtime 1. **Group** : com.google.code.findbugs. **Name** : jsr305. **Version** : 3.0.2. @@ -10328,6 +10328,6 @@ This report was generated on **Fri May 22 18:04:50 WEST 2026** using The dependencies distributed under several licenses, are used according their commercial-use-friendly license. -This report was generated on **Fri May 22 18:04:50 WEST 2026** using +This report was generated on **Fri May 22 18:20:28 WEST 2026** using [Gradle-License-Report plugin](https://github.com/jk1/Gradle-License-Report) by Evgeny Naumenko, licensed under [Apache 2.0 License](https://github.com/jk1/Gradle-License-Report/blob/master/LICENSE). \ No newline at end of file diff --git a/docs/dependencies/pom.xml b/docs/dependencies/pom.xml index 66c5415121..fe27740b04 100644 --- a/docs/dependencies/pom.xml +++ b/docs/dependencies/pom.xml @@ -10,7 +10,7 @@ all modules and does not describe the project structure per-subproject. --> io.spine.tools validation -2.0.0-SNAPSHOT.441 +2.0.0-SNAPSHOT.442 2015 From 05d3347b6c8431fc9dca98d42c4ed1c724c66e84 Mon Sep 17 00:00:00 2001 From: alexander-yevsyukov Date: Fri, 22 May 2026 18:22:43 +0100 Subject: [PATCH 09/12] Keep task documents for the records --- .agents/tasks/issue-175-set-once-timestamp.md | 58 ++++++++ .agents/tasks/reject-required-on-empty.md | 129 ++++++++++++++++++ 2 files changed, 187 insertions(+) create mode 100644 .agents/tasks/issue-175-set-once-timestamp.md create mode 100644 .agents/tasks/reject-required-on-empty.md diff --git a/.agents/tasks/issue-175-set-once-timestamp.md b/.agents/tasks/issue-175-set-once-timestamp.md new file mode 100644 index 0000000000..a09a19a7e1 --- /dev/null +++ b/.agents/tasks/issue-175-set-once-timestamp.md @@ -0,0 +1,58 @@ +--- +slug: issue-175-set-once-timestamp +branch: air/please-work-on-this-issue-https-github.com-spineeventengine-vali-8bd73481-e +owner: claude +status: in-progress +started: 2026-05-22 +--- + +## Goal + +Close [issue #175](https://github.com/SpineEventEngine/validation/issues/175) by adding +a positive integration test that applies `(set_once)` to a field of type +`google.protobuf.Timestamp` — an external message imported from a Protobuf dependency. + +## Context + +The issue has two items: + +1. Negative tests: fail when the option is applied to unsupported field types. +2. Positive test for `(set_once)` on an external message type like `Timestamp`, + guarding the regression that motivated the original report. + +Item 1 is already covered at the reaction level: + +- `context-tests/.../SetOnceOptionSpec.kt` exercises both unsupported categories + (`repeated`, `map`) for `(set_once)`, which is exhaustive because the supported-type + check is purely structural (`!isList && !isMap`). +- `context-tests/.../GoesReactionSpec.kt` + `GoesReactionTestEnv.kt` enumerate **all 13** + unsupported primitive types (`bool` + 12 numeric variants) as both target and companion, + matching `SUPPORTED_PRIMITIVES = {STRING, BYTES}`. + +Item 2 has no coverage today — the `(set_once)` proto fixtures only use the locally +defined `Name` message. `(goes)` already uses `Timestamp` in several of its fixtures, so +the pattern is well-established. + +## Plan + +- [x] Draft this task file and the system plan. +- [x] Add `import "google/protobuf/timestamp.proto";` and a new `Measurement` + message with one `(set_once) = true` `Timestamp` field to + `tests/validating/src/testFixtures/proto/spine/test/tools/validate/set_once_fields.proto`. +- [x] Add two distinct `Timestamp` constants (`BIRTHDAY1`, `BIRTHDAY2`) to + `tests/validating/src/testFixtures/kotlin/io/spine/test/options/setonce/SetOnceTestEnv.kt`. +- [x] Add two nested test classes to + `tests/validating/src/test/kotlin/io/spine/test/options/setonce/SetOnceFieldsITest.kt` + mirroring the existing `…non-default message` / `…default and same-value message` + pair, but for `Measurement`. +- [x] `./gradlew :tests:validating:test --tests "*SetOnceFieldsITest*"` passes (82/82). +- [x] `./gradlew :tests:validating:test` passes (460/460, no regressions). +- [x] Bump `version.gradle.kts` via the `bump-version` skill (SNAPSHOT.441 → .442). +- [ ] Run the `pre-pr` skill (version gate, build/check, reviewers). +- [ ] Delete this file on merge to master. + +## Log + +- 2026-05-22 — drafted, approved by user (turn 4), executing. +- 2026-05-22 — implementation + tests complete; 460/460 pass. Proceeding to version bump. +- 2026-05-22 — version bumped to `2.0.0-SNAPSHOT.442`; dependency reports regenerated and committed. diff --git a/.agents/tasks/reject-required-on-empty.md b/.agents/tasks/reject-required-on-empty.md new file mode 100644 index 0000000000..c79fa22e50 --- /dev/null +++ b/.agents/tasks/reject-required-on-empty.md @@ -0,0 +1,129 @@ +--- +slug: reject-required-on-empty +branch: improve-console-output +owner: claude +status: in-progress +started: 2026-05-22 +related-issues: + - https://github.com/SpineEventEngine/validation/issues/146 +--- + +## Goal + +Make the Validation Compiler reject `(required)` set on any field whose type is +`google.protobuf.Empty` — singular, `repeated`, or `map<_, Empty>` — at +**compile time**, producing a clear error pointing at the offending field. +Today, such fields produce a runtime "always invalid" message because the +generated check compares against the default instance of `Empty`, which always +matches. + +Scope is limited to the **explicit** `(required)` option in this repo. The +parallel rule for *implicitly* required IDs (command messages, entity states) +lives in `SpineEventEngine/core-jvm-compiler` and will be filed as a +follow-up issue linked from #146. + +## Context + +Issue: https://github.com/SpineEventEngine/validation/issues/146 + +Today's behavior (runtime-only) is captured by `RequiredMessageITest.not be +applied to the type 'Empty'` and the `AlwaysInvalid` fixture at +`tests/validating/src/testFixtures/proto/spine/test/tools/validate/required.proto:63`. +The test comment explicitly references this issue as "we should do more and +raise compile-time error". + +Decisions made with the user before drafting (one question at a time): +- (Q1) Implicit-required case (command IDs / entity-state IDs): only this + repo; open a follow-up issue under `core-jvm-compiler` and cross-link it + with #146. +- (Q2) Existing `AlwaysInvalid` runtime fixture: keep the proto definition, + but move it to a "should-fail-compilation" fixture set in `context-tests` + (analogous to `RequiredBoolField`). +- (Q3) Structural placement: inline a new private helper next to + `checkFieldType()` in `RequiredOption.kt`; call it from the existing + `RequiredReaction.whenever()`. +- (Q4) Only reject `Empty` with `(required)` set. Pointless `repeated Empty` + / `map<_, Empty>` *without* `(required)` are out of scope — not the job + of Validation. + +### Key code locations + +- `context/src/main/kotlin/io/spine/tools/validation/option/required/RequiredOption.kt` + — `RequiredReaction.whenever()` (line ~91) and the private + `checkFieldType()` helper (line ~149) live here. The new helper goes next + to `checkFieldType()` and is invoked from `whenever()`. +- `context/src/main/kotlin/io/spine/tools/validation/option/required/RequiredFieldSupport.kt` + — type-support rules; not touched by this change. +- `context-tests/src/testFixtures/proto/spine/validation/required_option_spec.proto` + — where the new "bad" fixtures land (three new messages). +- `context-tests/src/test/kotlin/io/spine/tools/validation/RequiredOptionSpec.kt` + — where the new compile-time tests land. +- `tests/validating/src/testFixtures/proto/spine/test/tools/validate/required.proto:63` + — `AlwaysInvalid` to be removed (replaced by the context-tests fixtures). +- `tests/validating/src/test/kotlin/io/spine/test/options/required/RequiredMessageITest.kt:60-77` + — the `not be applied to the type 'Empty'` test method to be removed + (along with its `AlwaysInvalid` and `Empty` imports). + +### API used to detect `Empty` + +Following the existing `refersToAny()` pattern in +`compiler-api/.../FieldTypeExts.kt`: +- `TypeNameOrBuilder.isAny` checks `packageName == "google.protobuf" && + simpleName == "Any"`. +- `Type.isAny = isMessage && message.isAny`. +- `FieldType.refersToAny()` handles singular / list / map. + +We mirror that locally in `RequiredOption.kt` with a `refersToEmpty()` +private helper. No change to `compiler-api` is needed for this issue. + +## Plan + +- [ ] **Add compile-time check** — in `RequiredOption.kt`: + - Add private helper `checkFieldIsNotEmpty(field, file)` next to + `checkFieldType()`, using `Compilation.check(...)` with a message like: + `"The field `${field.qualifiedName}` of type `${field.type.name}` cannot + be marked as `($REQUIRED)` because `google.protobuf.Empty` has no + fields and its instances are always equal to the default value."` + - Add a private `FieldType.refersToEmpty()` helper that checks singular + message / `list.message` / `map.valueType.message` for + `packageName == "google.protobuf" && simpleName == "Empty"`. + - Call `checkFieldIsNotEmpty(field, file)` from + `RequiredReaction.whenever()` immediately after `checkFieldType(...)`. +- [ ] **Move the runtime fixture to a compile-time fixture set**: + - Delete `AlwaysInvalid` from + `tests/validating/src/testFixtures/proto/spine/test/tools/validate/required.proto`. + - Add three new fixture messages to + `context-tests/src/testFixtures/proto/spine/validation/required_option_spec.proto`: + `RequiredEmptyField` (singular), `RequiredRepeatedEmpty` (repeated), + `RequiredMapWithEmptyValue` (`map`). +- [ ] **Add compile-time tests** in `RequiredOptionSpec.kt`: + - Three tests asserting `assertCompilationFails(...)` on each new + fixture with a substring matching the new error message. + - Reuse the existing `unsupportedFieldType(field)` only if the message + is exactly the same wording; otherwise add a sibling helper + `rejectsEmpty(field)` in the same file. +- [ ] **Remove the runtime test**: + - Delete the `not be applied to the type 'Empty'` test method in + `RequiredMessageITest.kt`. + - Drop the now-unused `import com.google.protobuf.Empty` and + `import io.spine.test.tools.validate.AlwaysInvalid`. +- [ ] **Search for any other live references** to `AlwaysInvalid` in + `tests/validating` proto/code (the `spine.test.validate.AlwaysInvalid` + in `messages.proto` is a DIFFERENT message — string fields — and + must remain untouched). +- [ ] **Pre-PR**: + - Run `bump-version` per repo policy. + - Run the build per `.agents/running-builds.md`. + - `kotlin-review` on the diff. + - `review-docs` on KDoc changes (any new public docs?). +- [ ] **Follow-up issue** under `core-jvm-compiler`: + - File a new issue: "Reject `(required)`-implicit ID fields of type + `google.protobuf.Empty` at compile-time". + - Link it back to #146 in both directions (mention from each issue). + - Body: short rationale, link to this change in `validation`, and the + affected reactions (`RequiredIdReaction`, `EntityStateIdReaction`). + +## Log + +- 2026-05-22 — drafted from user clarifications Q1–Q4 above; awaiting + approval via `ExitPlanMode`. From f775de0620714e33624ca2200d3fbd275dbedaa8 Mon Sep 17 00:00:00 2001 From: Alexander Yevsyukov Date: Fri, 22 May 2026 20:33:33 +0300 Subject: [PATCH 10/12] Remove exta trail backtick Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../proto/spine/test/tools/validate/set_once_fields.proto | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/validating/src/testFixtures/proto/spine/test/tools/validate/set_once_fields.proto b/tests/validating/src/testFixtures/proto/spine/test/tools/validate/set_once_fields.proto index b93f6c415f..924dc8a88b 100644 --- a/tests/validating/src/testFixtures/proto/spine/test/tools/validate/set_once_fields.proto +++ b/tests/validating/src/testFixtures/proto/spine/test/tools/validate/set_once_fields.proto @@ -102,7 +102,7 @@ message StudentSetOnceFalse { } // Tests that the constraint works with an external message type imported -// from a dependency (`google.protobuf.Timestamp`).` +// from a dependency (`google.protobuf.Timestamp`). message Measurement { google.protobuf.Timestamp timestamp = 1 [(set_once) = true]; } From 8a712adab20b8fb4fb53e3bbcbbe03443f987782 Mon Sep 17 00:00:00 2001 From: alexander-yevsyukov Date: Fri, 22 May 2026 18:34:15 +0100 Subject: [PATCH 11/12] Update `site-commons` --- docs/_preview/go.mod | 2 +- docs/_preview/go.sum | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/_preview/go.mod b/docs/_preview/go.mod index 7443952b80..f7adfdf8ee 100644 --- a/docs/_preview/go.mod +++ b/docs/_preview/go.mod @@ -3,6 +3,6 @@ module spine.io/validation/docs/preview go 1.25.6 require ( - github.com/SpineEventEngine/site-commons v0.0.0-20260507130158-84db050dfe11 // indirect + github.com/SpineEventEngine/site-commons v0.0.0-20260522171914-2a606d89558f // indirect github.com/gohugoio/hugo-mod-bootstrap-scss/v5 v5.20300.20800 // indirect ) diff --git a/docs/_preview/go.sum b/docs/_preview/go.sum index a0140275a3..bcfc772e1c 100644 --- a/docs/_preview/go.sum +++ b/docs/_preview/go.sum @@ -1,5 +1,7 @@ github.com/SpineEventEngine/site-commons v0.0.0-20260507130158-84db050dfe11 h1:NTO0ua9IaaO6xmn9m11i4IJjciPXhxKTW+/pAKTOlZw= github.com/SpineEventEngine/site-commons v0.0.0-20260507130158-84db050dfe11/go.mod h1:tkAl4StIREKmz9r5PiJtuDhvwMMkFXKWcaTyxhIikho= +github.com/SpineEventEngine/site-commons v0.0.0-20260522171914-2a606d89558f h1:yF43XTDNj+w1S0fLA/XhrWMrhAoDpqPB7gmc4mUF/9Q= +github.com/SpineEventEngine/site-commons v0.0.0-20260522171914-2a606d89558f/go.mod h1:tkAl4StIREKmz9r5PiJtuDhvwMMkFXKWcaTyxhIikho= github.com/gohugoio/hugo-mod-bootstrap-scss/v5 v5.20300.20800 h1:j5myhhzYwIHTr5ctK96Elfgp5uRROvrlTzYwwe1nF8o= github.com/gohugoio/hugo-mod-bootstrap-scss/v5 v5.20300.20800/go.mod h1:P5GGyhdxi00C5zW7vkRo/IS532gZY/YS2TS395Xaxho= github.com/gohugoio/hugo-mod-jslibs-dist/popperjs/v2 v2.21100.20000/go.mod h1:mFberT6ZtcchrsDtfvJM7aAH2bDKLdOnruUHl0hlapI= From 38a40f52558ea7d95b46b854ee1accd4f94f6d32 Mon Sep 17 00:00:00 2001 From: alexander-yevsyukov Date: Fri, 22 May 2026 18:36:26 +0100 Subject: [PATCH 12/12] Update build time --- docs/dependencies/dependencies.md | 32 +++++++++++++++---------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/docs/dependencies/dependencies.md b/docs/dependencies/dependencies.md index bd3c9cf554..372e6f58b3 100644 --- a/docs/dependencies/dependencies.md +++ b/docs/dependencies/dependencies.md @@ -1090,7 +1090,7 @@ The dependencies distributed under several licenses, are used according their commercial-use-friendly license. -This report was generated on **Fri May 22 18:20:29 WEST 2026** using +This report was generated on **Fri May 22 18:35:09 WEST 2026** using [Gradle-License-Report plugin](https://github.com/jk1/Gradle-License-Report) by Evgeny Naumenko, licensed under [Apache 2.0 License](https://github.com/jk1/Gradle-License-Report/blob/master/LICENSE). @@ -1791,7 +1791,7 @@ This report was generated on **Fri May 22 18:20:29 WEST 2026** using The dependencies distributed under several licenses, are used according their commercial-use-friendly license. -This report was generated on **Fri May 22 18:20:28 WEST 2026** using +This report was generated on **Fri May 22 18:35:09 WEST 2026** using [Gradle-License-Report plugin](https://github.com/jk1/Gradle-License-Report) by Evgeny Naumenko, licensed under [Apache 2.0 License](https://github.com/jk1/Gradle-License-Report/blob/master/LICENSE). @@ -2864,7 +2864,7 @@ This report was generated on **Thu May 21 18:44:23 WEST 2026** using The dependencies distributed under several licenses, are used according their commercial-use-friendly license. -This report was generated on **Fri May 22 18:20:29 WEST 2026** using +This report was generated on **Fri May 22 18:35:09 WEST 2026** using [Gradle-License-Report plugin](https://github.com/jk1/Gradle-License-Report) by Evgeny Naumenko, licensed under [Apache 2.0 License](https://github.com/jk1/Gradle-License-Report/blob/master/LICENSE). @@ -3961,7 +3961,7 @@ This report was generated on **Fri May 22 18:20:29 WEST 2026** using The dependencies distributed under several licenses, are used according their commercial-use-friendly license. -This report was generated on **Fri May 22 18:20:29 WEST 2026** using +This report was generated on **Fri May 22 18:35:09 WEST 2026** using [Gradle-License-Report plugin](https://github.com/jk1/Gradle-License-Report) by Evgeny Naumenko, licensed under [Apache 2.0 License](https://github.com/jk1/Gradle-License-Report/blob/master/LICENSE). @@ -4015,7 +4015,7 @@ This report was generated on **Fri May 22 18:20:29 WEST 2026** using The dependencies distributed under several licenses, are used according their commercial-use-friendly license. -This report was generated on **Fri May 22 18:20:27 WEST 2026** using +This report was generated on **Fri May 22 18:35:08 WEST 2026** using [Gradle-License-Report plugin](https://github.com/jk1/Gradle-License-Report) by Evgeny Naumenko, licensed under [Apache 2.0 License](https://github.com/jk1/Gradle-License-Report/blob/master/LICENSE). @@ -4798,7 +4798,7 @@ This report was generated on **Fri May 22 18:20:27 WEST 2026** using The dependencies distributed under several licenses, are used according their commercial-use-friendly license. -This report was generated on **Fri May 22 18:20:29 WEST 2026** using +This report was generated on **Fri May 22 18:35:09 WEST 2026** using [Gradle-License-Report plugin](https://github.com/jk1/Gradle-License-Report) by Evgeny Naumenko, licensed under [Apache 2.0 License](https://github.com/jk1/Gradle-License-Report/blob/master/LICENSE). @@ -5605,7 +5605,7 @@ This report was generated on **Fri May 22 18:20:29 WEST 2026** using The dependencies distributed under several licenses, are used according their commercial-use-friendly license. -This report was generated on **Fri May 22 18:20:28 WEST 2026** using +This report was generated on **Fri May 22 18:35:09 WEST 2026** using [Gradle-License-Report plugin](https://github.com/jk1/Gradle-License-Report) by Evgeny Naumenko, licensed under [Apache 2.0 License](https://github.com/jk1/Gradle-License-Report/blob/master/LICENSE). @@ -6294,7 +6294,7 @@ This report was generated on **Fri May 22 18:20:28 WEST 2026** using The dependencies distributed under several licenses, are used according their commercial-use-friendly license. -This report was generated on **Fri May 22 18:20:28 WEST 2026** using +This report was generated on **Fri May 22 18:35:09 WEST 2026** using [Gradle-License-Report plugin](https://github.com/jk1/Gradle-License-Report) by Evgeny Naumenko, licensed under [Apache 2.0 License](https://github.com/jk1/Gradle-License-Report/blob/master/LICENSE). @@ -6759,7 +6759,7 @@ This report was generated on **Fri May 22 18:20:28 WEST 2026** using The dependencies distributed under several licenses, are used according their commercial-use-friendly license. -This report was generated on **Fri May 22 18:20:28 WEST 2026** using +This report was generated on **Fri May 22 18:35:08 WEST 2026** using [Gradle-License-Report plugin](https://github.com/jk1/Gradle-License-Report) by Evgeny Naumenko, licensed under [Apache 2.0 License](https://github.com/jk1/Gradle-License-Report/blob/master/LICENSE). @@ -7385,7 +7385,7 @@ This report was generated on **Fri May 22 18:20:28 WEST 2026** using The dependencies distributed under several licenses, are used according their commercial-use-friendly license. -This report was generated on **Fri May 22 18:20:28 WEST 2026** using +This report was generated on **Fri May 22 18:35:08 WEST 2026** using [Gradle-License-Report plugin](https://github.com/jk1/Gradle-License-Report) by Evgeny Naumenko, licensed under [Apache 2.0 License](https://github.com/jk1/Gradle-License-Report/blob/master/LICENSE). @@ -7953,7 +7953,7 @@ This report was generated on **Fri May 22 18:20:28 WEST 2026** using The dependencies distributed under several licenses, are used according their commercial-use-friendly license. -This report was generated on **Fri May 22 18:20:28 WEST 2026** using +This report was generated on **Fri May 22 18:35:09 WEST 2026** using [Gradle-License-Report plugin](https://github.com/jk1/Gradle-License-Report) by Evgeny Naumenko, licensed under [Apache 2.0 License](https://github.com/jk1/Gradle-License-Report/blob/master/LICENSE). @@ -8382,7 +8382,7 @@ This report was generated on **Fri May 22 18:20:28 WEST 2026** using The dependencies distributed under several licenses, are used according their commercial-use-friendly license. -This report was generated on **Fri May 22 18:20:28 WEST 2026** using +This report was generated on **Fri May 22 18:35:08 WEST 2026** using [Gradle-License-Report plugin](https://github.com/jk1/Gradle-License-Report) by Evgeny Naumenko, licensed under [Apache 2.0 License](https://github.com/jk1/Gradle-License-Report/blob/master/LICENSE). @@ -8993,7 +8993,7 @@ This report was generated on **Fri May 22 18:20:28 WEST 2026** using The dependencies distributed under several licenses, are used according their commercial-use-friendly license. -This report was generated on **Fri May 22 18:20:28 WEST 2026** using +This report was generated on **Fri May 22 18:35:09 WEST 2026** using [Gradle-License-Report plugin](https://github.com/jk1/Gradle-License-Report) by Evgeny Naumenko, licensed under [Apache 2.0 License](https://github.com/jk1/Gradle-License-Report/blob/master/LICENSE). @@ -9738,7 +9738,7 @@ This report was generated on **Fri May 22 18:20:28 WEST 2026** using The dependencies distributed under several licenses, are used according their commercial-use-friendly license. -This report was generated on **Fri May 22 18:20:28 WEST 2026** using +This report was generated on **Fri May 22 18:35:09 WEST 2026** using [Gradle-License-Report plugin](https://github.com/jk1/Gradle-License-Report) by Evgeny Naumenko, licensed under [Apache 2.0 License](https://github.com/jk1/Gradle-License-Report/blob/master/LICENSE). @@ -9978,7 +9978,7 @@ This report was generated on **Fri May 22 18:20:28 WEST 2026** using The dependencies distributed under several licenses, are used according their commercial-use-friendly license. -This report was generated on **Fri May 22 18:20:28 WEST 2026** using +This report was generated on **Fri May 22 18:35:08 WEST 2026** using [Gradle-License-Report plugin](https://github.com/jk1/Gradle-License-Report) by Evgeny Naumenko, licensed under [Apache 2.0 License](https://github.com/jk1/Gradle-License-Report/blob/master/LICENSE). @@ -10328,6 +10328,6 @@ This report was generated on **Fri May 22 18:20:28 WEST 2026** using The dependencies distributed under several licenses, are used according their commercial-use-friendly license. -This report was generated on **Fri May 22 18:20:28 WEST 2026** using +This report was generated on **Fri May 22 18:35:08 WEST 2026** using [Gradle-License-Report plugin](https://github.com/jk1/Gradle-License-Report) by Evgeny Naumenko, licensed under [Apache 2.0 License](https://github.com/jk1/Gradle-License-Report/blob/master/LICENSE). \ No newline at end of file