diff --git a/.agents/_TOC.md b/.agents/_TOC.md index 065df133a9..83375f417f 100644 --- a/.agents/_TOC.md +++ b/.agents/_TOC.md @@ -13,4 +13,4 @@ 11. [Advanced safety rules](advanced-safety-rules.md) 12. [Refactoring guidelines](refactoring-guidelines.md) 13. [Common tasks](common-tasks.md) -14. [Java to Kotlin conversion](java-kotlin-conversion.md) +14. [Java to Kotlin conversion](skills/java-to-kotlin/SKILL.md) diff --git a/.agents/quick-reference-card.md b/.agents/quick-reference-card.md index 6c25b9a7f6..e2be69cb81 100644 --- a/.agents/quick-reference-card.md +++ b/.agents/quick-reference-card.md @@ -3,7 +3,6 @@ ``` 🔑 Key Information: - Kotlin/Java project with CQRS architecture -- Use ChatGPT for documentation, Codex for code generation, GPT-4o for complex analysis - Follow coding guidelines in Spine Event Engine docs - Always include tests with code changes - Version bump required for all PRs diff --git a/.agents/skills/bump-gradle/SKILL.md b/.agents/skills/bump-gradle/SKILL.md new file mode 100644 index 0000000000..e5d09269fd --- /dev/null +++ b/.agents/skills/bump-gradle/SKILL.md @@ -0,0 +1,117 @@ +--- +name: bump-gradle +description: > + Update the Gradle wrapper version used by this repository. Use when asked to + upgrade Gradle, bump the Gradle wrapper, move the project to the latest + Gradle release from the official release notes, run the Gradle build, and + commit Gradle wrapper and dependency report changes separately. +--- + +# Bump Gradle + +Use the official Gradle release notes as the source of truth for both the +latest version and the wrapper update command: + +https://docs.gradle.org/current/release-notes.html#upgrade-instructions + +Always check that page at task time. Do not rely on remembered Gradle versions. + +## Checklist + +1. Work from the target repository root. + + Confirm `./gradlew` and `gradle/wrapper/gradle-wrapper.properties` exist + before changing anything. Inspect `git status --short` and preserve unrelated + user changes. If Gradle wrapper files are already modified, inspect the diff + and continue only when those edits are part of the same requested Gradle + bump; otherwise ask before overwriting or staging them. + +2. Read the latest Gradle version from the release notes. + + Open the Upgrade instructions section at the URL above. Use the version in + the release heading and the wrapper command shown there. They should agree; + if they do not, stop and report the mismatch. + +3. Run the wrapper update command. + + Substitute the version from the release notes: + + ```bash + ./gradlew wrapper --gradle-version=GRADLE_VERSION && ./gradlew wrapper + ``` + + For example, if the release notes say Gradle `9.5.1`, run: + + ```bash + ./gradlew wrapper --gradle-version=9.5.1 && ./gradlew wrapper + ``` + +4. Run the build. + + ```bash + ./gradlew clean build + ``` + + If the wrapper update or build fails, do not commit partial changes. Report + the failing command and the relevant error output. + +5. Commit only Gradle-related files. + + Inspect `git status --short` and `git diff --name-only`. Stage only files + created or updated by the Gradle wrapper bump, normally: + + ```text + gradle/wrapper/gradle-wrapper.properties + gradle/wrapper/gradle-wrapper.jar + gradlew + gradlew.bat + ``` + + Include other Gradle-owned files only when they are directly required by the + wrapper update and are clearly part of the same change. Do not stage + dependency reports or unrelated build output in this commit. + + Commit with the exact subject, replacing `GRADLE_VERSION`: + + ```text + Bump Gradle -> `GRADLE_VERSION` + ``` + + Example: + + ```bash + git commit -m 'Bump Gradle -> `9.5.1`' + ``` + + If no Gradle-related files changed, do not create an empty commit; report + that the wrapper was already current after verification. + +6. Commit dependency reports separately when the build updates them. + + Stage only generated dependency report files. In repositories using this + config, the usual paths are: + + ```text + docs/dependencies/pom.xml + docs/dependencies/dependencies.md + ``` + + Include other changed files only when they are clearly generated dependency + reports from the build. Commit them separately with: + + ```text + Update dependency reports + ``` + +7. Verify the final branch state. + + Confirm the recent commit subjects and make sure no owned Gradle bump or + dependency report changes remain unstaged: + + ```bash + git log --format=%s -2 + git status --short + ``` + + Leave unrelated pre-existing user changes alone and mention them separately + in the final response. diff --git a/.agents/skills/bump-gradle/agents/openai.yaml b/.agents/skills/bump-gradle/agents/openai.yaml new file mode 100644 index 0000000000..6edf97877f --- /dev/null +++ b/.agents/skills/bump-gradle/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Bump Gradle" + short_description: "Update the Gradle wrapper safely" + default_prompt: "Use $bump-gradle to update this repository to the latest Gradle wrapper version from the official release notes, build, and split Gradle/report commits." diff --git a/.agents/skills/bump-version/SKILL.md b/.agents/skills/bump-version/SKILL.md new file mode 100644 index 0000000000..7143c3e9fa --- /dev/null +++ b/.agents/skills/bump-version/SKILL.md @@ -0,0 +1,118 @@ +--- +name: bump-version +description: > + Bump the project version in `version.gradle.kts` following the Spine SDK + versioning policy. Use when starting a new branch, before opening a PR, or + when CI rejects a branch for a missing/insufficient version increment. Covers + locating the published version value, choosing the increment, committing the + bump, rebuilding reports, and resolving version conflicts. +--- + +# Bump the project version + +The authoritative policy is [Spine SDK Versioning][version-policy]. In this +skill's target repository, CI runs the `Version Guard` workflow, which invokes +`checkVersionIncrement` through `IncrementGuard`. The task fails if the current +project version already exists in the Maven repository. It does not compare git +branches or inspect commit subjects; the checks below are agent-side guardrails. + +## Checklist + +1. Work from the target repository root. + + Confirm `version.gradle.kts` exists before editing. If it is absent, stop and + report that this skill does not apply to the current checkout. + + Inspect `git status --short` before changing files. Preserve unrelated user + changes and stage only the version/report files this workflow owns. + +2. Locate `version.gradle.kts` and update the value that feeds + `versionToPublish`. + + The published version may be a literal: + + ```kotlin + val versionToPublish: String by extra("2.0.0-SNAPSHOT.182") + ``` + + Or it may come from another variable: + + ```kotlin + val compilerVersion: String by extra("2.0.0-SNAPSHOT.043") + val versionToPublish by extra(compilerVersion) + ``` + + In the second case, update the source value (`compilerVersion` here), not + only the `versionToPublish` alias. + +3. Choose the increment. + + For the normal snapshot-line PR, increment the trailing snapshot number by + one: `2.0.0-SNAPSHOT.182` -> `2.0.0-SNAPSHOT.183`. Preserve existing + zero-padding: `2.0.0-SNAPSHOT.009` -> `2.0.0-SNAPSHOT.010`. + + For a breaking snapshot-line PR, advance to the next multiple of 10 that is + strictly greater than the current value: `.187` -> `.190`, and `.180` -> + `.190`. + + For release-line work, follow the [policy][version-policy]: urgent fixes bump `PATCH`; + feature work or significant fixes bump `MINOR` and reset `PATCH` to `0`. + +4. Commit only the `version.gradle.kts` change with this subject: + + ```text + Bump version -> `2.0.0-SNAPSHOT.183` + ``` + + Use the actual new version in the subject. Do not include unrelated files in + this commit. + +5. Run the build to verify the bump and regenerate reports: + + ```bash + ./gradlew clean build + ``` + + Repos using this config commonly finalize `generatePom` and + `mergeAllLicenseReports` after `build`, which updates + `docs/dependencies/pom.xml` and `docs/dependencies/dependencies.md` when + those reports are configured. + +6. If `docs/dependencies/pom.xml` or `docs/dependencies/dependencies.md` changed, + commit those generated files separately: + + ```text + Update dependency reports + ``` + + If the PR has the `License Reports` workflow, make sure the branch modifies + `docs/dependencies/pom.xml` and `docs/dependencies/dependencies.md`. + +7. Validate the branch state. + + ```bash + BASE=master + git fetch --quiet origin "$BASE" + RANGE="$(git merge-base HEAD origin/$BASE)..HEAD" + git log --format=%s "$RANGE" | grep '^Bump version ->' + git diff --name-only "$RANGE" -- version.gradle.kts | grep '^version.gradle.kts$' + ``` + + Use the actual merge target for `BASE` when it is not `master`. + Also confirm `git status --short` has no uncommitted changes created by the + version bump or report regeneration. + +## Conflict Rule + +When merging a base branch into a feature branch: + +- If the base branch version is lower, keep the feature branch version. +- If the base branch version is greater than or equal to the feature branch + version, set the feature branch version to `base + 1`, or apply the breaking + change rounding rule. + +Do not require a completely clean worktree if unrelated user changes are +present. Instead, make sure no uncommitted changes were created by the version +bump or report regeneration. + +[version-policy]: https://github.com/SpineEventEngine/documentation/wiki/Versioning diff --git a/.agents/skills/bump-version/agents/openai.yaml b/.agents/skills/bump-version/agents/openai.yaml new file mode 100644 index 0000000000..12f6e4f9b8 --- /dev/null +++ b/.agents/skills/bump-version/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Bump Version" + short_description: "Bump Spine project versions safely" + default_prompt: "Use $bump-version to bump the project version in version.gradle.kts, commit the version change, rebuild dependency reports, and verify the branch." diff --git a/.agents/java-kotlin-conversion.md b/.agents/skills/java-to-kotlin/SKILL.md similarity index 88% rename from .agents/java-kotlin-conversion.md rename to .agents/skills/java-to-kotlin/SKILL.md index 95cf929543..d3abdc2f7b 100644 --- a/.agents/java-kotlin-conversion.md +++ b/.agents/skills/java-to-kotlin/SKILL.md @@ -1,3 +1,11 @@ +--- +name: java-to-kotlin +description: > + Convert Java code to Kotlin, including Java API comments from Javadoc to KDoc. + Use when asked to migrate Java files, classes, methods, nullability semantics, + or common Java patterns into idiomatic Kotlin while preserving behavior. +--- + # 🪄 Converting Java code to Kotlin * Java code API comments are Javadoc format. diff --git a/.agents/skills/java-to-kotlin/agents/openai.yaml b/.agents/skills/java-to-kotlin/agents/openai.yaml new file mode 100644 index 0000000000..252920fedc --- /dev/null +++ b/.agents/skills/java-to-kotlin/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Java to Kotlin" + short_description: "Convert Java code to idiomatic Kotlin" + default_prompt: "Use $java-to-kotlin to convert Java code to Kotlin while preserving behavior, nullability, and API documentation wording." diff --git a/.agents/skills/move-files/SKILL.md b/.agents/skills/move-files/SKILL.md new file mode 100644 index 0000000000..2885f4828f --- /dev/null +++ b/.agents/skills/move-files/SKILL.md @@ -0,0 +1,49 @@ +--- +name: move-files +description: > + Move or rename any files/directories in a repo: preserve history, update all + references and build metadata, verify no stale paths remain. +--- + +# Move Files + +## Workflow + +1. Preflight. + - Run `git status --short`. + - Map each `source -> destination`. + - Classify scope: simple same-module moves stay targeted; package, module, or + cross-module moves need broader inspection. + - Ask before ambiguous mappings, destination conflicts, or unclear semantic + package/module changes. + +2. Search before moving. + - Search all old identifiers: paths, names, resource refs, doc links. + - For Gradle/module/source-set moves, check `settings.gradle.kts`, + `build.gradle.kts`, and `buildSrc`. + - For Kotlin/Java, update package declarations only when package intent + changes. + +3. Move safely. + - Prefer `git mv` for tracked files in the repo. + - Use filesystem moves only for untracked/generated/out-of-git files. + - Create parent directories first. + - For case-only renames, move through a temporary name. + +4. Repair references. + - Update all references: imports, build metadata, docs, resources, and scripts. + - Start search scope narrow: affected directory, then module, then repo-wide. + - Prefer precise edits; avoid broad replacements on generic names. + +5. Verify. + - Re-run targeted searches for old tokens. + - Run `git status --short` and confirm the delta matches the move. + - Run focused validation for moved files, or state what could not run. + +## Repo Notes + +Follow `.agents/project-structure-expectations.md` for module/source-set/test moves. + +## Report + +Return: `Moved[]`, `UpdatedRefs[]`, `Verification[]`, `Risks[]`. diff --git a/.agents/skills/move-files/agents/openai.yaml b/.agents/skills/move-files/agents/openai.yaml new file mode 100644 index 0000000000..ba90a9f8f2 --- /dev/null +++ b/.agents/skills/move-files/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Move Files" + short_description: "Move files safely across a repo" + default_prompt: "Use $move-files to relocate files or directories in this repository while preserving history, updating references, and verifying the result." diff --git a/.agents/skills/update-copyright/SKILL.md b/.agents/skills/update-copyright/SKILL.md new file mode 100644 index 0000000000..6afc4c7cf2 --- /dev/null +++ b/.agents/skills/update-copyright/SKILL.md @@ -0,0 +1,16 @@ +--- +name: update-copyright +description: > + Update source file copyright headers from the IntelliJ IDEA copyright profile, + replacing `today.year` with the current year. + Automatically apply when source files are modified in a change set. +--- + +# Copyright Update + +**Command:** `python3 .agents/skills/update-copyright/scripts/update_copyright.py` + +1. Scope: explicit files/dirs from the user, or all tracked source files if none given. +2. No explicit paths → run with `--dry-run` first, then without. +3. Relay stdout (notice source, file count, changed paths) to the user. +4. Never add a copyright header to a file that does not already have one. diff --git a/.agents/skills/update-copyright/agents/openai.yaml b/.agents/skills/update-copyright/agents/openai.yaml new file mode 100644 index 0000000000..246dd647f7 --- /dev/null +++ b/.agents/skills/update-copyright/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Copyright Update" + short_description: "Refresh source copyright headers" + default_prompt: "Use $update-copyright to refresh source file copyright headers from the IntelliJ IDEA copyright profile in this repository." diff --git a/.agents/skills/update-copyright/scripts/update_copyright.py b/.agents/skills/update-copyright/scripts/update_copyright.py new file mode 100755 index 0000000000..2dbf8bbc48 --- /dev/null +++ b/.agents/skills/update-copyright/scripts/update_copyright.py @@ -0,0 +1,389 @@ +#!/usr/bin/env python3 +"""Update source copyright headers from IntelliJ IDEA copyright profiles.""" + +from __future__ import annotations + +import argparse +import datetime as dt +import html +import re +import subprocess +import sys +from pathlib import Path +from xml.etree import ElementTree as ET + + +BLOCK_EXTENSIONS = { + ".c", + ".cc", + ".cpp", + ".cs", + ".css", + ".cxx", + ".dart", + ".go", + ".gradle", + ".groovy", + ".h", + ".hh", + ".hpp", + ".java", + ".js", + ".jsx", + ".kt", + ".kts", + ".less", + ".m", + ".mm", + ".proto", + ".rs", + ".scala", + ".scss", + ".swift", + ".ts", + ".tsx", +} +HASH_EXTENSIONS = { + ".bash", + ".bzl", + ".properties", + ".pl", + ".py", + ".rb", + ".sh", + ".toml", + ".yaml", + ".yml", + ".zsh", +} +XML_EXTENSIONS = { + ".fxml", + ".pom", + ".wsdl", + ".xml", + ".xsd", + ".xsl", + ".xslt", +} +EXCLUDED_DIRS = { + ".agents", + ".git", + ".gradle", + ".idea", + ".kotlin", + "build", + "generated", + "out", + "tmp", +} +EXCLUDED_FILES = { + "gradlew", + "gradlew.bat", +} + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description=( + "Update source copyright headers from " + ".idea/copyright/profiles_settings.xml." + ) + ) + parser.add_argument( + "paths", + nargs="*", + help="Files or directories to update. Defaults to tracked source files.", + ) + parser.add_argument( + "--root", + type=Path, + default=Path.cwd(), + help="Repository root. Defaults to the current working directory.", + ) + parser.add_argument( + "--year", + default=str(dt.date.today().year), + help="Year to substitute for today.year. Defaults to the current year.", + ) + parser.add_argument( + "--dry-run", + action="store_true", + help="Report files that would change without writing them.", + ) + parser.add_argument( + "--check", + action="store_true", + help="Exit with status 1 if any file would change; do not write files.", + ) + return parser.parse_args() + + +def profile_filename(profile_name: str) -> str: + stem = re.sub(r"[^A-Za-z0-9]+", "_", profile_name).strip("_") + if not stem: + raise ValueError("The default copyright profile name is empty.") + return f"{stem}.xml" + + +def load_notice(root: Path, year: str) -> tuple[str, Path]: + settings_path = root / ".idea" / "copyright" / "profiles_settings.xml" + if not settings_path.is_file(): + raise FileNotFoundError(f"Missing {settings_path}") + + settings_root = ET.parse(settings_path).getroot() + settings = settings_root.find(".//settings") + if settings is None: + raise ValueError(f"{settings_path} does not contain a settings tag.") + + default_profile = settings.get("default") + if not default_profile: + raise ValueError(f"{settings_path} settings tag has no default attribute.") + + profile_path = settings_path.parent / profile_filename(default_profile) + if not profile_path.is_file(): + raise FileNotFoundError( + f"Default profile {default_profile!r} resolves to missing {profile_path}" + ) + + profile_root = ET.parse(profile_path).getroot() + notice = None + for option in profile_root.findall(".//option"): + if option.get("name") == "notice": + notice = option.get("value") + break + if notice is None: + raise ValueError(f"{profile_path} has no option named 'notice'.") + + decoded = html.unescape(notice) + decoded = decoded.replace("${today.year}", year) + decoded = decoded.replace("$today.year", year) + decoded = decoded.replace("today.year", year) + return decoded.rstrip(), profile_path + + +def style_for(path: Path) -> str | None: + name = path.name + suffix = path.suffix.lower() + if name.endswith((".sh.template", ".bash.template", ".zsh.template")): + return "hash" + if suffix in BLOCK_EXTENSIONS: + return "block" + if suffix in HASH_EXTENSIONS: + return "hash" + if suffix in XML_EXTENSIONS: + return "xml" + return None + + +def is_excluded(path: Path) -> bool: + if path.name in EXCLUDED_FILES: + return True + parts = path.parts + if len(parts) >= 2 and parts[0] == "gradle" and parts[1] == "wrapper": + return True + return any(part in EXCLUDED_DIRS for part in parts) + + +def tracked_files(root: Path) -> list[Path]: + try: + result = subprocess.run( + ["git", "-C", str(root), "ls-files", "-z"], + check=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + except (FileNotFoundError, subprocess.CalledProcessError): + return [ + path.relative_to(root) + for path in root.rglob("*") + if path.is_file() and not is_excluded(path.relative_to(root)) + ] + + paths = [] + for item in result.stdout.decode("utf-8").split("\0"): + if not item: + continue + path = Path(item) + if (root / path).is_file(): + paths.append(path) + return paths + + +def expand_requested_paths(root: Path, requested: list[str]) -> list[Path]: + if not requested: + paths = tracked_files(root) + else: + paths = [] + for item in requested: + path = (root / item).resolve() + if not path.exists(): + raise FileNotFoundError(f"Path does not exist: {item}") + if not path.is_relative_to(root): + raise ValueError( + f"Path is outside the repository root: {item!r} " + f"(resolved to {path}, root is {root})" + ) + if path.is_dir(): + for child in path.rglob("*"): + if child.is_file(): + paths.append(child.relative_to(root)) + else: + paths.append(path.relative_to(root)) + + unique = sorted(set(paths), key=lambda p: p.as_posix()) + return [ + path + for path in unique + if style_for(path) is not None and not is_excluded(path) + ] + + +def newline_for(text: str) -> str: + return "\r\n" if "\r\n" in text else "\n" + + +def build_header(notice: str, style: str, newline: str) -> str: + lines = notice.splitlines() + if style == "block": + body = newline.join(f" * {line}" if line else " *" for line in lines) + return f"/*{newline}{body}{newline} */{newline}{newline}" + if style == "hash": + body = newline.join(f"# {line}" if line else "#" for line in lines) + return f"{body}{newline}{newline}" + if style == "xml": + body = newline.join(f" ~ {line}" if line else " ~" for line in lines) + return f"{newline}{newline}" + raise ValueError(f"Unsupported comment style: {style}") + + +def split_leading_directive(text: str, style: str, newline: str) -> tuple[str, str]: + if style == "hash" and text.startswith("#!"): + line_end = text.find("\n") + if line_end == -1: + return text + newline + newline, "" + prefix = text[: line_end + 1] + newline + return prefix, strip_leading_blank_lines(text[line_end + 1 :]) + + if style == "xml" and text.startswith("") + if close != -1: + line_end = text.find("\n", close) + if line_end == -1: + return text + newline + newline, "" + prefix = text[: line_end + 1] + newline + return prefix, strip_leading_blank_lines(text[line_end + 1 :]) + + return "", strip_leading_blank_lines(text) + + +def strip_leading_blank_lines(text: str) -> str: + return re.sub(r"^(?:[ \t]*\r?\n)+", "", text) + + +def strip_existing_header(text: str, style: str) -> tuple[str, bool]: + if style == "block" and text.startswith("/*"): + close = text.find("*/") + if close != -1: + candidate = text[: close + 2] + if is_copyright_header(candidate): + return strip_leading_blank_lines(text[close + 2 :]), True + + if style == "xml" and text.startswith("") + if close != -1: + candidate = text[: close + 3] + if is_copyright_header(candidate): + return strip_leading_blank_lines(text[close + 3 :]), True + + if style == "hash": + lines = text.splitlines(keepends=True) + end = 0 + for line in lines: + stripped = line.strip() + if stripped == "" or stripped.startswith("#"): + end += len(line) + continue + break + candidate = text[:end] + if candidate and is_copyright_header(candidate): + return strip_leading_blank_lines(text[end:]), True + + return text, False + + +def is_copyright_header(text: str) -> bool: + limited = text[:5000] + return "Copyright" in limited and ( + "Licensed under" in limited or "All rights reserved" in limited + ) + + +def updated_text(text: str, notice: str, style: str) -> str: + original = text + bom = "\ufeff" if text.startswith("\ufeff") else "" + if bom: + text = text[1:] + newline = newline_for(text) + prefix, body = split_leading_directive(text, style, newline) + body, had_header = strip_existing_header(body, style) + if not had_header: + return original + return bom + prefix + build_header(notice, style, newline) + body + + +def update_file(root: Path, path: Path, notice: str, dry_run: bool) -> bool: + absolute = root / path + style = style_for(path) + if style is None: + return False + + try: + text = absolute.read_text(encoding="utf-8") + except FileNotFoundError: + print(f"Skipping missing file: {path}", file=sys.stderr) + return False + except UnicodeDecodeError: + print(f"Skipping non-UTF-8 file: {path}", file=sys.stderr) + return False + + next_text = updated_text(text, notice, style) + if next_text == text: + return False + + if not dry_run: + with absolute.open("w", encoding="utf-8", newline="") as file: + file.write(next_text) + return True + + +def main() -> int: + args = parse_args() + root = args.root.resolve() + notice, profile_path = load_notice(root, args.year) + try: + paths = expand_requested_paths(root, args.paths) + except (FileNotFoundError, ValueError) as exc: + print(f"error: {exc}", file=sys.stderr) + return 2 + dry_run = args.dry_run or args.check + + changed = [ + path + for path in paths + if update_file(root, path, notice, dry_run=dry_run) + ] + + rel_profile = profile_path.relative_to(root) + action = "Would update" if dry_run else "Updated" + print(f"Notice source: {rel_profile}") + print(f"{action} {len(changed)} file(s).") + for path in changed: + print(path.as_posix()) + + if args.check and changed: + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.agents/skills/update-copyright/tests/test_update_copyright.py b/.agents/skills/update-copyright/tests/test_update_copyright.py new file mode 100644 index 0000000000..8770b3275e --- /dev/null +++ b/.agents/skills/update-copyright/tests/test_update_copyright.py @@ -0,0 +1,130 @@ +from __future__ import annotations + +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + + +SCRIPT = Path(__file__).resolve().parents[1] / "scripts" / "update_copyright.py" + + +class UpdateCopyrightTest(unittest.TestCase): + def test_default_run_leaves_plain_source_without_header_unchanged(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + root = Path(temp_dir) + self.write_profile(root) + source = root / "Foo.java" + original = "class Foo {}\n" + source.write_text(original, encoding="utf-8") + + subprocess.run(["git", "init", "-q"], cwd=root, check=True) + subprocess.run(["git", "add", "Foo.java"], cwd=root, check=True) + + result = self.run_script(root) + + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn("Updated 0 file(s).", result.stdout) + self.assertEqual(result.stderr, "") + self.assertEqual(source.read_text(encoding="utf-8"), original) + + def test_existing_header_is_updated(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + root = Path(temp_dir) + self.write_profile(root) + source = root / "Foo.java" + source.write_text( + "/*\n" + " * Copyright 2024 ACME\n" + " * All rights reserved\n" + " */\n" + "\n" + "class Foo {}\n", + encoding="utf-8", + ) + + result = self.run_script(root, "--year", "2026", "Foo.java") + + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn("Updated 1 file(s).", result.stdout) + self.assertIn("Foo.java", result.stdout) + self.assertEqual(result.stderr, "") + self.assertEqual( + source.read_text(encoding="utf-8"), + "/*\n" + " * Copyright 2026 ACME\n" + " * All rights reserved\n" + " */\n" + "\n" + "class Foo {}\n", + ) + + def test_default_run_skips_tracked_files_deleted_from_working_tree(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + root = Path(temp_dir) + self.write_profile(root) + source = root / "Foo.java" + source.write_text("class Foo {}\n", encoding="utf-8") + + subprocess.run(["git", "init", "-q"], cwd=root, check=True) + subprocess.run(["git", "add", "Foo.java"], cwd=root, check=True) + source.unlink() + + result = subprocess.run( + [ + sys.executable, + str(SCRIPT), + "--root", + str(root), + "--dry-run", + ], + check=False, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn("Would update 0 file(s).", result.stdout) + self.assertEqual(result.stderr, "") + + @staticmethod + def run_script(root: Path, *args: str) -> subprocess.CompletedProcess[str]: + return subprocess.run( + [ + sys.executable, + str(SCRIPT), + "--root", + str(root), + *args, + ], + check=False, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + + @staticmethod + def write_profile(root: Path) -> None: + copyright_dir = root / ".idea" / "copyright" + copyright_dir.mkdir(parents=True) + (copyright_dir / "profiles_settings.xml").write_text( + '' + '' + "\n", + encoding="utf-8", + ) + (copyright_dir / "Default.xml").write_text( + '' + "" + '" + "\n", + encoding="utf-8", + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/.agents/skills/writer/SKILL.md b/.agents/skills/writer/SKILL.md index 5c720265b3..6b9d86f88e 100644 --- a/.agents/skills/writer/SKILL.md +++ b/.agents/skills/writer/SKILL.md @@ -3,7 +3,7 @@ name: writer description: > Write, edit, and restructure user-facing and developer-facing documentation. Use when asked to create/update docs such as `README.md`, `docs/**`, and - other Markdown documentation; + other Markdown documentation, including keeping docs navigation data in sync; when drafting tutorials, guides, troubleshooting pages, or migration notes; and when improving inline API documentation (KDoc) and examples. --- @@ -24,10 +24,45 @@ description: > - `docs/`: longer-form docs (follow existing conventions in that tree). - Source KDoc: API usage, examples, and semantics that belong with the code. +## Keep docs navigation in sync + +- When adding, removing, moving, or renaming a page under + `docs/content/docs/
/`, keep the current version's matching + `sidenav.yml` in sync. +- Use `docs/data/versions.yml` to identify the current documentation version for + that section. The current version is the entry with `is_main: true`; its + `version_id` maps to `docs/data/docs/
//sidenav.yml`. +- Do not update historical version entries or their navigation files unless the + user explicitly asks to edit that historical version. +- Map page files to `file_path` values relative to the current version's + `content_path`, without `.md`; `_index.md` maps to its directory path, such as + `01-getting-started/_index.md` -> `01-getting-started`. +- Keep each `page` label aligned with the page frontmatter `title` unless the + existing navigation intentionally uses a shorter reader-facing label. +- Preserve the existing ordering, nesting, keys, comments, and YAML quoting + style. Remove nav entries for deleted pages and update `file_path` values for + moved pages. +- If a docs content change should not appear in navigation, say so explicitly in + the final response. + ## Follow local documentation conventions - Follow `.agents/documentation-guidelines.md` and `.agents/documentation-tasks.md`. - Use fenced code blocks for commands and examples; format file/dir names as code. +- When referencing a documentation page or section in body prose, use typographic + double quotation marks only if the visible reference text is the actual page or + section title, such as the “Getting started” page or the “Troubleshooting” + section. The title normally starts with a capital letter. Do not add these + quotes around generic or descriptive links such as “this page”, “the next + section”, “declaring constraints”, or `4.3`, even if they point to a page or + section. Do not add these quotes in “What’s next” sections or navigation + elements. Keep file paths, identifiers, frontmatter values, navigation labels, + and Markdown link labels in their expected syntax. +- In Markdown files, prefer footnote-style reference links for external `https://` + targets instead of inline links. Write readable body text like + `[label][short-id]`, then place the URL definition near the end of the file, + such as `[short-id]: https://example.com/long/path`. Keep reference IDs short + and descriptive. Inline links are still fine for local relative paths. - Avoid widows, runts, orphans, and rivers by reflowing paragraphs when needed. ## Make docs actionable @@ -48,4 +83,3 @@ description: > - For code changes, follow `.agents/running-builds.md`. - For documentation-only changes in Kotlin/Java sources, prefer `./gradlew dokka`. - diff --git a/.agents/version-policy.md b/.agents/version-policy.md index 65dc457490..95ac4513e7 100644 --- a/.agents/version-policy.md +++ b/.agents/version-policy.md @@ -1,30 +1,15 @@ # Version policy -## We use semver -The version of the project is kept in the `version.gradle.kts` file in the root of the project. +The project follows the [Spine SDK Versioning policy][wiki-versioning]. +The version is kept in `version.gradle.kts` at the project root and follows +[Semantic Versioning 2.0.0][semver] with Spine-specific extensions +(snapshot `NUMBER`, patch, and flavor suffixes). -The version numbers in these files follow the conventions of -[Semantic Versioning 2.0.0](https://semver.org/). +PRs without a version bump fail CI. -## Quick checklist for versioning -1. Increment the patch version in `version.gradle.kts`. - Retain zero-padding if applicable: - - Example: `"2.0.0-SNAPSHOT.009"` → `"2.0.0-SNAPSHOT.010"` -2. Commit the version bump separately with this comment: - ```text - Bump version → `$newVersion` - ``` -3. Rebuild using `./gradlew clean build`. -4. Update `pom.xml`, `dependencies.md` and commit changes with: `Update dependency reports` +For the bump procedure — version-number selection, the commit-message +convention, the rebuild, dependency-report updates, and conflict resolution — +use the [`bump-version`](skills/bump-version/SKILL.md) skill. -Remember: PRs without version bumps will fail CI (conflict resolution detailed above). - -## Resolving conflicts in `version.gradle.kts` -A branch conflict over the version number should be resolved as described below. - * If a merged branch has a number which is less than that of the current branch, the version of - the current branch stays. - * If the merged branch has the number which is greater or equal to that of the current branch, - the number should be increased by one. - -## When to bump the version? - - When a new branch is created. +[semver]: https://semver.org/ +[wiki-versioning]: https://github.com/SpineEventEngine/documentation/wiki/Versioning diff --git a/.claude/skills b/.claude/skills new file mode 120000 index 0000000000..2b7a412b8f --- /dev/null +++ b/.claude/skills @@ -0,0 +1 @@ +../.agents/skills \ No newline at end of file diff --git a/.github/workflows/build-on-ubuntu.yml b/.github/workflows/build-on-ubuntu.yml index f8c24933f9..cd6b93714c 100644 --- a/.github/workflows/build-on-ubuntu.yml +++ b/.github/workflows/build-on-ubuntu.yml @@ -1,10 +1,10 @@ -name: Build under Ubuntu +name: Ubuntu CI on: push jobs: build: - name: Build under Ubuntu + name: Build on Ubuntu runs-on: ubuntu-latest steps: diff --git a/.github/workflows/build-on-windows.yml b/.github/workflows/build-on-windows.yml index 4e6b57f1fd..91a0bfef32 100644 --- a/.github/workflows/build-on-windows.yml +++ b/.github/workflows/build-on-windows.yml @@ -1,11 +1,11 @@ -name: Build under Windows +name: Windows CI on: pull_request jobs: build: - name: Build under Windows runs-on: windows-latest + name: Build on Windows steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/ensure-reports-updated.yml b/.github/workflows/ensure-reports-updated.yml index fdd8b8e673..315cd202b7 100644 --- a/.github/workflows/ensure-reports-updated.yml +++ b/.github/workflows/ensure-reports-updated.yml @@ -1,6 +1,6 @@ # Ensures that the license report files were modified in this PR. -name: Ensure license reports updated +name: License Reports on: pull_request: @@ -8,18 +8,18 @@ on: - '**' jobs: - build: - name: Ensure license reports updated + check: + name: Ensure license reports are updated runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 with: - # Configure the checkout of all branches, so that it is possible to run the comparison. + # Configure the checkout of all branches so that it is possible to run the comparison. fetch-depth: 0 # Check out the `config` submodule to fetch the required script file. submodules: true - - name: Check that both `pom.xml` and license report files are modified + - name: Check that dependency report files are modified shell: bash run: chmod +x ./config/scripts/ensure-reports-updated.sh && ./config/scripts/ensure-reports-updated.sh diff --git a/.github/workflows/gradle-wrapper-validation.yml b/.github/workflows/gradle-wrapper-validation.yml index 858cebbcc4..50eb05eb15 100644 --- a/.github/workflows/gradle-wrapper-validation.yml +++ b/.github/workflows/gradle-wrapper-validation.yml @@ -1,4 +1,4 @@ -name: Validate Gradle Wrapper +name: Gradle Wrapper validation on: push: branches: @@ -9,7 +9,7 @@ on: jobs: validation: - name: Gradle Wrapper Validation + name: Validate Gradle Wrapper runs-on: ubuntu-latest steps: - name: Checkout latest code diff --git a/.github/workflows/increment-guard.yml b/.github/workflows/increment-guard.yml index 1993841a65..38ce6f4d3e 100644 --- a/.github/workflows/increment-guard.yml +++ b/.github/workflows/increment-guard.yml @@ -1,7 +1,7 @@ # Ensures that the current lib version is not yet published but executing the Gradle # `checkVersionIncrement` task. -name: Check version increment +name: Version Guard on: push: @@ -9,7 +9,7 @@ on: - '**' jobs: - build: + check: name: Check version increment runs-on: ubuntu-latest diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 7de0c5101e..f7218c618e 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -6,7 +6,9 @@ on: jobs: publish: + name: Publish to Maven repositories runs-on: ubuntu-latest + steps: - uses: actions/checkout@v4 with: diff --git a/.github/workflows/remove-obsolete-artifacts-from-packages.yaml b/.github/workflows/remove-obsolete-artifacts-from-packages.yaml index fe8ad849d0..f706171007 100644 --- a/.github/workflows/remove-obsolete-artifacts-from-packages.yaml +++ b/.github/workflows/remove-obsolete-artifacts-from-packages.yaml @@ -34,7 +34,7 @@ env: jobs: retrieve-package-names: - name: Retrieve the package names published from this repository + name: Retrieve package names runs-on: ubuntu-latest outputs: package-names: ${{ steps.request-package-names.outputs.package-names }} @@ -54,7 +54,7 @@ jobs: echo "package-names=$(<./package-names.json)" >> $GITHUB_OUTPUT delete-obsolete-artifacts: - name: Remove obsolete artifacts published from this repository to GitHub Packages + name: Delete obsolete artifacts needs: retrieve-package-names runs-on: ubuntu-latest strategy: diff --git a/.gitignore b/.gitignore index 48de9f27a3..5f85d295e8 100644 --- a/.gitignore +++ b/.gitignore @@ -39,6 +39,7 @@ # Internal tool directories. .fleet/ +.junie/memory/ # Kotlin temp directories. **/.kotlin/ @@ -130,3 +131,7 @@ pubspec.lock /tmp .gradle-test-kit/ + +# Python cache +__pycache__/ +*.pyc diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml index 0bd1d9dc2b..7be402da6f 100644 --- a/.idea/inspectionProfiles/Project_Default.xml +++ b/.idea/inspectionProfiles/Project_Default.xml @@ -255,6 +255,18 @@