diff --git a/.agents/plugins/marketplace.json b/.agents/plugins/marketplace.json new file mode 100644 index 0000000000..056d5a9a83 --- /dev/null +++ b/.agents/plugins/marketplace.json @@ -0,0 +1,20 @@ +{ + "name": "spring-tools-local", + "interface": { + "displayName": "Spring Tools" + }, + "plugins": [ + { + "name": "spring-tools", + "source": { + "source": "local", + "path": "./agent-plugins/spring-tools" + }, + "policy": { + "installation": "AVAILABLE", + "authentication": "ON_INSTALL" + }, + "category": "Developer tools" + } + ] +} diff --git a/.claude/skills/convert-openrewrite-to-jdt/SKILL.md b/.claude/skills/convert-openrewrite-to-jdt/SKILL.md index 1e4cc94214..83315a7492 100644 --- a/.claude/skills/convert-openrewrite-to-jdt/SKILL.md +++ b/.claude/skills/convert-openrewrite-to-jdt/SKILL.md @@ -112,12 +112,12 @@ engine* differ between the two mechanisms: ./mvnw -q -o test -pl commons/commons-rewrite # if you removed a recipe/test from this module ``` -12. **Plugin docs** — if `claude-plugins/spring-tools/explanations/.md` +12. **Plugin docs** — if `agent-plugins/spring-tools/explanations/.md` exists for this diagnostic, check its before/after examples still match; usually no change is needed since that file documents observable behavior, not implementation. 13. **Copyright header** — add/bump the current year in the EPL header of every file - you touch, per this repo's root `CLAUDE.md`. + you touch, per this repo's root `AGENTS.md`. ## Key files diff --git a/.github/workflows/agent-plugin-check.yml b/.github/workflows/agent-plugin-check.yml new file mode 100644 index 0000000000..0f6150ee98 --- /dev/null +++ b/.github/workflows/agent-plugin-check.yml @@ -0,0 +1,155 @@ +name: Agent Plugin Check + +# Keeps the agent plugin under agent-plugins/spring-tools consistent with the +# language server: every diagnostic code has an explanation playbook, playbooks are +# well-formed, hooks, skills and agents only reference MCP tools that exist (and every +# tool is reachable through one of them), the plugin manifest validates, and the +# launcher/language server (including its file watcher) work end to end over MCP stdio. + +on: + push: + branches: + - main + paths: + - 'agent-plugins/**' + - '.agents/plugins/marketplace.json' + - 'headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/**/*ProblemType.java' + - 'headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/validation/generations/AbstractDiagnosticValidator.java' + - 'headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/yaml/reconcile/ApplicationYamlASTReconciler.java' + - 'headless-services/commons/commons-yaml/src/main/java/org/springframework/ide/vscode/commons/yaml/reconcile/YamlSchemaProblems.java' + - 'headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/mcp/**' + - 'headless-services/spring-boot-language-server-standalone/src/main/java/**' + - '.github/workflows/agent-plugin-check.yml' + pull_request: + paths: + - 'agent-plugins/**' + - '.agents/plugins/marketplace.json' + - 'headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/**/*ProblemType.java' + - 'headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/validation/generations/AbstractDiagnosticValidator.java' + - 'headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/yaml/reconcile/ApplicationYamlASTReconciler.java' + - 'headless-services/commons/commons-yaml/src/main/java/org/springframework/ide/vscode/commons/yaml/reconcile/YamlSchemaProblems.java' + - 'headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/mcp/**' + - 'headless-services/spring-boot-language-server-standalone/src/main/java/**' + - '.github/workflows/agent-plugin-check.yml' + workflow_dispatch: + inputs: + run-evals: + description: 'Also run the behavioral eval suite (claude plugin eval; needs the ANTHROPIC_API_KEY secret and costs model calls)' + type: boolean + default: false + +permissions: + contents: read + +concurrency: + group: agent-plugin-check-${{ github.ref }} + cancel-in-progress: true + +jobs: + explanations-and-config: + name: Explanation playbooks and plugin configuration + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # 7.0.1 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # 7.0.0 + with: + node-version: 22 + - name: Check explanation playbooks (coverage, structure, cross-references, coverage table) + run: node agent-plugins/tools/check-explanations.mjs --require-all --todo + - name: Check plugin configuration and cross-host adapters + run: node agent-plugins/tools/check-plugin-config.mjs --cross-host + - name: Run plugin tooling tests + run: node --test --test-reporter=spec 'agent-plugins/tools/test/*.test.mjs' + + documentation-links: + name: Documentation links resolve + runs-on: ubuntu-latest + # Only on the main branch: external link checks depend on docs.spring.io availability. + if: github.event_name != 'pull_request' + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # 7.0.1 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # 7.0.0 + with: + node-version: 22 + - name: Check that every documentation link in the playbooks returns HTTP 200 + run: node agent-plugins/tools/check-explanations.mjs --require-all --links + + plugin-manifest: + name: Plugin manifest validates with the Claude Code CLI + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # 7.0.1 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # 7.0.0 + with: + node-version: 22 + - name: Install the Claude Code CLI into a temporary prefix + run: npm install --no-audit --no-fund --prefix "$RUNNER_TEMP/claude-code" @anthropic-ai/claude-code@latest + - name: Validate the plugin manifest (no login required) + run: | + "$RUNNER_TEMP/claude-code/node_modules/.bin/claude" --version + "$RUNNER_TEMP/claude-code/node_modules/.bin/claude" plugin validate ./agent-plugins/spring-tools --strict + + mcp-smoke: + name: End-to-end MCP smoke test against a freshly built language server + runs-on: ubuntu-latest + timeout-minutes: 45 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # 7.0.1 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # 7.0.0 + with: + node-version: 22 + - uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # 5.6.0 + with: + java-version: '25' + distribution: 'temurin' + cache: maven + cache-dependency-path: 'headless-services/**/pom.xml' + - name: Build the standalone language server and copy the JAR into the plugin + run: ./agent-plugins/update-local-jars.sh + - name: Start launcher.js over MCP stdio, index a sample project and exercise the tools the hooks and skills rely on + run: node agent-plugins/tools/smoke/mcp-smoke.mjs --keep + - name: Upload the language server log on failure + if: failure() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # 7.0.1 + with: + name: boot-ls-log + path: /tmp/spring-tools-smoke-*/data/boot-ls.log + if-no-files-found: ignore + + plugin-evals: + name: Behavioral evals (claude plugin eval, on demand) + runs-on: ubuntu-latest + if: github.event_name == 'workflow_dispatch' && inputs.run-evals + timeout-minutes: 60 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # 7.0.1 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # 7.0.0 + with: + node-version: 22 + - name: Install the Claude Code CLI into a temporary prefix + run: npm install --no-audit --no-fund --prefix "$RUNNER_TEMP/claude-code" @anthropic-ai/claude-code@latest + - name: Run the eval suite against the recorded MCP mocks (the language server is not started) + working-directory: agent-plugins/spring-tools + env: + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + run: | + if [ -z "$ANTHROPIC_API_KEY" ]; then + echo '::error::the ANTHROPIC_API_KEY secret is not set; the eval suite needs model access' + exit 1 + fi + "$RUNNER_TEMP/claude-code/node_modules/.bin/claude" plugin eval . \ + --trust-plugin \ + --json "$RUNNER_TEMP/eval-results.json" \ + --threshold 0.8 \ + --judge-model claude-haiku-4-5 \ + --no-publish \ + --max-cost-usd 15 + - name: Upload the eval results and report + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # 7.0.1 + with: + name: plugin-eval-results + path: | + ${{ runner.temp }}/eval-results.json + agent-plugins/spring-tools/evals/results/**/report.html + if-no-files-found: ignore diff --git a/.github/workflows/release-standalone-ls.yml b/.github/workflows/release-standalone-ls.yml index 522472cd8d..a1e96ef1b0 100644 --- a/.github/workflows/release-standalone-ls.yml +++ b/.github/workflows/release-standalone-ls.yml @@ -81,7 +81,7 @@ jobs: --arg ref "$ref" \ ' .plugins |= if map(.name == $name) | any then - map(if .name == $name then .version=$version | .source.ref=$ref else . end) + map(if .name == $name then .version=$version | .source.ref=$ref | .source.path="agent-plugins/spring-tools" else . end) else . + [{ "name": $name, @@ -89,7 +89,7 @@ jobs: "source": { "source": "git-subdir", "url": "https://github.com/spring-projects/spring-tools.git", - "path": "claude-plugins/spring-tools", + "path": "agent-plugins/spring-tools", "ref": $ref }, "description": "Spring Boot Language Server", diff --git a/.github/workflows/snapshot-standalone-ls.yml b/.github/workflows/snapshot-standalone-ls.yml index cf765a204f..12441366f2 100644 --- a/.github/workflows/snapshot-standalone-ls.yml +++ b/.github/workflows/snapshot-standalone-ls.yml @@ -41,7 +41,7 @@ jobs: echo "S3 path: ${s3_path}" aws s3 cp ./standalone-ls-dist/$jar_file s3://$AWS_S3_BUCKET/$s3_path/$jar_file --no-progress --checksum-algorithm CRC32 aws s3 cp ./standalone-ls-dist/$sha_file s3://$AWS_S3_BUCKET/$s3_path/$sha_file --no-progress --checksum-algorithm CRC32 - - name: Update plugin.json for Snapshot + - name: Update plugin manifest versions for Snapshot id: update-plugin run: | git config --global user.name "@spring-projects/spring-ide-admin" @@ -52,12 +52,16 @@ jobs: # Create or reset a local branch to the current commit (which is what triggered the action) git checkout -B claude-snapshot - # Update the version in plugin.json + # Keep both the Claude Code manifest and the portable Agent Plugins manifest in sync. tmp=$(mktemp) - jq --arg v "$version" '.version = $v' claude-plugins/spring-tools/.claude-plugin/plugin.json > "$tmp" && mv "$tmp" claude-plugins/spring-tools/.claude-plugin/plugin.json + jq --arg v "$version" '.version = $v' agent-plugins/spring-tools/.claude-plugin/plugin.json > "$tmp" && mv "$tmp" agent-plugins/spring-tools/.claude-plugin/plugin.json + tmp=$(mktemp) + jq --arg v "$version" '.version = $v' agent-plugins/spring-tools/plugin.json > "$tmp" && mv "$tmp" agent-plugins/spring-tools/plugin.json - git add claude-plugins/spring-tools/.claude-plugin/plugin.json - git commit -m "Update Claude plugin version to $version" + git add agent-plugins/spring-tools/.claude-plugin/plugin.json agent-plugins/spring-tools/plugin.json + if ! git diff --cached --quiet; then + git commit -m "Update agent plugin versions to $version" + fi # Push to the remote claude-snapshot branch forcefully. # If it doesn't exist, this creates it. If it does exist, it completely overwrites it @@ -78,7 +82,7 @@ jobs: --arg sha "$sha" \ ' .plugins |= if map(.name == $name) | any then - map(if .name == $name then .version=$version | .source.sha=$sha | .source.ref="claude-snapshot" else . end) + map(if .name == $name then .version=$version | .source.sha=$sha | .source.ref="claude-snapshot" | .source.path="agent-plugins/spring-tools" else . end) else . + [{ "name": $name, @@ -86,7 +90,7 @@ jobs: "source": { "source": "git-subdir", "url": "https://github.com/spring-projects/spring-tools.git", - "path": "claude-plugins/spring-tools", + "path": "agent-plugins/spring-tools", "ref": "claude-snapshot", "sha": $sha }, diff --git a/.gitignore b/.gitignore index e89ca093a7..e7f901c13f 100644 --- a/.gitignore +++ b/.gitignore @@ -33,6 +33,7 @@ hs_err_pid* servers/ target/ +build/ bin/ *.log *.log.*.gz diff --git a/CLAUDE.md b/AGENTS.md similarity index 76% rename from CLAUDE.md rename to AGENTS.md index e1af35f391..6e2920173d 100644 --- a/CLAUDE.md +++ b/AGENTS.md @@ -1,6 +1,6 @@ -# CLAUDE.md +# AGENTS.md -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. +This file provides guidance to AI coding agents (Claude Code, Codex, GitHub Copilot, OpenCode and others) when working with code in this repository. ## Repository Overview @@ -30,8 +30,10 @@ sts4/ │ ├── commons-vscode/ # Shared TypeScript utilities │ └── boot-dev-pack/ # Extension pack bundling Spring Boot extensions ├── nodejs-packages/ # Shared Node.js utilities -└── claude-plugins/ # Claude Code plugin (standalone LS + MCP server) - ├── spring-tools/ # Plugin source +└── agent-plugins/ # Agent plugin (standalone LS + MCP server) for Claude Code, Codex, GitHub Copilot and OpenCode + ├── spring-tools/ # Plugin source (skills, agents, hooks, evals, explanations/.md playbooks) + ├── tools/ # Node.js checkers + tests keeping playbooks/skills/agents in sync with the LS + ├── research-notes/ # TODO_quickfixes.md: problem code → quick fix → playbook coverage table └── update-local-jars.sh # Rebuilds the standalone LS JAR for the plugin ``` @@ -83,15 +85,27 @@ npm run vsce-pre-release-package `preinstall.sh` packs `commons-vscode` locally, then builds the Spring Boot language server fat JAR and extracts it into `language-server/`. -### Claude Code Plugin +### Agent Plugin -The `claude-plugins/spring-tools` plugin uses the **standalone** language server (no JDT LS, uses Jandex for type indexing): +The `agent-plugins/spring-tools` plugin uses the **standalone** language server (no JDT LS, uses Jandex for type indexing): ```bash -cd claude-plugins +cd agent-plugins ./update-local-jars.sh # Rebuild and copy the standalone LS JAR into the plugin ``` +The same plugin source serves every host: `plugin.json` and `mcp.json` for Claude Code, `com.github.copilot/` for GitHub Copilot, `opencode/` for OpenCode, and `.agents/plugins/marketplace.json` as the Codex catalog pointing to it (not a second implementation). Plugin JavaScript uses four-space indentation; plugin tests use Node's `node:test` with `*.test.mjs` filenames. + +Every diagnostic code needs a playbook `agent-plugins/spring-tools/explanations/.md` and a row in `agent-plugins/research-notes/TODO_quickfixes.md`. Every MCP tool (`boot/mcp/*Tools.java`) must be reachable through a skill or hook and have a behavioral eval with a mock. When adding or renaming a problem type or an MCP tool, run from the repository root (details in `agent-plugins/spring-tools/README.md`): + +```bash +node agent-plugins/tools/check-explanations.mjs --require-all --todo --links +node agent-plugins/tools/check-plugin-config.mjs +npm --prefix agent-plugins/tools test +``` + +After changing `launcher.js`, `install.js`, `hooks/hooks.json` or the standalone LS, run the end-to-end MCP smoke test (needs the rebuilt JAR from `update-local-jars.sh` and Java 21+): `node agent-plugins/tools/smoke/mcp-smoke.mjs`. + ## Running Tests ```bash @@ -138,7 +152,8 @@ IDE Client (VSCode/Eclipse) ←—LSP over stdio/socket—→ Java Language Serv ``` - The full Spring Boot LS (`spring-boot-language-server`) requires JDT Language Server in the same process for Java type resolution -- The standalone variant (`spring-boot-language-server-standalone`) resolves types via Maven/Gradle classpath + Jandex indexing — used by the Claude Code plugin +- The standalone variant (`spring-boot-language-server-standalone`) resolves types via Maven/Gradle classpath + Jandex indexing — used by the agent plugin +- In the plugin the LSP transport is disabled (`-Dlanguageserver.enabled=false`), so no document is ever "open": the `getProjectDiagnostics` MCP tool only sees what the indexers produce — Java diagnostics from `SpringIndexerJava`, config-file diagnostics (`application*`, `bootstrap*`, `*.factories`) from `SpringIndexerConfigFiles`, plus the build-file version validation ### Key Commons Modules diff --git a/README-dev-env.md b/README-dev-env.md index 27897ab3fc..0291637777 100644 --- a/README-dev-env.md +++ b/README-dev-env.md @@ -1,5 +1,57 @@ # Spring Tools - Dev Environment Setup +## Build commands + +Run each block from the repository root. Java sources target Java 21; CI uses JDK 25. + +### Java language servers + +```bash +cd headless-services +./mvnw clean install +# Build a server and its dependencies: +./mvnw clean install -pl spring-boot-language-server -am +# Run one test after dependencies have been installed: +./mvnw test -pl spring-boot-language-server -Dtest=BootLanguageServerTest +``` + +### Eclipse integrations and distribution + +Select a target-platform profile supported by the module's parent POM. + +```bash +cd eclipse-language-servers +./mvnw -Pe440 clean install +``` + +```bash +cd eclipse-distribution +./mvnw -Pe440 -Psnapshot clean package +``` + +### VS Code extension + +```bash +cd vscode-extensions/vscode-spring-boot +./scripts/preinstall.sh +npm install +npm run check-types +npm run lint +npm run compile +npm run watch +``` + +The preinstall script builds shared dependencies and the language server JAR. +Use `npm run vsce-package` to package the extension. + +### Agent plugin + +The IDE language server integrates with JDT LS. The agent plugin instead uses +the standalone server with Maven/Gradle classpath resolution and Jandex indexing; +its file watcher keeps MCP diagnostics current without open editor documents. +See the [plugin guide](agent-plugins/spring-tools/README.md) for building a local +JAR, smoke tests, playbook checks, and behavioral evals. + ## Running the Spring Tools Java Language Server locally Import the headless-services modules into your workspace as existing Maven projects. diff --git a/README.md b/README.md index fd199add56..25f6d5d485 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ Depending on your development environment, you can choose between: - __Visual Studio Code:__ We provide extensions for Visual Studio Code if you prefer this lightweight editor for your development. - __Cursor:__ We provide extensions for Cursor if you prefer this lightweight editor for agentic development. -- __Claude Code:__ The Spring Tools plugin for Claude Code brings more Spring smartness to Claude. +- __AI coding agents:__ The [Spring Tools Language Server agent plugin](agent-plugins/spring-tools/README.md) provides Spring-aware MCP tools and skills for Claude Code, GitHub Copilot CLI and Codex; OpenCode can connect to the same server over MCP. - __Spring Tools for Eclipse distribution:__ a ready-to-use distribution of the latest Eclipse Desktop IDE, prepackaged with Spring Tools and a few additional third-party extensions. Download, unpack, and start. - __Spring Tools for Eclipse extensions:__ you can install the Spring Tools for Eclipse into an existing Eclipse Desktop IDE installation and use it from there. - __Eclipse Theia:__ You can install the same extensions into Theia-based environments like gitpod. diff --git a/RELEASE.md b/RELEASE.md index f5c882e203..9064ead49f 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -6,12 +6,12 @@ schedules: - **Eclipse track** — Eclipse LS Extensions → Eclipse Distribution → Test → Publish - **VSCode track** — VSCode Extensions RC → Test → Publish -- **Claude Code plugin track** — Test locally → Release (build + publish) +- **agent plugin track** — Test locally → Release (build + publish) The Eclipse and VSCode tracks each have their own release-candidate loop: if testing turns up a problem in one track, only that track's release step is re-spun. The other track keeps whatever candidate it already has and does -not need to be rebuilt. The Claude Code plugin track has no candidate/staging +not need to be rebuilt. The agent plugin track has no candidate/staging step at all — see below. Every step below except "Test" is a GitHub Actions workflow, triggered via @@ -94,18 +94,19 @@ stateDiagram-v2 PublishVSCode --> [*] ``` -## Claude Code Plugin Track +## Agent Plugin Track -The `spring-tools` Claude Code plugin (`claude-plugins/spring-tools`) bundles -the standalone Spring Boot language server (Jandex-based, no JDT LS) as an -MCP server for Claude Code. Its release process is different from the other +The `spring-tools` [agent plugin](agent-plugins/spring-tools/README.md) downloads +and launches the standalone Spring Boot language server (Jandex-based, no JDT LS) +as an MCP server for Claude Code, GitHub Copilot CLI, Codex, and OpenCode. +Its release process is different from the other two tracks: there is no RC/staging step — the release workflow builds *and* publishes atomically in one shot, so verification has to happen locally *before* that workflow is dispatched. | # | Step | Workflow | Key inputs | |---|------|----------|------------| -| 1 | Test locally | — manual | Run [`claude-plugins/update-local-jars.sh`](claude-plugins/update-local-jars.sh) to build the standalone LS jar from the current `headless-services` snapshot and drop it into `spring-tools/language-server/`, then exercise the plugin through the local `spring-tools-local` marketplace ([`claude-plugins/.claude-plugin/marketplace.json`](claude-plugins/.claude-plugin/marketplace.json)) | +| 1 | Test locally | — manual | Run [`agent-plugins/update-local-jars.sh`](agent-plugins/update-local-jars.sh) to build the standalone LS jar from the current `headless-services` snapshot and drop it into `spring-tools/language-server/`, then run the automated checks from the repository root — `node agent-plugins/tools/check-explanations.mjs --require-all --todo --links`, `node agent-plugins/tools/check-plugin-config.mjs`, `node --test 'agent-plugins/tools/test/*.test.mjs'` and the end-to-end `node agent-plugins/tools/smoke/mcp-smoke.mjs` (the same checks CI runs in [`agent-plugin-check.yml`](.github/workflows/agent-plugin-check.yml)), run the behavioral eval suite with a logged-in Claude Code (`cd agent-plugins/spring-tools && claude plugin eval . --no-publish`, or dispatch the `plugin-evals` job of that workflow) — and exercise the plugin through the local `spring-tools-local` marketplace ([`agent-plugins/.claude-plugin/marketplace.json`](agent-plugins/.claude-plugin/marketplace.json)) | | 2 | Release (build + publish) | [`release-standalone-ls.yml`](.github/workflows/release-standalone-ls.yml) | `dist: release` — builds the standalone LS jar, uploads it to the CDN, tags the repo `v`, and updates the release marketplace's `marketplace.json` to point at that tag | (Nightly/on-demand snapshot builds of the plugin are handled separately by @@ -143,7 +144,7 @@ stateDiagram-v2 TestVSCode --> PublishVSCode : pass PublishVSCode --> [*] } - state "Claude Code Plugin Track" as ClaudePlugin { + state "Agent Plugin Track" as ClaudePlugin { [*] --> TestLocally TestLocally --> TestLocally : issues found TestLocally --> ReleaseAndPublish : pass @@ -189,10 +190,11 @@ next **minor**: [`vscode-extensions/vscode-spring-boot/package.json`](vscode-extensions/vscode-spring-boot/package.json). Extensions that weren't released (`vscode-concourse`, `vscode-manifest-yaml`, `vscode-bosh`, `boot-dev-pack`) are left alone. -- **Claude Code plugin** — bump `version` in - [`claude-plugins/spring-tools/.claude-plugin/plugin.json`](claude-plugins/spring-tools/.claude-plugin/plugin.json) +- **agent plugin** — bump `version` in + [`agent-plugins/spring-tools/.claude-plugin/plugin.json`](agent-plugins/spring-tools/.claude-plugin/plugin.json) + and [`agent-plugins/spring-tools/plugin.json`](agent-plugins/spring-tools/plugin.json) to match the new `headless-services` version. This is exactly what makes - the [Claude Code Plugin Track](#claude-code-plugin-track) work: it leaves + the [Agent Plugin Track](#agent-plugin-track) work: it leaves `main` on a version that has never been tagged, ready for the next `release-standalone-ls.yml` run. @@ -213,7 +215,7 @@ cycle** — adopt the new target platform: `snapshot-all.yml`. `gh-hosted-eclipse-distro-build.yml` and `eclipse-ls-extensions-build.yml` themselves don't need changes — they're reusable workflows that just take `eclipse_profile` as an input. -- Also worth checking: `CLAUDE.md`'s "Eclipse profile options" line is +- Also worth checking: `AGENTS.md`'s "Eclipse profile options" line is currently stale (`e439`/`e440`) relative to what the poms and release workflows actually build (`e440`/`e441`) — keep it in sync when adopting a new train. diff --git a/claude-plugins/.claude-plugin/marketplace.json b/agent-plugins/.claude-plugin/marketplace.json similarity index 100% rename from claude-plugins/.claude-plugin/marketplace.json rename to agent-plugins/.claude-plugin/marketplace.json diff --git a/claude-plugins/research-notes/TODO_quickfixes.md b/agent-plugins/research-notes/TODO_quickfixes.md similarity index 96% rename from claude-plugins/research-notes/TODO_quickfixes.md rename to agent-plugins/research-notes/TODO_quickfixes.md index 1a66a3395d..2f0107f339 100644 --- a/claude-plugins/research-notes/TODO_quickfixes.md +++ b/agent-plugins/research-notes/TODO_quickfixes.md @@ -1,8 +1,8 @@ # Spring Tools Validation Problem Types & Quick Fixes -This document tracks all validation problem types defined in the Spring Tools language server, their corresponding quick fix implementations (JDT refactoring or OpenRewrite recipe), and whether they have an explanation playbook in the Claude plugin (`claude-plugins/spring-tools/explanations/.md`). +This document tracks all validation problem types defined in the Spring Tools language server, their corresponding quick fix implementations (JDT refactoring or OpenRewrite recipe), and whether they have an explanation playbook in the Claude plugin (`agent-plugins/spring-tools/explanations/.md`). -The table is kept in sync with the source and the playbook files by `node claude-plugins/tools/check-explanations.mjs --require-all --todo` (also run in CI): every code found in the `*ProblemType` enums and the `BOOT_VERSION_VALIDATION_CODE` constant must have a row here, every row must name an existing code, and the Playbook column must match the files on disk. +The table is kept in sync with the source and the playbook files by `node agent-plugins/tools/check-explanations.mjs --require-all --todo` (also run in CI): every code found in the `*ProblemType` enums and the `BOOT_VERSION_VALIDATION_CODE` constant must have a row here, every row must name an existing code, and the Playbook column must match the files on disk. When a new problem type is added to the language server, add a row to the matching section below, write the playbook (see the "Explanation playbooks" section of the plugin README for the structure and the authoring checklist), and run the checker. @@ -24,6 +24,7 @@ When a new problem type is added to the language server, add a row to the matchi | `WEB_SECURITY_CONFIGURER_ADAPTER` | `WebSecurityConfigurerAdapter` is removed | OpenRewrite: `org.openrewrite.java.spring.security5.WebSecurityConfigurerAdapter` | ✅ | | `DOMAIN_ID_FOR_REPOSITORY` | Invalid Domain ID type for Spring Data Repository | *None* | ✅ | | `WEB_ANNOTATION_NAMES` | Implicit web annotations names | OpenRewrite: `org.openrewrite.java.spring.ImplicitWebAnnotationNames` | ✅ | +| `MODEL_ATTRIBUTE_NAMES` | Redundant explicit `@ModelAttribute` name matching the type-derived default | *None* | ✅ | | `VALUE_CLASSPATH_RESOURCE_TYPE` | Invalid type for classpath resource in `@Value` | *None* | ✅ | | `WEB_CONFIGURER_CONFIGURATION` | Missing `@Configuration` on web configurer | OpenRewrite: `org.openrewrite.java.spring.boot2.AddConfigurationAnnotation` | ✅ | | `MISSING_VALIDATED_ANNOTATION` | Missing `@Validated` on component | OpenRewrite: `org.openrewrite.java.spring.boot2.AddValidatedAnnotation` | ✅ | diff --git a/claude-plugins/spring-tools/.claude-plugin/plugin.json b/agent-plugins/spring-tools/.claude-plugin/plugin.json similarity index 100% rename from claude-plugins/spring-tools/.claude-plugin/plugin.json rename to agent-plugins/spring-tools/.claude-plugin/plugin.json diff --git a/agent-plugins/spring-tools/.gitignore b/agent-plugins/spring-tools/.gitignore new file mode 100644 index 0000000000..d54cee59ec --- /dev/null +++ b/agent-plugins/spring-tools/.gitignore @@ -0,0 +1,4 @@ +language-server/ +node_modules/ +boot-ls.log +evals/results/ diff --git a/agent-plugins/spring-tools/README.md b/agent-plugins/spring-tools/README.md new file mode 100644 index 0000000000..7edae8234f --- /dev/null +++ b/agent-plugins/spring-tools/README.md @@ -0,0 +1,357 @@ +# Spring Tools Language Server — Agent Plugin + +An agent plugin that contributes the Spring Tools Language Server, exposing Spring Boot diagnostics, bean/request-mapping lookups and other project insights as MCP tools, skills, hooks and a review subagent. The portable [Agent Plugins 1.0](https://agent-plugins.org) package (`plugin.json`, `mcp.json`, and `skills/`) is supported by GitHub Copilot CLI and Codex. Claude Code also reads its native `.claude-plugin/` manifest and `hooks/`; Copilot has its own hook and agent adapters under `com.github.copilot/`. OpenCode can use the same language server over MCP and discovers shared skills through its Agent Skills-compatible `.agents/skills/` path. + +Unlike the VS Code extension, this plugin uses the **standalone** variant of the language server which operates **without** JDT Language Server. Project classpath is computed directly via Maven and Gradle tooling; type indexing uses Jandex. + +## Requirements + +- Java 21+ — found via `SPRING_TOOLS_JAVA`, then `$JAVA_HOME/bin/java`, then `java` on `PATH` (the first candidate that reports Java 21 or newer is used) +- Maven or Gradle projects in your workspace +- Claude Code, GitHub Copilot CLI 1.0.83+, Codex CLI, or OpenCode + +Optional environment variables: + +| Variable | Purpose | +|---|---| +| `SPRING_TOOLS_JAVA` | Path of the `java` executable to run the language server with | +| `SPRING_TOOLS_JAVA_OPTS` | Extra JVM options, e.g. `-Xmx2g`; appended after the defaults so they take precedence | +| `SPRING_TOOLS_LS_JAR` | Run a local language server JAR instead of the downloaded one (no download happens) | +| `SPRING_TOOLS_SOURCE_DIR` | Spring Tools repository root to use for a local Maven build if downloading the language server fails | +| `SPRING_TOOLS_PROJECT_DIR` | The directory to index, overriding the workspace the agent reports (see [How it works](#how-it-works)) | +| `SPRING_TOOLS_DATA_DIR` | Persistent location for the language-server log and runtime files; the downloaded JAR is stored in a subdirectory for the current plugin version. Defaults to `~/.spring-tools/data` when the host provides no plugin-data directory | +| `SPRING_TOOLS_LS_WATCH` | Set to `false` to turn off the language server's own file watcher (see [What the language server provides](#what-the-language-server-provides)); only Claude's MCP hooks provide a synchronous change-notification fast path | +| `HTTPS_PROXY` / `NO_PROXY` | The JAR download honors the usual proxy variables (`HTTPS_PROXY`, `ALL_PROXY`, `NO_PROXY`, lower-case variants; HTTP proxies with optional basic auth) | +| `MCP_TIMEOUT` | Claude Code's MCP startup timeout in ms; raise it if the language server is reported as failed on a slow machine | + +## Installation in Claude Code + +### 1. Add the Marketplace + +First, add either the Release or Snapshot marketplace to Claude Code: + +**To use the stable release:** +```bash +claude plugin marketplace add https://cdn.spring.io/spring-tools/release/claude-plugins/marketplace.json +``` + +**To use the bleeding-edge snapshot:** +```bash +claude plugin marketplace add https://cdn.spring.io/spring-tools/snapshot/claude-plugins/marketplace.json +``` + +### 2. Install the Plugin + +Once the marketplace is added, install the plugin: + +**If you added the stable release marketplace:** +```bash +claude plugin install spring-tools@spring-tools-marketplace +``` + +**If you added the snapshot marketplace:** +```bash +claude plugin install spring-tools@spring-tools-snapshots +``` + +### 3. Update the Plugin + +When new versions of the plugin are published to the marketplace, update it by running: + +```bash +claude plugin marketplace update +claude plugin update spring-tools +``` + +### 4. Testing the Plugin + +To verify that the Spring Boot Language Server is correctly booting up and serving MCP tools to Claude Code, you must run Claude Code **interactively** (don't use the `-p` single-shot flag, as it will kill the CLI before the language server finishes initializing). + +Open a Spring Boot project and start Claude Code: +```bash +claude +``` + +Then, ask Claude a test query to verify the MCP integration. For example: +> "Check the project diagnostics for CoffeeController.java." + +Claude calls the `getProjectDiagnostics` MCP tool and summarizes the exact Spring Boot warnings and quick fixes it reports. Note that the MCP server is available within a few seconds, but the project itself only shows up in `getProjectList` once the language server has resolved the Maven/Gradle project model - a few seconds with a warm dependency cache, up to a couple of minutes on a cold one. The `validate` skill retries `getProjectList` while the list is still empty. + +The published marketplace URLs retain the historical `claude-plugins` path for compatibility. The shared plugin source lives under `agent-plugins/` in this repository. + +### 5. Local Testing + +We maintain a local marketplace configuration (`agent-plugins/.claude-plugin/marketplace.json`) to make testing the plugin directly from the source tree easy. + +1. Run the update script to build the standalone language server JAR and copy it into the source plugin directory for local development (run this from the `agent-plugins` directory): + ```bash + ./update-local-jars.sh + ``` +2. Add the local `agent-plugins` directory as a marketplace (run this from the repository root): + ```bash + claude plugin marketplace add ./agent-plugins + ``` +3. Install the plugin from your new local marketplace: + ```bash + claude plugin install spring-tools@spring-tools-local + ``` + +4. Start Claude Code with the locally built JAR selected explicitly (run from the repository root): + ```bash + SPRING_TOOLS_LS_JAR="$PWD/agent-plugins/spring-tools/language-server/spring-boot-language-server-standalone-exec.jar" claude + ``` + Normal installs keep the downloaded JAR in persistent, versioned plugin data; this environment override is only for local development and avoids downloading the published JAR. + +## Codex and GitHub Copilot CLI + +The plugin’s `plugin.json` and `mcp.json` form the portable Agent Plugins package. This repository includes a Codex marketplace catalog at [`.agents/plugins/marketplace.json`](../../.agents/plugins/marketplace.json). Register the checkout once, then install the plugin: + +```bash +codex plugin marketplace add /path/to/spring-tools +codex plugin add spring-tools@spring-tools-local +``` + +Use `/plugins` in Codex to manage the installed plugin. GitHub Copilot CLI can install directly from this repository's plugin subdirectory: + +```bash +copilot plugin install spring-projects/spring-tools:agent-plugins/spring-tools +``` + +Both hosts get the shared `skills/` and launch the same `launcher.js`. The host-specific `com.github.copilot/` directory supplies Copilot's agent front matter and session-start hook. Its reviewer limits tools to read/search and this plugin's MCP server. The root Agent Plugins files are also the Codex entry point, so those capabilities do not need a second copy. + +## OpenCode + +OpenCode does not load Agent Plugins packages directly. Its ready-to-merge MCP config is [`opencode/opencode.jsonc`](opencode/opencode.jsonc), and its read-only reviewer adapter is in [`opencode/agents/spring-reviewer.md`](opencode/agents/spring-reviewer.md). Set `SPRING_TOOLS_PLUGIN_ROOT` to the installed plugin directory, then merge the config fragment into your existing `opencode.json` or `opencode.jsonc`: + +```jsonc +"mcp": { + "spring-tools": { + "type": "local", + "command": ["node", "{env:SPRING_TOOLS_PLUGIN_ROOT}/launcher.js"], + "enabled": true, + "timeout": 120000 + } +} +``` + +OpenCode discovers skills under `.agents/skills/` and agents under `.opencode/agents/`. From the workspace root, copy the plugin's shared skills and reviewer adapter into those locations: + +```sh +mkdir -p .agents/skills .opencode/agents +cp -R "$SPRING_TOOLS_PLUGIN_ROOT/skills/." .agents/skills/ +cp "$SPRING_TOOLS_PLUGIN_ROOT/opencode/agents/spring-reviewer.md" .opencode/agents/ +``` + +Invoke the reviewer with `@spring-reviewer`; the shared `spring-review` skill is available to the primary agent too. Set `SPRING_TOOLS_PROJECT_DIR` if OpenCode's MCP process working directory is not the project to index. OpenCode relies on the language-server watcher; it cannot use Claude's MCP-tool hooks. Diagnostic playbooks live beside `launcher.js` in `explanations/` and are named in the MCP server instructions. + +## Configuring language server preferences + +You can customize validation severities and other language server settings on a per-project basis by placing a settings file inside the `.claude/` directory of your project. Two formats are supported and may coexist — the properties file provides the base values and the JSON file overrides them. + +### Properties format (`.claude/spring-tools.properties`) + +Flat `key=value` format where each key is the full dot-separated settings path. This is the simplest format to get started with: + +```properties +# Category enablement toggles (AUTO / ON / OFF) +boot-java.validation.java.boot2=OFF +boot-java.validation.java.boot3=AUTO +boot-java.validation.java.boot4=ON +boot-java.validation.spel.on=ON +boot-java.validation.java.version-validation=OFF + +# Per-problem severity overrides (IGNORE / HINT / INFO / WARNING / ERROR) +spring-boot.ls.problem.boot2.JAVA_PUBLIC_BEAN_METHOD=IGNORE +spring-boot.ls.problem.boot2.JAVA_AUTOWIRED_CONSTRUCTOR=IGNORE +``` + +### JSON format (`.claude/spring-tools.json`) + +Nested JSON matching the VSCode `boot-java` / `spring-boot` configuration structure. Useful when you want to express several settings for the same category together: + +```json +{ + "boot-java": { + "validation": { + "java": { + "boot2": "OFF", + "boot3": "AUTO", + "boot4": "ON", + "version-validation": "OFF" + }, + "spel": { "on": "ON" } + } + }, + "spring-boot": { + "ls": { + "problem": { + "boot2": { + "JAVA_PUBLIC_BEAN_METHOD": "IGNORE", + "JAVA_AUTOWIRED_CONSTRUCTOR": "IGNORE" + } + } + } + } +} +``` + +### Available settings + +**Category enablement toggles** (`boot-java.validation.*`) accept `AUTO`, `ON`, or `OFF`. `AUTO` (the default for the `boot2`/`boot3`/`boot4` categories) applies a category only when the project's Spring Boot version matches it, `ON` forces it on for every Spring Boot project regardless of version (for example to see Boot 4 findings while still on Boot 3), and `OFF` disables it. The `spring-aot`, `spel`, `version-validation`, `data-query`, `cron` and `spring-ai` categories only support `ON`/`OFF`; the `application-properties` and `application-yaml` categories have no toggle — silence individual codes with a severity of `IGNORE` instead. + +**Per-problem severity overrides** (`spring-boot.ls.problem..`) accept `IGNORE`, `HINT`, `INFO`, `WARNING`, or `ERROR`. + +**Per-problem parameters** (`spring-boot.ls.problem-parameters...`) tune individual checks, for example `spring-boot.ls.problem-parameters.spring-ai.SPRING_AI_TOOL_DESCRIPTION_TOO_SHORT.minimum-length=40`; the version-validation category has a category-wide parameter `spring-boot.ls.problem-parameters.version-validation.use-project-build-file=false` (look up available versions on spring.io instead of the project's Maven repositories). + +The full list of available categories, problem codes and parameters is embedded in the language server JAR as `problem-types.json` (inside the nested `spring-boot-language-server-*.jar`). They are the same keys used in the VSCode extension's settings, so the `boot-java.*` / `spring-boot.*` entries of `vscode-extensions/vscode-spring-boot/package.json` double as a reference. + +Settings are applied once at startup. You must restart the language server for changes to take effect: restart Claude Code, or reconnect the `spring-tools-mcp` server from the `/mcp` menu. + +## What the language server provides + +Via MCP tools: + +- **Diagnostics** — Spring-specific warnings and quick fixes (missing annotations, incorrect bean wiring, etc.), including version validation results and checks of `application.properties` / `application.yml` / `META-INF/spring.factories` (unknown or deprecated properties, type mismatches, structural errors, unsupported factories keys). Config files under test resources are only included when `boot-java.scan-java-test-sources.on=true` is set (see below) +- **Project insight** — bean, component, and request-mapping lookups; logical structure and architectural change tracking; resolved project classpath; Java and Spring Boot version; release and support information for Spring projects + +Skills are available by name to Codex, Copilot CLI and OpenCode, and are namespaced as `/spring-tools:` in Claude Code. Hosts can also select them automatically when relevant: + +- **`validate`** — collects the Spring Tools diagnostics for a project and drives the fixes +- **`quickfix`** — looks up the explanation and fix instructions for a diagnostic code in `explanations/` and applies them +- **`create-spring-boot-project`** — scaffolds a new project from start.spring.io +- **`beans`** — bean definitions, injection points and usages ("where is this bean injected?", "which beans implement X?") +- **`endpoints`** — request mappings, optionally filtered by HTTP method ("which endpoints exist?") +- **`architecture`** — logical structure and stereotypes; captures a baseline and reports the structural changes since +- **`spring-versions`** — the Spring Boot version in use versus the latest releases, support windows and upcoming releases +- **`project-info`** — project list, Java version, Boot version and resolved classpath ("is this project indexed?", "which dependency provides X?") +- **`refresh`** — forces the language server to re-index the workspace from disk (or a single file) when something was missed + +Agents (`agents/`) — the plugin also ships a reviewer subagent: + +- **`spring-reviewer`** — a read-only Spring review of the current changes: it combines the `getProjectDiagnostics` results with the bean, endpoint and structure tools and reports blocking issues, recommendations and notes without editing anything. Claude Code and Copilot CLI ship dedicated reviewer agents; Codex gets the shared `spring-review` skill; OpenCode can install its reviewer adapter as described above. Invoke the Copilot agent by its file-derived ID: `copilot --agent spring-reviewer`. + +The language server keeps its index current in two ways. It **watches** the workspace directory itself and re-indexes changed Java/Kotlin/Groovy, config and build files after a short quiet period — so edits made by an external editor, a code generator or a shell command show up on their own, as do Maven/Gradle projects created after the server started (e.g. by the `create-spring-boot-project` skill). Extra roots added with `/add-dir` are not watched, and neither are edits outside the project root. Set `SPRING_TOOLS_LS_WATCH=false` to turn this off, e.g. for a huge monorepo or a workspace on a network drive; the watcher also disables itself above 20,000 directories. In addition, hooks give the server a synchronous fast path for the agent's own actions: the file-change hooks fire after `Edit`/`Write` tool calls on Java/Kotlin/Groovy source files and build/config files (`.java`, `.kt`, `.kts`, `.groovy`, `.xml`, `.properties`, `.yml`, `.yaml`, `.factories`, `.gradle`) — edits to unrelated files don't trigger them — and the workspace-refresh hook fires after shell commands that change files without going through the file tools: `git`, `rm`, `mv`, `cp`, `sed`, `patch`, `tar`, `unzip` (and `git`, `Remove-Item`, `Move-Item`, `Copy-Item`, `Expand-Archive`, `tar` in PowerShell). A session-start hook runs `install.js --if-missing` so the language server JAR is downloaded before the MCP server needs it. Claude Code reads these from `hooks/hooks.json`; Copilot CLI reads `com.github.copilot/hooks/hooks.json`, which only contains the session-start download: Copilot hooks run shell commands and have no way to call a tool of a running MCP server, so there the file watcher alone keeps the index current. + +## Troubleshooting + +- **`spring-tools-mcp` shows as failed in `/mcp`** — on the very first start the ~100 MB JAR has to be downloaded; if that takes longer than Claude Code's MCP startup timeout the server is marked failed. Reconnect it from `/mcp` (the download continues/completes in the background) or start Claude Code again; raise `MCP_TIMEOUT` on slow connections. +- **`No Java 21+ runtime found`** on stderr — the launcher lists every candidate it tried with the version it found. Install a JDK 21+ and expose it via `PATH`, `JAVA_HOME` or `SPRING_TOOLS_JAVA`. +- **`getProjectList` is empty** — the Maven/Gradle model is still resolving (up to a couple of minutes on a cold dependency cache); the `validate` skill retries. Use the `project-info` skill to check it; if a project still doesn't appear (for example it lives outside the host's workspace), use the `refresh` skill. In Claude Code, the corresponding commands are `/spring-tools:project-info`, `/add-dir`, and `/spring-tools:refresh`. +- **Changes made outside the agent aren't picked up** — the watcher logs `file watcher started for ` in `boot-ls.log`; if it logs `not started` instead (too many directories, unsupported file system, `SPRING_TOOLS_LS_WATCH=false`), use the `refresh` skill after external changes. Claude Code's shortcut is `/spring-tools:refresh`. +- **Download blocked by a corporate proxy** — set `HTTPS_PROXY`; the installer only downloads from `cdn.spring.io` over HTTPS and verifies the SHA-256 published next to the JAR. If download fails and a Spring Tools source checkout is available, the installer builds the standalone server with its Maven wrapper. For an installed plugin, set `SPRING_TOOLS_SOURCE_DIR` to the repository root; otherwise download the JAR yourself and point `SPRING_TOOLS_LS_JAR` at it. +- **Logs** — `boot-ls.log` in `SPRING_TOOLS_DATA_DIR`, the host's plugin-data directory, or `~/.spring-tools/data`. + +## Explanation playbooks + +Every diagnostic the language server can report through `getProjectDiagnostics` carries a `code` (for example `JAVA_PUBLIC_BEAN_METHOD`). For each code there is one Markdown playbook `explanations/.md` that the `quickfix` skill reads before touching the user's code. A playbook has two sections: + +- `## Explanations` — what the diagnostic flags, why it matters, when the language server raises it (Spring/Boot version range, required dependency, what exactly is inspected), and a `For more details, see:` list of official documentation links. +- `## Fixes` — one or more `**Fix N: …**` blocks with prose and *Before:* / *After:* code blocks. Cross-references to other diagnostics are written as backticked codes (for example ``see `JAVA_LAMBDA_DSL` ``) and must point to an existing playbook. + +The codes come from the `*ProblemType` enums of the language server (`Boot2JavaProblemType`, `Boot3JavaProblemType`, `Boot4JavaProblemType`, `SpringAotJavaProblemType`, `SpringAiProblemType`, `SpelProblemType`, `cron/CronProblemType`, `data/jpa/queries/QueryProblemType`, `properties/reconcile/ApplicationPropertiesProblemType`, `yaml/reconcile/ApplicationYamlProblemType`) plus two codes that are not enum constants: `BOOT_VERSION_VALIDATION_CODE`, which all Spring Boot version-validation diagnostics share, and `YamlSchemaProblem`, which the YAML reconciler inherits from `commons-yaml` for malformed `<<` merge keys. Coverage is tracked in [`research-notes/TODO_quickfixes.md`](../research-notes/TODO_quickfixes.md). + +### Keeping the playbooks consistent + +`agent-plugins/tools/` contains a Node.js checker and tests (Node 20+, no dependencies) that keep the playbooks and the plugin configuration in sync with the language server. Run them from the repository root: + +```bash +# every code has a playbook, no orphan files, structure/cross-references valid, TODO table in sync +node agent-plugins/tools/check-explanations.mjs --require-all --todo + +# additionally verify that every documentation link returns HTTP 200 +node agent-plugins/tools/check-explanations.mjs --require-all --links + +# only a subset (e.g. while writing a new playbook) +node agent-plugins/tools/check-explanations.mjs --codes JAVA_PUBLIC_BEAN_METHOD,JAVA_LAMBDA_DSL --links + +# Run the offline playbook and plugin configuration checks together +npm --prefix agent-plugins/tools run check + +# hooks, skills and agents reference only MCP tools the language server offers, every tool is reachable +# through a skill or hook, skills grant exactly the tools they document, the eval suite mocks every tool +node agent-plugins/tools/check-plugin-config.mjs + +# tests: checker behaviour (incl. negative-control fixtures), hooks/skills/agents/manifest consistency with the MCP tools, +# installer (against a local mock CDN) and launcher (Java selection, JVM arguments, server instructions) +node --test --test-reporter=spec 'agent-plugins/tools/test/*.test.mjs' + +# end-to-end: start launcher.js over MCP stdio like Claude Code does, index a sample project, exercise the +# diagnostics/bean/endpoint/structure tools, edit files with and without notifying the server (file watcher), +# create a project after startup and verify it is discovered (needs the JAR + Java 21+) +node agent-plugins/tools/smoke/mcp-smoke.mjs +``` + +The GitHub Actions workflow `.github/workflows/agent-plugin-check.yml` runs the same checks for changes to `.agents/plugins/marketplace.json`, under `agent-plugins/`, the `*ProblemType` enums, the MCP tool sources and the standalone language server, validates the manifest with `claude plugin validate --strict`, and runs the smoke test against a freshly built language server. + +When a new MCP tool is added to the language server, give it a home: mention it in the skill that covers its use case (its `allowed-tools` list and body) or in `hooks/hooks.json`; `check-plugin-config.mjs` fails until every tool is reachable through a skill or hook. + +### Behavioral evals + +`evals/` holds a [`claude plugin eval`](https://code.claude.com/docs/en/plugin-evals) suite: one case per skill and for the `spring-reviewer` agent, plus a negative control (an unrelated question must not trigger any skill). Each case is a prompt phrased the way a user would type it, with graders for the result (a regex or a judged rubric) and for the path (`tool_used` on the skill and on the MCP tool it should call). The language server is not started during a run: `evals/mocks/spring-tools-mcp/` answers every MCP tool from recordings taken against the real server on the `sf7-validation` test project (`_tools.json` carries the real tool descriptions and schemas), and the `validate` and reviewer cases override `getProjectDiagnostics` with a recording that contains a `JAVA_AUTOWIRED_CONSTRUCTOR` finding. + +```bash +# runs every case three times with the plugin and three times without it, judged with your Claude Code credentials +cd agent-plugins/spring-tools && claude plugin eval . --no-publish + +# one case, one arm, one run - cheap while iterating on a skill description +claude plugin eval . --case validate-finds-spring-problems --runs 1 --ablation none + +# re-record the mocks after the language server's tools or the fixture project changed (needs the JAR + Java 21+) +node agent-plugins/tools/evals/record-mocks.mjs +``` + +Every run and every `llm` grader is a model call on your account (Claude Code 2.1.269+, logged in or `ANTHROPIC_API_KEY`). `check-plugin-config.mjs` keeps the suite consistent offline: every tool has a mock, `_tools.json` matches the server's tool list, graders reference only existing tools/skills/agents, and every skill and agent has a case. The `create-project-routes-to-skill` case checks routing only — eval runs don't grant `Bash`, so the skill can't download from start.spring.io there. + +When a new diagnostic code is added to the language server: + +1. Create `explanations/.md` following the structure above; verify every fact against the reconciler that raises the code and against the current Spring documentation, and prefer the current API (state deprecation/removal versions when showing an older one). +2. Add a row to the coverage table in `research-notes/TODO_quickfixes.md`. +3. Run the checker with `--require-all --todo --links` until it passes. + +## Plugin structure + +``` +spring-tools/ +├── .claude-plugin/ +│ └── plugin.json # Claude Code manifest (metadata + MCP server config + hooks) +├── plugin.json # Agent Plugins 1.0 manifest (metadata), used by Copilot CLI and Codex +├── mcp.json # Agent Plugins 1.0 MCP server config, used by Copilot CLI and Codex +├── com.github.copilot/ # Copilot-specific extensions (client namespace of the Agent Plugins spec) +│ ├── agents/ +│ │ └── spring-reviewer.agent.md # Same agent, front matter Copilot understands +│ └── hooks/ +│ └── hooks.json # sessionStart JAR download +├── launcher.js # Node.js script that picks a Java 21+ runtime, downloads the JAR (if missing) and starts it +├── install.js # Node.js script that downloads and verifies the JAR (also run by the session-start hook) +├── hooks/ +│ └── hooks.json # Claude Code hooks: SessionStart JAR download + notifications about Claude's file/project changes +├── agents/ +│ └── spring-reviewer.md # Read-only Spring review subagent built on the MCP tools +├── skills/ # Portable shared skills (namespaced as /spring-tools: in Claude Code) +│ ├── validate/ +│ ├── quickfix/ +│ ├── create-spring-boot-project/ +│ ├── beans/ +│ ├── endpoints/ +│ ├── architecture/ +│ ├── spring-versions/ +│ ├── project-info/ +│ ├── refresh/ +│ └── spring-review/ # Canonical review procedure for Codex and other skill hosts +├── opencode/ # OpenCode MCP config fragment and reviewer agent adapter +├── explanations/ # One Markdown playbook per diagnostic code (explanation + fixes) +├── evals/ # Behavioral eval suite: one case per skill/agent + recorded MCP mocks (results/ is gitignored) +└── README.md +``` + +The manifests describe one plugin for different hosts. `install.js` stores the checksum-verified JAR in the persistent data directory, isolated under the plugin version; it does not write into the installed plugin tree. `check-plugin-config.mjs` fails if shared metadata drifts, if a reviewer adapter differs from `skills/spring-review/SKILL.md`, or if `mcp.json` stops launching `launcher.js`. Run `node agent-plugins/tools/sync-agent-adapters.mjs --write` after changing the shared review procedure to refresh Claude, Copilot and OpenCode adapters. Hooks cannot be one shared file: Claude supports MCP-tool hooks, Copilot supports command hooks, and Codex/OpenCode rely on the language-server watcher. Codex and Copilot consume the portable root `plugin.json`, `mcp.json`, and `skills/`, so those capabilities are defined once. + +The sibling directory `agent-plugins/tools/` (not part of the published plugin) holds `check-explanations.mjs`, `check-plugin-config.mjs`, `lib/plugin-config.mjs`, the `test/` suite, the `smoke/mcp-smoke.mjs` end-to-end test, the `evals/record-mocks.mjs` recorder and the `evals/run-with-copilot.mjs` runner described above. + +## How it works + +The agent parses the MCP configuration of the plugin at startup (`.claude-plugin/plugin.json` in Claude Code, portable `mcp.json` in Copilot CLI and Codex, or the OpenCode fragment). This triggers `launcher.js`, which selects a Java 21+ runtime and checks if the heavy Java JAR is downloaded. If not, it downloads it from Spring's CDN (`install.js`, SHA-256 verified, written atomically; normally a session-start hook has already done this). Then it boots the standalone Spring Tools Language Server, instructing it to expose its MCP tools over `stdio` and passing server instructions that tell the agent which tools to start with and where the explanation playbooks live. + +The directory the server indexes is resolved in this order: `SPRING_TOOLS_PROJECT_DIR`, then `CLAUDE_PROJECT_DIR` (Claude Code), then the workspace Copilot records for the session in `~/.copilot/session-state//workspace.yaml`, then `PWD`, then the working directory the MCP server was started in. An invalid explicit override is an error. If none of the detected paths identifies a directory outside the plugin root, the launcher stops with configuration instructions. The chosen directory and its source are logged. The server watches that directory for changes unless `SPRING_TOOLS_LS_WATCH=false`. Logs go to `SPRING_TOOLS_DATA_DIR`, the host's plugin-data directory, or `~/.spring-tools/data`. + +The standalone server uses the resolved project directory for project discovery, settings-file loading, and file watching. Watcher behavior can be configured with `languageserver.standalone.filewatcher.enabled`, `languageserver.standalone.filewatcher.debounce-ms`, and `languageserver.standalone.filewatcher.max-directories`. diff --git a/agent-plugins/spring-tools/agents/spring-reviewer.md b/agent-plugins/spring-tools/agents/spring-reviewer.md new file mode 100644 index 0000000000..9fcc4ddcd5 --- /dev/null +++ b/agent-plugins/spring-tools/agents/spring-reviewer.md @@ -0,0 +1,24 @@ +--- +name: spring-reviewer +description: Reviews Spring Boot code changes with the Spring Tools language server - diagnostics, bean wiring, endpoints and logical structure. Use after editing a Spring Boot project or when asked for a Spring-aware review; read-only, never edits files. +tools: Read, Grep, Glob, mcp__plugin_spring-tools_spring-tools-mcp__* +model: inherit +--- +You are a Spring Boot reviewer. You review the current state of the Spring Boot projects in the workspace with the `spring-tools` MCP server and report findings; you never modify files. + +Work through these steps and keep tool output out of the report - summarize. + +1. `getProjectList` - note every project (`projectName`, `location`, `isSpringBootProject`, `javaVersion`). Retry a few times if the list is empty right after startup. Skip projects that are not Spring Boot projects unless the user asked about them. +2. `getProjectDiagnostics` per project. For each Spring diagnostic code read the fix playbook for that code - the `.md` files in the plugin's `explanations` directory, at `${CLAUDE_PLUGIN_ROOT}/explanations/.md` in Claude Code and at the absolute path named in the spring-tools MCP server instructions otherwise - and describe the fix precisely (file, range, what to change) instead of pasting the playbook. +3. Bean wiring: `getBeanDetails` for an overview, then `findBeansByType` for every interface with several implementations (missing `@Primary`/`@Qualifier`) and `getBeanUsageInfo` for beans that are defined but never injected (dead configuration) or injected but not defined. +4. Endpoints: `getRequestMappings` - flag duplicate paths, handler methods without an HTTP method, controllers that mix API versions inconsistently, and endpoints without a matching stereotype (`@RestController`/`@Controller`). +5. Structure: `getLogicalStructureChanges` (a git-backed project has a baseline automatically) to see what the change under review added, removed or moved; `findComponentsByStereotype` to check layering rules the project follows (e.g. controllers calling repositories directly). +6. Versions: `getSpringBootVersion` plus `getLatestReleaseInformation` with slug `spring-boot` - mention when the project runs on a generation whose OSS support has ended. + +Report format: + +- **Blocking**: problems that break the application context, the build or security (each with file:line and the fix). +- **Should fix**: Spring diagnostics with a playbook, wiring ambiguities, structural regressions. +- **Notes**: version/support status, observations, things you could not verify (e.g. the language server was still indexing). + +Use file:line references from the tool output. Read source files only to confirm a finding. When the language server reports nothing for a project, say so explicitly rather than inventing findings. diff --git a/agent-plugins/spring-tools/com.github.copilot/agents/spring-reviewer.agent.md b/agent-plugins/spring-tools/com.github.copilot/agents/spring-reviewer.agent.md new file mode 100644 index 0000000000..d93dcfa916 --- /dev/null +++ b/agent-plugins/spring-tools/com.github.copilot/agents/spring-reviewer.agent.md @@ -0,0 +1,26 @@ +--- +name: spring-reviewer +description: Reviews Spring Boot code changes with the Spring Tools language server - diagnostics, bean wiring, endpoints and logical structure. Use after editing a Spring Boot project or when asked for a Spring-aware review; read-only, never edits files. +tools: + - read + - search + - spring-tools-mcp/* +--- +You are a Spring Boot reviewer. You review the current state of the Spring Boot projects in the workspace with the `spring-tools` MCP server and report findings; you never modify files. + +Work through these steps and keep tool output out of the report - summarize. + +1. `getProjectList` - note every project (`projectName`, `location`, `isSpringBootProject`, `javaVersion`). Retry a few times if the list is empty right after startup. Skip projects that are not Spring Boot projects unless the user asked about them. +2. `getProjectDiagnostics` per project. For each Spring diagnostic code read the fix playbook for that code - the `.md` files in the plugin's `explanations` directory, at `${CLAUDE_PLUGIN_ROOT}/explanations/.md` in Claude Code and at the absolute path named in the spring-tools MCP server instructions otherwise - and describe the fix precisely (file, range, what to change) instead of pasting the playbook. +3. Bean wiring: `getBeanDetails` for an overview, then `findBeansByType` for every interface with several implementations (missing `@Primary`/`@Qualifier`) and `getBeanUsageInfo` for beans that are defined but never injected (dead configuration) or injected but not defined. +4. Endpoints: `getRequestMappings` - flag duplicate paths, handler methods without an HTTP method, controllers that mix API versions inconsistently, and endpoints without a matching stereotype (`@RestController`/`@Controller`). +5. Structure: `getLogicalStructureChanges` (a git-backed project has a baseline automatically) to see what the change under review added, removed or moved; `findComponentsByStereotype` to check layering rules the project follows (e.g. controllers calling repositories directly). +6. Versions: `getSpringBootVersion` plus `getLatestReleaseInformation` with slug `spring-boot` - mention when the project runs on a generation whose OSS support has ended. + +Report format: + +- **Blocking**: problems that break the application context, the build or security (each with file:line and the fix). +- **Should fix**: Spring diagnostics with a playbook, wiring ambiguities, structural regressions. +- **Notes**: version/support status, observations, things you could not verify (e.g. the language server was still indexing). + +Use file:line references from the tool output. Read source files only to confirm a finding. When the language server reports nothing for a project, say so explicitly rather than inventing findings. diff --git a/agent-plugins/spring-tools/com.github.copilot/hooks/hooks.json b/agent-plugins/spring-tools/com.github.copilot/hooks/hooks.json new file mode 100644 index 0000000000..1949225b0e --- /dev/null +++ b/agent-plugins/spring-tools/com.github.copilot/hooks/hooks.json @@ -0,0 +1,13 @@ +{ + "version": 1, + "hooks": { + "sessionStart": [ + { + "type": "command", + "bash": "node \"$PLUGIN_ROOT/install.js\" --if-missing", + "powershell": "node \"$env:PLUGIN_ROOT/install.js\" --if-missing", + "timeoutSec": 900 + } + ] + } +} diff --git a/agent-plugins/spring-tools/evals/architecture-describes-components/graders/describes-stereotypes.md b/agent-plugins/spring-tools/evals/architecture-describes-components/graders/describes-stereotypes.md new file mode 100644 index 0000000000..415e9193f4 --- /dev/null +++ b/agent-plugins/spring-tools/evals/architecture-describes-components/graders/describes-stereotypes.md @@ -0,0 +1,8 @@ +--- +type: llm +--- + +The user asked for an architecture overview of the Spring Boot project `sf7-validation`: its components and their stereotypes. + +PASS if the response names `TestController` as a REST controller (stereotype `@RestController`/`Controller`), names `Config` and `WebConfig` as configuration classes (`@Configuration`), names `Sf7ValidationApplication` as the `@SpringBootApplication` entry point, and does not invent components, repositories or services that do not exist in the project. +FAIL if any of these four components is missing, if invented components are listed, or if the response only describes generic Spring architecture without referring to this project's classes. diff --git a/agent-plugins/spring-tools/evals/architecture-describes-components/graders/lists-the-components.md b/agent-plugins/spring-tools/evals/architecture-describes-components/graders/lists-the-components.md new file mode 100644 index 0000000000..e1678460e0 --- /dev/null +++ b/agent-plugins/spring-tools/evals/architecture-describes-components/graders/lists-the-components.md @@ -0,0 +1,4 @@ +--- +type: regex +pattern: TestController[\s\S]*WebConfig|WebConfig[\s\S]*TestController +--- diff --git a/agent-plugins/spring-tools/evals/architecture-describes-components/graders/skill-fired.md b/agent-plugins/spring-tools/evals/architecture-describes-components/graders/skill-fired.md new file mode 100644 index 0000000000..8906863770 --- /dev/null +++ b/agent-plugins/spring-tools/evals/architecture-describes-components/graders/skill-fired.md @@ -0,0 +1,5 @@ +--- +type: tool_used +tool: Skill +input_match: '"skill"\s*:\s*"(?:[\w-]+:)?architecture"' +--- diff --git a/agent-plugins/spring-tools/evals/architecture-describes-components/graders/used-stereotypes-tool.md b/agent-plugins/spring-tools/evals/architecture-describes-components/graders/used-stereotypes-tool.md new file mode 100644 index 0000000000..c1c984d795 --- /dev/null +++ b/agent-plugins/spring-tools/evals/architecture-describes-components/graders/used-stereotypes-tool.md @@ -0,0 +1,4 @@ +--- +type: tool_used +tool: mcp__plugin_spring-tools_spring-tools-mcp__getLogicalStructure +--- diff --git a/agent-plugins/spring-tools/evals/architecture-describes-components/prompt.md b/agent-plugins/spring-tools/evals/architecture-describes-components/prompt.md new file mode 100644 index 0000000000..84d0014d74 --- /dev/null +++ b/agent-plugins/spring-tools/evals/architecture-describes-components/prompt.md @@ -0,0 +1,8 @@ +--- +max_turns: 12 +allowed_tools: [Read, Glob, Grep, Skill] +tags: [smoke, architecture] +expected_outcome: Claude describes the project's components from the language server's logical structure - the Sf7ValidationApplication entry point, the Config and WebConfig configuration classes and the TestController REST controller - with their stereotypes. +--- + +Give me an architecture overview of this project: which Spring components exist and what role (stereotype) does each one play? diff --git a/agent-plugins/spring-tools/evals/beans-explains-wiring/graders/describes-wiring.md b/agent-plugins/spring-tools/evals/beans-explains-wiring/graders/describes-wiring.md new file mode 100644 index 0000000000..d5721f07bc --- /dev/null +++ b/agent-plugins/spring-tools/evals/beans-explains-wiring/graders/describes-wiring.md @@ -0,0 +1,8 @@ +--- +type: llm +--- + +The question asked where the Spring bean `testController` is defined, what its type is, and whether it is injected anywhere. + +PASS if the response says the bean is of type `com.example.demo.apiversioning.TestController`, is defined by the `@RestController` (or `@Controller`) annotated class in `TestController.java`, and states that no other bean injects or uses it (no injection points / usages found). +FAIL if the response claims the bean is injected somewhere, gives a different type, or says the information is unavailable. diff --git a/agent-plugins/spring-tools/evals/beans-explains-wiring/graders/names-the-bean-type.md b/agent-plugins/spring-tools/evals/beans-explains-wiring/graders/names-the-bean-type.md new file mode 100644 index 0000000000..85305aee99 --- /dev/null +++ b/agent-plugins/spring-tools/evals/beans-explains-wiring/graders/names-the-bean-type.md @@ -0,0 +1,4 @@ +--- +type: regex +pattern: com\.example\.demo\.apiversioning\.TestController +--- diff --git a/agent-plugins/spring-tools/evals/beans-explains-wiring/graders/skill-fired.md b/agent-plugins/spring-tools/evals/beans-explains-wiring/graders/skill-fired.md new file mode 100644 index 0000000000..20841b1934 --- /dev/null +++ b/agent-plugins/spring-tools/evals/beans-explains-wiring/graders/skill-fired.md @@ -0,0 +1,5 @@ +--- +type: tool_used +tool: Skill +input_match: '"skill"\s*:\s*"(?:[\w-]+:)?beans"' +--- diff --git a/agent-plugins/spring-tools/evals/beans-explains-wiring/graders/used-bean-usage-tool.md b/agent-plugins/spring-tools/evals/beans-explains-wiring/graders/used-bean-usage-tool.md new file mode 100644 index 0000000000..b78d5459ce --- /dev/null +++ b/agent-plugins/spring-tools/evals/beans-explains-wiring/graders/used-bean-usage-tool.md @@ -0,0 +1,5 @@ +--- +type: tool_used +tool: mcp__plugin_spring-tools_spring-tools-mcp__getBeanUsageInfo +input_match: testController +--- diff --git a/agent-plugins/spring-tools/evals/beans-explains-wiring/prompt.md b/agent-plugins/spring-tools/evals/beans-explains-wiring/prompt.md new file mode 100644 index 0000000000..fb9c6996e5 --- /dev/null +++ b/agent-plugins/spring-tools/evals/beans-explains-wiring/prompt.md @@ -0,0 +1,8 @@ +--- +max_turns: 12 +allowed_tools: [Read, Glob, Grep, Skill] +tags: [smoke, beans] +expected_outcome: Claude answers from the language server's bean index - the bean testController of type com.example.demo.apiversioning.TestController is a @RestController defined in TestController.java and is not injected anywhere else in the project. +--- + +Where is the bean testController defined in this project, what is its type, and is it injected anywhere? diff --git a/agent-plugins/spring-tools/evals/create-project-routes-to-skill/graders/skill-fired.md b/agent-plugins/spring-tools/evals/create-project-routes-to-skill/graders/skill-fired.md new file mode 100644 index 0000000000..9d47dfa211 --- /dev/null +++ b/agent-plugins/spring-tools/evals/create-project-routes-to-skill/graders/skill-fired.md @@ -0,0 +1,5 @@ +--- +type: tool_used +tool: Skill +input_match: '"skill"\s*:\s*"(?:[\w-]+:)?create-spring-boot-project"' +--- diff --git a/agent-plugins/spring-tools/evals/create-project-routes-to-skill/prompt.md b/agent-plugins/spring-tools/evals/create-project-routes-to-skill/prompt.md new file mode 100644 index 0000000000..25467020b8 --- /dev/null +++ b/agent-plugins/spring-tools/evals/create-project-routes-to-skill/prompt.md @@ -0,0 +1,9 @@ +--- +max_turns: 8 +allowed_tools: [Read, Glob, Grep, Skill] +tags: [routing] +description: Routing only - Bash is not granted in eval runs, so the skill cannot download from start.spring.io here; this case checks that a scaffolding request reaches the create-spring-boot-project skill at all. +expected_outcome: Claude invokes the create-spring-boot-project skill for the request (it may then report that it cannot run curl in this session). +--- + +Create a new Spring Boot project called inventory-service with Spring Web and Spring Data JPA, using Maven and Java 21, in a folder named inventory-service. diff --git a/agent-plugins/spring-tools/evals/endpoints-lists-request-mappings/graders/lists-greeting-endpoint.md b/agent-plugins/spring-tools/evals/endpoints-lists-request-mappings/graders/lists-greeting-endpoint.md new file mode 100644 index 0000000000..c9968393db --- /dev/null +++ b/agent-plugins/spring-tools/evals/endpoints-lists-request-mappings/graders/lists-greeting-endpoint.md @@ -0,0 +1,4 @@ +--- +type: regex +pattern: GET[^\n]{0,40}/greeting|/greeting[^\n]{0,40}GET +--- diff --git a/agent-plugins/spring-tools/evals/endpoints-lists-request-mappings/graders/names-the-controller.md b/agent-plugins/spring-tools/evals/endpoints-lists-request-mappings/graders/names-the-controller.md new file mode 100644 index 0000000000..99bb3a17c6 --- /dev/null +++ b/agent-plugins/spring-tools/evals/endpoints-lists-request-mappings/graders/names-the-controller.md @@ -0,0 +1,4 @@ +--- +type: regex +pattern: TestController +--- diff --git a/agent-plugins/spring-tools/evals/endpoints-lists-request-mappings/graders/skill-fired.md b/agent-plugins/spring-tools/evals/endpoints-lists-request-mappings/graders/skill-fired.md new file mode 100644 index 0000000000..5bcbceff5e --- /dev/null +++ b/agent-plugins/spring-tools/evals/endpoints-lists-request-mappings/graders/skill-fired.md @@ -0,0 +1,5 @@ +--- +type: tool_used +tool: Skill +input_match: '"skill"\s*:\s*"(?:[\w-]+:)?endpoints"' +--- diff --git a/agent-plugins/spring-tools/evals/endpoints-lists-request-mappings/graders/used-request-mappings-tool.md b/agent-plugins/spring-tools/evals/endpoints-lists-request-mappings/graders/used-request-mappings-tool.md new file mode 100644 index 0000000000..4e50dc35d5 --- /dev/null +++ b/agent-plugins/spring-tools/evals/endpoints-lists-request-mappings/graders/used-request-mappings-tool.md @@ -0,0 +1,4 @@ +--- +type: tool_used +tool: mcp__plugin_spring-tools_spring-tools-mcp__getRequestMappings +--- diff --git a/agent-plugins/spring-tools/evals/endpoints-lists-request-mappings/prompt.md b/agent-plugins/spring-tools/evals/endpoints-lists-request-mappings/prompt.md new file mode 100644 index 0000000000..96def9f9fa --- /dev/null +++ b/agent-plugins/spring-tools/evals/endpoints-lists-request-mappings/prompt.md @@ -0,0 +1,8 @@ +--- +max_turns: 12 +allowed_tools: [Read, Glob, Grep, Skill] +tags: [smoke, endpoints] +expected_outcome: Claude lists the single endpoint of the workspace project, GET /greeting (API version 1) handled by TestController.sayHello(), from the language server's request mappings. +--- + +Which HTTP endpoints does this project expose, and which controller methods handle them? diff --git a/agent-plugins/spring-tools/evals/ignores-unrelated-request/graders/answers-the-question.md b/agent-plugins/spring-tools/evals/ignores-unrelated-request/graders/answers-the-question.md new file mode 100644 index 0000000000..1fca23e95f --- /dev/null +++ b/agent-plugins/spring-tools/evals/ignores-unrelated-request/graders/answers-the-question.md @@ -0,0 +1,5 @@ +--- +type: regex +pattern: tuple +flags: i +--- diff --git a/agent-plugins/spring-tools/evals/ignores-unrelated-request/graders/no-project-lookup.md b/agent-plugins/spring-tools/evals/ignores-unrelated-request/graders/no-project-lookup.md new file mode 100644 index 0000000000..b12b709973 --- /dev/null +++ b/agent-plugins/spring-tools/evals/ignores-unrelated-request/graders/no-project-lookup.md @@ -0,0 +1,7 @@ +--- +type: tool_used +tool: mcp__plugin_spring-tools_spring-tools-mcp__getProjectList +min: 0 +max: 0 +arm: both +--- diff --git a/agent-plugins/spring-tools/evals/ignores-unrelated-request/graders/no-skill-invoked.md b/agent-plugins/spring-tools/evals/ignores-unrelated-request/graders/no-skill-invoked.md new file mode 100644 index 0000000000..a01eeac9f7 --- /dev/null +++ b/agent-plugins/spring-tools/evals/ignores-unrelated-request/graders/no-skill-invoked.md @@ -0,0 +1,7 @@ +--- +type: tool_used +tool: Skill +min: 0 +max: 0 +arm: both +--- diff --git a/agent-plugins/spring-tools/evals/ignores-unrelated-request/prompt.md b/agent-plugins/spring-tools/evals/ignores-unrelated-request/prompt.md new file mode 100644 index 0000000000..93655f0fae --- /dev/null +++ b/agent-plugins/spring-tools/evals/ignores-unrelated-request/prompt.md @@ -0,0 +1,8 @@ +--- +max_turns: 5 +allowed_tools: [Read, Glob, Grep, Skill] +tags: [smoke, negative-control] +expected_outcome: Claude answers the question directly without invoking any Spring Tools skill or MCP tool. +--- + +Explain the difference between a Python list and a tuple in two sentences. diff --git a/agent-plugins/spring-tools/evals/mcp-tools-covered/graders/capture-logical-structure-baseline.md b/agent-plugins/spring-tools/evals/mcp-tools-covered/graders/capture-logical-structure-baseline.md new file mode 100644 index 0000000000..d2dcab6358 --- /dev/null +++ b/agent-plugins/spring-tools/evals/mcp-tools-covered/graders/capture-logical-structure-baseline.md @@ -0,0 +1,4 @@ +--- +type: tool_used +tool: mcp__plugin_spring-tools_spring-tools-mcp__captureLogicalStructureBaseline +--- diff --git a/agent-plugins/spring-tools/evals/mcp-tools-covered/graders/clear-logical-structure-baseline.md b/agent-plugins/spring-tools/evals/mcp-tools-covered/graders/clear-logical-structure-baseline.md new file mode 100644 index 0000000000..170dd5ccb6 --- /dev/null +++ b/agent-plugins/spring-tools/evals/mcp-tools-covered/graders/clear-logical-structure-baseline.md @@ -0,0 +1,4 @@ +--- +type: tool_used +tool: mcp__plugin_spring-tools_spring-tools-mcp__clearLogicalStructureBaseline +--- diff --git a/agent-plugins/spring-tools/evals/mcp-tools-covered/graders/file-changed.md b/agent-plugins/spring-tools/evals/mcp-tools-covered/graders/file-changed.md new file mode 100644 index 0000000000..6618d1afa7 --- /dev/null +++ b/agent-plugins/spring-tools/evals/mcp-tools-covered/graders/file-changed.md @@ -0,0 +1,4 @@ +--- +type: tool_used +tool: mcp__plugin_spring-tools_spring-tools-mcp__fileChanged +--- diff --git a/agent-plugins/spring-tools/evals/mcp-tools-covered/graders/file-deleted.md b/agent-plugins/spring-tools/evals/mcp-tools-covered/graders/file-deleted.md new file mode 100644 index 0000000000..29ea0fa0f8 --- /dev/null +++ b/agent-plugins/spring-tools/evals/mcp-tools-covered/graders/file-deleted.md @@ -0,0 +1,4 @@ +--- +type: tool_used +tool: mcp__plugin_spring-tools_spring-tools-mcp__fileDeleted +--- diff --git a/agent-plugins/spring-tools/evals/mcp-tools-covered/graders/find-beans-by-type.md b/agent-plugins/spring-tools/evals/mcp-tools-covered/graders/find-beans-by-type.md new file mode 100644 index 0000000000..0347b762ab --- /dev/null +++ b/agent-plugins/spring-tools/evals/mcp-tools-covered/graders/find-beans-by-type.md @@ -0,0 +1,4 @@ +--- +type: tool_used +tool: mcp__plugin_spring-tools_spring-tools-mcp__findBeansByType +--- diff --git a/agent-plugins/spring-tools/evals/mcp-tools-covered/graders/find-components-by-stereotype.md b/agent-plugins/spring-tools/evals/mcp-tools-covered/graders/find-components-by-stereotype.md new file mode 100644 index 0000000000..a66c7bf3ad --- /dev/null +++ b/agent-plugins/spring-tools/evals/mcp-tools-covered/graders/find-components-by-stereotype.md @@ -0,0 +1,4 @@ +--- +type: tool_used +tool: mcp__plugin_spring-tools_spring-tools-mcp__findComponentsByStereotype +--- diff --git a/agent-plugins/spring-tools/evals/mcp-tools-covered/graders/find-request-mappings-by-method.md b/agent-plugins/spring-tools/evals/mcp-tools-covered/graders/find-request-mappings-by-method.md new file mode 100644 index 0000000000..136a64d818 --- /dev/null +++ b/agent-plugins/spring-tools/evals/mcp-tools-covered/graders/find-request-mappings-by-method.md @@ -0,0 +1,4 @@ +--- +type: tool_used +tool: mcp__plugin_spring-tools_spring-tools-mcp__findRequestMappingsByMethod +--- diff --git a/agent-plugins/spring-tools/evals/mcp-tools-covered/graders/get-bean-details.md b/agent-plugins/spring-tools/evals/mcp-tools-covered/graders/get-bean-details.md new file mode 100644 index 0000000000..f9fcc6c85a --- /dev/null +++ b/agent-plugins/spring-tools/evals/mcp-tools-covered/graders/get-bean-details.md @@ -0,0 +1,4 @@ +--- +type: tool_used +tool: mcp__plugin_spring-tools_spring-tools-mcp__getBeanDetails +--- diff --git a/agent-plugins/spring-tools/evals/mcp-tools-covered/graders/get-generations.md b/agent-plugins/spring-tools/evals/mcp-tools-covered/graders/get-generations.md new file mode 100644 index 0000000000..45872f1f99 --- /dev/null +++ b/agent-plugins/spring-tools/evals/mcp-tools-covered/graders/get-generations.md @@ -0,0 +1,4 @@ +--- +type: tool_used +tool: mcp__plugin_spring-tools_spring-tools-mcp__getGenerations +--- diff --git a/agent-plugins/spring-tools/evals/mcp-tools-covered/graders/get-latest-boot-versions.md b/agent-plugins/spring-tools/evals/mcp-tools-covered/graders/get-latest-boot-versions.md new file mode 100644 index 0000000000..322d78f92a --- /dev/null +++ b/agent-plugins/spring-tools/evals/mcp-tools-covered/graders/get-latest-boot-versions.md @@ -0,0 +1,4 @@ +--- +type: tool_used +tool: mcp__plugin_spring-tools_spring-tools-mcp__getLatestBootVersionsFromMavenRepo +--- diff --git a/agent-plugins/spring-tools/evals/mcp-tools-covered/graders/get-list-components-and-stereotypes.md b/agent-plugins/spring-tools/evals/mcp-tools-covered/graders/get-list-components-and-stereotypes.md new file mode 100644 index 0000000000..3f69a0ed31 --- /dev/null +++ b/agent-plugins/spring-tools/evals/mcp-tools-covered/graders/get-list-components-and-stereotypes.md @@ -0,0 +1,4 @@ +--- +type: tool_used +tool: mcp__plugin_spring-tools_spring-tools-mcp__getListOfComponentsAndTheirStereotypes +--- diff --git a/agent-plugins/spring-tools/evals/mcp-tools-covered/graders/get-logical-structure-baseline-history.md b/agent-plugins/spring-tools/evals/mcp-tools-covered/graders/get-logical-structure-baseline-history.md new file mode 100644 index 0000000000..9ba5d2348b --- /dev/null +++ b/agent-plugins/spring-tools/evals/mcp-tools-covered/graders/get-logical-structure-baseline-history.md @@ -0,0 +1,4 @@ +--- +type: tool_used +tool: mcp__plugin_spring-tools_spring-tools-mcp__getLogicalStructureBaselineHistory +--- diff --git a/agent-plugins/spring-tools/evals/mcp-tools-covered/graders/get-logical-structure-changes.md b/agent-plugins/spring-tools/evals/mcp-tools-covered/graders/get-logical-structure-changes.md new file mode 100644 index 0000000000..966f78af48 --- /dev/null +++ b/agent-plugins/spring-tools/evals/mcp-tools-covered/graders/get-logical-structure-changes.md @@ -0,0 +1,4 @@ +--- +type: tool_used +tool: mcp__plugin_spring-tools_spring-tools-mcp__getLogicalStructureChanges +--- diff --git a/agent-plugins/spring-tools/evals/mcp-tools-covered/graders/get-logical-structure.md b/agent-plugins/spring-tools/evals/mcp-tools-covered/graders/get-logical-structure.md new file mode 100644 index 0000000000..c1c984d795 --- /dev/null +++ b/agent-plugins/spring-tools/evals/mcp-tools-covered/graders/get-logical-structure.md @@ -0,0 +1,4 @@ +--- +type: tool_used +tool: mcp__plugin_spring-tools_spring-tools-mcp__getLogicalStructure +--- diff --git a/agent-plugins/spring-tools/evals/mcp-tools-covered/graders/get-project-list.md b/agent-plugins/spring-tools/evals/mcp-tools-covered/graders/get-project-list.md new file mode 100644 index 0000000000..36a7ae4b7b --- /dev/null +++ b/agent-plugins/spring-tools/evals/mcp-tools-covered/graders/get-project-list.md @@ -0,0 +1,4 @@ +--- +type: tool_used +tool: mcp__plugin_spring-tools_spring-tools-mcp__getProjectList +--- diff --git a/agent-plugins/spring-tools/evals/mcp-tools-covered/graders/get-releases.md b/agent-plugins/spring-tools/evals/mcp-tools-covered/graders/get-releases.md new file mode 100644 index 0000000000..7b076b677b --- /dev/null +++ b/agent-plugins/spring-tools/evals/mcp-tools-covered/graders/get-releases.md @@ -0,0 +1,4 @@ +--- +type: tool_used +tool: mcp__plugin_spring-tools_spring-tools-mcp__getReleases +--- diff --git a/agent-plugins/spring-tools/evals/mcp-tools-covered/graders/get-resolved-project-classpath.md b/agent-plugins/spring-tools/evals/mcp-tools-covered/graders/get-resolved-project-classpath.md new file mode 100644 index 0000000000..d28e8430a7 --- /dev/null +++ b/agent-plugins/spring-tools/evals/mcp-tools-covered/graders/get-resolved-project-classpath.md @@ -0,0 +1,4 @@ +--- +type: tool_used +tool: mcp__plugin_spring-tools_spring-tools-mcp__getResolvedProjectClasspath +--- diff --git a/agent-plugins/spring-tools/evals/mcp-tools-covered/graders/get-spring-boot-version.md b/agent-plugins/spring-tools/evals/mcp-tools-covered/graders/get-spring-boot-version.md new file mode 100644 index 0000000000..e65867a865 --- /dev/null +++ b/agent-plugins/spring-tools/evals/mcp-tools-covered/graders/get-spring-boot-version.md @@ -0,0 +1,4 @@ +--- +type: tool_used +tool: mcp__plugin_spring-tools_spring-tools-mcp__getSpringBootVersion +--- diff --git a/agent-plugins/spring-tools/evals/mcp-tools-covered/graders/get-stereotypes-list.md b/agent-plugins/spring-tools/evals/mcp-tools-covered/graders/get-stereotypes-list.md new file mode 100644 index 0000000000..ba53b0d7cf --- /dev/null +++ b/agent-plugins/spring-tools/evals/mcp-tools-covered/graders/get-stereotypes-list.md @@ -0,0 +1,4 @@ +--- +type: tool_used +tool: mcp__plugin_spring-tools_spring-tools-mcp__getStereotypesList +--- diff --git a/agent-plugins/spring-tools/evals/mcp-tools-covered/graders/get-structure-dependencies.md b/agent-plugins/spring-tools/evals/mcp-tools-covered/graders/get-structure-dependencies.md new file mode 100644 index 0000000000..bd93ef5c1c --- /dev/null +++ b/agent-plugins/spring-tools/evals/mcp-tools-covered/graders/get-structure-dependencies.md @@ -0,0 +1,4 @@ +--- +type: tool_used +tool: mcp__plugin_spring-tools_spring-tools-mcp__getStructureDependencies +--- diff --git a/agent-plugins/spring-tools/evals/mcp-tools-covered/graders/get-upcoming-releases.md b/agent-plugins/spring-tools/evals/mcp-tools-covered/graders/get-upcoming-releases.md new file mode 100644 index 0000000000..06d5446aca --- /dev/null +++ b/agent-plugins/spring-tools/evals/mcp-tools-covered/graders/get-upcoming-releases.md @@ -0,0 +1,4 @@ +--- +type: tool_used +tool: mcp__plugin_spring-tools_spring-tools-mcp__getUpcomingReleases +--- diff --git a/agent-plugins/spring-tools/evals/mcp-tools-covered/prompt.md b/agent-plugins/spring-tools/evals/mcp-tools-covered/prompt.md new file mode 100644 index 0000000000..86ca10aaac --- /dev/null +++ b/agent-plugins/spring-tools/evals/mcp-tools-covered/prompt.md @@ -0,0 +1,14 @@ +--- +max_turns: 40 +allowed_tools: [Read, Glob, Grep, Skill] +tags: [smoke, mcp-coverage] +expected_outcome: The agent calls every listed Spring Tools MCP tool at least once and summarizes the checked areas without editing files. +--- + +This eval runs against an isolated mock Spring Tools server. Call every MCP tool listed below at least once, using `sf7-validation` as the IDE project name and `spring-boot` as the Spring IO project slug where those parameters apply. For required type, bean, HTTP method, stereotype, and file-path arguments, use `org.example.Service`, `service`, `GET`, `Controller`, and `/tmp/spring-tools-eval/Probe.java` as appropriate. Use each tool's schema for any optional arguments. + +Call the project discovery, diagnostics, information, component, endpoint, structure, Spring release, workspace refresh, and file-notification tools, including the tools that capture or clear a logical-structure baseline. The mocked calls are isolated and are safe for this eval. Do not edit project files. After the calls, briefly summarize which categories you checked. + +The required tool calls are: + +`getProjectList`, `getProjectDiagnostics`, `getSpringBootVersion`, `getJavaVersion`, `getResolvedProjectClasspath`, `getBeanDetails`, `getBeanUsageInfo`, `findBeansByType`, `getRequestMappings`, `findRequestMappingsByMethod`, `getStereotypesList`, `getListOfComponentsAndTheirStereotypes`, `findComponentsByStereotype`, `getLogicalStructure`, `getStructureDependencies`, `captureLogicalStructureBaseline`, `clearLogicalStructureBaseline`, `getLogicalStructureChanges`, `getLogicalStructureBaselineHistory`, `getReleases`, `getGenerations`, `getUpcomingReleases`, `getLatestReleaseInformation`, `getLatestBootVersionsFromMavenRepo`, `fileChanged`, `fileDeleted`, and `refreshWorkspace`. diff --git a/agent-plugins/spring-tools/evals/mocks/spring-tools-mcp/_tools.json b/agent-plugins/spring-tools/evals/mocks/spring-tools-mcp/_tools.json new file mode 100644 index 0000000000..d439ec1e0e --- /dev/null +++ b/agent-plugins/spring-tools/evals/mocks/spring-tools-mcp/_tools.json @@ -0,0 +1,500 @@ +{ + "tools": [ + { + "name": "getLogicalStructure", + "description": "Returns the logical structure of the given project as a tree, the same structure that the Spring Tools\nlogical structure view renders in VSCode and Eclipse.\nThe tree groups the components of the project by their stereotypes (and by application modules for Spring Modulith projects),\nso it shows how the application is organized logically instead of by files and folders.\nEach node carries a display label, an icon identifier, a stable node id, the source location of the element it\nrepresents, and its child nodes, which is everything a client needs to render the tree itself.\nOptionally, dependencies of the project (ids from getStructureDependencies) can be included: their components\nthen appear in the tree as if they were the project's own.\nUse getProjectList to obtain valid project names. Use getStereotypesList or getListOfComponentsAndTheirStereotypes\nif you need the flat stereotype information instead of the tree.\n", + "inputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "type": "object", + "properties": { + "projectName": { + "type": "string", + "description": "IDE project name from getProjectList().projectName (case-insensitive match)" + }, + "dependencies": { + "type": "array", + "items": { + "type": "string" + }, + "description": "ids of dependencies to include, from getStructureDependencies().id - none when omitted" + } + }, + "required": [ + "projectName" + ] + } + }, + { + "name": "getStereotypesList", + "description": "This function provides information about all the stereotype definitions that are defined and available in the given project\n", + "inputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "type": "object", + "properties": { + "projectName": { + "type": "string", + "description": "IDE project name from getProjectList().projectName (case-insensitive match)" + } + }, + "required": [ + "projectName" + ] + } + }, + { + "name": "getJavaVersion", + "description": "Returns the Java/JRE version configured for the project's classpath.\n", + "inputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "type": "object", + "properties": { + "projectName": { + "type": "string", + "description": "IDE project name from getProjectList().projectName (case-insensitive match)" + } + }, + "required": [ + "projectName" + ] + } + }, + { + "name": "findBeansByType", + "description": "Find all beans that match a specific type.\nThis is useful for finding all implementations of an interface or all beans of a certain class.\n", + "inputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "type": "object", + "properties": { + "projectName": { + "type": "string", + "description": "IDE project name from getProjectList().projectName (case-insensitive match)" + }, + "typeName": { + "type": "string", + "description": "the fully qualified type name to search for" + } + }, + "required": [ + "projectName", + "typeName" + ] + } + }, + { + "name": "getResolvedProjectClasspath", + "description": "Returns non-system binary classpath entries for the project (resolved JARs) with versions from build tooling.\nEach Library.name is the classpath entry path (often a local .m2 or Gradle cache path), not necessarily a Maven coordinate; use it to see exact resolved artifacts.\n", + "inputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "type": "object", + "properties": { + "projectName": { + "type": "string", + "description": "IDE project name from getProjectList().projectName (case-insensitive match)" + } + }, + "required": [ + "projectName" + ] + } + }, + { + "name": "getBeanUsageInfo", + "description": "Get detailed information about a specific bean, including where it's defined and all places where it's injected.\nThis helps understand bean usage and dependencies across the application.\nReturns information for all beans with the given name (there may be multiple beans with the same name in different contexts).\n", + "inputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "type": "object", + "properties": { + "projectName": { + "type": "string", + "description": "IDE project name from getProjectList().projectName (case-insensitive match)" + }, + "beanName": { + "type": "string", + "description": "the name of the bean to analyze" + } + }, + "required": [ + "projectName", + "beanName" + ] + } + }, + { + "name": "getRequestMappings", + "description": "Get all REST endpoints/request mappings defined in the project.\nThis includes endpoints from @RequestMapping, @GetMapping, @PostMapping, @PutMapping, @DeleteMapping, @PatchMapping annotations,\nas well as HttpExchange interfaces.\n", + "inputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "type": "object", + "properties": { + "projectName": { + "type": "string", + "description": "IDE project name from getProjectList().projectName (case-insensitive match)" + } + }, + "required": [ + "projectName" + ] + } + }, + { + "name": "refreshWorkspace", + "description": "Notifies the MCP Server to refresh the entire workspace (e.g., after a git checkout or pull).", + "inputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "type": "object", + "properties": {}, + "required": [] + } + }, + { + "name": "getGenerations", + "description": "Get support generation windows (OSS and commercial end dates) for a Spring project from the live Spring IO API.\nFor the current GA line's support dates tied to the latest release only, prefer getLatestReleaseInformation instead.\n", + "inputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "type": "object", + "properties": { + "project": { + "type": "string", + "description": "Spring IO API project slug, e.g. \"spring-boot\", \"spring-framework\". Same semantics as getReleases project parameter.\n" + } + }, + "required": [ + "project" + ] + } + }, + { + "name": "getStructureDependencies", + "description": "Lists the dependencies of the given project that can be included in its logical structure (see getLogicalStructure):\nother projects of the workspace it depends on, and its libraries, identified by group and artifact id where known.\nThe components of an included library show up in the logical structure if they sit within the project's main\napplication package, as for an included workspace project.\nUse getProjectList to obtain valid project names.\n", + "inputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "type": "object", + "properties": { + "projectName": { + "type": "string", + "description": "IDE project name from getProjectList().projectName (case-insensitive match)" + } + }, + "required": [ + "projectName" + ] + } + }, + { + "name": "findComponentsByStereotype", + "description": "Find all Spring components by stereotype (Controller, Service, Repository, Component, Entity, etc.).\nReturns all components that have the specified stereotype.\n", + "inputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "type": "object", + "properties": { + "projectName": { + "type": "string", + "description": "IDE project name from getProjectList().projectName (case-insensitive match)" + }, + "stereotypeName": { + "type": "string", + "description": "the stereotype name to filter by (e.g., 'Controller', 'Service', 'Repository', 'Entity')" + } + }, + "required": [ + "projectName", + "stereotypeName" + ] + } + }, + { + "name": "getListOfComponentsAndTheirStereotypes", + "description": "This function returns a list of classes or components from the given project and lists the stereotypes that each class or component has.\nThis way you can identify, for example, all components from a specific stereotype (e.g. all data repositories, all services, all entities, and so on)\n", + "inputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "type": "object", + "properties": { + "projectName": { + "type": "string", + "description": "IDE project name from getProjectList().projectName (case-insensitive match)" + } + }, + "required": [ + "projectName" + ] + } + }, + { + "name": "fileChanged", + "description": "Notifies the MCP Server that a file has been created or modified on disk so it can update its internal index and diagnostics.", + "inputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "type": "object", + "properties": { + "filePath": { + "type": "string", + "description": "Absolute path to the file" + } + }, + "required": [ + "filePath" + ] + } + }, + { + "name": "getBeanDetails", + "description": "Returns indexed Spring beans for one workspace Java project, including injection points where the index exposes them.\nUse getProjectList to obtain valid project names. For a single named bean or type-scoped queries, prefer getBeanUsageInfo or findBeansByType.\n", + "inputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "type": "object", + "properties": { + "projectName": { + "type": "string", + "description": "IDE project name from getProjectList().projectName (case-insensitive match)" + } + }, + "required": [ + "projectName" + ] + } + }, + { + "name": "getUpcomingReleases", + "description": "Get upcoming Spring ecosystem releases from the Spring release calendar API (date range from today for calendar.window.days).\nNeither getReleases nor getLatestReleaseInformation provide this calendar view; use this for forward-looking dates.\nCalendar base URL is workspace setting boot-java.io.calendar-api.\n", + "inputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "type": "object", + "properties": {}, + "required": [] + } + }, + { + "name": "findRequestMappingsByMethod", + "description": "Find request mappings by HTTP method (GET, POST, PUT, DELETE, PATCH, etc.).\nReturns all endpoints that handle the specified HTTP method.\n", + "inputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "type": "object", + "properties": { + "projectName": { + "type": "string", + "description": "IDE project name from getProjectList().projectName (case-insensitive match)" + }, + "httpMethod": { + "type": "string", + "description": "the HTTP method to filter by (e.g., 'GET', 'POST', 'PUT', 'DELETE', 'PATCH')" + } + }, + "required": [ + "projectName", + "httpMethod" + ] + } + }, + { + "name": "getProjectDiagnostics", + "description": "Returns all current Spring Tools diagnostics or problems or validations (errors, warnings, infos, hints) for a specific project.\nDiagnostics are produced by the Spring Tools language server during indexing and validation and cover Java sources,\nSpring Boot configuration files (application*.properties, application*.yml, META-INF/spring.factories) and the Spring Boot version in the build file.\nEach entry identifies the source file, the exact source location, the severity, and the message.\nOnly diagnostics known to Spring Tools are included; general Java compiler errors are not.\nUse getProjectList to obtain valid project names.\n", + "inputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "type": "object", + "properties": { + "projectName": { + "type": "string", + "description": "IDE project name from getProjectList().projectName (case-insensitive match)" + } + }, + "required": [ + "projectName" + ] + } + }, + { + "name": "getLatestBootVersionsFromMavenRepo", + "description": "Returns the latest available Spring Boot versions from the Maven repositories configured in the project's build file (pom.xml).\nUnlike getLatestReleaseInformation (which queries the Spring IO API), this reflects what is actually resolvable from the project's own configured Maven repositories, including private or enterprise repos.\nlatestPatchVersion, latestMinorVersion, and latestMajorVersion are the newest available versions newer than currentVersion for each respective level (null means already up-to-date at that level).\nOnly works for Maven (pom.xml) projects; returns null for Gradle projects or when Maven metadata cannot be fetched.\n", + "inputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "type": "object", + "properties": { + "projectName": { + "type": "string", + "description": "IDE project name from getProjectList().projectName (case-insensitive match)" + } + }, + "required": [ + "projectName" + ] + } + }, + { + "name": "getLogicalStructureBaselineHistory", + "description": "Lists the retained logical structure baseline snapshots for the given project, most recent first, each\nwith the git commit it was captured at (sha and message) and when it was captured. The most recent entry\nis always the one getLogicalStructureChanges compares against; how many are retained is controlled by\nthe boot-java.structure.baseline-history-size setting (10 by default). Useful to see recent history or\nto recognize which commit a highlighted diff is being compared against.\nUse getProjectList to obtain valid project names.\n", + "inputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "type": "object", + "properties": { + "projectName": { + "type": "string", + "description": "IDE project name from getProjectList().projectName (case-insensitive match)" + } + }, + "required": [ + "projectName" + ] + } + }, + { + "name": "fileDeleted", + "description": "Notifies the MCP Server that a file has been deleted on disk so it can update its internal index and diagnostics.", + "inputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "type": "object", + "properties": { + "filePath": { + "type": "string", + "description": "Absolute path to the file" + } + }, + "required": [ + "filePath" + ] + } + }, + { + "name": "captureLogicalStructureBaseline", + "description": "Captures the current logical structure of the given project as a baseline snapshot, so that a later\ncall to getLogicalStructureChanges can show what changed in the logical structure of the project since\nthis point in time (e.g. after a refactoring or a series of edits).\nCapturing a new baseline replaces any previously captured baseline for the same project. Usually not\nneeded for git-backed projects: those get a baseline automatically on every commit, so that changes\nare always shown against the last commit - call this only to pin a baseline mid-branch, on top of\nuncommitted changes. Note such a manual baseline is replaced automatically by the next commit's.\nUse getProjectList to obtain valid project names.\n", + "inputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "type": "object", + "properties": { + "projectName": { + "type": "string", + "description": "IDE project name from getProjectList().projectName (case-insensitive match)" + } + }, + "required": [ + "projectName" + ] + } + }, + { + "name": "getProjectList", + "description": "Lists all Java projects in the workspace with Boot flag, JRE level and root directory.\nUse each Project.projectName when calling other tools; those tools match this name case-insensitively.\nProject.location is the absolute path of the project root (the directory holding pom.xml or build.gradle),\nso a source file belongs to the project whose location is the longest prefix of the file's path.\n", + "inputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "type": "object", + "properties": {}, + "required": [] + } + }, + { + "name": "getLatestReleaseInformation", + "description": "Returns the latest general-availability (current) release for a Spring portfolio project plus OSS and commercial support end dates for that generation.\nData is resolved from the language server's Spring projects metadata (same source used for validations), not a live per-call HAL dump.\nUse getReleases when you need every release record; use getGenerations when you need all generation windows from the live Spring IO API; use getUpcomingReleases for scheduled upcoming releases from the calendar API.\n", + "inputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "type": "object", + "properties": { + "projectName": { + "type": "string", + "description": "Spring project technical id, e.g. \"spring-boot\", \"spring-framework\", \"spring-data-jpa\" (not the IDE workspace project name).\n" + } + }, + "required": [ + "projectName" + ] + } + }, + { + "name": "getSpringBootVersion", + "description": "Returns the Spring Boot version for a workspace Java project (from the resolved classpath / BOM).\n", + "inputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "type": "object", + "properties": { + "projectName": { + "type": "string", + "description": "IDE project name from getProjectList().projectName (case-insensitive match)" + } + }, + "required": [ + "projectName" + ] + } + }, + { + "name": "clearLogicalStructureBaseline", + "description": "Removes the captured logical structure baseline for the given project, if any, so getLogicalStructureChanges\nreports \"no baseline\" again until a new one is captured. Purely a manual undo: for a git-backed project\nwith automatic baseline capture enabled, a fresh baseline is captured again as soon as no source changes\nare pending, and otherwise at its next commit.\nUse getProjectList to obtain valid project names.\n", + "inputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "type": "object", + "properties": { + "projectName": { + "type": "string", + "description": "IDE project name from getProjectList().projectName (case-insensitive match)" + } + }, + "required": [ + "projectName" + ] + } + }, + { + "name": "getReleases", + "description": "Get the full list of releases for a Spring project from the live Spring IO API (HAL).\nFor only the current GA release plus OSS/commercial support end dates for that line, prefer getLatestReleaseInformation instead.\n", + "inputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "type": "object", + "properties": { + "project": { + "type": "string", + "description": "Spring IO API project slug (path segment under /projects), e.g. \"spring-boot\", \"spring-framework\", \"spring-data-jpa\".\nThis is not the IDE workspace project name from getProjectList; it is the Spring portfolio project id from Spring IO.\nBase URL is configured as workspace setting boot-java.io.api (projects list URL; API base is derived from it).\n" + } + }, + "required": [ + "project" + ] + } + }, + { + "name": "getLogicalStructureChanges", + "description": "Shows what changed in the logical structure of the given project since the baseline captured via\ncaptureLogicalStructureBaseline (or automatically from the project's git history, if it's git-backed -\nsee the note on captureLogicalStructureBaseline), rendered as an ascii-art tree with +/-/~ markers for\nadded, removed and modified nodes.\nReturns a plain message instead of a tree when no baseline exists yet for the project, or when nothing\nchanged since it.\nUse getProjectList to obtain valid project names.\n", + "inputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "type": "object", + "properties": { + "projectName": { + "type": "string", + "description": "IDE project name from getProjectList().projectName (case-insensitive match)" + }, + "includeUnchanged": { + "type": "boolean", + "description": "whether to also show the unchanged parts of the tree instead of collapsing them, defaults to false" + } + }, + "required": [ + "projectName" + ] + } + } + ] +} diff --git a/agent-plugins/spring-tools/evals/mocks/spring-tools-mcp/captureLogicalStructureBaseline.md b/agent-plugins/spring-tools/evals/mocks/spring-tools-mcp/captureLogicalStructureBaseline.md new file mode 100644 index 0000000000..2356f25d03 --- /dev/null +++ b/agent-plugins/spring-tools/evals/mocks/spring-tools-mcp/captureLogicalStructureBaseline.md @@ -0,0 +1,5 @@ +--- +expect: + projectName: sf7-validation +--- +"captured logical structure baseline for project 'sf7-validation' with 6 element(s) at 2026-10-09T17:06:31.957977932Z" diff --git a/agent-plugins/spring-tools/evals/mocks/spring-tools-mcp/clearLogicalStructureBaseline.md b/agent-plugins/spring-tools/evals/mocks/spring-tools-mcp/clearLogicalStructureBaseline.md new file mode 100644 index 0000000000..12d2b6387b --- /dev/null +++ b/agent-plugins/spring-tools/evals/mocks/spring-tools-mcp/clearLogicalStructureBaseline.md @@ -0,0 +1,5 @@ +--- +expect: + projectName: sf7-validation +--- +"cleared the logical structure baseline for project 'sf7-validation'" diff --git a/agent-plugins/spring-tools/evals/mocks/spring-tools-mcp/fileChanged.md b/agent-plugins/spring-tools/evals/mocks/spring-tools-mcp/fileChanged.md new file mode 100644 index 0000000000..4c8a430739 --- /dev/null +++ b/agent-plugins/spring-tools/evals/mocks/spring-tools-mcp/fileChanged.md @@ -0,0 +1,5 @@ +--- +expect: + filePath: string +--- +"Done" diff --git a/agent-plugins/spring-tools/evals/mocks/spring-tools-mcp/fileDeleted.md b/agent-plugins/spring-tools/evals/mocks/spring-tools-mcp/fileDeleted.md new file mode 100644 index 0000000000..4c8a430739 --- /dev/null +++ b/agent-plugins/spring-tools/evals/mocks/spring-tools-mcp/fileDeleted.md @@ -0,0 +1,5 @@ +--- +expect: + filePath: string +--- +"Done" diff --git a/agent-plugins/spring-tools/evals/mocks/spring-tools-mcp/findBeansByType.md b/agent-plugins/spring-tools/evals/mocks/spring-tools-mcp/findBeansByType.md new file mode 100644 index 0000000000..4f9b78e0e9 --- /dev/null +++ b/agent-plugins/spring-tools/evals/mocks/spring-tools-mcp/findBeansByType.md @@ -0,0 +1,23 @@ +--- +expect: + projectName: sf7-validation + typeName: string +--- +[ + { + "name": "testController", + "type": "com.example.demo.apiversioning.TestController", + "annotations": [ + "org.springframework.web.bind.annotation.RestController", + "org.springframework.stereotype.Controller", + "org.springframework.web.bind.annotation.ResponseBody", + "org.springframework.stereotype.Component", + "org.springframework.stereotype.Indexed" + ], + "sourceFile": "file:///workspace/sf7-validation/src/main/java/com/example/demo/apiversioning/TestController.java", + "startLine": 7, + "startColumn": 13, + "endLine": 7, + "endColumn": 27 + } +] diff --git a/agent-plugins/spring-tools/evals/mocks/spring-tools-mcp/findComponentsByStereotype.md b/agent-plugins/spring-tools/evals/mocks/spring-tools-mcp/findComponentsByStereotype.md new file mode 100644 index 0000000000..ec059ba4b4 --- /dev/null +++ b/agent-plugins/spring-tools/evals/mocks/spring-tools-mcp/findComponentsByStereotype.md @@ -0,0 +1,13 @@ +--- +expect: + projectName: sf7-validation + stereotypeName: string +--- +[ + { + "name": "com.example.demo.apiversioning.TestController", + "stereotypes": [ + "Controller" + ] + } +] diff --git a/agent-plugins/spring-tools/evals/mocks/spring-tools-mcp/findRequestMappingsByMethod.md b/agent-plugins/spring-tools/evals/mocks/spring-tools-mcp/findRequestMappingsByMethod.md new file mode 100644 index 0000000000..5fd88124e1 --- /dev/null +++ b/agent-plugins/spring-tools/evals/mocks/spring-tools-mcp/findRequestMappingsByMethod.md @@ -0,0 +1,24 @@ +--- +expect: + projectName: sf7-validation + httpMethod: string +--- +[ + { + "path": "/greeting", + "httpMethods": [ + "GET" + ], + "contentTypes": [], + "acceptTypes": [], + "version": "1", + "controllerName": "testController", + "controllerType": "com.example.demo.apiversioning.TestController", + "methodSignature": "com.example.demo.apiversioning.TestController.sayHello() : java.lang.String", + "sourceFile": "file:///workspace/sf7-validation/src/main/java/com/example/demo/apiversioning/TestController.java", + "startLine": 7, + "startColumn": 13, + "endLine": 7, + "endColumn": 27 + } +] diff --git a/agent-plugins/spring-tools/evals/mocks/spring-tools-mcp/getBeanDetails.md b/agent-plugins/spring-tools/evals/mocks/spring-tools-mcp/getBeanDetails.md new file mode 100644 index 0000000000..37adba4ac5 --- /dev/null +++ b/agent-plugins/spring-tools/evals/mocks/spring-tools-mcp/getBeanDetails.md @@ -0,0 +1,515 @@ +--- +expect: + projectName: sf7-validation +--- +[ + { + "name": "testController", + "type": "com.example.demo.apiversioning.TestController", + "location": { + "range": { + "end": { + "character": 27, + "line": 7 + }, + "start": { + "character": 13, + "line": 7 + } + }, + "uri": "file:///workspace/sf7-validation/src/main/java/com/example/demo/apiversioning/TestController.java" + }, + "injectionPoints": [], + "supertypes": [ + "java.lang.Object" + ], + "annotations": [ + { + "annotationName": "RestController", + "annotationType": "org.springframework.web.bind.annotation.RestController", + "attributes": {}, + "location": { + "range": { + "end": { + "character": 15, + "line": 6 + }, + "start": { + "character": 0, + "line": 6 + } + }, + "uri": "file:///workspace/sf7-validation/src/main/java/com/example/demo/apiversioning/TestController.java" + }, + "metaAnnotation": false + }, + { + "annotationName": "Controller", + "annotationType": "org.springframework.stereotype.Controller", + "attributes": {}, + "location": null, + "metaAnnotation": true + }, + { + "annotationName": "ResponseBody", + "annotationType": "org.springframework.web.bind.annotation.ResponseBody", + "attributes": {}, + "location": null, + "metaAnnotation": true + }, + { + "annotationName": "Component", + "annotationType": "org.springframework.stereotype.Component", + "attributes": {}, + "location": null, + "metaAnnotation": true + }, + { + "annotationName": "Indexed", + "annotationType": "org.springframework.stereotype.Indexed", + "attributes": {}, + "location": null, + "metaAnnotation": true + } + ], + "symbolLabel": "@+ 'testController' (@RestController <: @Controller, @Component) TestController", + "children": [ + { + "path": "/greeting", + "httpMethods": [ + "GET" + ], + "contentTypes": null, + "acceptTypes": null, + "version": "1", + "methodSignature": "com.example.demo.apiversioning.TestController.sayHello() : java.lang.String", + "contentHash": "42a21e32ee2e", + "children": [], + "documentSymbol": { + "children": null, + "deprecated": null, + "detail": null, + "kind": "Method", + "name": "@/greeting -- GET - Version: 1", + "range": { + "end": { + "character": 47, + "line": 9 + }, + "start": { + "character": 1, + "line": 9 + } + }, + "selectionRange": { + "end": { + "character": 47, + "line": 9 + }, + "start": { + "character": 1, + "line": 9 + } + }, + "tags": null + } + } + ], + "configuration": false, + "contentHash": null, + "documentSymbol": { + "children": null, + "deprecated": null, + "detail": null, + "kind": "Class", + "name": "@+ 'testController' (@RestController <: @Controller, @Component) TestController", + "range": { + "end": { + "character": 27, + "line": 7 + }, + "start": { + "character": 13, + "line": 7 + } + }, + "selectionRange": { + "end": { + "character": 27, + "line": 7 + }, + "start": { + "character": 13, + "line": 7 + } + }, + "tags": null + } + }, + { + "name": "sf7ValidationApplication", + "type": "com.example.demo.Sf7ValidationApplication", + "location": { + "range": { + "end": { + "character": 37, + "line": 6 + }, + "start": { + "character": 13, + "line": 6 + } + }, + "uri": "file:///workspace/sf7-validation/src/main/java/com/example/demo/Sf7ValidationApplication.java" + }, + "injectionPoints": [], + "supertypes": [ + "java.lang.Object" + ], + "annotations": [ + { + "annotationName": "SpringBootApplication", + "annotationType": "org.springframework.boot.autoconfigure.SpringBootApplication", + "attributes": {}, + "location": { + "range": { + "end": { + "character": 22, + "line": 5 + }, + "start": { + "character": 0, + "line": 5 + } + }, + "uri": "file:///workspace/sf7-validation/src/main/java/com/example/demo/Sf7ValidationApplication.java" + }, + "metaAnnotation": false + }, + { + "annotationName": "SpringBootConfiguration", + "annotationType": "org.springframework.boot.SpringBootConfiguration", + "attributes": {}, + "location": null, + "metaAnnotation": true + }, + { + "annotationName": "EnableAutoConfiguration", + "annotationType": "org.springframework.boot.autoconfigure.EnableAutoConfiguration", + "attributes": {}, + "location": null, + "metaAnnotation": true + }, + { + "annotationName": "ComponentScan", + "annotationType": "org.springframework.context.annotation.ComponentScan", + "attributes": { + "excludeFilters": [ + { + "name": "@Filter(type = public static final [unresolved] org.springframework.context.annotation.FilterType CUSTOM, classes = {TypeExcludeFilter.class})", + "location": null + }, + { + "name": "@Filter(type = public static final [unresolved] org.springframework.context.annotation.FilterType CUSTOM, classes = {AutoConfigurationExcludeFilter.class})", + "location": null + } + ] + }, + "location": null, + "metaAnnotation": true + }, + { + "annotationName": "Configuration", + "annotationType": "org.springframework.context.annotation.Configuration", + "attributes": {}, + "location": null, + "metaAnnotation": true + }, + { + "annotationName": "Indexed", + "annotationType": "org.springframework.stereotype.Indexed", + "attributes": {}, + "location": null, + "metaAnnotation": true + }, + { + "annotationName": "AutoConfigurationPackage", + "annotationType": "org.springframework.boot.autoconfigure.AutoConfigurationPackage", + "attributes": {}, + "location": null, + "metaAnnotation": true + }, + { + "annotationName": "Import", + "annotationType": "org.springframework.context.annotation.Import", + "attributes": { + "value": [ + { + "name": "org.springframework.boot.autoconfigure.AutoConfigurationImportSelector", + "location": null + } + ] + }, + "location": null, + "metaAnnotation": true + }, + { + "annotationName": "Component", + "annotationType": "org.springframework.stereotype.Component", + "attributes": {}, + "location": null, + "metaAnnotation": true + } + ], + "symbolLabel": "@+ 'sf7ValidationApplication' (@SpringBootApplication <: @SpringBootConfiguration, @Configuration, @Component) Sf7ValidationApplication", + "children": [], + "configuration": true, + "contentHash": null, + "documentSymbol": { + "children": null, + "deprecated": null, + "detail": null, + "kind": "Class", + "name": "@+ 'sf7ValidationApplication' (@SpringBootApplication <: @SpringBootConfiguration, @Configuration, @Component) Sf7ValidationApplication", + "range": { + "end": { + "character": 37, + "line": 6 + }, + "start": { + "character": 13, + "line": 6 + } + }, + "selectionRange": { + "end": { + "character": 37, + "line": 6 + }, + "start": { + "character": 13, + "line": 6 + } + }, + "tags": null + } + }, + { + "name": "webConfig", + "type": "com.example.demo.apiversioning.WebConfig", + "location": { + "range": { + "end": { + "character": 22, + "line": 7 + }, + "start": { + "character": 13, + "line": 7 + } + }, + "uri": "file:///workspace/sf7-validation/src/main/java/com/example/demo/apiversioning/WebConfig.java" + }, + "injectionPoints": [], + "supertypes": [ + "org.springframework.web.servlet.config.annotation.WebMvcConfigurer", + "java.lang.Object" + ], + "annotations": [ + { + "annotationName": "Configuration", + "annotationType": "org.springframework.context.annotation.Configuration", + "attributes": {}, + "location": { + "range": { + "end": { + "character": 14, + "line": 6 + }, + "start": { + "character": 0, + "line": 6 + } + }, + "uri": "file:///workspace/sf7-validation/src/main/java/com/example/demo/apiversioning/WebConfig.java" + }, + "metaAnnotation": false + }, + { + "annotationName": "Component", + "annotationType": "org.springframework.stereotype.Component", + "attributes": {}, + "location": null, + "metaAnnotation": true + }, + { + "annotationName": "Indexed", + "annotationType": "org.springframework.stereotype.Indexed", + "attributes": {}, + "location": null, + "metaAnnotation": true + } + ], + "symbolLabel": "@+ 'webConfig' (@Configuration <: @Component) WebConfig", + "children": [ + { + "configType": "WEB_CONFIG", + "pathPrefix": null, + "pathPrefixPredicate": null, + "versionSupportStrategies": [ + "Request Header: X-API-Version" + ], + "supportedVersions": [], + "versionParser": "org.springframework.web.accept.SemanticApiVersionParser", + "location": { + "range": { + "end": { + "character": 22, + "line": 7 + }, + "start": { + "character": 13, + "line": 7 + } + }, + "uri": "file:///workspace/sf7-validation/src/main/java/com/example/demo/apiversioning/WebConfig.java" + }, + "children": [], + "empty": false, + "versionSupportStrategiesWithRanges": [ + { + "versioningStrategy": "Request Header: X-API-Version", + "range": { + "end": { + "character": 46, + "line": 11 + }, + "start": { + "character": 2, + "line": 11 + } + } + } + ] + } + ], + "configuration": true, + "contentHash": null, + "documentSymbol": { + "children": null, + "deprecated": null, + "detail": null, + "kind": "Class", + "name": "@+ 'webConfig' (@Configuration <: @Component) WebConfig", + "range": { + "end": { + "character": 22, + "line": 7 + }, + "start": { + "character": 13, + "line": 7 + } + }, + "selectionRange": { + "end": { + "character": 22, + "line": 7 + }, + "start": { + "character": 13, + "line": 7 + } + }, + "tags": null + } + }, + { + "name": "config", + "type": "com.example.demo.Config", + "location": { + "range": { + "end": { + "character": 19, + "line": 5 + }, + "start": { + "character": 13, + "line": 5 + } + }, + "uri": "file:///workspace/sf7-validation/src/main/java/com/example/demo/Config.java" + }, + "injectionPoints": [], + "supertypes": [ + "java.lang.Object" + ], + "annotations": [ + { + "annotationName": "Configuration", + "annotationType": "org.springframework.context.annotation.Configuration", + "attributes": {}, + "location": { + "range": { + "end": { + "character": 14, + "line": 4 + }, + "start": { + "character": 0, + "line": 4 + } + }, + "uri": "file:///workspace/sf7-validation/src/main/java/com/example/demo/Config.java" + }, + "metaAnnotation": false + }, + { + "annotationName": "Component", + "annotationType": "org.springframework.stereotype.Component", + "attributes": {}, + "location": null, + "metaAnnotation": true + }, + { + "annotationName": "Indexed", + "annotationType": "org.springframework.stereotype.Indexed", + "attributes": {}, + "location": null, + "metaAnnotation": true + } + ], + "symbolLabel": "@+ 'config' (@Configuration <: @Component) Config", + "children": [], + "configuration": true, + "contentHash": null, + "documentSymbol": { + "children": null, + "deprecated": null, + "detail": null, + "kind": "Class", + "name": "@+ 'config' (@Configuration <: @Component) Config", + "range": { + "end": { + "character": 19, + "line": 5 + }, + "start": { + "character": 13, + "line": 5 + } + }, + "selectionRange": { + "end": { + "character": 19, + "line": 5 + }, + "start": { + "character": 13, + "line": 5 + } + }, + "tags": null + } + } +] diff --git a/agent-plugins/spring-tools/evals/mocks/spring-tools-mcp/getBeanUsageInfo.md b/agent-plugins/spring-tools/evals/mocks/spring-tools-mcp/getBeanUsageInfo.md new file mode 100644 index 0000000000..1f11ed17ee --- /dev/null +++ b/agent-plugins/spring-tools/evals/mocks/spring-tools-mcp/getBeanUsageInfo.md @@ -0,0 +1,28 @@ +--- +expect: + projectName: sf7-validation + beanName: string +--- +[ + { + "beanName": "testController", + "beanType": "com.example.demo.apiversioning.TestController", + "definition": { + "name": "testController", + "type": "com.example.demo.apiversioning.TestController", + "annotations": [ + "org.springframework.web.bind.annotation.RestController", + "org.springframework.stereotype.Controller", + "org.springframework.web.bind.annotation.ResponseBody", + "org.springframework.stereotype.Component", + "org.springframework.stereotype.Indexed" + ], + "sourceFile": "file:///workspace/sf7-validation/src/main/java/com/example/demo/apiversioning/TestController.java", + "startLine": 7, + "startColumn": 13, + "endLine": 7, + "endColumn": 27 + }, + "injectionPoints": [] + } +] diff --git a/agent-plugins/spring-tools/evals/mocks/spring-tools-mcp/getGenerations.md b/agent-plugins/spring-tools/evals/mocks/spring-tools-mcp/getGenerations.md new file mode 100644 index 0000000000..cbfa8d3549 --- /dev/null +++ b/agent-plugins/spring-tools/evals/mocks/spring-tools-mcp/getGenerations.md @@ -0,0 +1,114 @@ +--- +expect: + project: string +--- +[ + { + "name": "1.5.x", + "initialReleaseDate": "2017-01-31", + "ossSupportEndDate": "2019-08-31", + "commercialSupportEndDate": "2020-11-30" + }, + { + "name": "2.0.x", + "initialReleaseDate": "2018-03-31", + "ossSupportEndDate": "2019-03-31", + "commercialSupportEndDate": "2020-06-30" + }, + { + "name": "2.1.x", + "initialReleaseDate": "2018-10-31", + "ossSupportEndDate": "2019-10-31", + "commercialSupportEndDate": "2021-01-31" + }, + { + "name": "2.2.x", + "initialReleaseDate": "2019-10-31", + "ossSupportEndDate": "2020-10-31", + "commercialSupportEndDate": "2022-01-31" + }, + { + "name": "2.3.x", + "initialReleaseDate": "2020-05-31", + "ossSupportEndDate": "2021-05-31", + "commercialSupportEndDate": "2022-08-31" + }, + { + "name": "2.4.x", + "initialReleaseDate": "2020-11-30", + "ossSupportEndDate": "2021-11-30", + "commercialSupportEndDate": "2023-02-28" + }, + { + "name": "2.5.x", + "initialReleaseDate": "2021-05-31", + "ossSupportEndDate": "2022-05-31", + "commercialSupportEndDate": "2023-08-31" + }, + { + "name": "2.6.x", + "initialReleaseDate": "2021-11-30", + "ossSupportEndDate": "2022-11-30", + "commercialSupportEndDate": "2024-02-29" + }, + { + "name": "2.7.x", + "initialReleaseDate": "2022-05-31", + "ossSupportEndDate": "2023-06-30", + "commercialSupportEndDate": "2029-06-30" + }, + { + "name": "3.0.x", + "initialReleaseDate": "2022-11-30", + "ossSupportEndDate": "2023-12-31", + "commercialSupportEndDate": "2024-12-31" + }, + { + "name": "3.1.x", + "initialReleaseDate": "2023-05-31", + "ossSupportEndDate": "2024-06-30", + "commercialSupportEndDate": "2025-06-30" + }, + { + "name": "3.2.x", + "initialReleaseDate": "2023-11-30", + "ossSupportEndDate": "2024-12-31", + "commercialSupportEndDate": "2025-12-31" + }, + { + "name": "3.3.x", + "initialReleaseDate": "2024-05-31", + "ossSupportEndDate": "2025-06-30", + "commercialSupportEndDate": "2026-06-30" + }, + { + "name": "3.4.x", + "initialReleaseDate": "2024-11-30", + "ossSupportEndDate": "2025-12-31", + "commercialSupportEndDate": "2026-12-31" + }, + { + "name": "3.5.x", + "initialReleaseDate": "2025-05-31", + "ossSupportEndDate": "2026-06-30", + "commercialSupportEndDate": "2032-06-30" + }, + { + "name": "4.0.x", + "initialReleaseDate": "2025-11-30", + "ossSupportEndDate": "2026-12-31", + "commercialSupportEndDate": "2027-12-31" + }, + { + "name": "4.1.x", + "initialReleaseDate": "2026-06-30", + "ossSupportEndDate": "2027-07-31", + "commercialSupportEndDate": "2028-07-31" + }, + { + "name": "4.2.x", + "initialReleaseDate": "2026-11-30", + "ossSupportEndDate": "2027-12-31", + "commercialSupportEndDate": "2028-12-31" + } +] diff --git a/agent-plugins/spring-tools/evals/mocks/spring-tools-mcp/getJavaVersion.md b/agent-plugins/spring-tools/evals/mocks/spring-tools-mcp/getJavaVersion.md new file mode 100644 index 0000000000..9ae121a641 --- /dev/null +++ b/agent-plugins/spring-tools/evals/mocks/spring-tools-mcp/getJavaVersion.md @@ -0,0 +1,5 @@ +--- +expect: + projectName: sf7-validation +--- +"25.0.3+9-LTS" diff --git a/agent-plugins/spring-tools/evals/mocks/spring-tools-mcp/getLatestBootVersionsFromMavenRepo.md b/agent-plugins/spring-tools/evals/mocks/spring-tools-mcp/getLatestBootVersionsFromMavenRepo.md new file mode 100644 index 0000000000..a10deed2cc --- /dev/null +++ b/agent-plugins/spring-tools/evals/mocks/spring-tools-mcp/getLatestBootVersionsFromMavenRepo.md @@ -0,0 +1,10 @@ +--- +expect: + projectName: sf7-validation +--- +{ + "currentVersion": "4.0.0", + "latestPatchVersion": "4.0.8", + "latestMinorVersion": "4.1.1", + "latestMajorVersion": null +} diff --git a/agent-plugins/spring-tools/evals/mocks/spring-tools-mcp/getLatestReleaseInformation.md b/agent-plugins/spring-tools/evals/mocks/spring-tools-mcp/getLatestReleaseInformation.md new file mode 100644 index 0000000000..5c81bfea17 --- /dev/null +++ b/agent-plugins/spring-tools/evals/mocks/spring-tools-mcp/getLatestReleaseInformation.md @@ -0,0 +1,10 @@ +--- +expect: + projectName: [spring-boot, spring-framework, spring-security, spring-data-jpa, spring-cloud] +--- +{ + "projectName": "spring-boot", + "version": "4.1.1", + "endOfOssSupport": "2027-07-31", + "endOfCommercialSupport": "2028-07-31" +} diff --git a/agent-plugins/spring-tools/evals/mocks/spring-tools-mcp/getListOfComponentsAndTheirStereotypes.md b/agent-plugins/spring-tools/evals/mocks/spring-tools-mcp/getListOfComponentsAndTheirStereotypes.md new file mode 100644 index 0000000000..c80fd16ade --- /dev/null +++ b/agent-plugins/spring-tools/evals/mocks/spring-tools-mcp/getListOfComponentsAndTheirStereotypes.md @@ -0,0 +1,30 @@ +--- +expect: + projectName: sf7-validation +--- +[ + { + "name": "com.example.demo.apiversioning.TestController", + "stereotypes": [ + "Controller" + ] + }, + { + "name": "com.example.demo.Sf7ValidationApplication", + "stereotypes": [ + "Configuration class" + ] + }, + { + "name": "com.example.demo.apiversioning.WebConfig", + "stereotypes": [ + "Configuration class" + ] + }, + { + "name": "com.example.demo.Config", + "stereotypes": [ + "Configuration class" + ] + } +] diff --git a/agent-plugins/spring-tools/evals/mocks/spring-tools-mcp/getLogicalStructure.md b/agent-plugins/spring-tools/evals/mocks/spring-tools-mcp/getLogicalStructure.md new file mode 100644 index 0000000000..456d33c65a --- /dev/null +++ b/agent-plugins/spring-tools/evals/mocks/spring-tools-mcp/getLogicalStructure.md @@ -0,0 +1,183 @@ +--- +expect: + projectName: sf7-validation +--- +{ + "nodeId": "sf7-validation", + "text": "sf7-validation", + "icon": "project", + "kind": "application", + "hover": null, + "contentHash": null, + "location": null, + "reference": null, + "javaElement": null, + "children": [ + { + "nodeId": "sf7-validation/Request Mappings (Spring Web)||jar:nested:/plugin/language-server/2.5.0/spring-boot-language-server-standalone-exec.jar/!BOOT-INF/lib/spring-boot-language-server-2.5.0-SNAPSHOT.jar!/stereotype-defaults/spring-jmolecules-stereotypes.json", + "text": "Request Mappings (Spring Web)", + "icon": "globe", + "kind": "stereotype", + "hover": "defined in: [jar:nested:/plugin/language-server/2.5.0/spring-boot-language-server-standalone-exec.jar/!BOOT-INF/lib/spring-boot-language-server-2.5.0-SNAPSHOT.jar!/stereotype-defaults/spring-jmolecules-stereotypes.json]", + "contentHash": null, + "location": null, + "reference": { + "uri": "jar:nested:/plugin/language-server/2.5.0/spring-boot-language-server-standalone-exec.jar/!BOOT-INF/lib/spring-boot-language-server-2.5.0-SNAPSHOT.jar!/stereotype-defaults/spring-jmolecules-stereotypes.json", + "startLine": 76, + "startColumn": 2, + "endLine": 80, + "endColumn": 3 + }, + "javaElement": null, + "children": [ + { + "nodeId": "sf7-validation/Request Mappings (Spring Web)||jar:nested:/plugin/language-server/2.5.0/spring-boot-language-server-standalone-exec.jar/!BOOT-INF/lib/spring-boot-language-server-2.5.0-SNAPSHOT.jar!/stereotype-defaults/spring-jmolecules-stereotypes.json/@/greeting -- GET - Version: 1|file:///workspace/sf7-validation/src/main/java/com/example/demo/apiversioning/TestController.java:10:15", + "text": "@/greeting -- GET - Version: 1", + "icon": "symbol-method", + "kind": "method", + "hover": null, + "contentHash": "42a21e32ee2e", + "location": { + "uri": "file:///workspace/sf7-validation/src/main/java/com/example/demo/apiversioning/TestController.java", + "startLine": 10, + "startColumn": 15, + "endLine": 10, + "endColumn": 23 + }, + "reference": null, + "javaElement": null, + "children": [] + } + ] + }, + { + "nodeId": "sf7-validation/Configuration classes (Spring)||jar:nested:/plugin/language-server/2.5.0/spring-boot-language-server-standalone-exec.jar/!BOOT-INF/lib/spring-boot-language-server-2.5.0-SNAPSHOT.jar!/stereotype-defaults/spring-jmolecules-stereotypes.json", + "text": "Configuration classes (Spring)", + "icon": "gear", + "kind": "stereotype", + "hover": "defined in: [jar:nested:/plugin/language-server/2.5.0/spring-boot-language-server-standalone-exec.jar/!BOOT-INF/lib/spring-boot-language-server-2.5.0-SNAPSHOT.jar!/stereotype-defaults/spring-jmolecules-stereotypes.json]", + "contentHash": null, + "location": null, + "reference": { + "uri": "jar:nested:/plugin/language-server/2.5.0/spring-boot-language-server-standalone-exec.jar/!BOOT-INF/lib/spring-boot-language-server-2.5.0-SNAPSHOT.jar!/stereotype-defaults/spring-jmolecules-stereotypes.json", + "startLine": 11, + "startColumn": 2, + "endLine": 15, + "endColumn": 3 + }, + "javaElement": null, + "children": [ + { + "nodeId": "sf7-validation/Configuration classes (Spring)||jar:nested:/plugin/language-server/2.5.0/spring-boot-language-server-standalone-exec.jar/!BOOT-INF/lib/spring-boot-language-server-2.5.0-SNAPSHOT.jar!/stereotype-defaults/spring-jmolecules-stereotypes.json/c.e.d.Sf7ValidationApplication|file:///workspace/sf7-validation/src/main/java/com/example/demo/Sf7ValidationApplication.java:6:13", + "text": "c.e.d.Sf7ValidationApplication", + "icon": "symbol-class", + "kind": "type", + "hover": null, + "contentHash": "4649b9d66720", + "location": { + "uri": "file:///workspace/sf7-validation/src/main/java/com/example/demo/Sf7ValidationApplication.java", + "startLine": 6, + "startColumn": 13, + "endLine": 6, + "endColumn": 37 + }, + "reference": null, + "javaElement": null, + "children": [] + }, + { + "nodeId": "sf7-validation/Configuration classes (Spring)||jar:nested:/plugin/language-server/2.5.0/spring-boot-language-server-standalone-exec.jar/!BOOT-INF/lib/spring-boot-language-server-2.5.0-SNAPSHOT.jar!/stereotype-defaults/spring-jmolecules-stereotypes.json/c.e.d.apiversioning.WebConfig|file:///workspace/sf7-validation/src/main/java/com/example/demo/apiversioning/WebConfig.java:7:13", + "text": "c.e.d.apiversioning.WebConfig", + "icon": "symbol-class", + "kind": "type", + "hover": null, + "contentHash": "ff4e3d3b9360", + "location": { + "uri": "file:///workspace/sf7-validation/src/main/java/com/example/demo/apiversioning/WebConfig.java", + "startLine": 7, + "startColumn": 13, + "endLine": 7, + "endColumn": 22 + }, + "reference": null, + "javaElement": null, + "children": [] + }, + { + "nodeId": "sf7-validation/Configuration classes (Spring)||jar:nested:/plugin/language-server/2.5.0/spring-boot-language-server-standalone-exec.jar/!BOOT-INF/lib/spring-boot-language-server-2.5.0-SNAPSHOT.jar!/stereotype-defaults/spring-jmolecules-stereotypes.json/c.e.d.Config|file:///workspace/sf7-validation/src/main/java/com/example/demo/Config.java:5:13", + "text": "c.e.d.Config", + "icon": "symbol-class", + "kind": "type", + "hover": null, + "contentHash": "43a82cd2187c", + "location": { + "uri": "file:///workspace/sf7-validation/src/main/java/com/example/demo/Config.java", + "startLine": 5, + "startColumn": 13, + "endLine": 5, + "endColumn": 19 + }, + "reference": null, + "javaElement": null, + "children": [] + } + ] + }, + { + "nodeId": "sf7-validation/Controllers (Spring Web)||jar:nested:/plugin/language-server/2.5.0/spring-boot-language-server-standalone-exec.jar/!BOOT-INF/lib/spring-boot-language-server-2.5.0-SNAPSHOT.jar!/stereotype-defaults/spring-jmolecules-stereotypes.json", + "text": "Controllers (Spring Web)", + "icon": "globe", + "kind": "stereotype", + "hover": "defined in: [jar:nested:/plugin/language-server/2.5.0/spring-boot-language-server-standalone-exec.jar/!BOOT-INF/lib/spring-boot-language-server-2.5.0-SNAPSHOT.jar!/stereotype-defaults/spring-jmolecules-stereotypes.json]", + "contentHash": null, + "location": null, + "reference": { + "uri": "jar:nested:/plugin/language-server/2.5.0/spring-boot-language-server-standalone-exec.jar/!BOOT-INF/lib/spring-boot-language-server-2.5.0-SNAPSHOT.jar!/stereotype-defaults/spring-jmolecules-stereotypes.json", + "startLine": 68, + "startColumn": 2, + "endLine": 75, + "endColumn": 3 + }, + "javaElement": null, + "children": [ + { + "nodeId": "sf7-validation/Controllers (Spring Web)||jar:nested:/plugin/language-server/2.5.0/spring-boot-language-server-standalone-exec.jar/!BOOT-INF/lib/spring-boot-language-server-2.5.0-SNAPSHOT.jar!/stereotype-defaults/spring-jmolecules-stereotypes.json/c.e.d.apiversioning.TestController|file:///workspace/sf7-validation/src/main/java/com/example/demo/apiversioning/TestController.java:7:13", + "text": "c.e.d.apiversioning.TestController", + "icon": "symbol-class", + "kind": "type", + "hover": null, + "contentHash": "f4e7620b2f6c", + "location": { + "uri": "file:///workspace/sf7-validation/src/main/java/com/example/demo/apiversioning/TestController.java", + "startLine": 7, + "startColumn": 13, + "endLine": 7, + "endColumn": 27 + }, + "reference": null, + "javaElement": null, + "children": [ + { + "nodeId": "@/greeting -- GET - Version: 1|file:///workspace/sf7-validation/src/main/java/com/example/demo/apiversioning/TestController.java:9:1", + "text": "@/greeting -- GET - Version: 1", + "icon": "symbol-method", + "kind": "member", + "hover": null, + "contentHash": "42a21e32ee2e", + "location": { + "uri": "file:///workspace/sf7-validation/src/main/java/com/example/demo/apiversioning/TestController.java", + "startLine": 9, + "startColumn": 1, + "endLine": 9, + "endColumn": 47 + }, + "reference": null, + "javaElement": null, + "children": [] + } + ] + } + ] + } + ] +} diff --git a/agent-plugins/spring-tools/evals/mocks/spring-tools-mcp/getLogicalStructureBaselineHistory.md b/agent-plugins/spring-tools/evals/mocks/spring-tools-mcp/getLogicalStructureBaselineHistory.md new file mode 100644 index 0000000000..6eb3a0840d --- /dev/null +++ b/agent-plugins/spring-tools/evals/mocks/spring-tools-mcp/getLogicalStructureBaselineHistory.md @@ -0,0 +1,12 @@ +--- +expect: + projectName: sf7-validation +--- +[ + { + "commitSha": null, + "commitMessage": null, + "capturedAt": "2026-10-09T17:06:31.957977932Z", + "elementCount": 6 + } +] diff --git a/agent-plugins/spring-tools/evals/mocks/spring-tools-mcp/getLogicalStructureChanges.md b/agent-plugins/spring-tools/evals/mocks/spring-tools-mcp/getLogicalStructureChanges.md new file mode 100644 index 0000000000..36537636e9 --- /dev/null +++ b/agent-plugins/spring-tools/evals/mocks/spring-tools-mcp/getLogicalStructureChanges.md @@ -0,0 +1,5 @@ +--- +expect: + projectName: sf7-validation +--- +"no changes detected in the logical structure of project 'sf7-validation' since the baseline snapshot" diff --git a/agent-plugins/spring-tools/evals/mocks/spring-tools-mcp/getProjectDiagnostics.md b/agent-plugins/spring-tools/evals/mocks/spring-tools-mcp/getProjectDiagnostics.md new file mode 100644 index 0000000000..b400d6464b --- /dev/null +++ b/agent-plugins/spring-tools/evals/mocks/spring-tools-mcp/getProjectDiagnostics.md @@ -0,0 +1,28 @@ +--- +expect: + projectName: sf7-validation +--- +[ + { + "uri": "file:/workspace/sf7-validation/pom.xml", + "startLine": 0, + "startColumn": 0, + "endLine": 0, + "endColumn": 1, + "severity": "info", + "message": "Newer minor version of Spring Boot available: 4.1.1", + "code": "BOOT_VERSION_VALIDATION_CODE", + "source": null + }, + { + "uri": "file:/workspace/sf7-validation/pom.xml", + "startLine": 0, + "startColumn": 0, + "endLine": 0, + "endColumn": 1, + "severity": "warning", + "message": "Newer patch version of Spring Boot available: 4.0.8", + "code": "BOOT_VERSION_VALIDATION_CODE", + "source": null + } +] diff --git a/agent-plugins/spring-tools/evals/mocks/spring-tools-mcp/getProjectList.md b/agent-plugins/spring-tools/evals/mocks/spring-tools-mcp/getProjectList.md new file mode 100644 index 0000000000..08d73a93b3 --- /dev/null +++ b/agent-plugins/spring-tools/evals/mocks/spring-tools-mcp/getProjectList.md @@ -0,0 +1,8 @@ +[ + { + "projectName": "sf7-validation", + "isSpringBootProject": true, + "javaVersion": "25.0.3+9-LTS", + "location": "/workspace/sf7-validation" + } +] diff --git a/agent-plugins/spring-tools/evals/mocks/spring-tools-mcp/getReleases.md b/agent-plugins/spring-tools/evals/mocks/spring-tools-mcp/getReleases.md new file mode 100644 index 0000000000..f7c8429f79 --- /dev/null +++ b/agent-plugins/spring-tools/evals/mocks/spring-tools-mcp/getReleases.md @@ -0,0 +1,41 @@ +--- +expect: + project: string +--- +[ + { + "version": "3.5.16", + "status": "GENERAL_AVAILABILITY", + "current": false + }, + { + "version": "4.0.8", + "status": "GENERAL_AVAILABILITY", + "current": false + }, + { + "version": "4.0.9-SNAPSHOT", + "status": "SNAPSHOT", + "current": false + }, + { + "version": "4.1.1", + "status": "GENERAL_AVAILABILITY", + "current": true + }, + { + "version": "4.1.2-SNAPSHOT", + "status": "SNAPSHOT", + "current": false + }, + { + "version": "4.2.0-M2", + "status": "PRERELEASE", + "current": false + }, + { + "version": "4.2.0-SNAPSHOT", + "status": "SNAPSHOT", + "current": false + } +] diff --git a/agent-plugins/spring-tools/evals/mocks/spring-tools-mcp/getRequestMappings.md b/agent-plugins/spring-tools/evals/mocks/spring-tools-mcp/getRequestMappings.md new file mode 100644 index 0000000000..e2248e9a2f --- /dev/null +++ b/agent-plugins/spring-tools/evals/mocks/spring-tools-mcp/getRequestMappings.md @@ -0,0 +1,23 @@ +--- +expect: + projectName: sf7-validation +--- +[ + { + "path": "/greeting", + "httpMethods": [ + "GET" + ], + "contentTypes": [], + "acceptTypes": [], + "version": "1", + "controllerName": "testController", + "controllerType": "com.example.demo.apiversioning.TestController", + "methodSignature": "com.example.demo.apiversioning.TestController.sayHello() : java.lang.String", + "sourceFile": "file:///workspace/sf7-validation/src/main/java/com/example/demo/apiversioning/TestController.java", + "startLine": 7, + "startColumn": 13, + "endLine": 7, + "endColumn": 27 + } +] diff --git a/agent-plugins/spring-tools/evals/mocks/spring-tools-mcp/getResolvedProjectClasspath.md b/agent-plugins/spring-tools/evals/mocks/spring-tools-mcp/getResolvedProjectClasspath.md new file mode 100644 index 0000000000..694efb1e65 --- /dev/null +++ b/agent-plugins/spring-tools/evals/mocks/spring-tools-mcp/getResolvedProjectClasspath.md @@ -0,0 +1,334 @@ +--- +expect: + projectName: sf7-validation +--- +[ + { + "name": "~/.m2/repository/org/springframework/boot/spring-boot-starter-actuator/4.0.0/spring-boot-starter-actuator-4.0.0.jar", + "version": "4.0.0" + }, + { + "name": "~/.m2/repository/org/springframework/boot/spring-boot-starter/4.0.0/spring-boot-starter-4.0.0.jar", + "version": "4.0.0" + }, + { + "name": "~/.m2/repository/org/springframework/boot/spring-boot-starter-logging/4.0.0/spring-boot-starter-logging-4.0.0.jar", + "version": "4.0.0" + }, + { + "name": "~/.m2/repository/ch/qos/logback/logback-classic/1.5.21/logback-classic-1.5.21.jar", + "version": "1.5.21" + }, + { + "name": "~/.m2/repository/ch/qos/logback/logback-core/1.5.21/logback-core-1.5.21.jar", + "version": "1.5.21" + }, + { + "name": "~/.m2/repository/org/apache/logging/log4j/log4j-to-slf4j/2.25.2/log4j-to-slf4j-2.25.2.jar", + "version": "2.25.2" + }, + { + "name": "~/.m2/repository/org/apache/logging/log4j/log4j-api/2.25.2/log4j-api-2.25.2.jar", + "version": "2.25.2" + }, + { + "name": "~/.m2/repository/org/slf4j/jul-to-slf4j/2.0.17/jul-to-slf4j-2.0.17.jar", + "version": "2.0.17" + }, + { + "name": "~/.m2/repository/org/springframework/boot/spring-boot-autoconfigure/4.0.0/spring-boot-autoconfigure-4.0.0.jar", + "version": "4.0.0" + }, + { + "name": "~/.m2/repository/jakarta/annotation/jakarta.annotation-api/3.0.0/jakarta.annotation-api-3.0.0.jar", + "version": "3.0.0" + }, + { + "name": "~/.m2/repository/org/yaml/snakeyaml/2.5/snakeyaml-2.5.jar", + "version": "2.5" + }, + { + "name": "~/.m2/repository/org/springframework/boot/spring-boot-starter-micrometer-metrics/4.0.0/spring-boot-starter-micrometer-metrics-4.0.0.jar", + "version": "4.0.0" + }, + { + "name": "~/.m2/repository/org/springframework/boot/spring-boot-micrometer-metrics/4.0.0/spring-boot-micrometer-metrics-4.0.0.jar", + "version": "4.0.0" + }, + { + "name": "~/.m2/repository/org/springframework/boot/spring-boot-micrometer-observation/4.0.0/spring-boot-micrometer-observation-4.0.0.jar", + "version": "4.0.0" + }, + { + "name": "~/.m2/repository/org/springframework/boot/spring-boot-actuator-autoconfigure/4.0.0/spring-boot-actuator-autoconfigure-4.0.0.jar", + "version": "4.0.0" + }, + { + "name": "~/.m2/repository/org/springframework/boot/spring-boot-actuator/4.0.0/spring-boot-actuator-4.0.0.jar", + "version": "4.0.0" + }, + { + "name": "~/.m2/repository/org/springframework/boot/spring-boot-health/4.0.0/spring-boot-health-4.0.0.jar", + "version": "4.0.0" + }, + { + "name": "~/.m2/repository/org/springframework/boot/spring-boot/4.0.0/spring-boot-4.0.0.jar", + "version": "4.0.0" + }, + { + "name": "~/.m2/repository/io/micrometer/micrometer-observation/1.16.0/micrometer-observation-1.16.0.jar", + "version": "1.16.0" + }, + { + "name": "~/.m2/repository/io/micrometer/micrometer-commons/1.16.0/micrometer-commons-1.16.0.jar", + "version": "1.16.0" + }, + { + "name": "~/.m2/repository/io/micrometer/micrometer-jakarta9/1.16.0/micrometer-jakarta9-1.16.0.jar", + "version": "1.16.0" + }, + { + "name": "~/.m2/repository/io/micrometer/micrometer-core/1.16.0/micrometer-core-1.16.0.jar", + "version": "1.16.0" + }, + { + "name": "~/.m2/repository/org/hdrhistogram/HdrHistogram/2.2.2/HdrHistogram-2.2.2.jar", + "version": "2.2.2" + }, + { + "name": "~/.m2/repository/org/latencyutils/LatencyUtils/2.0.3/LatencyUtils-2.0.3.jar", + "version": "2.0.3" + }, + { + "name": "~/.m2/repository/org/springframework/boot/spring-boot-starter-web/4.0.0/spring-boot-starter-web-4.0.0.jar", + "version": "4.0.0" + }, + { + "name": "~/.m2/repository/org/springframework/boot/spring-boot-starter-jackson/4.0.0/spring-boot-starter-jackson-4.0.0.jar", + "version": "4.0.0" + }, + { + "name": "~/.m2/repository/org/springframework/boot/spring-boot-jackson/4.0.0/spring-boot-jackson-4.0.0.jar", + "version": "4.0.0" + }, + { + "name": "~/.m2/repository/tools/jackson/core/jackson-databind/3.0.2/jackson-databind-3.0.2.jar", + "version": "3.0.2" + }, + { + "name": "~/.m2/repository/com/fasterxml/jackson/core/jackson-annotations/2.20/jackson-annotations-2.20.jar", + "version": "2.20" + }, + { + "name": "~/.m2/repository/tools/jackson/core/jackson-core/3.0.2/jackson-core-3.0.2.jar", + "version": "3.0.2" + }, + { + "name": "~/.m2/repository/org/springframework/boot/spring-boot-starter-tomcat/4.0.0/spring-boot-starter-tomcat-4.0.0.jar", + "version": "4.0.0" + }, + { + "name": "~/.m2/repository/org/springframework/boot/spring-boot-starter-tomcat-runtime/4.0.0/spring-boot-starter-tomcat-runtime-4.0.0.jar", + "version": "4.0.0" + }, + { + "name": "~/.m2/repository/org/springframework/boot/spring-boot-web-server/4.0.0/spring-boot-web-server-4.0.0.jar", + "version": "4.0.0" + }, + { + "name": "~/.m2/repository/org/apache/tomcat/embed/tomcat-embed-core/11.0.14/tomcat-embed-core-11.0.14.jar", + "version": "11.0.14" + }, + { + "name": "~/.m2/repository/org/apache/tomcat/embed/tomcat-embed-el/11.0.14/tomcat-embed-el-11.0.14.jar", + "version": "11.0.14" + }, + { + "name": "~/.m2/repository/org/apache/tomcat/embed/tomcat-embed-websocket/11.0.14/tomcat-embed-websocket-11.0.14.jar", + "version": "11.0.14" + }, + { + "name": "~/.m2/repository/org/springframework/boot/spring-boot-tomcat/4.0.0/spring-boot-tomcat-4.0.0.jar", + "version": "4.0.0" + }, + { + "name": "~/.m2/repository/org/springframework/boot/spring-boot-http-converter/4.0.0/spring-boot-http-converter-4.0.0.jar", + "version": "4.0.0" + }, + { + "name": "~/.m2/repository/org/springframework/boot/spring-boot-webmvc/4.0.0/spring-boot-webmvc-4.0.0.jar", + "version": "4.0.0" + }, + { + "name": "~/.m2/repository/org/springframework/boot/spring-boot-servlet/4.0.0/spring-boot-servlet-4.0.0.jar", + "version": "4.0.0" + }, + { + "name": "~/.m2/repository/org/springframework/spring-core/7.0.1/spring-core-7.0.1.jar", + "version": "7.0.1" + }, + { + "name": "~/.m2/repository/commons-logging/commons-logging/1.3.5/commons-logging-1.3.5.jar", + "version": "1.3.5" + }, + { + "name": "~/.m2/repository/org/jspecify/jspecify/1.0.0/jspecify-1.0.0.jar", + "version": "1.0.0" + }, + { + "name": "~/.m2/repository/org/springframework/spring-context/7.0.1/spring-context-7.0.1.jar", + "version": "7.0.1" + }, + { + "name": "~/.m2/repository/org/springframework/spring-aop/7.0.1/spring-aop-7.0.1.jar", + "version": "7.0.1" + }, + { + "name": "~/.m2/repository/org/springframework/spring-expression/7.0.1/spring-expression-7.0.1.jar", + "version": "7.0.1" + }, + { + "name": "~/.m2/repository/org/springframework/spring-beans/7.0.1/spring-beans-7.0.1.jar", + "version": "7.0.1" + }, + { + "name": "~/.m2/repository/org/springframework/spring-web/7.0.1/spring-web-7.0.1.jar", + "version": "7.0.1" + }, + { + "name": "~/.m2/repository/org/springframework/spring-webmvc/7.0.1/spring-webmvc-7.0.1.jar", + "version": "7.0.1" + }, + { + "name": "~/.m2/repository/org/springframework/spring-webflux/7.0.1/spring-webflux-7.0.1.jar", + "version": "7.0.1" + }, + { + "name": "~/.m2/repository/io/projectreactor/reactor-core/3.8.0/reactor-core-3.8.0.jar", + "version": "3.8.0" + }, + { + "name": "~/.m2/repository/org/reactivestreams/reactive-streams/1.0.4/reactive-streams-1.0.4.jar", + "version": "1.0.4" + }, + { + "name": "~/.m2/repository/org/springframework/boot/spring-boot-starter-test/4.0.0/spring-boot-starter-test-4.0.0.jar", + "version": "4.0.0" + }, + { + "name": "~/.m2/repository/org/springframework/boot/spring-boot-test/4.0.0/spring-boot-test-4.0.0.jar", + "version": "4.0.0" + }, + { + "name": "~/.m2/repository/org/springframework/boot/spring-boot-test-autoconfigure/4.0.0/spring-boot-test-autoconfigure-4.0.0.jar", + "version": "4.0.0" + }, + { + "name": "~/.m2/repository/com/jayway/jsonpath/json-path/2.9.0/json-path-2.9.0.jar", + "version": "2.9.0" + }, + { + "name": "~/.m2/repository/org/slf4j/slf4j-api/2.0.17/slf4j-api-2.0.17.jar", + "version": "2.0.17" + }, + { + "name": "~/.m2/repository/jakarta/xml/bind/jakarta.xml.bind-api/4.0.4/jakarta.xml.bind-api-4.0.4.jar", + "version": "4.0.4" + }, + { + "name": "~/.m2/repository/jakarta/activation/jakarta.activation-api/2.1.4/jakarta.activation-api-2.1.4.jar", + "version": "2.1.4" + }, + { + "name": "~/.m2/repository/net/minidev/json-smart/2.6.0/json-smart-2.6.0.jar", + "version": "2.6.0" + }, + { + "name": "~/.m2/repository/net/minidev/accessors-smart/2.6.0/accessors-smart-2.6.0.jar", + "version": "2.6.0" + }, + { + "name": "~/.m2/repository/org/ow2/asm/asm/9.7.1/asm-9.7.1.jar", + "version": "9.7.1" + }, + { + "name": "~/.m2/repository/org/assertj/assertj-core/3.27.6/assertj-core-3.27.6.jar", + "version": "3.27.6" + }, + { + "name": "~/.m2/repository/net/bytebuddy/byte-buddy/1.17.8/byte-buddy-1.17.8.jar", + "version": "1.17.8" + }, + { + "name": "~/.m2/repository/org/awaitility/awaitility/4.3.0/awaitility-4.3.0.jar", + "version": "4.3.0" + }, + { + "name": "~/.m2/repository/org/hamcrest/hamcrest/3.0/hamcrest-3.0.jar", + "version": "3.0" + }, + { + "name": "~/.m2/repository/org/junit/jupiter/junit-jupiter/6.0.1/junit-jupiter-6.0.1.jar", + "version": "6.0.1" + }, + { + "name": "~/.m2/repository/org/junit/jupiter/junit-jupiter-api/6.0.1/junit-jupiter-api-6.0.1.jar", + "version": "6.0.1" + }, + { + "name": "~/.m2/repository/org/opentest4j/opentest4j/1.3.0/opentest4j-1.3.0.jar", + "version": "1.3.0" + }, + { + "name": "~/.m2/repository/org/junit/platform/junit-platform-commons/6.0.1/junit-platform-commons-6.0.1.jar", + "version": "6.0.1" + }, + { + "name": "~/.m2/repository/org/apiguardian/apiguardian-api/1.1.2/apiguardian-api-1.1.2.jar", + "version": "1.1.2" + }, + { + "name": "~/.m2/repository/org/junit/jupiter/junit-jupiter-params/6.0.1/junit-jupiter-params-6.0.1.jar", + "version": "6.0.1" + }, + { + "name": "~/.m2/repository/org/junit/jupiter/junit-jupiter-engine/6.0.1/junit-jupiter-engine-6.0.1.jar", + "version": "6.0.1" + }, + { + "name": "~/.m2/repository/org/junit/platform/junit-platform-engine/6.0.1/junit-platform-engine-6.0.1.jar", + "version": "6.0.1" + }, + { + "name": "~/.m2/repository/org/mockito/mockito-core/5.20.0/mockito-core-5.20.0.jar", + "version": "5.20.0" + }, + { + "name": "~/.m2/repository/net/bytebuddy/byte-buddy-agent/1.17.8/byte-buddy-agent-1.17.8.jar", + "version": "1.17.8" + }, + { + "name": "~/.m2/repository/org/objenesis/objenesis/3.3/objenesis-3.3.jar", + "version": "3.3" + }, + { + "name": "~/.m2/repository/org/mockito/mockito-junit-jupiter/5.20.0/mockito-junit-jupiter-5.20.0.jar", + "version": "5.20.0" + }, + { + "name": "~/.m2/repository/org/skyscreamer/jsonassert/1.5.3/jsonassert-1.5.3.jar", + "version": "1.5.3" + }, + { + "name": "~/.m2/repository/com/vaadin/external/google/android-json/0.0.20131108.vaadin1/android-json-0.0.20131108.vaadin1.jar", + "version": "0.0.20131108.vaadin1" + }, + { + "name": "~/.m2/repository/org/springframework/spring-test/7.0.1/spring-test-7.0.1.jar", + "version": "7.0.1" + }, + { + "name": "~/.m2/repository/org/xmlunit/xmlunit-core/2.10.4/xmlunit-core-2.10.4.jar", + "version": "2.10.4" + } +] diff --git a/agent-plugins/spring-tools/evals/mocks/spring-tools-mcp/getSpringBootVersion.md b/agent-plugins/spring-tools/evals/mocks/spring-tools-mcp/getSpringBootVersion.md new file mode 100644 index 0000000000..9075b0c23f --- /dev/null +++ b/agent-plugins/spring-tools/evals/mocks/spring-tools-mcp/getSpringBootVersion.md @@ -0,0 +1,16 @@ +--- +expect: + projectName: sf7-validation +--- +{ + "build": 0, + "major": 4, + "minor": 0, + "patch": 0, + "preRelease": false, + "qualifier": null, + "qualifierNumber": 0, + "release": true, + "releaseType": "RELEASE", + "snapshot": false +} diff --git a/agent-plugins/spring-tools/evals/mocks/spring-tools-mcp/getStereotypesList.md b/agent-plugins/spring-tools/evals/mocks/spring-tools-mcp/getStereotypesList.md new file mode 100644 index 0000000000..8b49378db4 --- /dev/null +++ b/agent-plugins/spring-tools/evals/mocks/spring-tools-mcp/getStereotypesList.md @@ -0,0 +1,405 @@ +--- +expect: + projectName: sf7-validation +--- +[ + { + "assignments": [ + { + "target": "jakarta.persistence.AttributeConverter" + }, + { + "target": "javax.persistence.AttributeConverter" + } + ], + "sources": [ + "jar:nested:/plugin/language-server/2.5.0/spring-boot-language-server-standalone-exec.jar/!BOOT-INF/lib/spring-boot-language-server-2.5.0-SNAPSHOT.jar!/stereotype-defaults/jpa-jmolecules-stereotypes.json" + ], + "stereotype": { + "displayName": "Attribute Converter", + "groups": [ + "jpa" + ], + "identifier": "jpa.AttributeConverter", + "inherited": true, + "priority": 0 + } + }, + { + "assignments": [ + { + "target": "jakarta.persistence.Embeddable" + }, + { + "target": "javax.persistence.Embeddable" + } + ], + "sources": [ + "jar:nested:/plugin/language-server/2.5.0/spring-boot-language-server-standalone-exec.jar/!BOOT-INF/lib/spring-boot-language-server-2.5.0-SNAPSHOT.jar!/stereotype-defaults/jpa-jmolecules-stereotypes.json" + ], + "stereotype": { + "displayName": "Embeddable", + "groups": [ + "jpa" + ], + "identifier": "jpa.Embeddable", + "inherited": true, + "priority": 0 + } + }, + { + "assignments": [ + { + "target": "jakarta.persistence.Entity" + }, + { + "target": "javax.persistence.Entity" + } + ], + "sources": [ + "jar:nested:/plugin/language-server/2.5.0/spring-boot-language-server-standalone-exec.jar/!BOOT-INF/lib/spring-boot-language-server-2.5.0-SNAPSHOT.jar!/stereotype-defaults/jpa-jmolecules-stereotypes.json" + ], + "stereotype": { + "displayName": "Entity", + "groups": [ + "jpa" + ], + "identifier": "jpa.Entity", + "inherited": true, + "priority": 0 + } + }, + { + "assignments": [ + { + "target": "jakarta.persistence.MappedSuperclass" + }, + { + "target": "javax.persistence.MappedSuperclass" + } + ], + "sources": [ + "jar:nested:/plugin/language-server/2.5.0/spring-boot-language-server-standalone-exec.jar/!BOOT-INF/lib/spring-boot-language-server-2.5.0-SNAPSHOT.jar!/stereotype-defaults/jpa-jmolecules-stereotypes.json" + ], + "stereotype": { + "displayName": "Mapped Superclass", + "groups": [ + "jpa" + ], + "identifier": "jpa.MappedSuperclass", + "inherited": true, + "priority": 0 + } + }, + { + "assignments": [ + { + "target": "org.springframework.context.annotation.Configuration" + } + ], + "sources": [ + "jar:nested:/plugin/language-server/2.5.0/spring-boot-language-server-standalone-exec.jar/!BOOT-INF/lib/spring-boot-language-server-2.5.0-SNAPSHOT.jar!/stereotype-defaults/spring-jmolecules-stereotypes.json" + ], + "stereotype": { + "displayName": "Configuration class", + "groups": [ + "spring" + ], + "identifier": "spring.Configuration", + "inherited": true, + "priority": 0 + } + }, + { + "assignments": [ + { + "target": "org.springframework.context.ApplicationListener" + }, + { + "target": "org.springframework.context.event.EventListener" + }, + { + "target": "org.springframework.data.rest.core.event.AbstractRepositoryEventListener" + } + ], + "sources": [ + "jar:nested:/plugin/language-server/2.5.0/spring-boot-language-server-standalone-exec.jar/!BOOT-INF/lib/spring-boot-language-server-2.5.0-SNAPSHOT.jar!/stereotype-defaults/spring-jmolecules-stereotypes.json" + ], + "stereotype": { + "displayName": "Event Listener", + "groups": [ + "spring" + ], + "identifier": "spring.EventListener", + "inherited": true, + "priority": 0 + } + }, + { + "assignments": [ + { + "target": "org.springframework.format.Formatter" + } + ], + "sources": [ + "jar:nested:/plugin/language-server/2.5.0/spring-boot-language-server-standalone-exec.jar/!BOOT-INF/lib/spring-boot-language-server-2.5.0-SNAPSHOT.jar!/stereotype-defaults/spring-jmolecules-stereotypes.json" + ], + "stereotype": { + "displayName": "Formatter", + "groups": [ + "spring" + ], + "identifier": "spring.Formatter", + "inherited": true, + "priority": 0 + } + }, + { + "assignments": [ + { + "target": "org.springframework.stereotype.Service" + } + ], + "sources": [ + "jar:nested:/plugin/language-server/2.5.0/spring-boot-language-server-standalone-exec.jar/!BOOT-INF/lib/spring-boot-language-server-2.5.0-SNAPSHOT.jar!/stereotype-defaults/spring-jmolecules-stereotypes.json" + ], + "stereotype": { + "displayName": "Service", + "groups": [ + "spring", + "ddd" + ], + "identifier": "spring.Service", + "inherited": true, + "priority": 0 + } + }, + { + "assignments": [ + { + "target": "org.springframework.validation.Validator" + } + ], + "sources": [ + "jar:nested:/plugin/language-server/2.5.0/spring-boot-language-server-standalone-exec.jar/!BOOT-INF/lib/spring-boot-language-server-2.5.0-SNAPSHOT.jar!/stereotype-defaults/spring-jmolecules-stereotypes.json" + ], + "stereotype": { + "displayName": "Validator", + "groups": [ + "spring" + ], + "identifier": "spring.Validator", + "inherited": true, + "priority": 0 + } + }, + { + "assignments": [ + { + "target": "org.springframework.aot.hint.RuntimeHintsRegistrar" + } + ], + "sources": [ + "jar:nested:/plugin/language-server/2.5.0/spring-boot-language-server-standalone-exec.jar/!BOOT-INF/lib/spring-boot-language-server-2.5.0-SNAPSHOT.jar!/stereotype-defaults/spring-jmolecules-stereotypes.json" + ], + "stereotype": { + "displayName": "Runtime Hints", + "groups": [ + "spring" + ], + "identifier": "spring.aot.RuntimeHints", + "inherited": true, + "priority": 0 + } + }, + { + "assignments": [ + { + "target": "org.springframework.boot.context.properties.ConfigurationProperties" + } + ], + "sources": [ + "jar:nested:/plugin/language-server/2.5.0/spring-boot-language-server-standalone-exec.jar/!BOOT-INF/lib/spring-boot-language-server-2.5.0-SNAPSHOT.jar!/stereotype-defaults/spring-jmolecules-stereotypes.json" + ], + "stereotype": { + "displayName": "Configuration Properties", + "groups": [ + "spring.boot" + ], + "identifier": "spring.boot.ConfigurationProperties", + "inherited": true, + "priority": 0 + } + }, + { + "assignments": [ + { + "target": "org.springframework.boot.jackson.JsonMixin" + } + ], + "sources": [ + "jar:nested:/plugin/language-server/2.5.0/spring-boot-language-server-standalone-exec.jar/!BOOT-INF/lib/spring-boot-language-server-2.5.0-SNAPSHOT.jar!/stereotype-defaults/spring-jmolecules-stereotypes.json" + ], + "stereotype": { + "displayName": "JSON Mixin", + "groups": [ + "spring.boot", + "spring.web.rest" + ], + "identifier": "spring.boot.JsonMixin", + "inherited": true, + "priority": 0 + } + }, + { + "assignments": [ + { + "target": "org.springframework.data.repository.Repository" + }, + { + "target": "org.springframework.data.repository.RepositoryDefinition" + }, + { + "target": "org.springframework.stereotype.Repository" + } + ], + "sources": [ + "jar:nested:/plugin/language-server/2.5.0/spring-boot-language-server-standalone-exec.jar/!BOOT-INF/lib/spring-boot-language-server-2.5.0-SNAPSHOT.jar!/stereotype-defaults/spring-jmolecules-stereotypes.json" + ], + "stereotype": { + "displayName": "Repository", + "groups": [ + "spring" + ], + "identifier": "spring.data.Repository", + "inherited": true, + "priority": 0 + } + }, + { + "assignments": [ + { + "target": "org.springframework.data.rest.core.config.Projection" + } + ], + "sources": [ + "jar:nested:/plugin/language-server/2.5.0/spring-boot-language-server-standalone-exec.jar/!BOOT-INF/lib/spring-boot-language-server-2.5.0-SNAPSHOT.jar!/stereotype-defaults/spring-jmolecules-stereotypes.json" + ], + "stereotype": { + "displayName": "Projection", + "groups": [ + "spring.data" + ], + "identifier": "spring.data.rest.Projection", + "inherited": true, + "priority": 0 + } + }, + { + "assignments": [ + { + "target": "org.springframework.hateoas.RepresentationModel" + } + ], + "sources": [ + "jar:nested:/plugin/language-server/2.5.0/spring-boot-language-server-standalone-exec.jar/!BOOT-INF/lib/spring-boot-language-server-2.5.0-SNAPSHOT.jar!/stereotype-defaults/spring-jmolecules-stereotypes.json" + ], + "stereotype": { + "displayName": "Representation Model", + "groups": [ + "spring.web.rest.hypermedia" + ], + "identifier": "spring.hateoas.RepresentationModel", + "inherited": true, + "priority": 0 + } + }, + { + "assignments": [ + { + "target": "org.springframework.hateoas.server.RepresentationModelProcessor" + } + ], + "sources": [ + "jar:nested:/plugin/language-server/2.5.0/spring-boot-language-server-standalone-exec.jar/!BOOT-INF/lib/spring-boot-language-server-2.5.0-SNAPSHOT.jar!/stereotype-defaults/spring-jmolecules-stereotypes.json" + ], + "stereotype": { + "displayName": "Representation Model Processor", + "groups": [ + "spring.web.rest.hypermedia" + ], + "identifier": "spring.hateoas.RepresentationModelProcessor", + "inherited": true, + "priority": 0 + } + }, + { + "assignments": [ + { + "target": "org.springframework.messaging.handler.annotation.MessageMapping" + }, + { + "target": "org.springframework.kafka.annotation.KafkaListener" + }, + { + "target": "org.springframework.amqp.rabbit.annotation.RabbitListener" + } + ], + "sources": [ + "jar:nested:/plugin/language-server/2.5.0/spring-boot-language-server-standalone-exec.jar/!BOOT-INF/lib/spring-boot-language-server-2.5.0-SNAPSHOT.jar!/stereotype-defaults/spring-jmolecules-stereotypes.json" + ], + "stereotype": { + "displayName": "Message Listener", + "groups": [ + "spring.messaging" + ], + "identifier": "spring.messaging.MessageListener", + "inherited": true, + "priority": 0 + } + }, + { + "assignments": [ + { + "target": "org.springframework.data.rest.webmvc.BasePathAwareController" + }, + { + "target": "org.springframework.stereotype.Controller" + }, + { + "target": "org.springframework.web.bind.annotation.RestController" + } + ], + "sources": [ + "jar:nested:/plugin/language-server/2.5.0/spring-boot-language-server-standalone-exec.jar/!BOOT-INF/lib/spring-boot-language-server-2.5.0-SNAPSHOT.jar!/stereotype-defaults/spring-jmolecules-stereotypes.json" + ], + "stereotype": { + "displayName": "Controller", + "groups": [ + "spring.web" + ], + "identifier": "spring.web.Controller", + "inherited": true, + "priority": 0 + } + }, + { + "assignments": [ + { + "target": "org.springframework.web.bind.annotation.RequestMapping" + } + ], + "sources": [ + "jar:nested:/plugin/language-server/2.5.0/spring-boot-language-server-standalone-exec.jar/!BOOT-INF/lib/spring-boot-language-server-2.5.0-SNAPSHOT.jar!/stereotype-defaults/spring-jmolecules-stereotypes.json" + ], + "stereotype": { + "displayName": "Request Mappings", + "groups": [ + "spring.web" + ], + "identifier": "spring.web.RequestMapping", + "inherited": true, + "priority": 0 + } + } +] diff --git a/agent-plugins/spring-tools/evals/mocks/spring-tools-mcp/getStructureDependencies.md b/agent-plugins/spring-tools/evals/mocks/spring-tools-mcp/getStructureDependencies.md new file mode 100644 index 0000000000..a1e07e116b --- /dev/null +++ b/agent-plugins/spring-tools/evals/mocks/spring-tools-mcp/getStructureDependencies.md @@ -0,0 +1,536 @@ +--- +expect: + projectName: sf7-validation +--- +[ + { + "id": "gav:commons-logging:commons-logging", + "kind": "JAR", + "displayName": "commons-logging", + "groupId": "commons-logging", + "artifactId": "commons-logging", + "version": "1.3.5", + "projectName": null, + "location": "~/.m2/repository/commons-logging/commons-logging/1.3.5/commons-logging-1.3.5.jar" + }, + { + "id": "gav:org.hdrhistogram:HdrHistogram", + "kind": "JAR", + "displayName": "HdrHistogram", + "groupId": "org.hdrhistogram", + "artifactId": "HdrHistogram", + "version": "2.2.2", + "projectName": null, + "location": "~/.m2/repository/org/hdrhistogram/HdrHistogram/2.2.2/HdrHistogram-2.2.2.jar" + }, + { + "id": "gav:com.fasterxml.jackson.core:jackson-annotations", + "kind": "JAR", + "displayName": "jackson-annotations", + "groupId": "com.fasterxml.jackson.core", + "artifactId": "jackson-annotations", + "version": "2.20", + "projectName": null, + "location": "~/.m2/repository/com/fasterxml/jackson/core/jackson-annotations/2.20/jackson-annotations-2.20.jar" + }, + { + "id": "gav:tools.jackson.core:jackson-core", + "kind": "JAR", + "displayName": "jackson-core", + "groupId": "tools.jackson.core", + "artifactId": "jackson-core", + "version": "3.0.2", + "projectName": null, + "location": "~/.m2/repository/tools/jackson/core/jackson-core/3.0.2/jackson-core-3.0.2.jar" + }, + { + "id": "gav:tools.jackson.core:jackson-databind", + "kind": "JAR", + "displayName": "jackson-databind", + "groupId": "tools.jackson.core", + "artifactId": "jackson-databind", + "version": "3.0.2", + "projectName": null, + "location": "~/.m2/repository/tools/jackson/core/jackson-databind/3.0.2/jackson-databind-3.0.2.jar" + }, + { + "id": "gav:jakarta.annotation:jakarta.annotation-api", + "kind": "JAR", + "displayName": "jakarta.annotation-api", + "groupId": "jakarta.annotation", + "artifactId": "jakarta.annotation-api", + "version": "3.0.0", + "projectName": null, + "location": "~/.m2/repository/jakarta/annotation/jakarta.annotation-api/3.0.0/jakarta.annotation-api-3.0.0.jar" + }, + { + "id": "gav:org.jspecify:jspecify", + "kind": "JAR", + "displayName": "jspecify", + "groupId": "org.jspecify", + "artifactId": "jspecify", + "version": "1.0.0", + "projectName": null, + "location": "~/.m2/repository/org/jspecify/jspecify/1.0.0/jspecify-1.0.0.jar" + }, + { + "id": "gav:org.slf4j:jul-to-slf4j", + "kind": "JAR", + "displayName": "jul-to-slf4j", + "groupId": "org.slf4j", + "artifactId": "jul-to-slf4j", + "version": "2.0.17", + "projectName": null, + "location": "~/.m2/repository/org/slf4j/jul-to-slf4j/2.0.17/jul-to-slf4j-2.0.17.jar" + }, + { + "id": "gav:org.latencyutils:LatencyUtils", + "kind": "JAR", + "displayName": "LatencyUtils", + "groupId": "org.latencyutils", + "artifactId": "LatencyUtils", + "version": "2.0.3", + "projectName": null, + "location": "~/.m2/repository/org/latencyutils/LatencyUtils/2.0.3/LatencyUtils-2.0.3.jar" + }, + { + "id": "gav:org.apache.logging.log4j:log4j-api", + "kind": "JAR", + "displayName": "log4j-api", + "groupId": "org.apache.logging.log4j", + "artifactId": "log4j-api", + "version": "2.25.2", + "projectName": null, + "location": "~/.m2/repository/org/apache/logging/log4j/log4j-api/2.25.2/log4j-api-2.25.2.jar" + }, + { + "id": "gav:org.apache.logging.log4j:log4j-to-slf4j", + "kind": "JAR", + "displayName": "log4j-to-slf4j", + "groupId": "org.apache.logging.log4j", + "artifactId": "log4j-to-slf4j", + "version": "2.25.2", + "projectName": null, + "location": "~/.m2/repository/org/apache/logging/log4j/log4j-to-slf4j/2.25.2/log4j-to-slf4j-2.25.2.jar" + }, + { + "id": "gav:ch.qos.logback:logback-classic", + "kind": "JAR", + "displayName": "logback-classic", + "groupId": "ch.qos.logback", + "artifactId": "logback-classic", + "version": "1.5.21", + "projectName": null, + "location": "~/.m2/repository/ch/qos/logback/logback-classic/1.5.21/logback-classic-1.5.21.jar" + }, + { + "id": "gav:ch.qos.logback:logback-core", + "kind": "JAR", + "displayName": "logback-core", + "groupId": "ch.qos.logback", + "artifactId": "logback-core", + "version": "1.5.21", + "projectName": null, + "location": "~/.m2/repository/ch/qos/logback/logback-core/1.5.21/logback-core-1.5.21.jar" + }, + { + "id": "gav:io.micrometer:micrometer-commons", + "kind": "JAR", + "displayName": "micrometer-commons", + "groupId": "io.micrometer", + "artifactId": "micrometer-commons", + "version": "1.16.0", + "projectName": null, + "location": "~/.m2/repository/io/micrometer/micrometer-commons/1.16.0/micrometer-commons-1.16.0.jar" + }, + { + "id": "gav:io.micrometer:micrometer-core", + "kind": "JAR", + "displayName": "micrometer-core", + "groupId": "io.micrometer", + "artifactId": "micrometer-core", + "version": "1.16.0", + "projectName": null, + "location": "~/.m2/repository/io/micrometer/micrometer-core/1.16.0/micrometer-core-1.16.0.jar" + }, + { + "id": "gav:io.micrometer:micrometer-jakarta9", + "kind": "JAR", + "displayName": "micrometer-jakarta9", + "groupId": "io.micrometer", + "artifactId": "micrometer-jakarta9", + "version": "1.16.0", + "projectName": null, + "location": "~/.m2/repository/io/micrometer/micrometer-jakarta9/1.16.0/micrometer-jakarta9-1.16.0.jar" + }, + { + "id": "gav:io.micrometer:micrometer-observation", + "kind": "JAR", + "displayName": "micrometer-observation", + "groupId": "io.micrometer", + "artifactId": "micrometer-observation", + "version": "1.16.0", + "projectName": null, + "location": "~/.m2/repository/io/micrometer/micrometer-observation/1.16.0/micrometer-observation-1.16.0.jar" + }, + { + "id": "gav:org.reactivestreams:reactive-streams", + "kind": "JAR", + "displayName": "reactive-streams", + "groupId": "org.reactivestreams", + "artifactId": "reactive-streams", + "version": "1.0.4", + "projectName": null, + "location": "~/.m2/repository/org/reactivestreams/reactive-streams/1.0.4/reactive-streams-1.0.4.jar" + }, + { + "id": "gav:io.projectreactor:reactor-core", + "kind": "JAR", + "displayName": "reactor-core", + "groupId": "io.projectreactor", + "artifactId": "reactor-core", + "version": "3.8.0", + "projectName": null, + "location": "~/.m2/repository/io/projectreactor/reactor-core/3.8.0/reactor-core-3.8.0.jar" + }, + { + "id": "gav:org.slf4j:slf4j-api", + "kind": "JAR", + "displayName": "slf4j-api", + "groupId": "org.slf4j", + "artifactId": "slf4j-api", + "version": "2.0.17", + "projectName": null, + "location": "~/.m2/repository/org/slf4j/slf4j-api/2.0.17/slf4j-api-2.0.17.jar" + }, + { + "id": "gav:org.yaml:snakeyaml", + "kind": "JAR", + "displayName": "snakeyaml", + "groupId": "org.yaml", + "artifactId": "snakeyaml", + "version": "2.5", + "projectName": null, + "location": "~/.m2/repository/org/yaml/snakeyaml/2.5/snakeyaml-2.5.jar" + }, + { + "id": "gav:org.springframework:spring-aop", + "kind": "JAR", + "displayName": "spring-aop", + "groupId": "org.springframework", + "artifactId": "spring-aop", + "version": "7.0.1", + "projectName": null, + "location": "~/.m2/repository/org/springframework/spring-aop/7.0.1/spring-aop-7.0.1.jar" + }, + { + "id": "gav:org.springframework:spring-beans", + "kind": "JAR", + "displayName": "spring-beans", + "groupId": "org.springframework", + "artifactId": "spring-beans", + "version": "7.0.1", + "projectName": null, + "location": "~/.m2/repository/org/springframework/spring-beans/7.0.1/spring-beans-7.0.1.jar" + }, + { + "id": "gav:org.springframework.boot:spring-boot", + "kind": "JAR", + "displayName": "spring-boot", + "groupId": "org.springframework.boot", + "artifactId": "spring-boot", + "version": "4.0.0", + "projectName": null, + "location": "~/.m2/repository/org/springframework/boot/spring-boot/4.0.0/spring-boot-4.0.0.jar" + }, + { + "id": "gav:org.springframework.boot:spring-boot-actuator", + "kind": "JAR", + "displayName": "spring-boot-actuator", + "groupId": "org.springframework.boot", + "artifactId": "spring-boot-actuator", + "version": "4.0.0", + "projectName": null, + "location": "~/.m2/repository/org/springframework/boot/spring-boot-actuator/4.0.0/spring-boot-actuator-4.0.0.jar" + }, + { + "id": "gav:org.springframework.boot:spring-boot-actuator-autoconfigure", + "kind": "JAR", + "displayName": "spring-boot-actuator-autoconfigure", + "groupId": "org.springframework.boot", + "artifactId": "spring-boot-actuator-autoconfigure", + "version": "4.0.0", + "projectName": null, + "location": "~/.m2/repository/org/springframework/boot/spring-boot-actuator-autoconfigure/4.0.0/spring-boot-actuator-autoconfigure-4.0.0.jar" + }, + { + "id": "gav:org.springframework.boot:spring-boot-autoconfigure", + "kind": "JAR", + "displayName": "spring-boot-autoconfigure", + "groupId": "org.springframework.boot", + "artifactId": "spring-boot-autoconfigure", + "version": "4.0.0", + "projectName": null, + "location": "~/.m2/repository/org/springframework/boot/spring-boot-autoconfigure/4.0.0/spring-boot-autoconfigure-4.0.0.jar" + }, + { + "id": "gav:org.springframework.boot:spring-boot-health", + "kind": "JAR", + "displayName": "spring-boot-health", + "groupId": "org.springframework.boot", + "artifactId": "spring-boot-health", + "version": "4.0.0", + "projectName": null, + "location": "~/.m2/repository/org/springframework/boot/spring-boot-health/4.0.0/spring-boot-health-4.0.0.jar" + }, + { + "id": "gav:org.springframework.boot:spring-boot-http-converter", + "kind": "JAR", + "displayName": "spring-boot-http-converter", + "groupId": "org.springframework.boot", + "artifactId": "spring-boot-http-converter", + "version": "4.0.0", + "projectName": null, + "location": "~/.m2/repository/org/springframework/boot/spring-boot-http-converter/4.0.0/spring-boot-http-converter-4.0.0.jar" + }, + { + "id": "gav:org.springframework.boot:spring-boot-jackson", + "kind": "JAR", + "displayName": "spring-boot-jackson", + "groupId": "org.springframework.boot", + "artifactId": "spring-boot-jackson", + "version": "4.0.0", + "projectName": null, + "location": "~/.m2/repository/org/springframework/boot/spring-boot-jackson/4.0.0/spring-boot-jackson-4.0.0.jar" + }, + { + "id": "gav:org.springframework.boot:spring-boot-micrometer-metrics", + "kind": "JAR", + "displayName": "spring-boot-micrometer-metrics", + "groupId": "org.springframework.boot", + "artifactId": "spring-boot-micrometer-metrics", + "version": "4.0.0", + "projectName": null, + "location": "~/.m2/repository/org/springframework/boot/spring-boot-micrometer-metrics/4.0.0/spring-boot-micrometer-metrics-4.0.0.jar" + }, + { + "id": "gav:org.springframework.boot:spring-boot-micrometer-observation", + "kind": "JAR", + "displayName": "spring-boot-micrometer-observation", + "groupId": "org.springframework.boot", + "artifactId": "spring-boot-micrometer-observation", + "version": "4.0.0", + "projectName": null, + "location": "~/.m2/repository/org/springframework/boot/spring-boot-micrometer-observation/4.0.0/spring-boot-micrometer-observation-4.0.0.jar" + }, + { + "id": "gav:org.springframework.boot:spring-boot-servlet", + "kind": "JAR", + "displayName": "spring-boot-servlet", + "groupId": "org.springframework.boot", + "artifactId": "spring-boot-servlet", + "version": "4.0.0", + "projectName": null, + "location": "~/.m2/repository/org/springframework/boot/spring-boot-servlet/4.0.0/spring-boot-servlet-4.0.0.jar" + }, + { + "id": "gav:org.springframework.boot:spring-boot-starter", + "kind": "JAR", + "displayName": "spring-boot-starter", + "groupId": "org.springframework.boot", + "artifactId": "spring-boot-starter", + "version": "4.0.0", + "projectName": null, + "location": "~/.m2/repository/org/springframework/boot/spring-boot-starter/4.0.0/spring-boot-starter-4.0.0.jar" + }, + { + "id": "gav:org.springframework.boot:spring-boot-starter-actuator", + "kind": "JAR", + "displayName": "spring-boot-starter-actuator", + "groupId": "org.springframework.boot", + "artifactId": "spring-boot-starter-actuator", + "version": "4.0.0", + "projectName": null, + "location": "~/.m2/repository/org/springframework/boot/spring-boot-starter-actuator/4.0.0/spring-boot-starter-actuator-4.0.0.jar" + }, + { + "id": "gav:org.springframework.boot:spring-boot-starter-jackson", + "kind": "JAR", + "displayName": "spring-boot-starter-jackson", + "groupId": "org.springframework.boot", + "artifactId": "spring-boot-starter-jackson", + "version": "4.0.0", + "projectName": null, + "location": "~/.m2/repository/org/springframework/boot/spring-boot-starter-jackson/4.0.0/spring-boot-starter-jackson-4.0.0.jar" + }, + { + "id": "gav:org.springframework.boot:spring-boot-starter-logging", + "kind": "JAR", + "displayName": "spring-boot-starter-logging", + "groupId": "org.springframework.boot", + "artifactId": "spring-boot-starter-logging", + "version": "4.0.0", + "projectName": null, + "location": "~/.m2/repository/org/springframework/boot/spring-boot-starter-logging/4.0.0/spring-boot-starter-logging-4.0.0.jar" + }, + { + "id": "gav:org.springframework.boot:spring-boot-starter-micrometer-metrics", + "kind": "JAR", + "displayName": "spring-boot-starter-micrometer-metrics", + "groupId": "org.springframework.boot", + "artifactId": "spring-boot-starter-micrometer-metrics", + "version": "4.0.0", + "projectName": null, + "location": "~/.m2/repository/org/springframework/boot/spring-boot-starter-micrometer-metrics/4.0.0/spring-boot-starter-micrometer-metrics-4.0.0.jar" + }, + { + "id": "gav:org.springframework.boot:spring-boot-starter-tomcat", + "kind": "JAR", + "displayName": "spring-boot-starter-tomcat", + "groupId": "org.springframework.boot", + "artifactId": "spring-boot-starter-tomcat", + "version": "4.0.0", + "projectName": null, + "location": "~/.m2/repository/org/springframework/boot/spring-boot-starter-tomcat/4.0.0/spring-boot-starter-tomcat-4.0.0.jar" + }, + { + "id": "gav:org.springframework.boot:spring-boot-starter-tomcat-runtime", + "kind": "JAR", + "displayName": "spring-boot-starter-tomcat-runtime", + "groupId": "org.springframework.boot", + "artifactId": "spring-boot-starter-tomcat-runtime", + "version": "4.0.0", + "projectName": null, + "location": "~/.m2/repository/org/springframework/boot/spring-boot-starter-tomcat-runtime/4.0.0/spring-boot-starter-tomcat-runtime-4.0.0.jar" + }, + { + "id": "gav:org.springframework.boot:spring-boot-starter-web", + "kind": "JAR", + "displayName": "spring-boot-starter-web", + "groupId": "org.springframework.boot", + "artifactId": "spring-boot-starter-web", + "version": "4.0.0", + "projectName": null, + "location": "~/.m2/repository/org/springframework/boot/spring-boot-starter-web/4.0.0/spring-boot-starter-web-4.0.0.jar" + }, + { + "id": "gav:org.springframework.boot:spring-boot-tomcat", + "kind": "JAR", + "displayName": "spring-boot-tomcat", + "groupId": "org.springframework.boot", + "artifactId": "spring-boot-tomcat", + "version": "4.0.0", + "projectName": null, + "location": "~/.m2/repository/org/springframework/boot/spring-boot-tomcat/4.0.0/spring-boot-tomcat-4.0.0.jar" + }, + { + "id": "gav:org.springframework.boot:spring-boot-web-server", + "kind": "JAR", + "displayName": "spring-boot-web-server", + "groupId": "org.springframework.boot", + "artifactId": "spring-boot-web-server", + "version": "4.0.0", + "projectName": null, + "location": "~/.m2/repository/org/springframework/boot/spring-boot-web-server/4.0.0/spring-boot-web-server-4.0.0.jar" + }, + { + "id": "gav:org.springframework.boot:spring-boot-webmvc", + "kind": "JAR", + "displayName": "spring-boot-webmvc", + "groupId": "org.springframework.boot", + "artifactId": "spring-boot-webmvc", + "version": "4.0.0", + "projectName": null, + "location": "~/.m2/repository/org/springframework/boot/spring-boot-webmvc/4.0.0/spring-boot-webmvc-4.0.0.jar" + }, + { + "id": "gav:org.springframework:spring-context", + "kind": "JAR", + "displayName": "spring-context", + "groupId": "org.springframework", + "artifactId": "spring-context", + "version": "7.0.1", + "projectName": null, + "location": "~/.m2/repository/org/springframework/spring-context/7.0.1/spring-context-7.0.1.jar" + }, + { + "id": "gav:org.springframework:spring-core", + "kind": "JAR", + "displayName": "spring-core", + "groupId": "org.springframework", + "artifactId": "spring-core", + "version": "7.0.1", + "projectName": null, + "location": "~/.m2/repository/org/springframework/spring-core/7.0.1/spring-core-7.0.1.jar" + }, + { + "id": "gav:org.springframework:spring-expression", + "kind": "JAR", + "displayName": "spring-expression", + "groupId": "org.springframework", + "artifactId": "spring-expression", + "version": "7.0.1", + "projectName": null, + "location": "~/.m2/repository/org/springframework/spring-expression/7.0.1/spring-expression-7.0.1.jar" + }, + { + "id": "gav:org.springframework:spring-web", + "kind": "JAR", + "displayName": "spring-web", + "groupId": "org.springframework", + "artifactId": "spring-web", + "version": "7.0.1", + "projectName": null, + "location": "~/.m2/repository/org/springframework/spring-web/7.0.1/spring-web-7.0.1.jar" + }, + { + "id": "gav:org.springframework:spring-webflux", + "kind": "JAR", + "displayName": "spring-webflux", + "groupId": "org.springframework", + "artifactId": "spring-webflux", + "version": "7.0.1", + "projectName": null, + "location": "~/.m2/repository/org/springframework/spring-webflux/7.0.1/spring-webflux-7.0.1.jar" + }, + { + "id": "gav:org.springframework:spring-webmvc", + "kind": "JAR", + "displayName": "spring-webmvc", + "groupId": "org.springframework", + "artifactId": "spring-webmvc", + "version": "7.0.1", + "projectName": null, + "location": "~/.m2/repository/org/springframework/spring-webmvc/7.0.1/spring-webmvc-7.0.1.jar" + }, + { + "id": "gav:org.apache.tomcat.embed:tomcat-embed-core", + "kind": "JAR", + "displayName": "tomcat-embed-core", + "groupId": "org.apache.tomcat.embed", + "artifactId": "tomcat-embed-core", + "version": "11.0.14", + "projectName": null, + "location": "~/.m2/repository/org/apache/tomcat/embed/tomcat-embed-core/11.0.14/tomcat-embed-core-11.0.14.jar" + }, + { + "id": "gav:org.apache.tomcat.embed:tomcat-embed-el", + "kind": "JAR", + "displayName": "tomcat-embed-el", + "groupId": "org.apache.tomcat.embed", + "artifactId": "tomcat-embed-el", + "version": "11.0.14", + "projectName": null, + "location": "~/.m2/repository/org/apache/tomcat/embed/tomcat-embed-el/11.0.14/tomcat-embed-el-11.0.14.jar" + }, + { + "id": "gav:org.apache.tomcat.embed:tomcat-embed-websocket", + "kind": "JAR", + "displayName": "tomcat-embed-websocket", + "groupId": "org.apache.tomcat.embed", + "artifactId": "tomcat-embed-websocket", + "version": "11.0.14", + "projectName": null, + "location": "~/.m2/repository/org/apache/tomcat/embed/tomcat-embed-websocket/11.0.14/tomcat-embed-websocket-11.0.14.jar" + } +] diff --git a/agent-plugins/spring-tools/evals/mocks/spring-tools-mcp/getUpcomingReleases.md b/agent-plugins/spring-tools/evals/mocks/spring-tools-mcp/getUpcomingReleases.md new file mode 100644 index 0000000000..5887782daa --- /dev/null +++ b/agent-plugins/spring-tools/evals/mocks/spring-tools-mcp/getUpcomingReleases.md @@ -0,0 +1,2186 @@ +[ + { + "allDay": true, + "backgroundColor": null, + "start": "2026-10-22", + "title": "Spring Modulith 1.4.14", + "url": "https://github.com/spring-projects/spring-modulith/milestone/117" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-10-22", + "title": "Spring Modulith 2.0.9", + "url": "https://github.com/spring-projects/spring-modulith/milestone/118" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-10-22", + "title": "Spring Modulith 2.1.2", + "url": "https://github.com/spring-projects/spring-modulith/milestone/119" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-10-22", + "title": "Spring Modulith 2.2.0-RC1", + "url": "https://github.com/spring-projects/spring-modulith/milestone/120" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-10-22", + "title": "Spring Vault 4.1.1", + "url": "https://github.com/spring-projects/spring-vault/milestone/78" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-10-22", + "title": "Spring Vault 4.0.4", + "url": "https://github.com/spring-projects/spring-vault/milestone/79" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-11-19", + "title": "Spring Vault 3.1.4 (Enterprise)", + "url": "https://enterprise.spring.io/projects/spring-vault" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-11-19", + "title": "Spring Vault 2.3.5 (Enterprise)", + "url": "https://enterprise.spring.io/projects/spring-vault" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-11-19", + "title": "Spring Vault 3.2.2 (Enterprise)", + "url": "https://enterprise.spring.io/projects/spring-vault" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-11-19", + "title": "Spring Vault 4.0.4.1 (Enterprise)", + "url": "https://enterprise.spring.io/projects/spring-vault" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-11-19", + "title": "Spring Vault 4.1.1.1 (Enterprise)", + "url": "https://enterprise.spring.io/projects/spring-vault" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-10-22", + "title": "Reactor 2025.0.8", + "url": "https://github.com/reactor/reactor/milestone/140" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-10-22", + "title": "Reactor 2026.0.0-RC1", + "url": "https://github.com/reactor/reactor/milestone/142" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-11-19", + "title": "Reactor 2020.0.53 (Enterprise)", + "url": "https://enterprise.spring.io/projects/reactor" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-11-19", + "title": "Reactor 2024.0.20 (Enterprise)", + "url": "https://enterprise.spring.io/projects/reactor" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-10-22", + "title": "BlockHound 1.0.18.RELEASE", + "url": "https://github.com/reactor/BlockHound/milestone/22" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-10-22", + "title": "Reactor Core 3.8.8", + "url": "https://github.com/reactor/reactor-core/milestone/247" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-10-22", + "title": "Reactor Core 3.9.0-RC1", + "url": "https://github.com/reactor/reactor-core/milestone/248" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-11-19", + "title": "Reactor Core 3.4.45 (Enterprise)", + "url": "https://enterprise.spring.io/projects/reactor-core" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-11-19", + "title": "Reactor Core 3.7.21 (Enterprise)", + "url": "https://enterprise.spring.io/projects/reactor-core" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-10-22", + "title": "Spring Cloud Circuitbreaker 5.0.4", + "url": "https://github.com/spring-cloud/spring-cloud-circuitbreaker/milestone/41" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-10-22", + "title": "Spring Cloud Circuitbreaker 5.1.0-RC1", + "url": "https://github.com/spring-cloud/spring-cloud-circuitbreaker/milestone/42" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-10-22", + "title": "Spring Cloud Zookeeper 5.0.3", + "url": "https://github.com/spring-cloud/spring-cloud-zookeeper/milestone/64" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-10-22", + "title": "Spring Cloud Zookeeper 5.1.0-RC1", + "url": "https://github.com/spring-cloud/spring-cloud-zookeeper/milestone/66" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-12-09", + "title": "Spring Tools 5.5.0.RELEASE", + "url": "https://github.com/spring-projects/spring-tools/milestone/104" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2027-03-17", + "title": "Spring Tools 5.6.0.RELEASE", + "url": "https://github.com/spring-projects/spring-tools/milestone/105" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-10-22", + "title": "Spring Plugin 4.2.0-RC1", + "url": "https://github.com/spring-projects/spring-plugin/milestone/24" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-11-19", + "title": "Spring Data Geode 2.7.22 (2021.2.22) (Enterprise)", + "url": "https://enterprise.spring.io/projects/spring-data-geode" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-11-19", + "title": "Spring Data R2dbc 1.5.22 (2021.2.22) (Enterprise)", + "url": "https://enterprise.spring.io/projects/spring-data-r2dbc" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-10-22", + "title": "Spring Batch 6.0.6", + "url": "https://github.com/spring-projects/spring-batch/milestone/181" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-10-22", + "title": "Spring Batch 6.1.0-RC1", + "url": "https://github.com/spring-projects/spring-batch/milestone/184" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-10-22", + "title": "Spring Integration 7.0.7", + "url": "https://github.com/spring-projects/spring-integration/milestone/399" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-10-22", + "title": "Spring Integration 7.1.2", + "url": "https://github.com/spring-projects/spring-integration/milestone/400" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-10-22", + "title": "Spring Integration 7.2.0-RC1", + "url": "https://github.com/spring-projects/spring-integration/milestone/401" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-11-19", + "title": "Spring Integration 6.5.12 (Enterprise)", + "url": "https://enterprise.spring.io/projects/spring-integration" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-11-19", + "title": "Spring Integration 5.5.23 (Enterprise)", + "url": "https://enterprise.spring.io/projects/spring-integration" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-11-19", + "title": "Spring Integration 6.4.14 (Enterprise)", + "url": "https://enterprise.spring.io/projects/spring-integration" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-11-19", + "title": "Spring Integration 7.0.7.1 (Enterprise)", + "url": "https://enterprise.spring.io/projects/spring-integration" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-11-19", + "title": "Spring Integration 7.1.2.1 (Enterprise)", + "url": "https://enterprise.spring.io/projects/spring-integration" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-10-22", + "title": "Spring Cloud Stream 5.0.4", + "url": "https://github.com/spring-cloud/spring-cloud-stream/milestone/112" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-10-22", + "title": "Spring Cloud Stream 5.1.0-RC1", + "url": "https://github.com/spring-cloud/spring-cloud-stream/milestone/113" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-11-19", + "title": "Spring Cloud Stream 3.2.13 (Enterprise)", + "url": "https://enterprise.spring.io/projects/spring-cloud-stream" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-11-19", + "title": "Spring Cloud Stream 4.3.5 (Enterprise)", + "url": "https://enterprise.spring.io/projects/spring-cloud-stream" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-11-19", + "title": "Spring Cloud Stream 4.2.8 (Enterprise)", + "url": "https://enterprise.spring.io/projects/spring-cloud-stream" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-11-19", + "title": "Spring Cloud Stream 5.0.4.1 (Enterprise)", + "url": "https://enterprise.spring.io/projects/spring-cloud-stream" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-11-19", + "title": "Spring Session 2021.2.9 (Enterprise)", + "url": "https://enterprise.spring.io/projects/spring-session" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-11-19", + "title": "Spring Session 3.4.11 (Enterprise)", + "url": "https://enterprise.spring.io/projects/spring-session" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-11-19", + "title": "Spring Session 3.5.9 (Enterprise)", + "url": "https://enterprise.spring.io/projects/spring-session" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-10-22", + "title": "Spring Cloud Openfeign 5.0.4", + "url": "https://github.com/spring-cloud/spring-cloud-openfeign/milestone/94" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-10-22", + "title": "Spring Cloud Openfeign 5.1.0-RC1", + "url": "https://github.com/spring-cloud/spring-cloud-openfeign/milestone/96" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-11-19", + "title": "Spring Cloud Openfeign 4.2.7 (Enterprise)", + "url": "https://enterprise.spring.io/projects/spring-cloud-openfeign" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-10-22", + "title": "Spring Data Mongodb 5.1.2 (2026.0.2)", + "url": "https://github.com/spring-projects/spring-data-mongodb/milestone/407" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-10-22", + "title": "Spring Data Mongodb 5.0.8 (2025.1.8)", + "url": "https://github.com/spring-projects/spring-data-mongodb/milestone/408" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-10-22", + "title": "Spring Data Mongodb 5.2.0-RC1 (2026.1.0)", + "url": "https://github.com/spring-projects/spring-data-mongodb/milestone/409" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-11-19", + "title": "Spring Data Mongodb 5.0.8.1 (2025.1.8.1) (Enterprise)", + "url": "https://enterprise.spring.io/projects/spring-data-mongodb" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-11-19", + "title": "Spring Data Mongodb 5.1.2.1 (2026.0.2.1) (Enterprise)", + "url": "https://enterprise.spring.io/projects/spring-data-mongodb" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-11-19", + "title": "Spring Data Mongodb 4.5.15 (2025.0.15) (Enterprise)", + "url": "https://enterprise.spring.io/projects/spring-data-mongodb" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-11-19", + "title": "Spring Data Mongodb 4.4.17 (2024.1.17) (Enterprise)", + "url": "https://enterprise.spring.io/projects/spring-data-mongodb" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-11-19", + "title": "Spring Data Mongodb 3.4.22 (2021.2.22) (Enterprise)", + "url": "https://enterprise.spring.io/projects/spring-data-mongodb" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-10-22", + "title": "Spring Graphql 2.0.6", + "url": "https://github.com/spring-projects/spring-graphql/milestone/80" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-10-22", + "title": "Spring Graphql 2.1.0-RC1", + "url": "https://github.com/spring-projects/spring-graphql/milestone/81" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-11-19", + "title": "Spring Graphql 1.3.11 (Enterprise)", + "url": "https://enterprise.spring.io/projects/spring-graphql" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-11-19", + "title": "Spring Graphql 1.4.8 (Enterprise)", + "url": "https://enterprise.spring.io/projects/spring-graphql" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-11-19", + "title": "Spring Graphql 2.0.6.1 (Enterprise)", + "url": "https://enterprise.spring.io/projects/spring-graphql" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-10-22", + "title": "Reactor Kotlin Extensions 1.3.3", + "url": "https://github.com/reactor/reactor-kotlin-extensions/milestone/33" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-11-19", + "title": "Reactor Kotlin Extensions 1.1.14 (Enterprise)", + "url": "https://enterprise.spring.io/projects/reactor-kotlin-extensions" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-11-19", + "title": "Reactor Kotlin Extensions 1.2.8 (Enterprise)", + "url": "https://enterprise.spring.io/projects/reactor-kotlin-extensions" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-10-22", + "title": "Reactor Addons 3.6.2", + "url": "https://github.com/reactor/reactor-addons/milestone/69" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-10-22", + "title": "Reactor Addons 3.7.0-RC1", + "url": "https://github.com/reactor/reactor-addons/milestone/72" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-11-19", + "title": "Reactor Addons 3.4.14 (Enterprise)", + "url": "https://enterprise.spring.io/projects/reactor-addons" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-11-19", + "title": "Reactor Addons 3.5.7 (Enterprise)", + "url": "https://enterprise.spring.io/projects/reactor-addons" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-10-22", + "title": "Spring Data Redis 4.1.2 (2026.0.2)", + "url": "https://github.com/spring-projects/spring-data-redis/milestone/390" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-10-22", + "title": "Spring Data Redis 4.0.8 (2025.1.8)", + "url": "https://github.com/spring-projects/spring-data-redis/milestone/391" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-10-22", + "title": "Spring Data Redis 4.2.0-RC1 (2026.1.0)", + "url": "https://github.com/spring-projects/spring-data-redis/milestone/392" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-11-19", + "title": "Spring Data Redis 4.0.8.1 (2025.1.8.1) (Enterprise)", + "url": "https://enterprise.spring.io/projects/spring-data-redis" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-11-19", + "title": "Spring Data Redis 4.1.2.1 (2026.0.2.1) (Enterprise)", + "url": "https://enterprise.spring.io/projects/spring-data-redis" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-11-19", + "title": "Spring Data Redis 3.5.15 (2025.0.15) (Enterprise)", + "url": "https://enterprise.spring.io/projects/spring-data-redis" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-11-19", + "title": "Spring Data Redis 3.4.17 (2024.1.17) (Enterprise)", + "url": "https://enterprise.spring.io/projects/spring-data-redis" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-11-19", + "title": "Spring Data Redis 2.7.22 (2021.2.22) (Enterprise)", + "url": "https://enterprise.spring.io/projects/spring-data-redis" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-10-22", + "title": "Spring Data Ldap 4.1.2 (2026.0.2)", + "url": "https://github.com/spring-projects/spring-data-ldap/milestone/322" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-10-22", + "title": "Spring Data Ldap 4.0.8 (2025.1.8)", + "url": "https://github.com/spring-projects/spring-data-ldap/milestone/323" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-10-22", + "title": "Spring Data Ldap 4.2.0-RC1 (2026.1.0)", + "url": "https://github.com/spring-projects/spring-data-ldap/milestone/324" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-11-19", + "title": "Spring Data Ldap 4.0.8.1 (2025.1.8.1) (Enterprise)", + "url": "https://enterprise.spring.io/projects/spring-data-ldap" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-11-19", + "title": "Spring Data Ldap 4.1.2.1 (2026.0.2.1) (Enterprise)", + "url": "https://enterprise.spring.io/projects/spring-data-ldap" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-11-19", + "title": "Spring Data Ldap 3.5.15 (2025.0.15) (Enterprise)", + "url": "https://enterprise.spring.io/projects/spring-data-ldap" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-11-19", + "title": "Spring Data Ldap 3.4.17 (2024.1.17) (Enterprise)", + "url": "https://enterprise.spring.io/projects/spring-data-ldap" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-11-19", + "title": "Spring Data Ldap 2.7.22 (2021.2.22) (Enterprise)", + "url": "https://enterprise.spring.io/projects/spring-data-ldap" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-10-22", + "title": "Spring Data Rest 5.1.2 (2026.0.2)", + "url": "https://github.com/spring-projects/spring-data-rest/milestone/384" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-10-22", + "title": "Spring Data Rest 5.0.8 (2025.1.8)", + "url": "https://github.com/spring-projects/spring-data-rest/milestone/385" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-10-22", + "title": "Spring Data Rest 5.2.0-RC1 (2026.1.0)", + "url": "https://github.com/spring-projects/spring-data-rest/milestone/386" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-11-19", + "title": "Spring Data Rest 5.0.8.1 (2025.1.8.1) (Enterprise)", + "url": "https://enterprise.spring.io/projects/spring-data-rest" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-11-19", + "title": "Spring Data Rest 5.1.2.1 (2026.0.2.1) (Enterprise)", + "url": "https://enterprise.spring.io/projects/spring-data-rest" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-11-19", + "title": "Spring Data Rest 4.5.15 (2025.0.15) (Enterprise)", + "url": "https://enterprise.spring.io/projects/spring-data-rest" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-11-19", + "title": "Spring Data Rest 4.4.17 (2024.1.17) (Enterprise)", + "url": "https://enterprise.spring.io/projects/spring-data-rest" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-11-19", + "title": "Spring Data Rest 3.7.22 (2021.2.22) (Enterprise)", + "url": "https://enterprise.spring.io/projects/spring-data-rest" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-11-19", + "title": "Spring Authorization Server 1.4.13 (Enterprise)", + "url": "https://enterprise.spring.io/projects/spring-authorization-server" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-11-19", + "title": "Spring Authorization Server 1.5.10 (Enterprise)", + "url": "https://enterprise.spring.io/projects/spring-authorization-server" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-10-22", + "title": "Spring Cloud Task 5.0.3", + "url": "https://github.com/spring-cloud/spring-cloud-task/milestone/81" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-10-22", + "title": "Spring Cloud Task 5.1.0-RC1", + "url": "https://github.com/spring-cloud/spring-cloud-task/milestone/84" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-11-19", + "title": "Spring Cloud Task 3.3.4 (Enterprise)", + "url": "https://enterprise.spring.io/projects/spring-cloud-task" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-11-19", + "title": "Spring Cloud Task 3.2.7 (Enterprise)", + "url": "https://enterprise.spring.io/projects/spring-cloud-task" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-10-22", + "title": "Spring Cloud Consul 5.0.4", + "url": "https://github.com/spring-cloud/spring-cloud-consul/milestone/82" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-10-22", + "title": "Spring Cloud Consul 5.1.0-RC1", + "url": "https://github.com/spring-cloud/spring-cloud-consul/milestone/84" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-10-22", + "title": "Spring Data Jpa 4.1.2 (2026.0.2)", + "url": "https://github.com/spring-projects/spring-data-jpa/milestone/407" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-10-22", + "title": "Spring Data Jpa 4.0.8 (2025.1.8)", + "url": "https://github.com/spring-projects/spring-data-jpa/milestone/408" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-10-22", + "title": "Spring Data Jpa 4.2.0-RC1 (2026.1.0)", + "url": "https://github.com/spring-projects/spring-data-jpa/milestone/409" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-11-19", + "title": "Spring Data Jpa 4.0.8.1 (2025.1.8.1) (Enterprise)", + "url": "https://enterprise.spring.io/projects/spring-data-jpa" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-11-19", + "title": "Spring Data Jpa 4.1.2.1 (2026.0.2.1) (Enterprise)", + "url": "https://enterprise.spring.io/projects/spring-data-jpa" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-11-19", + "title": "Spring Data Jpa 3.5.15 (2025.0.15) (Enterprise)", + "url": "https://enterprise.spring.io/projects/spring-data-jpa" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-11-19", + "title": "Spring Data Jpa 3.4.17 (2024.1.17) (Enterprise)", + "url": "https://enterprise.spring.io/projects/spring-data-jpa" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-11-19", + "title": "Spring Data Jpa 2.7.22 (2021.2.22) (Enterprise)", + "url": "https://enterprise.spring.io/projects/spring-data-jpa" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-10-22", + "title": "Spring Grpc 1.1.2", + "url": "https://github.com/spring-projects/spring-grpc/milestone/22" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-10-22", + "title": "Spring Kafka 4.0.8", + "url": "https://github.com/spring-projects/spring-kafka/milestone/300" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-10-22", + "title": "Spring Kafka 4.1.2", + "url": "https://github.com/spring-projects/spring-kafka/milestone/301" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-10-22", + "title": "Spring Kafka 4.2.0-RC1", + "url": "https://github.com/spring-projects/spring-kafka/milestone/303" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-11-19", + "title": "Spring Kafka 2.9.16 (Enterprise)", + "url": "https://enterprise.spring.io/projects/spring-kafka" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-11-19", + "title": "Spring Kafka 2.8.14 (Enterprise)", + "url": "https://enterprise.spring.io/projects/spring-kafka" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-11-19", + "title": "Spring Kafka 3.3.18 (Enterprise)", + "url": "https://enterprise.spring.io/projects/spring-kafka" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-11-19", + "title": "Spring Kafka 4.1.2.1 (Enterprise)", + "url": "https://enterprise.spring.io/projects/spring-kafka" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-11-19", + "title": "Spring Kafka 4.0.8.1 (Enterprise)", + "url": "https://enterprise.spring.io/projects/spring-kafka" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-10-22", + "title": "Spring Data Bom 2026.0.2", + "url": "https://github.com/spring-projects/spring-data-bom/milestone/210" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-10-22", + "title": "Spring Data Bom 2025.1.8", + "url": "https://github.com/spring-projects/spring-data-bom/milestone/211" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-10-22", + "title": "Spring Data Bom 2026.1.0-RC1", + "url": "https://github.com/spring-projects/spring-data-bom/milestone/212" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-11-19", + "title": "Spring Data Bom 2025.1.8.1 (Enterprise)", + "url": "https://enterprise.spring.io/projects/spring-data-bom" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-11-19", + "title": "Spring Data Bom 2026.0.2.1 (Enterprise)", + "url": "https://enterprise.spring.io/projects/spring-data-bom" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-11-19", + "title": "Spring Data Bom 2025.0.15 (Enterprise)", + "url": "https://enterprise.spring.io/projects/spring-data-bom" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-11-19", + "title": "Spring Data Bom 2024.1.17 (Enterprise)", + "url": "https://enterprise.spring.io/projects/spring-data-bom" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-11-19", + "title": "Spring Data Bom 2021.2.22 (Enterprise)", + "url": "https://enterprise.spring.io/projects/spring-data-bom" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-10-22", + "title": "Spring Data Commons 4.1.2 (2026.0.2)", + "url": "https://github.com/spring-projects/spring-data-commons/milestone/415" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-10-22", + "title": "Spring Data Commons 4.0.8 (2025.1.8)", + "url": "https://github.com/spring-projects/spring-data-commons/milestone/416" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-10-22", + "title": "Spring Data Commons 4.2.0-RC1 (2026.1.0)", + "url": "https://github.com/spring-projects/spring-data-commons/milestone/417" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-11-19", + "title": "Spring Data Commons 4.0.8.1 (2025.1.8.1) (Enterprise)", + "url": "https://enterprise.spring.io/projects/spring-data-commons" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-11-19", + "title": "Spring Data Commons 4.1.2.1 (2026.0.2.1) (Enterprise)", + "url": "https://enterprise.spring.io/projects/spring-data-commons" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-11-19", + "title": "Spring Data Commons 3.5.15 (2025.0.15) (Enterprise)", + "url": "https://enterprise.spring.io/projects/spring-data-commons" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-11-19", + "title": "Spring Data Commons 3.4.17 (2024.1.17) (Enterprise)", + "url": "https://enterprise.spring.io/projects/spring-data-commons" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-11-19", + "title": "Spring Data Commons 2.7.22 (2021.2.22) (Enterprise)", + "url": "https://enterprise.spring.io/projects/spring-data-commons" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-10-22", + "title": "Spring Cloud Commons 5.0.4", + "url": "https://github.com/spring-cloud/spring-cloud-commons/milestone/153" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-10-22", + "title": "Spring Cloud Commons 5.1.0-RC1", + "url": "https://github.com/spring-cloud/spring-cloud-commons/milestone/154" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-10-22", + "title": "Spring Cloud Config 5.0.6", + "url": "https://github.com/spring-cloud/spring-cloud-config/milestone/159" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-10-22", + "title": "Spring Cloud Config 5.1.0-RC1", + "url": "https://github.com/spring-cloud/spring-cloud-config/milestone/160" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-11-19", + "title": "Spring Cloud Config 4.3.6 (Enterprise)", + "url": "https://enterprise.spring.io/projects/spring-cloud-config" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-11-19", + "title": "Spring Cloud Config 4.2.10 (Enterprise)", + "url": "https://enterprise.spring.io/projects/spring-cloud-config" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-11-19", + "title": "Spring Cloud Config 3.1.16 (Enterprise)", + "url": "https://enterprise.spring.io/projects/spring-cloud-config" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-11-19", + "title": "Spring Cloud Config 5.0.6.1 (Enterprise)", + "url": "https://enterprise.spring.io/projects/spring-cloud-config" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-10-22", + "title": "Spring Data Keyvalue 4.1.2 (2026.0.2)", + "url": "https://github.com/spring-projects/spring-data-keyvalue/milestone/351" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-10-22", + "title": "Spring Data Keyvalue 4.0.8 (2025.1.8)", + "url": "https://github.com/spring-projects/spring-data-keyvalue/milestone/352" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-10-22", + "title": "Spring Data Keyvalue 4.2.0-RC1 (2026.1.0)", + "url": "https://github.com/spring-projects/spring-data-keyvalue/milestone/353" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-11-19", + "title": "Spring Data Keyvalue 4.0.8.1 (2025.1.8.1) (Enterprise)", + "url": "https://enterprise.spring.io/projects/spring-data-keyvalue" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-11-19", + "title": "Spring Data Keyvalue 4.1.2.1 (2026.0.2.1) (Enterprise)", + "url": "https://enterprise.spring.io/projects/spring-data-keyvalue" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-11-19", + "title": "Spring Data Keyvalue 3.5.15 (2025.0.15) (Enterprise)", + "url": "https://enterprise.spring.io/projects/spring-data-keyvalue" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-11-19", + "title": "Spring Data Keyvalue 3.4.17 (2024.1.17) (Enterprise)", + "url": "https://enterprise.spring.io/projects/spring-data-keyvalue" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-11-19", + "title": "Spring Data Keyvalue 2.7.22 (2021.2.22) (Enterprise)", + "url": "https://enterprise.spring.io/projects/spring-data-keyvalue" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-11-19", + "title": "Reactor Rabbitmq 1.5.9 (Enterprise)", + "url": "https://enterprise.spring.io/projects/reactor-rabbitmq" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-10-22", + "title": "Micrometer 1.17.2", + "url": "https://github.com/micrometer-metrics/micrometer/milestone/349" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-10-22", + "title": "Micrometer 1.16.8", + "url": "https://github.com/micrometer-metrics/micrometer/milestone/350" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-10-22", + "title": "Micrometer 1.18.0-RC1", + "url": "https://github.com/micrometer-metrics/micrometer/milestone/351" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-11-19", + "title": "Micrometer 1.9.20 (Enterprise)", + "url": "https://enterprise.spring.io/projects/micrometer" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-11-19", + "title": "Micrometer 1.14.18 (Enterprise)", + "url": "https://enterprise.spring.io/projects/micrometer" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-11-19", + "title": "Micrometer 1.15.14 (Enterprise)", + "url": "https://enterprise.spring.io/projects/micrometer" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-10-22", + "title": "Spring Restdocs 4.1.0-RC1", + "url": "https://github.com/spring-projects/spring-restdocs/milestone/55" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-10-22", + "title": "Spring Cloud Release 2025.1.4", + "url": "https://github.com/spring-cloud/spring-cloud-release/milestone/173" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-10-22", + "title": "Spring Cloud Release 2026.0.0-RC1", + "url": "https://github.com/spring-cloud/spring-cloud-release/milestone/174" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-11-19", + "title": "Spring Cloud Release 2025.0.5 (Enterprise)", + "url": "https://enterprise.spring.io/projects/spring-cloud-release" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-11-19", + "title": "Spring Cloud Release 2024.0.9 (Enterprise)", + "url": "https://enterprise.spring.io/projects/spring-cloud-release" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-11-19", + "title": "Spring Cloud Release 2021.0.16 (Enterprise)", + "url": "https://enterprise.spring.io/projects/spring-cloud-release" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-11-19", + "title": "Spring Cloud Release 2025.1.4.1 (Enterprise)", + "url": "https://enterprise.spring.io/projects/spring-cloud-release" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-10-22", + "title": "Spring Data Build 4.1.2 (2026.0.2)", + "url": "https://github.com/spring-projects/spring-data-build/milestone/386" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-10-22", + "title": "Spring Data Build 4.0.8 (2025.1.8)", + "url": "https://github.com/spring-projects/spring-data-build/milestone/387" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-10-22", + "title": "Spring Data Build 4.2.0-RC1 (2026.1.0)", + "url": "https://github.com/spring-projects/spring-data-build/milestone/388" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-11-19", + "title": "Spring Data Build 4.0.8.1 (2025.1.8.1) (Enterprise)", + "url": "https://enterprise.spring.io/projects/spring-data-build" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-11-19", + "title": "Spring Data Build 4.1.2.1 (2026.0.2.1) (Enterprise)", + "url": "https://enterprise.spring.io/projects/spring-data-build" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-11-19", + "title": "Spring Data Build 3.5.15 (2025.0.15) (Enterprise)", + "url": "https://enterprise.spring.io/projects/spring-data-build" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-11-19", + "title": "Spring Data Build 3.4.17 (2024.1.17) (Enterprise)", + "url": "https://enterprise.spring.io/projects/spring-data-build" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-11-19", + "title": "Spring Data Build 2.7.22 (2021.2.22) (Enterprise)", + "url": "https://enterprise.spring.io/projects/spring-data-build" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-10-22", + "title": "Spring Hateoas 3.1.3", + "url": "https://github.com/spring-projects/spring-hateoas/milestone/180" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-10-22", + "title": "Spring Hateoas 3.0.6", + "url": "https://github.com/spring-projects/spring-hateoas/milestone/181" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-10-22", + "title": "Spring Hateoas 3.2.0-RC1", + "url": "https://github.com/spring-projects/spring-hateoas/milestone/183" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-11-19", + "title": "Spring Hateoas 2.5.5 (Enterprise)", + "url": "https://enterprise.spring.io/projects/spring-hateoas" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-11-19", + "title": "Spring Hateoas 2.4.4 (Enterprise)", + "url": "https://enterprise.spring.io/projects/spring-hateoas" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-11-19", + "title": "Spring Hateoas 3.0.6.1 (Enterprise)", + "url": "https://enterprise.spring.io/projects/spring-hateoas" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-11-19", + "title": "Spring Hateoas 3.1.3.1 (Enterprise)", + "url": "https://enterprise.spring.io/projects/spring-hateoas" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-10-22", + "title": "Spring Data Elasticsearch 6.1.2 (2026.0.2)", + "url": "https://github.com/spring-projects/spring-data-elasticsearch/milestone/370" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-10-22", + "title": "Spring Data Elasticsearch 6.0.8 (2025.1.8)", + "url": "https://github.com/spring-projects/spring-data-elasticsearch/milestone/371" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-10-22", + "title": "Spring Data Elasticsearch 6.2.0-RC1 (2026.1.0)", + "url": "https://github.com/spring-projects/spring-data-elasticsearch/milestone/372" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-11-19", + "title": "Spring Data Elasticsearch 6.0.8.1 (2025.1.8.1) (Enterprise)", + "url": "https://enterprise.spring.io/projects/spring-data-elasticsearch" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-11-19", + "title": "Spring Data Elasticsearch 6.1.2.1 (2026.0.2.1) (Enterprise)", + "url": "https://enterprise.spring.io/projects/spring-data-elasticsearch" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-11-19", + "title": "Spring Data Elasticsearch 5.5.15 (2025.0.15) (Enterprise)", + "url": "https://enterprise.spring.io/projects/spring-data-elasticsearch" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-11-19", + "title": "Spring Data Elasticsearch 5.4.17 (2024.1.17) (Enterprise)", + "url": "https://enterprise.spring.io/projects/spring-data-elasticsearch" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-11-19", + "title": "Spring Data Elasticsearch 4.4.22 (2021.2.22) (Enterprise)", + "url": "https://enterprise.spring.io/projects/spring-data-elasticsearch" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-11-19", + "title": "Spring Data Envers 2.7.22 (2021.2.22) (Enterprise)", + "url": "https://enterprise.spring.io/projects/spring-data-envers" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-10-22", + "title": "Spring Data Couchbase 6.1.2 (2026.0.2)", + "url": "https://github.com/spring-projects/spring-data-couchbase/milestone/379" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-10-22", + "title": "Spring Data Couchbase 6.0.8 (2025.1.8)", + "url": "https://github.com/spring-projects/spring-data-couchbase/milestone/380" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-10-22", + "title": "Spring Data Couchbase 6.2.0-RC1 (2026.1.0)", + "url": "https://github.com/spring-projects/spring-data-couchbase/milestone/381" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-11-19", + "title": "Spring Data Couchbase 6.0.8.1 (2025.1.8.1) (Enterprise)", + "url": "https://enterprise.spring.io/projects/spring-data-couchbase" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-11-19", + "title": "Spring Data Couchbase 6.1.2.1 (2026.0.2.1) (Enterprise)", + "url": "https://enterprise.spring.io/projects/spring-data-couchbase" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-11-19", + "title": "Spring Data Couchbase 5.5.15 (2025.0.15) (Enterprise)", + "url": "https://enterprise.spring.io/projects/spring-data-couchbase" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-11-19", + "title": "Spring Data Couchbase 5.4.17 (2024.1.17) (Enterprise)", + "url": "https://enterprise.spring.io/projects/spring-data-couchbase" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-11-19", + "title": "Spring Data Couchbase 4.4.22 (2021.2.22) (Enterprise)", + "url": "https://enterprise.spring.io/projects/spring-data-couchbase" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-11-19", + "title": "Spring Ldap 2.4.7 (Enterprise)", + "url": "https://enterprise.spring.io/projects/spring-ldap" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-11-19", + "title": "Spring Ldap 3.2.20 (Enterprise)", + "url": "https://enterprise.spring.io/projects/spring-ldap" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-11-19", + "title": "Spring Ldap 3.3.10 (Enterprise)", + "url": "https://enterprise.spring.io/projects/spring-ldap" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-10-22", + "title": "Spring Ws 5.1.0-RC1", + "url": "https://github.com/spring-projects/spring-ws/milestone/129" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-10-22", + "title": "Spring Ws 5.0.3", + "url": "https://github.com/spring-projects/spring-ws/milestone/133" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-11-19", + "title": "Spring Ws 4.1.5 (Enterprise)", + "url": "https://enterprise.spring.io/projects/spring-ws" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-11-19", + "title": "Spring Ws 4.0.20 (Enterprise)", + "url": "https://enterprise.spring.io/projects/spring-ws" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-11-19", + "title": "Spring Ws 3.1.10 (Enterprise)", + "url": "https://enterprise.spring.io/projects/spring-ws" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-11-19", + "title": "Spring Ws 5.0.3.1 (Enterprise)", + "url": "https://enterprise.spring.io/projects/spring-ws" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-10-22", + "title": "Micrometer Tracing 1.6.8", + "url": "https://github.com/micrometer-metrics/tracing/milestone/152" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-10-22", + "title": "Micrometer Tracing 1.7.2", + "url": "https://github.com/micrometer-metrics/tracing/milestone/153" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-10-22", + "title": "Micrometer Tracing 1.8.0-RC1", + "url": "https://github.com/micrometer-metrics/tracing/milestone/155" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-11-19", + "title": "Micrometer Tracing 1.4.17 (Enterprise)", + "url": "https://enterprise.spring.io/projects/micrometer-tracing" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-11-19", + "title": "Micrometer Tracing 1.5.14 (Enterprise)", + "url": "https://enterprise.spring.io/projects/micrometer-tracing" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-10-22", + "title": "Spring Cloud Kubernetes 5.0.4", + "url": "https://github.com/spring-cloud/spring-cloud-kubernetes/milestone/95" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-10-22", + "title": "Spring Cloud Kubernetes 5.1.0-RC1", + "url": "https://github.com/spring-cloud/spring-cloud-kubernetes/milestone/97" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-11-19", + "title": "Spring Cloud Kubernetes 3.2.5 (Enterprise)", + "url": "https://enterprise.spring.io/projects/spring-cloud-kubernetes" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-11-19", + "title": "Spring Cloud Kubernetes 2.1.12 (Enterprise)", + "url": "https://enterprise.spring.io/projects/spring-cloud-kubernetes" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-11-19", + "title": "Spring Cloud Kubernetes 3.3.5 (Enterprise)", + "url": "https://enterprise.spring.io/projects/spring-cloud-kubernetes" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-11-19", + "title": "Spring Cloud Kubernetes 5.0.4.1 (Enterprise)", + "url": "https://enterprise.spring.io/projects/spring-cloud-kubernetes" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-10-22", + "title": "Spring Cloud Gateway 5.0.4", + "url": "https://github.com/spring-cloud/spring-cloud-gateway/milestone/130" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-10-22", + "title": "Spring Cloud Gateway 5.1.0-RC1", + "url": "https://github.com/spring-cloud/spring-cloud-gateway/milestone/132" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-11-19", + "title": "Spring Cloud Gateway 4.3.7 (Enterprise)", + "url": "https://enterprise.spring.io/projects/spring-cloud-gateway" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-11-19", + "title": "Spring Cloud Gateway 4.2.11 (Enterprise)", + "url": "https://enterprise.spring.io/projects/spring-cloud-gateway" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-11-19", + "title": "Spring Cloud Gateway 3.1.15 (Enterprise)", + "url": "https://enterprise.spring.io/projects/spring-cloud-gateway" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-11-19", + "title": "Spring Cloud Gateway 5.0.4.1 (Enterprise)", + "url": "https://enterprise.spring.io/projects/spring-cloud-gateway" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-10-22", + "title": "Spring Cloud Vault 5.0.3", + "url": "https://github.com/spring-cloud/spring-cloud-vault/milestone/77" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-10-22", + "title": "Spring Cloud Vault 5.1.0-RC1", + "url": "https://github.com/spring-cloud/spring-cloud-vault/milestone/78" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-11-19", + "title": "Spring Cloud Vault 4.2.6 (Enterprise)", + "url": "https://enterprise.spring.io/projects/spring-cloud-vault" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-11-19", + "title": "Spring Cloud Vault 3.1.6 (Enterprise)", + "url": "https://enterprise.spring.io/projects/spring-cloud-vault" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-11-19", + "title": "Spring Cloud Vault 4.3.5 (Enterprise)", + "url": "https://enterprise.spring.io/projects/spring-cloud-vault" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-11-19", + "title": "Spring Cloud Vault 5.0.3.1 (Enterprise)", + "url": "https://enterprise.spring.io/projects/spring-cloud-vault" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-10-22", + "title": "Spring Cloud Build 5.0.4", + "url": "https://github.com/spring-cloud/spring-cloud-build/milestone/74" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-10-22", + "title": "Spring Cloud Build 5.1.0-RC1", + "url": "https://github.com/spring-cloud/spring-cloud-build/milestone/77" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-11-19", + "title": "Spring Cloud Build 4.3.6 (Enterprise)", + "url": "https://enterprise.spring.io/projects/spring-cloud-build" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-11-19", + "title": "Spring Cloud Build 4.2.8 (Enterprise)", + "url": "https://enterprise.spring.io/projects/spring-cloud-build" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-11-19", + "title": "Spring Cloud Build 3.1.17 (Enterprise)", + "url": "https://enterprise.spring.io/projects/spring-cloud-build" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-11-19", + "title": "Spring Cloud Build 5.0.4.1 (Enterprise)", + "url": "https://enterprise.spring.io/projects/spring-cloud-build" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-10-22", + "title": "Spring Cloud Netflix 5.0.3", + "url": "https://github.com/spring-cloud/spring-cloud-netflix/milestone/150" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-10-22", + "title": "Spring Cloud Netflix 5.1.0-RC1", + "url": "https://github.com/spring-cloud/spring-cloud-netflix/milestone/152" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-11-19", + "title": "Spring Cloud Netflix 3.1.10 (Enterprise)", + "url": "https://enterprise.spring.io/projects/spring-cloud-netflix" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-11-19", + "title": "Spring Cloud Netflix 4.2.6 (Enterprise)", + "url": "https://enterprise.spring.io/projects/spring-cloud-netflix" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-11-19", + "title": "Spring Cloud Netflix 4.3.5 (Enterprise)", + "url": "https://enterprise.spring.io/projects/spring-cloud-netflix" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-11-19", + "title": "Spring Cloud Netflix 5.0.3.1 (Enterprise)", + "url": "https://enterprise.spring.io/projects/spring-cloud-netflix" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-10-22", + "title": "Spring Cloud Function 5.0.5", + "url": "https://github.com/spring-cloud/spring-cloud-function/milestone/66" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-10-22", + "title": "Spring Cloud Function 5.1.0-RC1", + "url": "https://github.com/spring-cloud/spring-cloud-function/milestone/67" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-11-19", + "title": "Spring Cloud Function 4.3.6 (Enterprise)", + "url": "https://enterprise.spring.io/projects/spring-cloud-function" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-11-19", + "title": "Spring Cloud Function 4.2.9 (Enterprise)", + "url": "https://enterprise.spring.io/projects/spring-cloud-function" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-11-19", + "title": "Spring Cloud Function 3.2.18 (Enterprise)", + "url": "https://enterprise.spring.io/projects/spring-cloud-function" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-11-19", + "title": "Spring Cloud Function 5.0.5.1 (Enterprise)", + "url": "https://enterprise.spring.io/projects/spring-cloud-function" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-10-22", + "title": "Spring Boot 4.0.9", + "url": "https://github.com/spring-projects/spring-boot/milestone/430" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-10-22", + "title": "Spring Boot 4.1.2", + "url": "https://github.com/spring-projects/spring-boot/milestone/432" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-10-22", + "title": "Spring Boot 4.2.0-RC1", + "url": "https://github.com/spring-projects/spring-boot/milestone/433" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-11-19", + "title": "Spring Boot 3.4.19 (Enterprise)", + "url": "https://enterprise.spring.io/projects/spring-boot" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-11-19", + "title": "Spring Boot 3.5.18 (Enterprise)", + "url": "https://enterprise.spring.io/projects/spring-boot" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-11-19", + "title": "Spring Boot 2.7.36 (Enterprise)", + "url": "https://enterprise.spring.io/projects/spring-boot" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-11-19", + "title": "Spring Boot 4.1.2.1 (Enterprise)", + "url": "https://enterprise.spring.io/projects/spring-boot" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-11-19", + "title": "Spring Boot 4.0.9.1 (Enterprise)", + "url": "https://enterprise.spring.io/projects/spring-boot" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-10-22", + "title": "Spring Data Neo4j 8.1.2 (2026.0.2)", + "url": "https://github.com/spring-projects/spring-data-neo4j/milestone/418" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-10-22", + "title": "Spring Data Neo4j 8.0.8 (2025.1.8)", + "url": "https://github.com/spring-projects/spring-data-neo4j/milestone/419" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-10-22", + "title": "Spring Data Neo4j 8.2.0-RC1 (2026.1.0)", + "url": "https://github.com/spring-projects/spring-data-neo4j/milestone/420" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-11-19", + "title": "Spring Data Neo4j 8.0.8.1 (2025.1.8.1) (Enterprise)", + "url": "https://enterprise.spring.io/projects/spring-data-neo4j" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-11-19", + "title": "Spring Data Neo4j 8.1.2.1 (2026.0.2.1) (Enterprise)", + "url": "https://enterprise.spring.io/projects/spring-data-neo4j" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-11-19", + "title": "Spring Data Neo4j 7.5.15 (2025.0.15) (Enterprise)", + "url": "https://enterprise.spring.io/projects/spring-data-neo4j" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-11-19", + "title": "Spring Data Neo4j 7.4.17 (2024.1.17) (Enterprise)", + "url": "https://enterprise.spring.io/projects/spring-data-neo4j" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-11-19", + "title": "Spring Data Neo4j 6.3.22 (2021.2.22) (Enterprise)", + "url": "https://enterprise.spring.io/projects/spring-data-neo4j" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-10-22", + "title": "Reactor Pool 1.2.8", + "url": "https://github.com/reactor/reactor-pool/milestone/87" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-10-22", + "title": "Reactor Pool 1.3.0-RC1", + "url": "https://github.com/reactor/reactor-pool/milestone/89" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-11-19", + "title": "Reactor Pool 0.2.16 (Enterprise)", + "url": "https://enterprise.spring.io/projects/reactor-pool" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-11-19", + "title": "Reactor Pool 1.1.11 (Enterprise)", + "url": "https://enterprise.spring.io/projects/reactor-pool" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-10-22", + "title": "Spring Data Relational 4.1.2 (2026.0.2)", + "url": "https://github.com/spring-projects/spring-data-relational/milestone/287" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-10-22", + "title": "Spring Data Relational 4.0.8 (2025.1.8)", + "url": "https://github.com/spring-projects/spring-data-relational/milestone/288" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-10-22", + "title": "Spring Data Relational 4.2.0-RC1 (2026.1.0)", + "url": "https://github.com/spring-projects/spring-data-relational/milestone/289" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-11-19", + "title": "Spring Data Relational 4.0.8.1 (2025.1.8.1) (Enterprise)", + "url": "https://enterprise.spring.io/projects/spring-data-relational" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-11-19", + "title": "Spring Data Relational 4.1.2.1 (2026.0.2.1) (Enterprise)", + "url": "https://enterprise.spring.io/projects/spring-data-relational" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-11-19", + "title": "Spring Data Relational 3.5.15 (2025.0.15) (Enterprise)", + "url": "https://enterprise.spring.io/projects/spring-data-relational" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-11-19", + "title": "Spring Data Relational 3.4.17 (2024.1.17) (Enterprise)", + "url": "https://enterprise.spring.io/projects/spring-data-relational" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-11-19", + "title": "Spring Data Relational 2.4.22 (2021.2.22) (Enterprise)", + "url": "https://enterprise.spring.io/projects/spring-data-relational" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-10-22", + "title": "Spring Data Cassandra 5.1.2 (2026.0.2)", + "url": "https://github.com/spring-projects/spring-data-cassandra/milestone/376" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-10-22", + "title": "Spring Data Cassandra 5.0.8 (2025.1.8)", + "url": "https://github.com/spring-projects/spring-data-cassandra/milestone/377" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-10-22", + "title": "Spring Data Cassandra 5.2.0-RC1 (2026.1.0)", + "url": "https://github.com/spring-projects/spring-data-cassandra/milestone/378" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-11-19", + "title": "Spring Data Cassandra 5.0.8.1 (2025.1.8.1) (Enterprise)", + "url": "https://enterprise.spring.io/projects/spring-data-cassandra" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-11-19", + "title": "Spring Data Cassandra 5.1.2.1 (2026.0.2.1) (Enterprise)", + "url": "https://enterprise.spring.io/projects/spring-data-cassandra" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-11-19", + "title": "Spring Data Cassandra 4.5.15 (2025.0.15) (Enterprise)", + "url": "https://enterprise.spring.io/projects/spring-data-cassandra" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-11-19", + "title": "Spring Data Cassandra 4.4.17 (2024.1.17) (Enterprise)", + "url": "https://enterprise.spring.io/projects/spring-data-cassandra" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-11-19", + "title": "Spring Data Cassandra 3.4.22 (2021.2.22) (Enterprise)", + "url": "https://enterprise.spring.io/projects/spring-data-cassandra" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-10-22", + "title": "Spring Amqp 4.1.2", + "url": "https://github.com/spring-projects/spring-amqp/milestone/279" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-10-22", + "title": "Spring Amqp 4.0.6", + "url": "https://github.com/spring-projects/spring-amqp/milestone/280" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-10-22", + "title": "Spring Amqp 4.2.0-RC1", + "url": "https://github.com/spring-projects/spring-amqp/milestone/282" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-11-19", + "title": "Spring Amqp 2.4.20 (Enterprise)", + "url": "https://enterprise.spring.io/projects/spring-amqp" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-11-19", + "title": "Spring Amqp 3.2.14 (Enterprise)", + "url": "https://enterprise.spring.io/projects/spring-amqp" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-11-19", + "title": "Spring Amqp 4.0.6.1 (Enterprise)", + "url": "https://enterprise.spring.io/projects/spring-amqp" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-11-19", + "title": "Spring Amqp 4.1.2.1 (Enterprise)", + "url": "https://enterprise.spring.io/projects/spring-amqp" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-11-09", + "title": "Spring Release Actions 0.0.9", + "url": "https://github.com/spring-io/spring-release-actions/milestone/9" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-11-19", + "title": "Reactor Kafka 1.3.27 (Enterprise)", + "url": "https://enterprise.spring.io/projects/reactor-kafka" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-11-19", + "title": "Spring Security 5.7.27 (Enterprise)", + "url": "https://enterprise.spring.io/projects/spring-security" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-11-19", + "title": "Spring Security 5.8.29 (Enterprise)", + "url": "https://enterprise.spring.io/projects/spring-security" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-11-19", + "title": "Spring Security 6.4.20 (Enterprise)", + "url": "https://enterprise.spring.io/projects/spring-security" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-11-19", + "title": "Spring Security 6.5.13 (Enterprise)", + "url": "https://enterprise.spring.io/projects/spring-security" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-10-22", + "title": "Spring Cloud Bus 5.0.4", + "url": "https://github.com/spring-cloud/spring-cloud-bus/milestone/61" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-10-22", + "title": "Spring Cloud Bus 5.1.0-RC1", + "url": "https://github.com/spring-cloud/spring-cloud-bus/milestone/63" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-10-22", + "title": "Spring Pulsar 2.0.8", + "url": "https://github.com/spring-projects/spring-pulsar/milestone/78" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-10-22", + "title": "Reactor Netty 1.3.8", + "url": "https://github.com/reactor/reactor-netty/milestone/234" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-10-22", + "title": "Reactor Netty 1.4.0-RC1", + "url": "https://github.com/reactor/reactor-netty/milestone/236" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-11-19", + "title": "Reactor Netty 1.0.54 (Enterprise)", + "url": "https://enterprise.spring.io/projects/reactor-netty" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-11-19", + "title": "Reactor Netty 1.2.20 (Enterprise)", + "url": "https://enterprise.spring.io/projects/reactor-netty" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-10-22", + "title": "Spring Framework 7.0.10", + "url": "https://github.com/spring-projects/spring-framework/milestone/436" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-10-22", + "title": "Spring Framework 7.1.0-RC1", + "url": "https://github.com/spring-projects/spring-framework/milestone/437" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-11-19", + "title": "Spring Framework 5.2.27.RELEASE (Enterprise)", + "url": "https://enterprise.spring.io/projects/spring-framework" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-11-19", + "title": "Spring Framework 5.3.51 (Enterprise)", + "url": "https://enterprise.spring.io/projects/spring-framework" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-11-19", + "title": "Spring Framework 6.0.33 (Enterprise)", + "url": "https://enterprise.spring.io/projects/spring-framework" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-11-19", + "title": "Spring Framework 6.1.30 (Enterprise)", + "url": "https://enterprise.spring.io/projects/spring-framework" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-11-19", + "title": "Spring Framework 6.2.21 (Enterprise)", + "url": "https://enterprise.spring.io/projects/spring-framework" + }, + { + "allDay": true, + "backgroundColor": null, + "start": "2026-11-19", + "title": "Spring Framework 7.0.10.1 (Enterprise)", + "url": "https://enterprise.spring.io/projects/spring-framework" + } +] diff --git a/agent-plugins/spring-tools/evals/mocks/spring-tools-mcp/refreshWorkspace.md b/agent-plugins/spring-tools/evals/mocks/spring-tools-mcp/refreshWorkspace.md new file mode 100644 index 0000000000..5a32621be4 --- /dev/null +++ b/agent-plugins/spring-tools/evals/mocks/spring-tools-mcp/refreshWorkspace.md @@ -0,0 +1 @@ +"Done" diff --git a/agent-plugins/spring-tools/evals/project-info-reports-versions/graders/reports-project-and-versions.md b/agent-plugins/spring-tools/evals/project-info-reports-versions/graders/reports-project-and-versions.md new file mode 100644 index 0000000000..4ce1d55c2f --- /dev/null +++ b/agent-plugins/spring-tools/evals/project-info-reports-versions/graders/reports-project-and-versions.md @@ -0,0 +1,4 @@ +--- +type: regex +pattern: sf7-validation[\s\S]*(Java\s*25|25\.0\.3)[\s\S]*4\.0\.0|sf7-validation[\s\S]*4\.0\.0[\s\S]*(Java\s*25|25\.0\.3) +--- diff --git a/agent-plugins/spring-tools/evals/project-info-reports-versions/graders/skill-fired.md b/agent-plugins/spring-tools/evals/project-info-reports-versions/graders/skill-fired.md new file mode 100644 index 0000000000..cc30263581 --- /dev/null +++ b/agent-plugins/spring-tools/evals/project-info-reports-versions/graders/skill-fired.md @@ -0,0 +1,5 @@ +--- +type: tool_used +tool: Skill +input_match: '"skill"\s*:\s*"(?:[\w-]+:)?project-info"' +--- diff --git a/agent-plugins/spring-tools/evals/project-info-reports-versions/graders/used-java-version-tool.md b/agent-plugins/spring-tools/evals/project-info-reports-versions/graders/used-java-version-tool.md new file mode 100644 index 0000000000..d20753a58e --- /dev/null +++ b/agent-plugins/spring-tools/evals/project-info-reports-versions/graders/used-java-version-tool.md @@ -0,0 +1,4 @@ +--- +type: tool_used +tool: mcp__plugin_spring-tools_spring-tools-mcp__getJavaVersion +--- diff --git a/agent-plugins/spring-tools/evals/project-info-reports-versions/prompt.md b/agent-plugins/spring-tools/evals/project-info-reports-versions/prompt.md new file mode 100644 index 0000000000..e10f8dfc30 --- /dev/null +++ b/agent-plugins/spring-tools/evals/project-info-reports-versions/prompt.md @@ -0,0 +1,8 @@ +--- +max_turns: 12 +allowed_tools: [Read, Glob, Grep, Skill] +tags: [smoke, project-info] +expected_outcome: Claude reports the facts the language server knows about the workspace - one Spring Boot project named sf7-validation at /workspace/sf7-validation, built with Java 25 (25.0.3) and Spring Boot 4.0.0. +--- + +Which Spring Boot projects are in this workspace, where are they, and which Java and Spring Boot versions do they use? diff --git a/agent-plugins/spring-tools/evals/quickfix-explains-diagnostic-code/graders/explains-and-fixes.md b/agent-plugins/spring-tools/evals/quickfix-explains-diagnostic-code/graders/explains-and-fixes.md new file mode 100644 index 0000000000..40fd11cf78 --- /dev/null +++ b/agent-plugins/spring-tools/evals/quickfix-explains-diagnostic-code/graders/explains-and-fixes.md @@ -0,0 +1,9 @@ +--- +type: llm +weight: 2 +--- + +The user asked what the Spring Tools diagnostic `JAVA_AUTOWIRED_CONSTRUCTOR` means and how to fix it. + +PASS if the response explains that Spring injects a class's single constructor implicitly (implicit constructor injection, since Spring Framework 4.3), so the `@Autowired` annotation on that constructor is unnecessary, and recommends removing the annotation (optionally noting that `@Autowired` is only needed to pick one of several constructors). +FAIL if the response says the annotation is required, describes a different problem (for example field injection or a missing bean), or gives no concrete fix. diff --git a/agent-plugins/spring-tools/evals/quickfix-explains-diagnostic-code/graders/read-the-playbook.md b/agent-plugins/spring-tools/evals/quickfix-explains-diagnostic-code/graders/read-the-playbook.md new file mode 100644 index 0000000000..ae11998cff --- /dev/null +++ b/agent-plugins/spring-tools/evals/quickfix-explains-diagnostic-code/graders/read-the-playbook.md @@ -0,0 +1,5 @@ +--- +type: tool_used +tool: Read +input_match: explanations[/\\]JAVA_AUTOWIRED_CONSTRUCTOR\.md +--- diff --git a/agent-plugins/spring-tools/evals/quickfix-explains-diagnostic-code/graders/skill-fired.md b/agent-plugins/spring-tools/evals/quickfix-explains-diagnostic-code/graders/skill-fired.md new file mode 100644 index 0000000000..8c885be01d --- /dev/null +++ b/agent-plugins/spring-tools/evals/quickfix-explains-diagnostic-code/graders/skill-fired.md @@ -0,0 +1,5 @@ +--- +type: tool_used +tool: Skill +input_match: '"skill"\s*:\s*"(?:[\w-]+:)?quickfix"' +--- diff --git a/agent-plugins/spring-tools/evals/quickfix-explains-diagnostic-code/prompt.md b/agent-plugins/spring-tools/evals/quickfix-explains-diagnostic-code/prompt.md new file mode 100644 index 0000000000..9ea14e7c29 --- /dev/null +++ b/agent-plugins/spring-tools/evals/quickfix-explains-diagnostic-code/prompt.md @@ -0,0 +1,8 @@ +--- +max_turns: 12 +allowed_tools: [Read, Glob, Grep, Skill] +tags: [smoke, quickfix] +expected_outcome: Claude reads the plugin's playbook explanations/JAVA_AUTOWIRED_CONSTRUCTOR.md and explains that a class with a single constructor is autowired implicitly (Spring 4.3+), so the @Autowired annotation on it is redundant and can be removed - keeping it only when a second constructor exists. +--- + +Spring Tools reports the diagnostic code JAVA_AUTOWIRED_CONSTRUCTOR on the constructor of my Sf7ValidationApplication class. What does it mean and how do I fix it? diff --git a/agent-plugins/spring-tools/evals/refresh-reindexes-workspace/graders/confirms-refresh.md b/agent-plugins/spring-tools/evals/refresh-reindexes-workspace/graders/confirms-refresh.md new file mode 100644 index 0000000000..c713f57ff2 --- /dev/null +++ b/agent-plugins/spring-tools/evals/refresh-reindexes-workspace/graders/confirms-refresh.md @@ -0,0 +1,5 @@ +--- +type: regex +pattern: re-?index|refresh +flags: i +--- diff --git a/agent-plugins/spring-tools/evals/refresh-reindexes-workspace/graders/skill-fired.md b/agent-plugins/spring-tools/evals/refresh-reindexes-workspace/graders/skill-fired.md new file mode 100644 index 0000000000..a896b7d359 --- /dev/null +++ b/agent-plugins/spring-tools/evals/refresh-reindexes-workspace/graders/skill-fired.md @@ -0,0 +1,5 @@ +--- +type: tool_used +tool: Skill +input_match: '"skill"\s*:\s*"(?:[\w-]+:)?refresh"' +--- diff --git a/agent-plugins/spring-tools/evals/refresh-reindexes-workspace/graders/used-refresh-tool.md b/agent-plugins/spring-tools/evals/refresh-reindexes-workspace/graders/used-refresh-tool.md new file mode 100644 index 0000000000..14d7997de3 --- /dev/null +++ b/agent-plugins/spring-tools/evals/refresh-reindexes-workspace/graders/used-refresh-tool.md @@ -0,0 +1,4 @@ +--- +type: tool_used +tool: mcp__plugin_spring-tools_spring-tools-mcp__refreshWorkspace +--- diff --git a/agent-plugins/spring-tools/evals/refresh-reindexes-workspace/prompt.md b/agent-plugins/spring-tools/evals/refresh-reindexes-workspace/prompt.md new file mode 100644 index 0000000000..343174425c --- /dev/null +++ b/agent-plugins/spring-tools/evals/refresh-reindexes-workspace/prompt.md @@ -0,0 +1,8 @@ +--- +max_turns: 10 +allowed_tools: [Read, Glob, Grep, Skill] +tags: [smoke, refresh] +expected_outcome: Claude asks the language server to re-index the workspace (refreshWorkspace) instead of merely explaining what a refresh is, and confirms that the index was refreshed. +--- + +I just switched git branches outside of this session and the Spring diagnostics look stale. Make the Spring Tools language server re-index the workspace from disk. diff --git a/agent-plugins/spring-tools/evals/reviewer-agent-reviews-project/graders/delegated-to-reviewer.md b/agent-plugins/spring-tools/evals/reviewer-agent-reviews-project/graders/delegated-to-reviewer.md new file mode 100644 index 0000000000..1113690d01 --- /dev/null +++ b/agent-plugins/spring-tools/evals/reviewer-agent-reviews-project/graders/delegated-to-reviewer.md @@ -0,0 +1,5 @@ +--- +type: tool_used +tool: Agent +input_match: spring-reviewer +--- diff --git a/agent-plugins/spring-tools/evals/reviewer-agent-reviews-project/graders/names-the-finding.md b/agent-plugins/spring-tools/evals/reviewer-agent-reviews-project/graders/names-the-finding.md new file mode 100644 index 0000000000..9b9afae120 --- /dev/null +++ b/agent-plugins/spring-tools/evals/reviewer-agent-reviews-project/graders/names-the-finding.md @@ -0,0 +1,5 @@ +--- +type: regex +pattern: JAVA_AUTOWIRED_CONSTRUCTOR|@Autowired +flags: i +--- diff --git a/agent-plugins/spring-tools/evals/reviewer-agent-reviews-project/graders/relays-findings.md b/agent-plugins/spring-tools/evals/reviewer-agent-reviews-project/graders/relays-findings.md new file mode 100644 index 0000000000..8783e103fd --- /dev/null +++ b/agent-plugins/spring-tools/evals/reviewer-agent-reviews-project/graders/relays-findings.md @@ -0,0 +1,9 @@ +--- +type: llm +weight: 2 +--- + +The user asked for a review of the Spring Boot project `sf7-validation` by the plugin's reviewer agent. + +PASS if the response relays review findings that include the unnecessary `@Autowired` annotation on the `Sf7ValidationApplication` constructor (with the advice to remove it) and mentions that newer Spring Boot versions are available (4.0.8 patch and/or 4.1.1 minor), without inventing problems the project does not have. +FAIL if the response contains no concrete finding about this project, invents problems such as missing beans or failing tests, or says the review could not be performed. diff --git a/agent-plugins/spring-tools/evals/reviewer-agent-reviews-project/mocks/spring-tools-mcp/getProjectDiagnostics.md b/agent-plugins/spring-tools/evals/reviewer-agent-reviews-project/mocks/spring-tools-mcp/getProjectDiagnostics.md new file mode 100644 index 0000000000..8ba1ed9a23 --- /dev/null +++ b/agent-plugins/spring-tools/evals/reviewer-agent-reviews-project/mocks/spring-tools-mcp/getProjectDiagnostics.md @@ -0,0 +1,39 @@ +--- +expect: + projectName: sf7-validation +--- +[ + { + "uri": "file:///workspace/sf7-validation/src/main/java/com/example/demo/Sf7ValidationApplication.java", + "startLine": 7, + "startColumn": 1, + "endLine": 7, + "endColumn": 56, + "severity": "warning", + "message": "Unnecessary `@Autowired` annotation", + "code": "JAVA_AUTOWIRED_CONSTRUCTOR", + "source": "vscode-spring-boot" + }, + { + "uri": "file:/workspace/sf7-validation/pom.xml", + "startLine": 0, + "startColumn": 0, + "endLine": 0, + "endColumn": 1, + "severity": "info", + "message": "Newer minor version of Spring Boot available: 4.1.1", + "code": "BOOT_VERSION_VALIDATION_CODE", + "source": null + }, + { + "uri": "file:/workspace/sf7-validation/pom.xml", + "startLine": 0, + "startColumn": 0, + "endLine": 0, + "endColumn": 1, + "severity": "warning", + "message": "Newer patch version of Spring Boot available: 4.0.8", + "code": "BOOT_VERSION_VALIDATION_CODE", + "source": null + } +] diff --git a/agent-plugins/spring-tools/evals/reviewer-agent-reviews-project/prompt.md b/agent-plugins/spring-tools/evals/reviewer-agent-reviews-project/prompt.md new file mode 100644 index 0000000000..7b474fde4c --- /dev/null +++ b/agent-plugins/spring-tools/evals/reviewer-agent-reviews-project/prompt.md @@ -0,0 +1,9 @@ +--- +max_turns: 20 +timeout_seconds: 600 +allowed_tools: [Read, Glob, Grep, Skill, Agent] +tags: [agent] +expected_outcome: Claude delegates to the plugin's spring-reviewer subagent, which collects diagnostics, bean, endpoint and version information from the language server and returns a review with the redundant @Autowired constructor as the finding and the available Spring Boot updates as a recommendation; Claude relays that review. +--- + +Use the spring-reviewer agent to review this project and give me its findings. diff --git a/agent-plugins/spring-tools/evals/spring-review-skill/graders/skill-fired.md b/agent-plugins/spring-tools/evals/spring-review-skill/graders/skill-fired.md new file mode 100644 index 0000000000..50d31d38fe --- /dev/null +++ b/agent-plugins/spring-tools/evals/spring-review-skill/graders/skill-fired.md @@ -0,0 +1,5 @@ +--- +type: tool_used +tool: Skill +input_match: '"skill"\s*:\s*"(?:[\w-]+:)?spring-review"' +--- diff --git a/agent-plugins/spring-tools/evals/spring-review-skill/prompt.md b/agent-plugins/spring-tools/evals/spring-review-skill/prompt.md new file mode 100644 index 0000000000..9472dc2dee --- /dev/null +++ b/agent-plugins/spring-tools/evals/spring-review-skill/prompt.md @@ -0,0 +1,9 @@ +--- +max_turns: 16 +timeout_seconds: 600 +allowed_tools: [Read, Glob, Grep, Skill] +tags: [skill] +expected_outcome: The agent loads the shared spring-review skill and follows its Spring Tools review workflow. +--- + +Use the spring-review skill to review the Spring Boot projects in this workspace. Report concrete findings and recommendations. diff --git a/agent-plugins/spring-tools/evals/spring-versions-checks-support/graders/gives-oss-support-end.md b/agent-plugins/spring-tools/evals/spring-versions-checks-support/graders/gives-oss-support-end.md new file mode 100644 index 0000000000..6d0356cdab --- /dev/null +++ b/agent-plugins/spring-tools/evals/spring-versions-checks-support/graders/gives-oss-support-end.md @@ -0,0 +1,4 @@ +--- +type: regex +pattern: 2027-07-31|July 31,? 2027|31 July 2027 +--- diff --git a/agent-plugins/spring-tools/evals/spring-versions-checks-support/graders/names-both-versions.md b/agent-plugins/spring-tools/evals/spring-versions-checks-support/graders/names-both-versions.md new file mode 100644 index 0000000000..20d457a019 --- /dev/null +++ b/agent-plugins/spring-tools/evals/spring-versions-checks-support/graders/names-both-versions.md @@ -0,0 +1,4 @@ +--- +type: regex +pattern: 4\.0\.0[\s\S]*4\.1\.1|4\.1\.1[\s\S]*4\.0\.0 +--- diff --git a/agent-plugins/spring-tools/evals/spring-versions-checks-support/graders/skill-fired.md b/agent-plugins/spring-tools/evals/spring-versions-checks-support/graders/skill-fired.md new file mode 100644 index 0000000000..491a0312a2 --- /dev/null +++ b/agent-plugins/spring-tools/evals/spring-versions-checks-support/graders/skill-fired.md @@ -0,0 +1,5 @@ +--- +type: tool_used +tool: Skill +input_match: '"skill"\s*:\s*"(?:[\w-]+:)?spring-versions"' +--- diff --git a/agent-plugins/spring-tools/evals/spring-versions-checks-support/graders/used-release-information-tool.md b/agent-plugins/spring-tools/evals/spring-versions-checks-support/graders/used-release-information-tool.md new file mode 100644 index 0000000000..7c44225ae2 --- /dev/null +++ b/agent-plugins/spring-tools/evals/spring-versions-checks-support/graders/used-release-information-tool.md @@ -0,0 +1,4 @@ +--- +type: tool_used +tool: mcp__plugin_spring-tools_spring-tools-mcp__getLatestReleaseInformation +--- diff --git a/agent-plugins/spring-tools/evals/spring-versions-checks-support/prompt.md b/agent-plugins/spring-tools/evals/spring-versions-checks-support/prompt.md new file mode 100644 index 0000000000..4422b4f577 --- /dev/null +++ b/agent-plugins/spring-tools/evals/spring-versions-checks-support/prompt.md @@ -0,0 +1,8 @@ +--- +max_turns: 12 +allowed_tools: [Read, Glob, Grep, Skill] +tags: [smoke, versions] +expected_outcome: Claude compares the project's Spring Boot 4.0.0 with the latest release 4.1.1 reported by the language server, notes that the project is behind (4.0.8 is the latest patch) and gives the OSS support end date 2027-07-31 for the current generation. +--- + +Is this project on a current Spring Boot version? Tell me what it uses, what the latest release is, and how long the OSS support lasts. diff --git a/agent-plugins/spring-tools/evals/validate-finds-spring-problems/graders/names-the-diagnostic.md b/agent-plugins/spring-tools/evals/validate-finds-spring-problems/graders/names-the-diagnostic.md new file mode 100644 index 0000000000..9b9afae120 --- /dev/null +++ b/agent-plugins/spring-tools/evals/validate-finds-spring-problems/graders/names-the-diagnostic.md @@ -0,0 +1,5 @@ +--- +type: regex +pattern: JAVA_AUTOWIRED_CONSTRUCTOR|@Autowired +flags: i +--- diff --git a/agent-plugins/spring-tools/evals/validate-finds-spring-problems/graders/reports-diagnostics.md b/agent-plugins/spring-tools/evals/validate-finds-spring-problems/graders/reports-diagnostics.md new file mode 100644 index 0000000000..a7b9af1ae4 --- /dev/null +++ b/agent-plugins/spring-tools/evals/validate-finds-spring-problems/graders/reports-diagnostics.md @@ -0,0 +1,9 @@ +--- +type: llm +weight: 2 +--- + +The response is a report of problems found in the Spring Boot project `sf7-validation`. + +PASS if the response reports an unnecessary or redundant `@Autowired` annotation on the constructor of `Sf7ValidationApplication` (a class with a single constructor does not need it) and says the annotation can be removed, and also mentions that a newer Spring Boot version is available (4.0.8 and/or 4.1.1). +FAIL if the response does not mention the `@Autowired` constructor problem, invents problems that are not in the diagnostics (for example missing dependencies or compilation errors), or only says that it cannot access the project. diff --git a/agent-plugins/spring-tools/evals/validate-finds-spring-problems/graders/skill-fired.md b/agent-plugins/spring-tools/evals/validate-finds-spring-problems/graders/skill-fired.md new file mode 100644 index 0000000000..83a415ddc7 --- /dev/null +++ b/agent-plugins/spring-tools/evals/validate-finds-spring-problems/graders/skill-fired.md @@ -0,0 +1,5 @@ +--- +type: tool_used +tool: Skill +input_match: '"skill"\s*:\s*"(?:[\w-]+:)?validate"' +--- diff --git a/agent-plugins/spring-tools/evals/validate-finds-spring-problems/graders/used-diagnostics-tool.md b/agent-plugins/spring-tools/evals/validate-finds-spring-problems/graders/used-diagnostics-tool.md new file mode 100644 index 0000000000..0122024439 --- /dev/null +++ b/agent-plugins/spring-tools/evals/validate-finds-spring-problems/graders/used-diagnostics-tool.md @@ -0,0 +1,4 @@ +--- +type: tool_used +tool: mcp__plugin_spring-tools_spring-tools-mcp__getProjectDiagnostics +--- diff --git a/agent-plugins/spring-tools/evals/validate-finds-spring-problems/mocks/spring-tools-mcp/getProjectDiagnostics.md b/agent-plugins/spring-tools/evals/validate-finds-spring-problems/mocks/spring-tools-mcp/getProjectDiagnostics.md new file mode 100644 index 0000000000..8ba1ed9a23 --- /dev/null +++ b/agent-plugins/spring-tools/evals/validate-finds-spring-problems/mocks/spring-tools-mcp/getProjectDiagnostics.md @@ -0,0 +1,39 @@ +--- +expect: + projectName: sf7-validation +--- +[ + { + "uri": "file:///workspace/sf7-validation/src/main/java/com/example/demo/Sf7ValidationApplication.java", + "startLine": 7, + "startColumn": 1, + "endLine": 7, + "endColumn": 56, + "severity": "warning", + "message": "Unnecessary `@Autowired` annotation", + "code": "JAVA_AUTOWIRED_CONSTRUCTOR", + "source": "vscode-spring-boot" + }, + { + "uri": "file:/workspace/sf7-validation/pom.xml", + "startLine": 0, + "startColumn": 0, + "endLine": 0, + "endColumn": 1, + "severity": "info", + "message": "Newer minor version of Spring Boot available: 4.1.1", + "code": "BOOT_VERSION_VALIDATION_CODE", + "source": null + }, + { + "uri": "file:/workspace/sf7-validation/pom.xml", + "startLine": 0, + "startColumn": 0, + "endLine": 0, + "endColumn": 1, + "severity": "warning", + "message": "Newer patch version of Spring Boot available: 4.0.8", + "code": "BOOT_VERSION_VALIDATION_CODE", + "source": null + } +] diff --git a/agent-plugins/spring-tools/evals/validate-finds-spring-problems/prompt.md b/agent-plugins/spring-tools/evals/validate-finds-spring-problems/prompt.md new file mode 100644 index 0000000000..99be1712cb --- /dev/null +++ b/agent-plugins/spring-tools/evals/validate-finds-spring-problems/prompt.md @@ -0,0 +1,8 @@ +--- +max_turns: 15 +allowed_tools: [Read, Glob, Grep, Skill] +tags: [smoke, diagnostics] +expected_outcome: Claude runs the Spring Tools diagnostics for the workspace project and reports the redundant @Autowired constructor in Sf7ValidationApplication with the fix, plus the available Spring Boot updates. +--- + +Check this Spring Boot project for problems and tell me what I should fix. diff --git a/claude-plugins/spring-tools/explanations/API_VERSIONING_NOT_CONFIGURED.md b/agent-plugins/spring-tools/explanations/API_VERSIONING_NOT_CONFIGURED.md similarity index 100% rename from claude-plugins/spring-tools/explanations/API_VERSIONING_NOT_CONFIGURED.md rename to agent-plugins/spring-tools/explanations/API_VERSIONING_NOT_CONFIGURED.md diff --git a/claude-plugins/spring-tools/explanations/API_VERSIONING_STRATEGY_CONFIGURATION_DUPLICATED.md b/agent-plugins/spring-tools/explanations/API_VERSIONING_STRATEGY_CONFIGURATION_DUPLICATED.md similarity index 100% rename from claude-plugins/spring-tools/explanations/API_VERSIONING_STRATEGY_CONFIGURATION_DUPLICATED.md rename to agent-plugins/spring-tools/explanations/API_VERSIONING_STRATEGY_CONFIGURATION_DUPLICATED.md diff --git a/claude-plugins/spring-tools/explanations/API_VERSIONING_VIA_PATH_SEGMENT_CONFIGURED_IN_COMBINATION.md b/agent-plugins/spring-tools/explanations/API_VERSIONING_VIA_PATH_SEGMENT_CONFIGURED_IN_COMBINATION.md similarity index 100% rename from claude-plugins/spring-tools/explanations/API_VERSIONING_VIA_PATH_SEGMENT_CONFIGURED_IN_COMBINATION.md rename to agent-plugins/spring-tools/explanations/API_VERSIONING_VIA_PATH_SEGMENT_CONFIGURED_IN_COMBINATION.md diff --git a/claude-plugins/spring-tools/explanations/API_VERSION_SYNTAX_ERROR.md b/agent-plugins/spring-tools/explanations/API_VERSION_SYNTAX_ERROR.md similarity index 100% rename from claude-plugins/spring-tools/explanations/API_VERSION_SYNTAX_ERROR.md rename to agent-plugins/spring-tools/explanations/API_VERSION_SYNTAX_ERROR.md diff --git a/claude-plugins/spring-tools/explanations/BOOT_VERSION_VALIDATION_CODE.md b/agent-plugins/spring-tools/explanations/BOOT_VERSION_VALIDATION_CODE.md similarity index 100% rename from claude-plugins/spring-tools/explanations/BOOT_VERSION_VALIDATION_CODE.md rename to agent-plugins/spring-tools/explanations/BOOT_VERSION_VALIDATION_CODE.md diff --git a/claude-plugins/spring-tools/explanations/CRON_FIELD.md b/agent-plugins/spring-tools/explanations/CRON_FIELD.md similarity index 100% rename from claude-plugins/spring-tools/explanations/CRON_FIELD.md rename to agent-plugins/spring-tools/explanations/CRON_FIELD.md diff --git a/claude-plugins/spring-tools/explanations/CRON_SYNTAX.md b/agent-plugins/spring-tools/explanations/CRON_SYNTAX.md similarity index 100% rename from claude-plugins/spring-tools/explanations/CRON_SYNTAX.md rename to agent-plugins/spring-tools/explanations/CRON_SYNTAX.md diff --git a/claude-plugins/spring-tools/explanations/DOMAIN_ID_FOR_REPOSITORY.md b/agent-plugins/spring-tools/explanations/DOMAIN_ID_FOR_REPOSITORY.md similarity index 100% rename from claude-plugins/spring-tools/explanations/DOMAIN_ID_FOR_REPOSITORY.md rename to agent-plugins/spring-tools/explanations/DOMAIN_ID_FOR_REPOSITORY.md diff --git a/claude-plugins/spring-tools/explanations/EXTRACT_REQUEST_MAPPING_PARENT_PATH.md b/agent-plugins/spring-tools/explanations/EXTRACT_REQUEST_MAPPING_PARENT_PATH.md similarity index 100% rename from claude-plugins/spring-tools/explanations/EXTRACT_REQUEST_MAPPING_PARENT_PATH.md rename to agent-plugins/spring-tools/explanations/EXTRACT_REQUEST_MAPPING_PARENT_PATH.md diff --git a/claude-plugins/spring-tools/explanations/FACTORIES_KEY_NOT_SUPPORTED.md b/agent-plugins/spring-tools/explanations/FACTORIES_KEY_NOT_SUPPORTED.md similarity index 100% rename from claude-plugins/spring-tools/explanations/FACTORIES_KEY_NOT_SUPPORTED.md rename to agent-plugins/spring-tools/explanations/FACTORIES_KEY_NOT_SUPPORTED.md diff --git a/claude-plugins/spring-tools/explanations/HQL_SYNTAX.md b/agent-plugins/spring-tools/explanations/HQL_SYNTAX.md similarity index 100% rename from claude-plugins/spring-tools/explanations/HQL_SYNTAX.md rename to agent-plugins/spring-tools/explanations/HQL_SYNTAX.md diff --git a/claude-plugins/spring-tools/explanations/HTTP_SECURITY_AUTHORIZE_HTTP_REQUESTS.md b/agent-plugins/spring-tools/explanations/HTTP_SECURITY_AUTHORIZE_HTTP_REQUESTS.md similarity index 100% rename from claude-plugins/spring-tools/explanations/HTTP_SECURITY_AUTHORIZE_HTTP_REQUESTS.md rename to agent-plugins/spring-tools/explanations/HTTP_SECURITY_AUTHORIZE_HTTP_REQUESTS.md diff --git a/claude-plugins/spring-tools/explanations/JAVA_AUTOWIRED_CONSTRUCTOR.md b/agent-plugins/spring-tools/explanations/JAVA_AUTOWIRED_CONSTRUCTOR.md similarity index 100% rename from claude-plugins/spring-tools/explanations/JAVA_AUTOWIRED_CONSTRUCTOR.md rename to agent-plugins/spring-tools/explanations/JAVA_AUTOWIRED_CONSTRUCTOR.md diff --git a/claude-plugins/spring-tools/explanations/JAVA_BEAN_NOT_REGISTERED_IN_AOT.md b/agent-plugins/spring-tools/explanations/JAVA_BEAN_NOT_REGISTERED_IN_AOT.md similarity index 100% rename from claude-plugins/spring-tools/explanations/JAVA_BEAN_NOT_REGISTERED_IN_AOT.md rename to agent-plugins/spring-tools/explanations/JAVA_BEAN_NOT_REGISTERED_IN_AOT.md diff --git a/claude-plugins/spring-tools/explanations/JAVA_BEAN_POST_PROCESSOR_IGNORED_IN_AOT.md b/agent-plugins/spring-tools/explanations/JAVA_BEAN_POST_PROCESSOR_IGNORED_IN_AOT.md similarity index 100% rename from claude-plugins/spring-tools/explanations/JAVA_BEAN_POST_PROCESSOR_IGNORED_IN_AOT.md rename to agent-plugins/spring-tools/explanations/JAVA_BEAN_POST_PROCESSOR_IGNORED_IN_AOT.md diff --git a/claude-plugins/spring-tools/explanations/JAVA_CONCRETE_BEAN_TYPE.md b/agent-plugins/spring-tools/explanations/JAVA_CONCRETE_BEAN_TYPE.md similarity index 100% rename from claude-plugins/spring-tools/explanations/JAVA_CONCRETE_BEAN_TYPE.md rename to agent-plugins/spring-tools/explanations/JAVA_CONCRETE_BEAN_TYPE.md diff --git a/claude-plugins/spring-tools/explanations/JAVA_CONSTRUCTOR_PARAMETER_INJECTION.md b/agent-plugins/spring-tools/explanations/JAVA_CONSTRUCTOR_PARAMETER_INJECTION.md similarity index 100% rename from claude-plugins/spring-tools/explanations/JAVA_CONSTRUCTOR_PARAMETER_INJECTION.md rename to agent-plugins/spring-tools/explanations/JAVA_CONSTRUCTOR_PARAMETER_INJECTION.md diff --git a/claude-plugins/spring-tools/explanations/JAVA_FINAL_AUTOWIRED_FIELD.md b/agent-plugins/spring-tools/explanations/JAVA_FINAL_AUTOWIRED_FIELD.md similarity index 100% rename from claude-plugins/spring-tools/explanations/JAVA_FINAL_AUTOWIRED_FIELD.md rename to agent-plugins/spring-tools/explanations/JAVA_FINAL_AUTOWIRED_FIELD.md diff --git a/claude-plugins/spring-tools/explanations/JAVA_LAMBDA_DSL.md b/agent-plugins/spring-tools/explanations/JAVA_LAMBDA_DSL.md similarity index 100% rename from claude-plugins/spring-tools/explanations/JAVA_LAMBDA_DSL.md rename to agent-plugins/spring-tools/explanations/JAVA_LAMBDA_DSL.md diff --git a/claude-plugins/spring-tools/explanations/JAVA_PRECISE_REQUEST_MAPPING.md b/agent-plugins/spring-tools/explanations/JAVA_PRECISE_REQUEST_MAPPING.md similarity index 100% rename from claude-plugins/spring-tools/explanations/JAVA_PRECISE_REQUEST_MAPPING.md rename to agent-plugins/spring-tools/explanations/JAVA_PRECISE_REQUEST_MAPPING.md diff --git a/claude-plugins/spring-tools/explanations/JAVA_PRECISE_SCOPE.md b/agent-plugins/spring-tools/explanations/JAVA_PRECISE_SCOPE.md similarity index 100% rename from claude-plugins/spring-tools/explanations/JAVA_PRECISE_SCOPE.md rename to agent-plugins/spring-tools/explanations/JAVA_PRECISE_SCOPE.md diff --git a/claude-plugins/spring-tools/explanations/JAVA_PUBLIC_BEAN_METHOD.md b/agent-plugins/spring-tools/explanations/JAVA_PUBLIC_BEAN_METHOD.md similarity index 100% rename from claude-plugins/spring-tools/explanations/JAVA_PUBLIC_BEAN_METHOD.md rename to agent-plugins/spring-tools/explanations/JAVA_PUBLIC_BEAN_METHOD.md diff --git a/claude-plugins/spring-tools/explanations/JAVA_REPOSITORY.md b/agent-plugins/spring-tools/explanations/JAVA_REPOSITORY.md similarity index 100% rename from claude-plugins/spring-tools/explanations/JAVA_REPOSITORY.md rename to agent-plugins/spring-tools/explanations/JAVA_REPOSITORY.md diff --git a/claude-plugins/spring-tools/explanations/JAVA_SPEL_EXPRESSION_SYNTAX.md b/agent-plugins/spring-tools/explanations/JAVA_SPEL_EXPRESSION_SYNTAX.md similarity index 100% rename from claude-plugins/spring-tools/explanations/JAVA_SPEL_EXPRESSION_SYNTAX.md rename to agent-plugins/spring-tools/explanations/JAVA_SPEL_EXPRESSION_SYNTAX.md diff --git a/claude-plugins/spring-tools/explanations/JAVA_TEST_SPRING_EXTENSION.md b/agent-plugins/spring-tools/explanations/JAVA_TEST_SPRING_EXTENSION.md similarity index 100% rename from claude-plugins/spring-tools/explanations/JAVA_TEST_SPRING_EXTENSION.md rename to agent-plugins/spring-tools/explanations/JAVA_TEST_SPRING_EXTENSION.md diff --git a/claude-plugins/spring-tools/explanations/JAVA_TYPE_NOT_SUPPORTED.md b/agent-plugins/spring-tools/explanations/JAVA_TYPE_NOT_SUPPORTED.md similarity index 100% rename from claude-plugins/spring-tools/explanations/JAVA_TYPE_NOT_SUPPORTED.md rename to agent-plugins/spring-tools/explanations/JAVA_TYPE_NOT_SUPPORTED.md diff --git a/claude-plugins/spring-tools/explanations/JPQL_SYNTAX.md b/agent-plugins/spring-tools/explanations/JPQL_SYNTAX.md similarity index 100% rename from claude-plugins/spring-tools/explanations/JPQL_SYNTAX.md rename to agent-plugins/spring-tools/explanations/JPQL_SYNTAX.md diff --git a/claude-plugins/spring-tools/explanations/MISSING_CONFIGURATION_ANNOTATION.md b/agent-plugins/spring-tools/explanations/MISSING_CONFIGURATION_ANNOTATION.md similarity index 100% rename from claude-plugins/spring-tools/explanations/MISSING_CONFIGURATION_ANNOTATION.md rename to agent-plugins/spring-tools/explanations/MISSING_CONFIGURATION_ANNOTATION.md diff --git a/claude-plugins/spring-tools/explanations/MISSING_VALIDATED_ANNOTATION.md b/agent-plugins/spring-tools/explanations/MISSING_VALIDATED_ANNOTATION.md similarity index 100% rename from claude-plugins/spring-tools/explanations/MISSING_VALIDATED_ANNOTATION.md rename to agent-plugins/spring-tools/explanations/MISSING_VALIDATED_ANNOTATION.md diff --git a/claude-plugins/spring-tools/explanations/MODEL_ATTRIBUTE_NAMES.md b/agent-plugins/spring-tools/explanations/MODEL_ATTRIBUTE_NAMES.md similarity index 93% rename from claude-plugins/spring-tools/explanations/MODEL_ATTRIBUTE_NAMES.md rename to agent-plugins/spring-tools/explanations/MODEL_ATTRIBUTE_NAMES.md index faa15e9aa7..b1565db0c3 100644 --- a/claude-plugins/spring-tools/explanations/MODEL_ATTRIBUTE_NAMES.md +++ b/agent-plugins/spring-tools/explanations/MODEL_ATTRIBUTE_NAMES.md @@ -44,3 +44,6 @@ public String preview(@ModelAttribute(binding = false) User user) { *Note: only parameters with a plain, non-generic class type are flagged. Arrays, collections and reactive types like `Mono` or `CompletableFuture` get different default names (e.g. `orderList` for `List` or `Order[]`), and other generic types such as `Optional` are not checked either, so leave their names alone. `@ModelAttribute` on methods (e.g. `@ModelAttribute("types")` on a method that provides reference data) is not flagged either.* *Note: for other web annotations like `@RequestParam` or `@PathVariable`, the default name is the parameter name instead - see `WEB_ANNOTATION_NAMES`.* + +## References +- [`@ModelAttribute` API documentation](https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/web/bind/annotation/ModelAttribute.html) diff --git a/claude-plugins/spring-tools/explanations/MODULITH_APPLICATION_MODULE_LISTENER.md b/agent-plugins/spring-tools/explanations/MODULITH_APPLICATION_MODULE_LISTENER.md similarity index 100% rename from claude-plugins/spring-tools/explanations/MODULITH_APPLICATION_MODULE_LISTENER.md rename to agent-plugins/spring-tools/explanations/MODULITH_APPLICATION_MODULE_LISTENER.md diff --git a/claude-plugins/spring-tools/explanations/MODULITH_TYPE_REF_VIOLATION.md b/agent-plugins/spring-tools/explanations/MODULITH_TYPE_REF_VIOLATION.md similarity index 100% rename from claude-plugins/spring-tools/explanations/MODULITH_TYPE_REF_VIOLATION.md rename to agent-plugins/spring-tools/explanations/MODULITH_TYPE_REF_VIOLATION.md diff --git a/claude-plugins/spring-tools/explanations/PATH_IN_CONTROLLER_ANNOTATION.md b/agent-plugins/spring-tools/explanations/PATH_IN_CONTROLLER_ANNOTATION.md similarity index 100% rename from claude-plugins/spring-tools/explanations/PATH_IN_CONTROLLER_ANNOTATION.md rename to agent-plugins/spring-tools/explanations/PATH_IN_CONTROLLER_ANNOTATION.md diff --git a/claude-plugins/spring-tools/explanations/PROPERTY_PLACE_HOLDER_SYNTAX.md b/agent-plugins/spring-tools/explanations/PROPERTY_PLACE_HOLDER_SYNTAX.md similarity index 100% rename from claude-plugins/spring-tools/explanations/PROPERTY_PLACE_HOLDER_SYNTAX.md rename to agent-plugins/spring-tools/explanations/PROPERTY_PLACE_HOLDER_SYNTAX.md diff --git a/claude-plugins/spring-tools/explanations/PROP_DEPRECATED.md b/agent-plugins/spring-tools/explanations/PROP_DEPRECATED.md similarity index 100% rename from claude-plugins/spring-tools/explanations/PROP_DEPRECATED.md rename to agent-plugins/spring-tools/explanations/PROP_DEPRECATED.md diff --git a/claude-plugins/spring-tools/explanations/PROP_DUPLICATE_KEY.md b/agent-plugins/spring-tools/explanations/PROP_DUPLICATE_KEY.md similarity index 100% rename from claude-plugins/spring-tools/explanations/PROP_DUPLICATE_KEY.md rename to agent-plugins/spring-tools/explanations/PROP_DUPLICATE_KEY.md diff --git a/claude-plugins/spring-tools/explanations/PROP_EXPECTED_DOT_OR_LBRACK.md b/agent-plugins/spring-tools/explanations/PROP_EXPECTED_DOT_OR_LBRACK.md similarity index 100% rename from claude-plugins/spring-tools/explanations/PROP_EXPECTED_DOT_OR_LBRACK.md rename to agent-plugins/spring-tools/explanations/PROP_EXPECTED_DOT_OR_LBRACK.md diff --git a/claude-plugins/spring-tools/explanations/PROP_INVALID_BEAN_NAVIGATION.md b/agent-plugins/spring-tools/explanations/PROP_INVALID_BEAN_NAVIGATION.md similarity index 100% rename from claude-plugins/spring-tools/explanations/PROP_INVALID_BEAN_NAVIGATION.md rename to agent-plugins/spring-tools/explanations/PROP_INVALID_BEAN_NAVIGATION.md diff --git a/claude-plugins/spring-tools/explanations/PROP_INVALID_BEAN_PROPERTY.md b/agent-plugins/spring-tools/explanations/PROP_INVALID_BEAN_PROPERTY.md similarity index 100% rename from claude-plugins/spring-tools/explanations/PROP_INVALID_BEAN_PROPERTY.md rename to agent-plugins/spring-tools/explanations/PROP_INVALID_BEAN_PROPERTY.md diff --git a/claude-plugins/spring-tools/explanations/PROP_INVALID_INDEXED_NAVIGATION.md b/agent-plugins/spring-tools/explanations/PROP_INVALID_INDEXED_NAVIGATION.md similarity index 100% rename from claude-plugins/spring-tools/explanations/PROP_INVALID_INDEXED_NAVIGATION.md rename to agent-plugins/spring-tools/explanations/PROP_INVALID_INDEXED_NAVIGATION.md diff --git a/claude-plugins/spring-tools/explanations/PROP_NON_INTEGER_IN_BRACKETS.md b/agent-plugins/spring-tools/explanations/PROP_NON_INTEGER_IN_BRACKETS.md similarity index 100% rename from claude-plugins/spring-tools/explanations/PROP_NON_INTEGER_IN_BRACKETS.md rename to agent-plugins/spring-tools/explanations/PROP_NON_INTEGER_IN_BRACKETS.md diff --git a/claude-plugins/spring-tools/explanations/PROP_NO_MATCHING_RBRACK.md b/agent-plugins/spring-tools/explanations/PROP_NO_MATCHING_RBRACK.md similarity index 100% rename from claude-plugins/spring-tools/explanations/PROP_NO_MATCHING_RBRACK.md rename to agent-plugins/spring-tools/explanations/PROP_NO_MATCHING_RBRACK.md diff --git a/claude-plugins/spring-tools/explanations/PROP_SYNTAX_ERROR.md b/agent-plugins/spring-tools/explanations/PROP_SYNTAX_ERROR.md similarity index 100% rename from claude-plugins/spring-tools/explanations/PROP_SYNTAX_ERROR.md rename to agent-plugins/spring-tools/explanations/PROP_SYNTAX_ERROR.md diff --git a/claude-plugins/spring-tools/explanations/PROP_UNKNOWN_PROPERTY.md b/agent-plugins/spring-tools/explanations/PROP_UNKNOWN_PROPERTY.md similarity index 100% rename from claude-plugins/spring-tools/explanations/PROP_UNKNOWN_PROPERTY.md rename to agent-plugins/spring-tools/explanations/PROP_UNKNOWN_PROPERTY.md diff --git a/claude-plugins/spring-tools/explanations/PROP_VALUE_TYPE_MISMATCH.md b/agent-plugins/spring-tools/explanations/PROP_VALUE_TYPE_MISMATCH.md similarity index 100% rename from claude-plugins/spring-tools/explanations/PROP_VALUE_TYPE_MISMATCH.md rename to agent-plugins/spring-tools/explanations/PROP_VALUE_TYPE_MISMATCH.md diff --git a/claude-plugins/spring-tools/explanations/REGISTRAR_BEAN_DECLARATION.md b/agent-plugins/spring-tools/explanations/REGISTRAR_BEAN_DECLARATION.md similarity index 100% rename from claude-plugins/spring-tools/explanations/REGISTRAR_BEAN_DECLARATION.md rename to agent-plugins/spring-tools/explanations/REGISTRAR_BEAN_DECLARATION.md diff --git a/claude-plugins/spring-tools/explanations/REGISTRAR_BEAN_INVALID_ANNOTATION.md b/agent-plugins/spring-tools/explanations/REGISTRAR_BEAN_INVALID_ANNOTATION.md similarity index 100% rename from claude-plugins/spring-tools/explanations/REGISTRAR_BEAN_INVALID_ANNOTATION.md rename to agent-plugins/spring-tools/explanations/REGISTRAR_BEAN_INVALID_ANNOTATION.md diff --git a/claude-plugins/spring-tools/explanations/REST_CONTROLLER_COMBINATION.md b/agent-plugins/spring-tools/explanations/REST_CONTROLLER_COMBINATION.md similarity index 100% rename from claude-plugins/spring-tools/explanations/REST_CONTROLLER_COMBINATION.md rename to agent-plugins/spring-tools/explanations/REST_CONTROLLER_COMBINATION.md diff --git a/claude-plugins/spring-tools/explanations/SPRING_AI_TOOL_DESCRIPTION_TOO_SHORT.md b/agent-plugins/spring-tools/explanations/SPRING_AI_TOOL_DESCRIPTION_TOO_SHORT.md similarity index 100% rename from claude-plugins/spring-tools/explanations/SPRING_AI_TOOL_DESCRIPTION_TOO_SHORT.md rename to agent-plugins/spring-tools/explanations/SPRING_AI_TOOL_DESCRIPTION_TOO_SHORT.md diff --git a/claude-plugins/spring-tools/explanations/SPRING_AI_TOOL_MISSING_DESCRIPTION.md b/agent-plugins/spring-tools/explanations/SPRING_AI_TOOL_MISSING_DESCRIPTION.md similarity index 100% rename from claude-plugins/spring-tools/explanations/SPRING_AI_TOOL_MISSING_DESCRIPTION.md rename to agent-plugins/spring-tools/explanations/SPRING_AI_TOOL_MISSING_DESCRIPTION.md diff --git a/claude-plugins/spring-tools/explanations/SPRING_DATA_STRING_PROPERTY_REFERENCE.md b/agent-plugins/spring-tools/explanations/SPRING_DATA_STRING_PROPERTY_REFERENCE.md similarity index 100% rename from claude-plugins/spring-tools/explanations/SPRING_DATA_STRING_PROPERTY_REFERENCE.md rename to agent-plugins/spring-tools/explanations/SPRING_DATA_STRING_PROPERTY_REFERENCE.md diff --git a/claude-plugins/spring-tools/explanations/SPRING_JUNIT_CONFIG_COMBINATION.md b/agent-plugins/spring-tools/explanations/SPRING_JUNIT_CONFIG_COMBINATION.md similarity index 100% rename from claude-plugins/spring-tools/explanations/SPRING_JUNIT_CONFIG_COMBINATION.md rename to agent-plugins/spring-tools/explanations/SPRING_JUNIT_CONFIG_COMBINATION.md diff --git a/claude-plugins/spring-tools/explanations/SQL_SYNTAX.md b/agent-plugins/spring-tools/explanations/SQL_SYNTAX.md similarity index 100% rename from claude-plugins/spring-tools/explanations/SQL_SYNTAX.md rename to agent-plugins/spring-tools/explanations/SQL_SYNTAX.md diff --git a/claude-plugins/spring-tools/explanations/VALUE_CLASSPATH_RESOURCE_TYPE.md b/agent-plugins/spring-tools/explanations/VALUE_CLASSPATH_RESOURCE_TYPE.md similarity index 100% rename from claude-plugins/spring-tools/explanations/VALUE_CLASSPATH_RESOURCE_TYPE.md rename to agent-plugins/spring-tools/explanations/VALUE_CLASSPATH_RESOURCE_TYPE.md diff --git a/claude-plugins/spring-tools/explanations/WEB_ANNOTATION_NAMES.md b/agent-plugins/spring-tools/explanations/WEB_ANNOTATION_NAMES.md similarity index 100% rename from claude-plugins/spring-tools/explanations/WEB_ANNOTATION_NAMES.md rename to agent-plugins/spring-tools/explanations/WEB_ANNOTATION_NAMES.md diff --git a/claude-plugins/spring-tools/explanations/WEB_CONFIGURER_CONFIGURATION.md b/agent-plugins/spring-tools/explanations/WEB_CONFIGURER_CONFIGURATION.md similarity index 100% rename from claude-plugins/spring-tools/explanations/WEB_CONFIGURER_CONFIGURATION.md rename to agent-plugins/spring-tools/explanations/WEB_CONFIGURER_CONFIGURATION.md diff --git a/claude-plugins/spring-tools/explanations/WEB_SECURITY_CONFIGURER_ADAPTER.md b/agent-plugins/spring-tools/explanations/WEB_SECURITY_CONFIGURER_ADAPTER.md similarity index 100% rename from claude-plugins/spring-tools/explanations/WEB_SECURITY_CONFIGURER_ADAPTER.md rename to agent-plugins/spring-tools/explanations/WEB_SECURITY_CONFIGURER_ADAPTER.md diff --git a/claude-plugins/spring-tools/explanations/YAML_DEPRECATED_ERROR.md b/agent-plugins/spring-tools/explanations/YAML_DEPRECATED_ERROR.md similarity index 100% rename from claude-plugins/spring-tools/explanations/YAML_DEPRECATED_ERROR.md rename to agent-plugins/spring-tools/explanations/YAML_DEPRECATED_ERROR.md diff --git a/claude-plugins/spring-tools/explanations/YAML_DEPRECATED_WARNING.md b/agent-plugins/spring-tools/explanations/YAML_DEPRECATED_WARNING.md similarity index 100% rename from claude-plugins/spring-tools/explanations/YAML_DEPRECATED_WARNING.md rename to agent-plugins/spring-tools/explanations/YAML_DEPRECATED_WARNING.md diff --git a/claude-plugins/spring-tools/explanations/YAML_DUPLICATE_KEY.md b/agent-plugins/spring-tools/explanations/YAML_DUPLICATE_KEY.md similarity index 100% rename from claude-plugins/spring-tools/explanations/YAML_DUPLICATE_KEY.md rename to agent-plugins/spring-tools/explanations/YAML_DUPLICATE_KEY.md diff --git a/claude-plugins/spring-tools/explanations/YAML_EXPECT_BEAN_PROPERTY_NAME.md b/agent-plugins/spring-tools/explanations/YAML_EXPECT_BEAN_PROPERTY_NAME.md similarity index 100% rename from claude-plugins/spring-tools/explanations/YAML_EXPECT_BEAN_PROPERTY_NAME.md rename to agent-plugins/spring-tools/explanations/YAML_EXPECT_BEAN_PROPERTY_NAME.md diff --git a/claude-plugins/spring-tools/explanations/YAML_EXPECT_MAPPING.md b/agent-plugins/spring-tools/explanations/YAML_EXPECT_MAPPING.md similarity index 100% rename from claude-plugins/spring-tools/explanations/YAML_EXPECT_MAPPING.md rename to agent-plugins/spring-tools/explanations/YAML_EXPECT_MAPPING.md diff --git a/claude-plugins/spring-tools/explanations/YAML_EXPECT_SCALAR.md b/agent-plugins/spring-tools/explanations/YAML_EXPECT_SCALAR.md similarity index 100% rename from claude-plugins/spring-tools/explanations/YAML_EXPECT_SCALAR.md rename to agent-plugins/spring-tools/explanations/YAML_EXPECT_SCALAR.md diff --git a/claude-plugins/spring-tools/explanations/YAML_EXPECT_TYPE_FOUND_MAPPING.md b/agent-plugins/spring-tools/explanations/YAML_EXPECT_TYPE_FOUND_MAPPING.md similarity index 100% rename from claude-plugins/spring-tools/explanations/YAML_EXPECT_TYPE_FOUND_MAPPING.md rename to agent-plugins/spring-tools/explanations/YAML_EXPECT_TYPE_FOUND_MAPPING.md diff --git a/claude-plugins/spring-tools/explanations/YAML_EXPECT_TYPE_FOUND_SEQUENCE.md b/agent-plugins/spring-tools/explanations/YAML_EXPECT_TYPE_FOUND_SEQUENCE.md similarity index 100% rename from claude-plugins/spring-tools/explanations/YAML_EXPECT_TYPE_FOUND_SEQUENCE.md rename to agent-plugins/spring-tools/explanations/YAML_EXPECT_TYPE_FOUND_SEQUENCE.md diff --git a/claude-plugins/spring-tools/explanations/YAML_INVALID_BEAN_PROPERTY.md b/agent-plugins/spring-tools/explanations/YAML_INVALID_BEAN_PROPERTY.md similarity index 100% rename from claude-plugins/spring-tools/explanations/YAML_INVALID_BEAN_PROPERTY.md rename to agent-plugins/spring-tools/explanations/YAML_INVALID_BEAN_PROPERTY.md diff --git a/claude-plugins/spring-tools/explanations/YAML_SHOULD_ESCAPE.md b/agent-plugins/spring-tools/explanations/YAML_SHOULD_ESCAPE.md similarity index 100% rename from claude-plugins/spring-tools/explanations/YAML_SHOULD_ESCAPE.md rename to agent-plugins/spring-tools/explanations/YAML_SHOULD_ESCAPE.md diff --git a/claude-plugins/spring-tools/explanations/YAML_SYNTAX_ERROR.md b/agent-plugins/spring-tools/explanations/YAML_SYNTAX_ERROR.md similarity index 100% rename from claude-plugins/spring-tools/explanations/YAML_SYNTAX_ERROR.md rename to agent-plugins/spring-tools/explanations/YAML_SYNTAX_ERROR.md diff --git a/claude-plugins/spring-tools/explanations/YAML_UNKNOWN_PROPERTY.md b/agent-plugins/spring-tools/explanations/YAML_UNKNOWN_PROPERTY.md similarity index 100% rename from claude-plugins/spring-tools/explanations/YAML_UNKNOWN_PROPERTY.md rename to agent-plugins/spring-tools/explanations/YAML_UNKNOWN_PROPERTY.md diff --git a/claude-plugins/spring-tools/explanations/YAML_VALUE_TYPE_MISMATCH.md b/agent-plugins/spring-tools/explanations/YAML_VALUE_TYPE_MISMATCH.md similarity index 100% rename from claude-plugins/spring-tools/explanations/YAML_VALUE_TYPE_MISMATCH.md rename to agent-plugins/spring-tools/explanations/YAML_VALUE_TYPE_MISMATCH.md diff --git a/claude-plugins/spring-tools/explanations/YamlSchemaProblem.md b/agent-plugins/spring-tools/explanations/YamlSchemaProblem.md similarity index 100% rename from claude-plugins/spring-tools/explanations/YamlSchemaProblem.md rename to agent-plugins/spring-tools/explanations/YamlSchemaProblem.md diff --git a/agent-plugins/spring-tools/hooks/hooks.json b/agent-plugins/spring-tools/hooks/hooks.json new file mode 100644 index 0000000000..d452f1b58d --- /dev/null +++ b/agent-plugins/spring-tools/hooks/hooks.json @@ -0,0 +1,243 @@ +{ + "description": "Downloads the language server JAR on first start and keeps the Spring Tools index in sync with files Claude edits (synchronously, before the next tool call) and after shell commands that rewrite the working tree. The language server additionally watches the workspace directory, so these hooks are the fast path, not the only path.", + "hooks": { + "SessionStart": [ + { + "matcher": "startup|resume|fork", + "hooks": [ + { + "type": "command", + "command": "node", + "args": ["${CLAUDE_PLUGIN_ROOT}/install.js", "--if-missing"], + "timeout": 900, + "statusMessage": "Spring Tools: preparing the language server" + } + ] + } + ], + "PostToolUse": [ + { + "matcher": "Edit|Write", + "hooks": [ + { + "type": "mcp_tool", + "if": "Edit(*.java)", + "server": "plugin:spring-tools:spring-tools-mcp", + "tool": "fileChanged", + "input": { + "filePath": "${tool_input.file_path}" + }, + "timeout": 30 + }, + { + "type": "mcp_tool", + "if": "Edit(*.kt)", + "server": "plugin:spring-tools:spring-tools-mcp", + "tool": "fileChanged", + "input": { + "filePath": "${tool_input.file_path}" + }, + "timeout": 30 + }, + { + "type": "mcp_tool", + "if": "Edit(*.kts)", + "server": "plugin:spring-tools:spring-tools-mcp", + "tool": "fileChanged", + "input": { + "filePath": "${tool_input.file_path}" + }, + "timeout": 30 + }, + { + "type": "mcp_tool", + "if": "Edit(*.groovy)", + "server": "plugin:spring-tools:spring-tools-mcp", + "tool": "fileChanged", + "input": { + "filePath": "${tool_input.file_path}" + }, + "timeout": 30 + }, + { + "type": "mcp_tool", + "if": "Edit(*.xml)", + "server": "plugin:spring-tools:spring-tools-mcp", + "tool": "fileChanged", + "input": { + "filePath": "${tool_input.file_path}" + }, + "timeout": 30 + }, + { + "type": "mcp_tool", + "if": "Edit(*.properties)", + "server": "plugin:spring-tools:spring-tools-mcp", + "tool": "fileChanged", + "input": { + "filePath": "${tool_input.file_path}" + }, + "timeout": 30 + }, + { + "type": "mcp_tool", + "if": "Edit(*.yml)", + "server": "plugin:spring-tools:spring-tools-mcp", + "tool": "fileChanged", + "input": { + "filePath": "${tool_input.file_path}" + }, + "timeout": 30 + }, + { + "type": "mcp_tool", + "if": "Edit(*.yaml)", + "server": "plugin:spring-tools:spring-tools-mcp", + "tool": "fileChanged", + "input": { + "filePath": "${tool_input.file_path}" + }, + "timeout": 30 + }, + { + "type": "mcp_tool", + "if": "Edit(*.factories)", + "server": "plugin:spring-tools:spring-tools-mcp", + "tool": "fileChanged", + "input": { + "filePath": "${tool_input.file_path}" + }, + "timeout": 30 + }, + { + "type": "mcp_tool", + "if": "Edit(*.gradle)", + "server": "plugin:spring-tools:spring-tools-mcp", + "tool": "fileChanged", + "input": { + "filePath": "${tool_input.file_path}" + }, + "timeout": 30 + } + ] + }, + { + "matcher": "Bash|PowerShell", + "hooks": [ + { + "type": "mcp_tool", + "if": "Bash(git *)", + "server": "plugin:spring-tools:spring-tools-mcp", + "tool": "refreshWorkspace", + "input": {}, + "timeout": 30 + }, + { + "type": "mcp_tool", + "if": "Bash(rm *)", + "server": "plugin:spring-tools:spring-tools-mcp", + "tool": "refreshWorkspace", + "input": {}, + "timeout": 30 + }, + { + "type": "mcp_tool", + "if": "Bash(mv *)", + "server": "plugin:spring-tools:spring-tools-mcp", + "tool": "refreshWorkspace", + "input": {}, + "timeout": 30 + }, + { + "type": "mcp_tool", + "if": "Bash(cp *)", + "server": "plugin:spring-tools:spring-tools-mcp", + "tool": "refreshWorkspace", + "input": {}, + "timeout": 30 + }, + { + "type": "mcp_tool", + "if": "Bash(sed *)", + "server": "plugin:spring-tools:spring-tools-mcp", + "tool": "refreshWorkspace", + "input": {}, + "timeout": 30 + }, + { + "type": "mcp_tool", + "if": "Bash(patch *)", + "server": "plugin:spring-tools:spring-tools-mcp", + "tool": "refreshWorkspace", + "input": {}, + "timeout": 30 + }, + { + "type": "mcp_tool", + "if": "Bash(tar *)", + "server": "plugin:spring-tools:spring-tools-mcp", + "tool": "refreshWorkspace", + "input": {}, + "timeout": 30 + }, + { + "type": "mcp_tool", + "if": "Bash(unzip *)", + "server": "plugin:spring-tools:spring-tools-mcp", + "tool": "refreshWorkspace", + "input": {}, + "timeout": 30 + }, + { + "type": "mcp_tool", + "if": "PowerShell(git *)", + "server": "plugin:spring-tools:spring-tools-mcp", + "tool": "refreshWorkspace", + "input": {}, + "timeout": 30 + }, + { + "type": "mcp_tool", + "if": "PowerShell(Remove-Item *)", + "server": "plugin:spring-tools:spring-tools-mcp", + "tool": "refreshWorkspace", + "input": {}, + "timeout": 30 + }, + { + "type": "mcp_tool", + "if": "PowerShell(Move-Item *)", + "server": "plugin:spring-tools:spring-tools-mcp", + "tool": "refreshWorkspace", + "input": {}, + "timeout": 30 + }, + { + "type": "mcp_tool", + "if": "PowerShell(Copy-Item *)", + "server": "plugin:spring-tools:spring-tools-mcp", + "tool": "refreshWorkspace", + "input": {}, + "timeout": 30 + }, + { + "type": "mcp_tool", + "if": "PowerShell(Expand-Archive *)", + "server": "plugin:spring-tools:spring-tools-mcp", + "tool": "refreshWorkspace", + "input": {}, + "timeout": 30 + }, + { + "type": "mcp_tool", + "if": "PowerShell(tar *)", + "server": "plugin:spring-tools:spring-tools-mcp", + "tool": "refreshWorkspace", + "input": {}, + "timeout": 30 + } + ] + } + ] + } +} diff --git a/agent-plugins/spring-tools/install.js b/agent-plugins/spring-tools/install.js new file mode 100644 index 0000000000..648168ce29 --- /dev/null +++ b/agent-plugins/spring-tools/install.js @@ -0,0 +1,445 @@ +#!/usr/bin/env node +/******************************************************************************* + * Copyright (c) 2026 Broadcom + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * https://www.eclipse.org/legal/epl-v10.html + * + * Contributors: + * Broadcom - initial API and implementation + *******************************************************************************/ +// Downloads the Spring Boot language server JAR matching the plugin version and verifies its +// SHA-256. Used in-process by launcher.js and as a CLI by the SessionStart hook: +// node install.js [--if-missing] [--dest ] +// Only stderr is used for logging so the launcher's stdout stays a clean MCP channel. +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const http = require('http'); +const https = require('https'); +const net = require('net'); +const tls = require('tls'); +const crypto = require('crypto'); +const os = require('os'); +const { spawnSync } = require('child_process'); +const { Transform } = require('stream'); +const { pipeline } = require('stream/promises'); + +const JAR_NAME = 'spring-boot-language-server-standalone-exec.jar'; +const DEFAULT_DOWNLOAD_BASE = 'https://cdn.spring.io/spring-tools'; +const MAX_REDIRECTS = 5; +const SOCKET_IDLE_TIMEOUT_MS = 60000; +const LOCK_STALE_MS = 30 * 60 * 1000; +const LOCK_WAIT_MAX_MS = 20 * 60 * 1000; +const LOCK_POLL_MS = 500; +const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '::1', '[::1]']); + +// The Agent Plugins manifest at the plugin root is the one both clients can read; the Claude Code +// manifest in .claude-plugin/ is the fallback for older layouts. +function pluginVersion(pluginRoot) { + const manifestPath = [path.join(pluginRoot, 'plugin.json'), path.join(pluginRoot, '.claude-plugin', 'plugin.json')] + .find((candidate) => fs.existsSync(candidate)); + if (!manifestPath) { + throw new Error(`no plugin.json found in ${pluginRoot}`); + } + const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); + if (typeof manifest.version !== 'string' || !/^[0-9A-Za-z][0-9A-Za-z.+-]*$/.test(manifest.version)) { + throw new Error('plugin.json has no valid version'); + } + return manifest.version; +} + +function defaultInstallDir(pluginRoot = __dirname, env = process.env, home = os.homedir()) { + const dataDir = env.SPRING_TOOLS_DATA_DIR || env.CLAUDE_PLUGIN_DATA || env.PLUGIN_DATA || path.join(home, '.spring-tools', 'data'); + // Keep artifacts from different plugin releases isolated; an old JAR must never satisfy + // --if-missing after the plugin version changes. + return path.resolve(dataDir, 'language-server', pluginVersion(pluginRoot)); +} + +/** Release versions live under release//, anything with a qualifier under snapshot/. */ +function downloadUrls(version, base = process.env.SPRING_TOOLS_LS_DOWNLOAD_BASE || DEFAULT_DOWNLOAD_BASE) { + const root = base.replace(/\/+$/, ''); + const jarUrl = version.includes('-') + ? `${root}/snapshot/language-server/spring-boot/${JAR_NAME}` + : `${root}/release/language-server/spring-boot/${version}/${JAR_NAME}`; + return { jarUrl, sha256Url: `${jarUrl}.sha256` }; +} + +/** Hosts a download may come from: the CDN, plus a configured mirror. Plain HTTP only for loopback (tests). */ +function assertAllowedUrl(url, base = process.env.SPRING_TOOLS_LS_DOWNLOAD_BASE || DEFAULT_DOWNLOAD_BASE) { + let parsed; + try { + parsed = new URL(url); + } catch { + throw new Error(`Invalid URL: ${url}`); + } + const allowedHosts = new Set([new URL(DEFAULT_DOWNLOAD_BASE).hostname, new URL(base).hostname]); + if (!allowedHosts.has(parsed.hostname)) { + throw new Error(`Refusing to fetch from untrusted host: ${url}`); + } + if (parsed.protocol !== 'https:' && !(parsed.protocol === 'http:' && LOOPBACK_HOSTS.has(parsed.hostname))) { + throw new Error(`Refusing insecure download URL: ${url}`); + } + return parsed; +} + +function hostMatchesNoProxy(hostname, noProxy) { + if (!noProxy) { + return false; + } + return noProxy.split(',').map((e) => e.trim().toLowerCase()).filter(Boolean).some((entry) => { + if (entry === '*') { + return true; + } + const pattern = entry.replace(/^\./, '').replace(/:\d+$/, ''); + return hostname === pattern || hostname.endsWith(`.${pattern}`); + }); +} + +/** Proxy URL for a target, following curl's HTTPS_PROXY/HTTP_PROXY/ALL_PROXY and NO_PROXY conventions. */ +function proxyFor(targetUrl, env = process.env) { + const target = new URL(targetUrl); + const hostname = target.hostname.toLowerCase(); + if (LOOPBACK_HOSTS.has(hostname) || hostMatchesNoProxy(hostname, env.NO_PROXY || env.no_proxy)) { + return null; + } + const candidates = target.protocol === 'https:' + ? [env.HTTPS_PROXY, env.https_proxy, env.ALL_PROXY, env.all_proxy] + : [env.HTTP_PROXY, env.http_proxy, env.ALL_PROXY, env.all_proxy]; + const proxy = candidates.find((value) => value && value.trim()); + if (!proxy) { + return null; + } + const parsed = new URL(/^[a-z][a-z0-9+.-]*:\/\//i.test(proxy) ? proxy : `http://${proxy}`); + if (parsed.protocol !== 'http:') { + throw new Error(`Unsupported proxy protocol ${parsed.protocol} (only http:// proxies are supported)`); + } + return parsed; +} + +function proxyAuthHeader(proxy) { + if (!proxy.username && !proxy.password) { + return null; + } + const credentials = `${decodeURIComponent(proxy.username)}:${decodeURIComponent(proxy.password)}`; + return `Basic ${Buffer.from(credentials).toString('base64')}`; +} + +/** Opens a CONNECT tunnel through an HTTP proxy and wraps it in TLS for the target host. */ +function connectThroughProxy(proxy, target) { + return new Promise((resolve, reject) => { + const port = target.port || 443; + const socket = net.connect({ host: proxy.hostname, port: Number(proxy.port) || 80 }); + socket.setTimeout(SOCKET_IDLE_TIMEOUT_MS, () => { + socket.destroy(new Error(`Timed out connecting to proxy ${proxy.host}`)); + }); + socket.once('error', reject); + socket.once('connect', () => { + const auth = proxyAuthHeader(proxy); + socket.write(`CONNECT ${target.hostname}:${port} HTTP/1.1\r\nHost: ${target.hostname}:${port}\r\n` + + (auth ? `Proxy-Authorization: ${auth}\r\n` : '') + 'Connection: keep-alive\r\n\r\n'); + }); + let response = ''; + const onData = (chunk) => { + response += chunk.toString('latin1'); + const end = response.indexOf('\r\n\r\n'); + if (end === -1) { + return; + } + socket.removeListener('data', onData); + const statusLine = response.slice(0, response.indexOf('\r\n')); + if (!/^HTTP\/1\.[01] 200\b/.test(statusLine)) { + socket.destroy(); + reject(new Error(`Proxy ${proxy.host} refused CONNECT to ${target.hostname}:${port}: ${statusLine}`)); + return; + } + const leftover = response.slice(end + 4); + if (leftover.length) { + socket.destroy(); + reject(new Error(`Proxy ${proxy.host} sent unexpected data after the CONNECT response`)); + return; + } + socket.pause(); + const secure = tls.connect({ socket, servername: target.hostname }); + secure.once('secureConnect', () => resolve(secure)); + secure.once('error', reject); + }; + socket.on('data', onData); + }); +} + +function urlToOptions(target) { + return { + protocol: target.protocol, + hostname: target.hostname, + port: target.port || undefined, + path: `${target.pathname}${target.search}`, + method: 'GET', + }; +} + +function createTunnelAgent(socket) { + const agent = new https.Agent({ keepAlive: false }); + agent.createConnection = () => socket; + return agent; +} + +/** GET with redirects, host allow-list and proxy support; resolves with a 200 response stream. */ +async function get(url, redirectCount = 0) { + const target = assertAllowedUrl(url); + const proxy = proxyFor(url); + const headers = { 'user-agent': 'spring-tools-claude-plugin' }; + let request; + if (proxy && target.protocol === 'https:') { + const socket = await connectThroughProxy(proxy, target); + request = https.request({ ...urlToOptions(target), headers, agent: createTunnelAgent(socket) }); + } else if (proxy) { + const auth = proxyAuthHeader(proxy); + request = http.request({ + host: proxy.hostname, port: Number(proxy.port) || 80, method: 'GET', path: target.href, + headers: { ...headers, host: target.host, ...(auth ? { 'proxy-authorization': auth } : {}) }, + }); + } else { + request = (target.protocol === 'https:' ? https : http).request({ ...urlToOptions(target), headers }); + } + const response = await new Promise((resolve, reject) => { + request.setTimeout(SOCKET_IDLE_TIMEOUT_MS, () => request.destroy(new Error(`Timed out fetching ${url}`))); + request.once('response', resolve); + request.once('error', reject); + request.end(); + }); + if (response.statusCode >= 300 && response.statusCode < 400 && response.headers.location) { + response.resume(); + if (redirectCount >= MAX_REDIRECTS) { + throw new Error(`Too many redirects while fetching ${url}`); + } + return get(new URL(response.headers.location, url).toString(), redirectCount + 1); + } + if (response.statusCode !== 200) { + response.resume(); + throw new Error(`Failed to fetch ${url}: HTTP ${response.statusCode}`); + } + return response; +} + +async function fetchText(url) { + const response = await get(url); + let data = ''; + for await (const chunk of response) { + data += chunk; + if (data.length > 4096) { + throw new Error(`Unexpectedly large checksum file at ${url}`); + } + } + return data.trim(); +} + +/** Streams the URL into dest while hashing; resolves with the hex SHA-256 of what was written. */ +async function downloadFile(url, dest, log) { + const response = await get(url); + const total = Number(response.headers['content-length']) || 0; + const hash = crypto.createHash('sha256'); + let received = 0; + let nextReport = 0; + const meter = new Transform({ + transform(chunk, encoding, callback) { + hash.update(chunk); + received += chunk.length; + if (total && received >= nextReport) { + log(` ${Math.floor((received / total) * 100)}% (${Math.round(received / 1048576)} of ${Math.round(total / 1048576)} MB)`); + nextReport += Math.max(total / 10, 1); + } + callback(null, chunk); + }, + }); + await pipeline(response, meter, fs.createWriteStream(dest, { flags: 'wx' })); + if (total && received !== total) { + throw new Error(`Download of ${url} ended after ${received} of ${total} bytes`); + } + return hash.digest('hex'); +} + +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +/** Creates .lock exclusively; waits while another installer holds it, breaks stale locks. */ +async function acquireLock(lockPath) { + const started = Date.now(); + for (;;) { + try { + fs.writeFileSync(lockPath, `${process.pid} ${new Date().toISOString()}\n`, { flag: 'wx' }); + return () => { try { fs.unlinkSync(lockPath); } catch { /* already gone */ } }; + } catch (error) { + if (error.code !== 'EEXIST') { + throw error; + } + } + try { + const lock = fs.statSync(lockPath); + const ownerPid = Number(fs.readFileSync(lockPath, 'utf8').trim().split(/\s+/, 1)[0]); + const hasOwnerPid = Number.isSafeInteger(ownerPid) && ownerPid > 0; + let ownerAlive = false; + if (hasOwnerPid) { + try { + process.kill(ownerPid, 0); + ownerAlive = true; + } catch (ownerError) { + if (ownerError.code === 'EPERM') { + ownerAlive = true; + } else if (ownerError.code !== 'ESRCH') { + throw ownerError; + } + } + } + if ((hasOwnerPid && !ownerAlive) || Date.now() - lock.mtimeMs > LOCK_STALE_MS) { + fs.unlinkSync(lockPath); + continue; + } + } catch (lockError) { + if (lockError.code !== 'ENOENT') { + throw lockError; + } + } + if (Date.now() - started > LOCK_WAIT_MAX_MS) { + throw new Error(`Gave up waiting for another installer holding ${lockPath}`); + } + await sleep(LOCK_POLL_MS); + } +} + +function removeStalePartials(dir) { + for (const entry of fs.readdirSync(dir)) { + if (entry.startsWith(JAR_NAME) && entry.endsWith('.part')) { + try { fs.unlinkSync(path.join(dir, entry)); } catch { /* best effort */ } + } + } +} + +function findSourceRoot(pluginRoot, env = process.env) { + const candidates = [env.SPRING_TOOLS_SOURCE_DIR, path.resolve(pluginRoot, '..', '..')].filter(Boolean); + return candidates.map((candidate) => path.resolve(candidate)).find((root) => + fs.existsSync(path.join(root, 'headless-services', 'pom.xml')) + && (fs.existsSync(path.join(root, 'headless-services', 'mvnw')) || fs.existsSync(path.join(root, 'headless-services', 'mvnw.cmd')))); +} + +function buildJar(pluginRoot, dest, log) { + const sourceRoot = findSourceRoot(pluginRoot); + if (!sourceRoot) { + throw new Error('No Spring Tools source checkout found for a local build. Set SPRING_TOOLS_SOURCE_DIR to the repository root, or install the JAR manually and set SPRING_TOOLS_LS_JAR.'); + } + const servicesDir = path.join(sourceRoot, 'headless-services'); + const wrapper = path.join(servicesDir, process.platform === 'win32' ? 'mvnw.cmd' : 'mvnw'); + const args = ['-pl', 'spring-boot-language-server-standalone', '-am', '-DskipTests', 'package']; + log(`Building Spring Boot Language Server from ${sourceRoot}`); + // Maven must not read MCP requests or write build output to the MCP stdout transport. + const result = spawnSync(wrapper, args, { cwd: servicesDir, stdio: ['ignore', 2, 2], shell: process.platform === 'win32' }); + if (result.error || result.status !== 0) { + throw new Error(`Local language server build failed${result.error ? `: ${result.error.message}` : ` (exit code ${result.status})`}`); + } + const targetDir = path.join(sourceRoot, 'headless-services', 'spring-boot-language-server-standalone', 'target'); + const builtJar = fs.readdirSync(targetDir).filter((name) => name.endsWith('-standalone-exec.jar')).sort().at(-1); + if (!builtJar) throw new Error(`Maven build completed but no standalone JAR was found in ${targetDir}`); + const temporary = path.join(dest, `${JAR_NAME}.${process.pid}.part`); + try { + fs.copyFileSync(path.join(targetDir, builtJar), temporary, fs.constants.COPYFILE_EXCL); + fs.renameSync(temporary, path.join(dest, JAR_NAME)); + } finally { + try { fs.unlinkSync(temporary); } catch { /* already renamed or absent */ } + } + log(`Built Spring Boot Language Server and installed it to ${path.join(dest, JAR_NAME)}`); +} + +/** + * Makes sure / exists for the given plugin version. + * @returns {Promise<{status: 'present'|'downloaded', jarPath: string}>} + */ +async function ensureJar({ pluginRoot = __dirname, dest = defaultInstallDir(pluginRoot), ifMissing = false, log = (line) => console.error(line) } = {}) { + const jarPath = path.join(dest, JAR_NAME); + if (ifMissing && fs.existsSync(jarPath)) { + return { status: 'present', jarPath }; + } + const version = pluginVersion(pluginRoot); + fs.mkdirSync(dest, { recursive: true }); + const release = await acquireLock(`${jarPath}.lock`); + try { + if (ifMissing && fs.existsSync(jarPath)) { + return { status: 'present', jarPath }; + } + removeStalePartials(dest); + try { + const { jarUrl, sha256Url } = downloadUrls(version); + log(`Downloading Spring Boot Language Server ${version} from ${jarUrl}`); + const expected = (await fetchText(sha256Url)).split(/\s+/)[0].toLowerCase(); + if (!/^[0-9a-f]{64}$/.test(expected)) { + throw new Error(`Checksum file ${sha256Url} does not contain a SHA-256 digest`); + } + const partPath = `${jarPath}.${process.pid}.part`; + try { + const actual = await downloadFile(jarUrl, partPath, log); + if (actual !== expected) throw new Error(`Checksum mismatch for ${JAR_NAME}: expected ${expected}, got ${actual}`); + fs.renameSync(partPath, jarPath); + } finally { + try { fs.unlinkSync(partPath); } catch { /* renamed away or never created */ } + } + log(`Installed Spring Boot Language Server ${version} to ${jarPath}`); + return { status: 'downloaded', jarPath }; + } catch (downloadError) { + log(`Language server download failed: ${downloadError.message}`); + try { + buildJar(pluginRoot, dest, log); + return { status: 'built', jarPath }; + } catch (buildError) { + throw new Error(`Could not download or build the Spring Tools language server. Download error: ${downloadError.message}. Build error: ${buildError.message}`); + } + } + } finally { + release(); + } +} + +function parseArgs(argv) { + const options = { ifMissing: false, dest: undefined, help: false }; + for (let i = 0; i < argv.length; i++) { + const arg = argv[i]; + if (arg === '--if-missing') options.ifMissing = true; + else if (arg === '--dest') { + if (!argv[i + 1] || argv[i + 1].startsWith('--')) { + throw new Error('--dest requires a directory path'); + } + options.dest = path.resolve(argv[++i]); + } + else if (arg === '--help' || arg === '-h') options.help = true; + else throw new Error(`unknown argument: ${arg}`); + } + return options; +} + +async function main() { + const options = parseArgs(process.argv.slice(2)); + if (options.help) { + console.error('usage: node install.js [--if-missing] [--dest ]'); + return 0; + } + if (process.env.SPRING_TOOLS_LS_JAR) { + console.error(`SPRING_TOOLS_LS_JAR is set (${process.env.SPRING_TOOLS_LS_JAR}); nothing to download.`); + return 0; + } + const result = await ensureJar({ ifMissing: options.ifMissing, dest: options.dest }); + if (result.status === 'downloaded') { + // stdout of a SessionStart hook is added to Claude's context. + console.log(`Spring Tools downloaded its language server to ${result.jarPath}. If the Spring Tools MCP server is listed as failed, reconnect it or restart the agent client.`); + } + return 0; +} + +if (require.main === module) { + main().then((code) => process.exit(code), (error) => { + console.error(`Spring Tools language server installation failed: ${error.message}`); + process.exit(1); + }); +} + +module.exports = { JAR_NAME, DEFAULT_DOWNLOAD_BASE, downloadUrls, assertAllowedUrl, proxyFor, hostMatchesNoProxy, ensureJar, pluginVersion, defaultInstallDir, parseArgs, findSourceRoot, createTunnelAgent }; diff --git a/agent-plugins/spring-tools/launcher.js b/agent-plugins/spring-tools/launcher.js new file mode 100644 index 0000000000..194b2ab504 --- /dev/null +++ b/agent-plugins/spring-tools/launcher.js @@ -0,0 +1,305 @@ +#!/usr/bin/env node +/******************************************************************************* + * Copyright (c) 2026 Broadcom + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * https://www.eclipse.org/legal/epl-v10.html + * + * Contributors: + * Broadcom - initial API and implementation + *******************************************************************************/ +// Starts the standalone Spring Boot language server as an MCP stdio server for Claude Code, +// GitHub Copilot CLI, Codex and OpenCode. +// Environment (all optional): +// SPRING_TOOLS_JAVA java executable to use (else $JAVA_HOME/bin/java, else java on PATH; first with Java 21+ wins) +// SPRING_TOOLS_JAVA_OPTS extra JVM options, e.g. "-Xmx2g" (appended after the defaults, so they override them) +// SPRING_TOOLS_LS_JAR path to a local language server JAR to run instead of the downloaded one +// SPRING_TOOLS_LS_WATCH "false" disables the built-in file watcher; use hooks or explicit change tools to update the index +// SPRING_TOOLS_PROJECT_DIR workspace directory to index, overriding the auto-detection below +// SPRING_TOOLS_DATA_DIR persistent log/runtime directory (else use client data dir or ~/.spring-tools/data) +'use strict'; + +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { spawn, spawnSync } = require('child_process'); +const { ensureJar, JAR_NAME, defaultInstallDir } = require('./install.js'); + +const MIN_JAVA_MAJOR = 21; + +// Shown to the client alongside the tool names when MCP tool search defers the full tool list. +const SERVER_INSTRUCTIONS = [ + 'Spring Tools language server for the Spring Boot projects in this workspace.', + 'Start with getProjectList (use the projectName field, location is the project root directory; retry while the list is still empty right after startup, the project model is being resolved).', + 'getProjectDiagnostics reports Spring-specific problems with a code such as JAVA_PUBLIC_BEAN_METHOD; every code has a fix playbook. Use the quickfix skill when available, or read the matching explanations/.md file, then validate the project with the validate skill or getProjectDiagnostics.', + 'Bean wiring: getBeanDetails, findBeansByType, getBeanUsageInfo. Endpoints: getRequestMappings, findRequestMappingsByMethod. Build facts: getSpringBootVersion, getJavaVersion, getResolvedProjectClasspath.', + 'Architecture: getLogicalStructure, getStereotypesList, findComponentsByStereotype and the logical-structure baseline tools. Spring versions and support: getReleases, getGenerations, getUpcomingReleases, getLatestReleaseInformation.', + 'For guidance, use the quickfix or validate Spring Tools skill when available.', + 'The server watches the workspace directory, so edits, new files and new projects are picked up within a second without any notification; call fileChanged/fileDeleted only when a change might have been missed and refreshWorkspace when the index looks stale (e.g. after a large git checkout).', +].join(' '); + +/** + * Server instructions with the absolute location of the fix playbooks appended. Clients that do not + * substitute path placeholders in skill and agent files learn where the playbooks are this way. + */ +function serverInstructions(pluginRoot = __dirname) { + return `${SERVER_INSTRUCTIONS} The fix playbooks are the .md files in ${path.join(pluginRoot, 'explanations')}.`; +} + +/** Major Java version from `java -version` output; null when unparseable. */ +function parseJavaMajor(output) { + const match = /version "(\d+)(?:\.(\d+))?/.exec(output || ''); + if (!match) { + return null; + } + const major = Number(match[1]); + return major === 1 ? Number(match[2]) : major; +} + +/** Java executables to try, most specific first. */ +function javaCandidates(env = process.env, platform = process.platform) { + const exe = platform === 'win32' ? 'java.exe' : 'java'; + const candidates = []; + if (env.SPRING_TOOLS_JAVA) { + candidates.push({ java: env.SPRING_TOOLS_JAVA, source: 'SPRING_TOOLS_JAVA' }); + } + if (env.JAVA_HOME) { + candidates.push({ java: path.join(env.JAVA_HOME, 'bin', exe), source: 'JAVA_HOME' }); + } + candidates.push({ java: 'java', source: 'PATH' }); + return candidates; +} + +function probeJava(java) { + const result = spawnSync(java, ['-version'], { encoding: 'utf8', timeout: 20000, windowsHide: true }); + if (result.error) { + return { output: '', error: result.error.message }; + } + return { output: `${result.stderr || ''}${result.stdout || ''}` }; +} + +/** First candidate that runs and reports Java >= MIN_JAVA_MAJOR, plus what was tried. */ +function pickJava(candidates, probe = probeJava) { + const tried = []; + for (const candidate of candidates) { + const probed = probe(candidate.java); + const major = probed.error ? null : parseJavaMajor(probed.output); + tried.push({ ...candidate, major, error: probed.error }); + if (major !== null && major >= MIN_JAVA_MAJOR) { + return { ...candidate, major, tried }; + } + } + return { java: null, major: null, tried }; +} + +function describeTried(tried) { + return tried.map((t) => `${t.java} (${t.source}): ${t.error ? t.error : t.major === null ? 'no version reported' : `Java ${t.major}`}`).join('; '); +} + +/** Splits a JVM options string on whitespace, honoring single/double quotes (also inside a token). */ +function splitOpts(value) { + const opts = []; + let current = ''; + let quote = null; + let inToken = false; + for (const char of value || '') { + if (quote) { + if (char === quote) { + quote = null; + } else { + current += char; + } + } else if (char === '"' || char === "'") { + quote = char; + inToken = true; + } else if (/\s/.test(char)) { + if (inToken) { + opts.push(current); + } + current = ''; + inToken = false; + } else { + current += char; + inToken = true; + } + } + if (inToken) { + opts.push(current); + } + return opts; +} + +/** true unless SPRING_TOOLS_LS_WATCH is set to false/0/no/off (case-insensitive). */ +function watchEnabled(value) { + return !/^\s*(false|0|no|off)\s*$/i.test(value || ''); +} + +function resolveDataDir({ env = process.env, home = os.homedir() } = {}) { + return path.resolve(env.SPRING_TOOLS_DATA_DIR || env.CLAUDE_PLUGIN_DATA || env.PLUGIN_DATA || path.join(home, '.spring-tools', 'data')); +} + +function isDirectory(dir) { + try { + return fs.statSync(dir).isDirectory(); + } catch { + return false; + } +} + +/** An absolute, existing directory that is not the plugin's own root (which is never a workspace). */ +function usableProjectDir(dir, pluginRoot) { + return !!dir && path.isAbsolute(dir) && path.resolve(dir) !== path.resolve(pluginRoot) && isDirectory(dir); +} + +/** + * GitHub Copilot CLI starts plugin MCP servers with the plugin root as working directory and passes + * no workspace path, but it records the session's directory before starting them. + */ +function copilotWorkspaceDir(env, home) { + const session = env.COPILOT_AGENT_SESSION_ID; + // A session id is a single path segment; anything else would escape the session-state directory. + if (!session || !/^[\w.-]+$/.test(session) || session === '.' || session === '..') { + return null; + } + try { + const state = fs.readFileSync(path.join(home, '.copilot', 'session-state', session, 'workspace.yaml'), 'utf8'); + return /^cwd:[ \t]*(.+?)[ \t]*$/m.exec(state)?.[1] ?? null; + } catch { + return null; + } +} + +/** + * Workspace the language server should index. Claude Code exports CLAUDE_PROJECT_DIR; Copilot CLI + * runs it in the plugin directory, so the workspace has to be recovered from session state. Other + * plugin hosts may also change the MCP process cwd, so prefer the inherited PWD to that fallback. + * @returns {{dir: string, source: string, detected: boolean}} + */ +function resolveProjectDir({ env = process.env, cwd = process.cwd(), pluginRoot = __dirname, home = os.homedir() } = {}) { + const override = env.SPRING_TOOLS_PROJECT_DIR; + if (override) { + const dir = path.resolve(cwd, override); + if (!isDirectory(dir)) { + throw new Error(`SPRING_TOOLS_PROJECT_DIR is not an existing directory: ${dir}`); + } + return { dir, source: 'SPRING_TOOLS_PROJECT_DIR', detected: true }; + } + const candidates = [ + { dir: env.CLAUDE_PROJECT_DIR, source: 'CLAUDE_PROJECT_DIR' }, + { dir: copilotWorkspaceDir(env, home), source: 'Copilot session state' }, + { dir: env.PWD, source: 'PWD' }, + { dir: cwd, source: 'working directory' }, + ]; + for (const candidate of candidates) { + if (usableProjectDir(candidate.dir, pluginRoot)) { + return { dir: path.resolve(candidate.dir), source: candidate.source, detected: true }; + } + } + return { dir: path.resolve(cwd), source: 'working directory', detected: false }; +} + +function buildJavaArgs({ jarPath, dataDir, projectDir, watch = true, extraOpts = [], instructions = serverInstructions() }) { + return [ + '-Xmx1024m', + '-Djdk.util.zip.disableZip64ExtraFieldValidation=true', + '-Dspring.config.location=classpath:/application.properties', + '-Dspring.profiles.active=file-logging', + `-Dlogging.file.name=${path.join(dataDir, 'boot-ls.log')}`, + '-Dlogging.level.root=INFO', + '-Dspring.ai.mcp.server.stdio=true', + `-Dspring.ai.mcp.server.instructions=${instructions}`, + // Only the MCP tools are exposed over stdio; nothing connects to the LSP transport. + '-Dlanguageserver.enabled=false', + `-Dspring.boot.ls.project.dir=${projectDir}`, + // The server follows on-disk changes under the project dir itself (java.nio WatchService). + `-Dspring.boot.ls.project.watch=${watch ? 'true' : 'false'}`, + ...extraOpts, + '-jar', + jarPath, + ]; +} + +async function resolveJar(log) { + if (process.env.SPRING_TOOLS_LS_JAR) { + const jarPath = path.resolve(process.env.SPRING_TOOLS_LS_JAR); + if (!fs.existsSync(jarPath)) { + throw new Error(`SPRING_TOOLS_LS_JAR points to a missing file: ${jarPath}`); + } + log(`Using language server JAR from SPRING_TOOLS_LS_JAR: ${jarPath}`); + return jarPath; + } + const jarDest = defaultInstallDir(__dirname); + const jarPath = path.join(jarDest, JAR_NAME); + if (!fs.existsSync(jarPath)) { + log(`${JAR_NAME} not found, downloading it (first start of this plugin version). If the client reports the MCP server as failed before the download finishes, reconnect it after the download completes.`); + } + return (await ensureJar({ pluginRoot: __dirname, dest: jarDest, ifMissing: true, log })).jarPath; +} + +async function start() { + const log = (line) => console.error(`[spring-tools] ${line}`); + // The plugin root changes on every plugin update, so runtime state such as the log file goes to + // the persistent data dir both clients export (PLUGIN_DATA is the Agent Plugins spelling). + const project = resolveProjectDir(); + const dataDir = resolveDataDir(); + if (project.detected) { + log(`Indexing ${project.dir} (from ${project.source})`); + } else { + throw new Error(`Could not determine the workspace directory; refusing to index the plugin directory ${project.dir}. Set SPRING_TOOLS_PROJECT_DIR in the MCP server environment to the workspace root. For Codex, configure the server env explicitly; exporting a variable in the parent shell may not forward it.`); + } + + const picked = pickJava(javaCandidates()); + if (!picked.java) { + throw new Error(`No Java ${MIN_JAVA_MAJOR}+ runtime found. Tried: ${describeTried(picked.tried)}. Install a JDK 21 or newer and put it on PATH, or point JAVA_HOME or SPRING_TOOLS_JAVA at it.`); + } + log(`Using Java ${picked.major} from ${picked.java} (${picked.source})`); + + const jarPath = await resolveJar(log); + fs.mkdirSync(dataDir, { recursive: true }); + const watch = watchEnabled(process.env.SPRING_TOOLS_LS_WATCH); + if (!watch) { + log('File watcher disabled by SPRING_TOOLS_LS_WATCH; the index only follows the plugin hooks and explicit fileChanged/refreshWorkspace calls.'); + } + const javaArgs = buildJavaArgs({ jarPath, dataDir, projectDir: project.dir, watch, extraOpts: splitOpts(process.env.SPRING_TOOLS_JAVA_OPTS) }); + const child = spawn(picked.java, javaArgs, { stdio: 'inherit', windowsHide: true }); + + // The client terminates this launcher when the session ends; forward that to the JVM so it + // does not linger as an orphan. The server shuts down gracefully within about a second. + let shutdownSignal = null; + let forceKillTimer; + for (const signal of ['SIGINT', 'SIGTERM', 'SIGQUIT']) { + process.once(signal, () => { + shutdownSignal = signal; + if (!child.killed) { + child.kill('SIGTERM'); + } + // Give the language server time to flush logs and release workspace resources. + forceKillTimer = setTimeout(() => child.kill('SIGKILL'), 5000); + forceKillTimer.unref(); + }); + } + + child.on('error', (error) => { + log(`Failed to start the Java process: ${error.message}`); + process.exit(1); + }); + child.on('close', (code, signal) => { + clearTimeout(forceKillTimer); + if (shutdownSignal) { + process.exit(0); + } else if (code !== 0) { + log(`Language server exited with ${signal ? `signal ${signal}` : `code ${code}`}; see ${path.join(dataDir, 'boot-ls.log')}`); + } + process.exit(code ?? 1); + }); +} + +if (require.main === module) { + start().catch((error) => { + console.error(`[spring-tools] ${error.message}`); + process.exit(1); + }); +} + +module.exports = { MIN_JAVA_MAJOR, SERVER_INSTRUCTIONS, serverInstructions, parseJavaMajor, javaCandidates, pickJava, splitOpts, watchEnabled, buildJavaArgs, resolveProjectDir, resolveDataDir }; diff --git a/agent-plugins/spring-tools/mcp.json b/agent-plugins/spring-tools/mcp.json new file mode 100644 index 0000000000..421d792222 --- /dev/null +++ b/agent-plugins/spring-tools/mcp.json @@ -0,0 +1,12 @@ +{ + "$schema": "https://agent-plugins.org/schemas/1.0.0/mcp.schema.json", + "mcpServers": { + "spring-tools-mcp": { + "type": "stdio", + "command": "node", + "args": [ + "${PLUGIN_ROOT}/launcher.js" + ] + } + } +} \ No newline at end of file diff --git a/agent-plugins/spring-tools/opencode/agents/spring-reviewer.md b/agent-plugins/spring-tools/opencode/agents/spring-reviewer.md new file mode 100644 index 0000000000..e71f9defc3 --- /dev/null +++ b/agent-plugins/spring-tools/opencode/agents/spring-reviewer.md @@ -0,0 +1,29 @@ +--- +description: Reviews Spring Boot projects with Spring Tools diagnostics, bean wiring, endpoints and architecture. Read-only reviewer subagent. +mode: subagent +permission: + read: allow + glob: allow + grep: allow + edit: deny + bash: deny + "spring-tools_*": allow +--- +You are a Spring Boot reviewer. You review the current state of the Spring Boot projects in the workspace with the `spring-tools` MCP server and report findings; you never modify files. + +Work through these steps and keep tool output out of the report - summarize. + +1. `getProjectList` - note every project (`projectName`, `location`, `isSpringBootProject`, `javaVersion`). Retry a few times if the list is empty right after startup. Skip projects that are not Spring Boot projects unless the user asked about them. +2. `getProjectDiagnostics` per project. For each Spring diagnostic code read the fix playbook for that code - the `.md` files in the plugin's `explanations` directory, at `${CLAUDE_PLUGIN_ROOT}/explanations/.md` in Claude Code and at the absolute path named in the spring-tools MCP server instructions otherwise - and describe the fix precisely (file, range, what to change) instead of pasting the playbook. +3. Bean wiring: `getBeanDetails` for an overview, then `findBeansByType` for every interface with several implementations (missing `@Primary`/`@Qualifier`) and `getBeanUsageInfo` for beans that are defined but never injected (dead configuration) or injected but not defined. +4. Endpoints: `getRequestMappings` - flag duplicate paths, handler methods without an HTTP method, controllers that mix API versions inconsistently, and endpoints without a matching stereotype (`@RestController`/`@Controller`). +5. Structure: `getLogicalStructureChanges` (a git-backed project has a baseline automatically) to see what the change under review added, removed or moved; `findComponentsByStereotype` to check layering rules the project follows (e.g. controllers calling repositories directly). +6. Versions: `getSpringBootVersion` plus `getLatestReleaseInformation` with slug `spring-boot` - mention when the project runs on a generation whose OSS support has ended. + +Report format: + +- **Blocking**: problems that break the application context, the build or security (each with file:line and the fix). +- **Should fix**: Spring diagnostics with a playbook, wiring ambiguities, structural regressions. +- **Notes**: version/support status, observations, things you could not verify (e.g. the language server was still indexing). + +Use file:line references from the tool output. Read source files only to confirm a finding. When the language server reports nothing for a project, say so explicitly rather than inventing findings. diff --git a/agent-plugins/spring-tools/opencode/opencode.jsonc b/agent-plugins/spring-tools/opencode/opencode.jsonc new file mode 100644 index 0000000000..f6aecb92cb --- /dev/null +++ b/agent-plugins/spring-tools/opencode/opencode.jsonc @@ -0,0 +1,11 @@ +{ + "$schema": "https://opencode.ai/config.json", + "mcp": { + "spring-tools": { + "type": "local", + "command": ["node", "{env:SPRING_TOOLS_PLUGIN_ROOT}/launcher.js"], + "enabled": true, + "timeout": 120000 + } + } +} diff --git a/agent-plugins/spring-tools/plugin.json b/agent-plugins/spring-tools/plugin.json new file mode 100644 index 0000000000..54521bdc37 --- /dev/null +++ b/agent-plugins/spring-tools/plugin.json @@ -0,0 +1,22 @@ +{ + "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", + "name": "spring-tools", + "description": "Spring Tools Language Server as an MCP server — Spring Boot diagnostics with fix playbooks, bean/endpoint/architecture insight, version and release information, plus a Spring review agent. Operates standalone without requiring JDT Language Server and watches the workspace for changes. On first run, downloads the language server JAR (checksum-verified) from cdn.spring.io and launches it as a local Java process; requires Java 21+ on PATH.", + "version": "2.5.0", + "author": { + "name": "Spring Tools", + "url": "https://spring.io/tools" + }, + "homepage": "https://spring.io/tools", + "repository": "https://github.com/spring-projects/spring-tools", + "license": "EPL-1.0", + "keywords": [ + "spring", + "spring-boot", + "java", + "language-server", + "diagnostics", + "mcp", + "code-review" + ] +} diff --git a/agent-plugins/spring-tools/skills/architecture/SKILL.md b/agent-plugins/spring-tools/skills/architecture/SKILL.md new file mode 100644 index 0000000000..93d5b59761 --- /dev/null +++ b/agent-plugins/spring-tools/skills/architecture/SKILL.md @@ -0,0 +1,30 @@ +--- +name: architecture +description: Use when a user or agent asks about the architecture, logical structure, layers, stereotypes or components of a Spring Boot project (all controllers, all repositories, aggregates, modules), or wants to know how the structure changed since a baseline or commit. Uses the spring-tools MCP server's logical structure and stereotype tools. +allowed-tools: + - Read + - mcp__plugin_spring-tools_spring-tools-mcp__getProjectList + - mcp__plugin_spring-tools_spring-tools-mcp__getLogicalStructure + - mcp__plugin_spring-tools_spring-tools-mcp__getStructureDependencies + - mcp__plugin_spring-tools_spring-tools-mcp__getStereotypesList + - mcp__plugin_spring-tools_spring-tools-mcp__findComponentsByStereotype + - mcp__plugin_spring-tools_spring-tools-mcp__getListOfComponentsAndTheirStereotypes + - mcp__plugin_spring-tools_spring-tools-mcp__captureLogicalStructureBaseline + - mcp__plugin_spring-tools_spring-tools-mcp__getLogicalStructureChanges + - mcp__plugin_spring-tools_spring-tools-mcp__getLogicalStructureBaselineHistory + - mcp__plugin_spring-tools_spring-tools-mcp__clearLogicalStructureBaseline +--- + +Answer architecture questions from the `spring-tools` MCP server. The language server classifies every Spring component with stereotypes (Controller, Service, Repository, Component, Entity, Aggregate, Module, ... as defined by the project's `spring-stereotype` or JMolecules metadata plus the built-ins) and renders them as the same logical structure tree that Spring Tools shows in VS Code and Eclipse. + +1. Resolve the project name with `getProjectList` (use the `projectName` field). Retry a few times while the list is still empty right after the session started. +2. Pick the tool that matches the question: + - "Show me the architecture / structure of the project" - `getLogicalStructure`. Present the tree top-down (modules or packages, then stereotype groups, then components). For large projects summarize each group with counts and only expand what the user asked about. + - "Does the structure include our other modules or dependencies?" - `getStructureDependencies` lists the workspace projects and libraries the project depends on; pass their `id` values to `getLogicalStructure` to fold their components into the tree when the user wants the wider picture. + - "Which stereotypes exist here?" - `getStereotypesList`; it also reveals custom stereotypes the project defines. + - "List all repositories / controllers / aggregates" - `findComponentsByStereotype` with the stereotype name. Use `getListOfComponentsAndTheirStereotypes` when you need every class with all of its stereotypes (e.g. to find classes that carry two roles). + - "What did this refactoring change structurally?" - before a larger change call `captureLogicalStructureBaseline`; afterwards `getLogicalStructureChanges` reports added, removed and moved components (pass `includeUnchanged: true` only when the user wants the full tree). For git-backed projects a baseline is derived from the last commit automatically, so `getLogicalStructureChanges` works without a capture as well. `getLogicalStructureBaselineHistory` lists retained snapshots with their commits; `clearLogicalStructureBaseline` discards a manual capture. +3. Read source files only to back a specific finding (e.g. show the class that violates a layering rule); tool output already contains file paths. +4. Combine with the `beans` skill for wiring between components, the `endpoints` skill for the HTTP surface of controllers, and the `validate` skill for stereotype- or structure-related problems reported by the language server. + +The index follows on-disk changes automatically (the language server watches the workspace), so the structure reflects the files as they are on disk. If a very recent edit is not reflected yet, wait a moment and call the tool again. diff --git a/agent-plugins/spring-tools/skills/beans/SKILL.md b/agent-plugins/spring-tools/skills/beans/SKILL.md new file mode 100644 index 0000000000..c0507ad04c --- /dev/null +++ b/agent-plugins/spring-tools/skills/beans/SKILL.md @@ -0,0 +1,22 @@ +--- +name: beans +description: Use when a user or agent asks how Spring beans are wired in a Spring Boot project - which beans exist, where a bean is defined, where it is injected, which beans implement an interface or match a type, or why a dependency cannot be resolved. Uses the spring-tools MCP server's bean index instead of grepping the sources. +allowed-tools: + - Read + - mcp__plugin_spring-tools_spring-tools-mcp__getProjectList + - mcp__plugin_spring-tools_spring-tools-mcp__getBeanDetails + - mcp__plugin_spring-tools_spring-tools-mcp__findBeansByType + - mcp__plugin_spring-tools_spring-tools-mcp__getBeanUsageInfo +--- + +Answer bean wiring questions from the Spring index of the `spring-tools` MCP server. The index knows every bean the language server found (stereotype-annotated classes, `@Bean` methods, configuration classes) together with their injection points, so prefer it over grepping the sources. + +1. Resolve the project name with `getProjectList` (use the `projectName` field; `location` is the project root, pick the project whose location contains the file the user is talking about). Retry a few times while the list is still empty right after the session started. +2. Pick the tool that matches the question: + - "Which beans are there / what does the application context contain?" - `getBeanDetails` with the project name. It returns every indexed bean with its type, defining element and injection points. Summarize by stereotype or package for large projects instead of listing everything. + - "Which beans implement `X` / which bean of type `X` will be injected?" - `findBeansByType` with the fully qualified type name. Several matches mean an `@Autowired` field of that type needs `@Qualifier`, `@Primary` or a `List`/`Map` injection. + - "Where is bean `foo` defined and where is it used?" - `getBeanUsageInfo` with the bean name. Bean names default to the decapitalized simple class name (`orderService` for `OrderService`) or the `@Bean` method name. +3. Read the defining source file only when the answer needs code (e.g. to show the constructor or the `@Bean` method); the tool output already contains file paths and line numbers. +4. When the question is about a missing or ambiguous dependency, cross-check with the `validate` skill - the language server reports codes such as `JAVA_AUTOWIRED_CONSTRUCTOR` or `JAVA_PUBLIC_BEAN_METHOD` for related problems. + +The index follows on-disk changes automatically (the language server watches the workspace), so results reflect files edited during the session. If a very recent edit is not reflected yet, wait a moment and call the tool again. diff --git a/claude-plugins/spring-tools/skills/create-spring-boot-project/SKILL.md b/agent-plugins/spring-tools/skills/create-spring-boot-project/SKILL.md similarity index 100% rename from claude-plugins/spring-tools/skills/create-spring-boot-project/SKILL.md rename to agent-plugins/spring-tools/skills/create-spring-boot-project/SKILL.md diff --git a/agent-plugins/spring-tools/skills/endpoints/SKILL.md b/agent-plugins/spring-tools/skills/endpoints/SKILL.md new file mode 100644 index 0000000000..5ca346d029 --- /dev/null +++ b/agent-plugins/spring-tools/skills/endpoints/SKILL.md @@ -0,0 +1,20 @@ +--- +name: endpoints +description: Use when a user or agent asks which REST endpoints, request mappings, routes or HTTP APIs a Spring Boot project exposes, which controller handles a path or HTTP method, or wants an API inventory. Uses the spring-tools MCP server's request mapping index instead of grepping for mapping annotations. +allowed-tools: + - Read + - mcp__plugin_spring-tools_spring-tools-mcp__getProjectList + - mcp__plugin_spring-tools_spring-tools-mcp__getRequestMappings + - mcp__plugin_spring-tools_spring-tools-mcp__findRequestMappingsByMethod +--- + +Answer endpoint questions from the request mapping index of the `spring-tools` MCP server. It resolves class-level and method-level `@RequestMapping`, `@GetMapping`, `@PostMapping`, `@PutMapping`, `@DeleteMapping` and `@PatchMapping` annotations (including combined paths, HTTP methods, API versions and content types), so prefer it over grepping the sources. + +1. Resolve the project name with `getProjectList` (use the `projectName` field; `location` is the project root). Retry a few times while the list is still empty right after the session started. +2. Pick the tool that matches the question: + - "Which endpoints does the application expose?" / "Give me the API inventory" - `getRequestMappings` with the project name. Present the result as a table of HTTP method, path, handler class and method; group by controller for large projects. + - "Which endpoints accept `DELETE`?" / "Which handler serves `POST /orders`?" - `findRequestMappingsByMethod` with the project name and the HTTP method (`GET`, `POST`, `PUT`, `DELETE`, `PATCH`), then filter by path. +3. Read the handler source file only when the answer needs code (request parameters, response type, security annotations); the tool output already contains file paths and line numbers. +4. For endpoint problems the language server also validates (e.g. `@RequestMapping` on a class without `@Controller`, or conflicting API versions), use the `validate` skill and fix the reported codes. + +The index follows on-disk changes automatically (the language server watches the workspace), so a controller added during the session shows up without any refresh. If a very recent edit is not reflected yet, wait a moment and call the tool again. diff --git a/agent-plugins/spring-tools/skills/project-info/SKILL.md b/agent-plugins/spring-tools/skills/project-info/SKILL.md new file mode 100644 index 0000000000..dc018f7682 --- /dev/null +++ b/agent-plugins/spring-tools/skills/project-info/SKILL.md @@ -0,0 +1,18 @@ +--- +name: project-info +description: Use when a user or agent needs facts about the Spring Boot projects in the workspace - which projects exist and where they are, whether a directory is a Spring Boot project, the Java version, the Spring Boot version, or the resolved classpath (which library or version of a dependency is on it). Uses the spring-tools MCP server's project model instead of parsing build files by hand. +allowed-tools: + - mcp__plugin_spring-tools_spring-tools-mcp__getProjectList + - mcp__plugin_spring-tools_spring-tools-mcp__getJavaVersion + - mcp__plugin_spring-tools_spring-tools-mcp__getSpringBootVersion + - mcp__plugin_spring-tools_spring-tools-mcp__getResolvedProjectClasspath + - Skill(spring-tools:refresh) +--- + +Answer project and build questions from the project model of the `spring-tools` MCP server. The language server resolves every Maven (`pom.xml`) and Gradle (`build.gradle`, `build.gradle.kts`) project below the workspace directory, including dependencies from private repositories the build is configured for, so prefer it over reading build files or running the build. + +1. `getProjectList` returns one entry per project with `projectName`, `isSpringBootProject`, `javaVersion` and `location` (the project root directory). Use `location` to map a file path to its project (the longest matching prefix wins in multi-module workspaces). Retry a few times while the list is still empty right after the session started - the project model is being resolved, which can take a couple of minutes on a cold dependency cache. +2. `getJavaVersion` and `getSpringBootVersion` (workspace project name) answer "which Java / Spring Boot version does this project target?" precisely; the Spring Boot version is resolved from the classpath, not from the parent POM declaration, so it is correct for BOM-managed and Gradle projects as well. +3. `getResolvedProjectClasspath` lists the resolved dependency JARs with their paths. Use it to answer "is library X on the classpath?", "which version of X is actually used?" (read the version from the JAR file name) or "which starters does this project pull in?"; for a large classpath summarize by group instead of listing every JAR. +4. When a project you expect is missing - e.g. it was just created or checked out and the list has not caught up - use the `refresh` skill and call `getProjectList` again. The server also watches the workspace, so new `pom.xml`/`build.gradle` files are normally discovered within a second by themselves. +5. For version support questions continue with the `spring-versions` skill; for validation problems use the `validate` skill. diff --git a/claude-plugins/spring-tools/skills/quickfix/SKILL.md b/agent-plugins/spring-tools/skills/quickfix/SKILL.md similarity index 100% rename from claude-plugins/spring-tools/skills/quickfix/SKILL.md rename to agent-plugins/spring-tools/skills/quickfix/SKILL.md diff --git a/agent-plugins/spring-tools/skills/refresh/SKILL.md b/agent-plugins/spring-tools/skills/refresh/SKILL.md new file mode 100644 index 0000000000..976fb53e99 --- /dev/null +++ b/agent-plugins/spring-tools/skills/refresh/SKILL.md @@ -0,0 +1,15 @@ +--- +name: refresh +description: Refresh the Spring Tools workspace so the language server re-indexes files on disk (use after /rewind, a large git checkout, external edits the watcher may have missed, or when a project is missing from getProjectList). +allowed-tools: + - mcp__plugin_spring-tools_spring-tools-mcp__refreshWorkspace + - mcp__plugin_spring-tools_spring-tools-mcp__fileChanged + - mcp__plugin_spring-tools_spring-tools-mcp__fileDeleted +--- + +Bring the Spring Tools index back in sync with the files on disk. The language server watches the workspace directory and the plugin's hooks report Claude's own edits, so this is only needed when the index looks stale (a diagnostic refers to code that is gone, a new project or class is missing, the tree changed massively after `/rewind` or a branch switch). + +- Without arguments, or after a git checkout/pull/stash, `/rewind` or a build tool regenerating sources: call the `refreshWorkspace` tool on the `spring-tools-mcp` MCP server. It re-reads the project model (discovering added and removed projects) and re-indexes every source file. +- When the user names one file or a small set of files that changed or disappeared outside of Claude's edits: call `fileChanged` (absolute path) for each file that exists and `fileDeleted` for each file that was removed instead of a full refresh - this is much cheaper on large workspaces. + +Do not do anything else. After the tool returns, briefly confirm to the user what was refreshed. diff --git a/agent-plugins/spring-tools/skills/spring-review/SKILL.md b/agent-plugins/spring-tools/skills/spring-review/SKILL.md new file mode 100644 index 0000000000..c7c7e2eb5e --- /dev/null +++ b/agent-plugins/spring-tools/skills/spring-review/SKILL.md @@ -0,0 +1,23 @@ +--- +name: spring-review +description: Reviews Spring Boot projects with Spring Tools diagnostics, bean wiring, endpoints, architecture and version insight. Use after editing a Spring Boot project or when asked for a Spring-aware review; read-only. +--- + +You are a Spring Boot reviewer. You review the current state of the Spring Boot projects in the workspace with the `spring-tools` MCP server and report findings; you never modify files. + +Work through these steps and keep tool output out of the report - summarize. + +1. `getProjectList` - note every project (`projectName`, `location`, `isSpringBootProject`, `javaVersion`). Retry a few times if the list is empty right after startup. Skip projects that are not Spring Boot projects unless the user asked about them. +2. `getProjectDiagnostics` per project. For each Spring diagnostic code read the fix playbook for that code - the `.md` files in the plugin's `explanations` directory, at `${CLAUDE_PLUGIN_ROOT}/explanations/.md` in Claude Code and at the absolute path named in the spring-tools MCP server instructions otherwise - and describe the fix precisely (file, range, what to change) instead of pasting the playbook. +3. Bean wiring: `getBeanDetails` for an overview, then `findBeansByType` for every interface with several implementations (missing `@Primary`/`@Qualifier`) and `getBeanUsageInfo` for beans that are defined but never injected (dead configuration) or injected but not defined. +4. Endpoints: `getRequestMappings` - flag duplicate paths, handler methods without an HTTP method, controllers that mix API versions inconsistently, and endpoints without a matching stereotype (`@RestController`/`@Controller`). +5. Structure: `getLogicalStructureChanges` (a git-backed project has a baseline automatically) to see what the change under review added, removed or moved; `findComponentsByStereotype` to check layering rules the project follows (e.g. controllers calling repositories directly). +6. Versions: `getSpringBootVersion` plus `getLatestReleaseInformation` with slug `spring-boot` - mention when the project runs on a generation whose OSS support has ended. + +Report format: + +- **Blocking**: problems that break the application context, the build or security (each with file:line and the fix). +- **Should fix**: Spring diagnostics with a playbook, wiring ambiguities, structural regressions. +- **Notes**: version/support status, observations, things you could not verify (e.g. the language server was still indexing). + +Use file:line references from the tool output. Read source files only to confirm a finding. When the language server reports nothing for a project, say so explicitly rather than inventing findings. diff --git a/agent-plugins/spring-tools/skills/spring-versions/SKILL.md b/agent-plugins/spring-tools/skills/spring-versions/SKILL.md new file mode 100644 index 0000000000..884f373866 --- /dev/null +++ b/agent-plugins/spring-tools/skills/spring-versions/SKILL.md @@ -0,0 +1,26 @@ +--- +name: spring-versions +description: Use when a user or agent asks which Spring Boot or Spring project version is current, whether a project is on a supported version, when OSS or commercial support ends, which releases exist or are upcoming, or whether a newer Spring Boot version is available for a project's Maven repositories. Uses the spring-tools MCP server's Spring IO release, generation and calendar tools. +allowed-tools: + - mcp__plugin_spring-tools_spring-tools-mcp__getProjectList + - mcp__plugin_spring-tools_spring-tools-mcp__getSpringBootVersion + - mcp__plugin_spring-tools_spring-tools-mcp__getLatestReleaseInformation + - mcp__plugin_spring-tools_spring-tools-mcp__getLatestBootVersionsFromMavenRepo + - mcp__plugin_spring-tools_spring-tools-mcp__getReleases + - mcp__plugin_spring-tools_spring-tools-mcp__getGenerations + - mcp__plugin_spring-tools_spring-tools-mcp__getUpcomingReleases +--- + +Answer Spring version and support questions with the live data the `spring-tools` MCP server obtains from Spring IO and the project's Maven repositories. Never guess release dates or support windows from memory - they change with every release. + +Two kinds of names are involved; do not mix them up: + +- the workspace project name from `getProjectList` (`projectName` field, e.g. `order-service`) for tools that look at the user's project; +- the Spring portfolio slug (`spring-boot`, `spring-framework`, `spring-data-jpa`, `spring-security`, ...) for tools that query Spring IO. + +1. "Which Spring Boot version does this project use?" - `getSpringBootVersion` with the workspace project name (resolve it with `getProjectList` first). +2. "Is it supported / when does support end / what is the current version?" - `getLatestReleaseInformation` with the portfolio slug. It returns the current GA version plus the OSS and commercial support end dates of that generation. For the generation the project is actually on, use `getGenerations` and pick the entry matching the project's major.minor. +3. "Which newer version can I upgrade to?" - `getLatestBootVersionsFromMavenRepo` with the workspace project name. It resolves the newest patch, minor and major versions that are actually available from the repositories configured in the project's `pom.xml` (including private mirrors); `null` means already up to date at that level. Gradle projects are not supported by this tool - fall back to `getLatestReleaseInformation`. +4. "Which releases exist?" - `getReleases` with the slug (every release record, including milestones and release candidates). +5. "What is coming up?" - `getUpcomingReleases` (the Spring release calendar for the next weeks; no parameter). +6. Report versions and dates verbatim from the tool output, state which repository or API they came from, and recommend the smallest upgrade that gets the project back onto a supported generation. Leave the actual build file change to the user unless they ask for it; after changing the Spring Boot version run the `validate` skill because version-dependent validations (e.g. `BOOT_VERSION_VALIDATION_CODE`) may change. diff --git a/claude-plugins/spring-tools/skills/validate/SKILL.md b/agent-plugins/spring-tools/skills/validate/SKILL.md similarity index 100% rename from claude-plugins/spring-tools/skills/validate/SKILL.md rename to agent-plugins/spring-tools/skills/validate/SKILL.md diff --git a/agent-plugins/tools/check-explanations.mjs b/agent-plugins/tools/check-explanations.mjs new file mode 100644 index 0000000000..b55b714e92 --- /dev/null +++ b/agent-plugins/tools/check-explanations.mjs @@ -0,0 +1,363 @@ +#!/usr/bin/env node +/******************************************************************************* + * Copyright (c) 2026 Broadcom + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * https://www.eclipse.org/legal/epl-v10.html + * + * Contributors: + * Broadcom - initial API and implementation + *******************************************************************************/ + +// Verifies the Claude plugin explanation playbooks against the language server's +// diagnostic codes: coverage (code <-> file), structure, cross-references, the +// coverage table in research-notes and, on request, that every documentation +// link resolves. + +import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +// Directories holding the `*ProblemType` enums whose constants are diagnostic codes. The +// version-validation enum is deliberately absent: its constants are preference keys only and +// its diagnostics all carry BOOT_VERSION_VALIDATION_CODE (see VERSION_VALIDATOR_FILE). +export const PROBLEM_TYPES_DIRS = [ + 'headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/java', + 'headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/properties/reconcile', + 'headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/yaml/reconcile', +]; +export const VERSION_VALIDATOR_FILE = 'headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/validation/generations/AbstractDiagnosticValidator.java'; +// The application-YAML reconciler flattens `<<` merge keys with commons-yaml's NodeMergeSupport, which +// reports malformed merge values with the generic YamlSchemaProblems.SCHEMA_PROBLEM code instead of an +// ApplicationYamlProblemType constant. The code is only collected while that dependency exists. +export const YAML_AST_RECONCILER_FILE = 'headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/yaml/reconcile/ApplicationYamlASTReconciler.java'; +export const YAML_SCHEMA_PROBLEMS_FILE = 'headless-services/commons/commons-yaml/src/main/java/org/springframework/ide/vscode/commons/yaml/reconcile/YamlSchemaProblems.java'; +export const EXPLANATIONS_DIR = 'agent-plugins/spring-tools/explanations'; +export const TODO_FILE = 'agent-plugins/research-notes/TODO_quickfixes.md'; + +const DOC_LINK = /https:\/\/(?:docs\.spring\.io|spring\.io|github\.com\/spring-projects)\//; +const URL_PATTERN = /https?:\/\/[^\s)>\]"'`]+/g; +const CROSS_REF = /`([A-Z][A-Z0-9_]{3,})`/g; +const LINK_HOSTS = new Set(['docs.spring.io', 'spring.io', 'enterprise.spring.io', 'github.com', 'docs.oracle.com', 'openjdk.org', 'yaml.org', 'raw.githubusercontent.com']); + +function walk(dir, predicate, out = []) { + if (!existsSync(dir)) { + return out; + } + for (const entry of readdirSync(dir)) { + const full = join(dir, entry); + if (statSync(full).isDirectory()) { + walk(full, predicate, out); + } else if (predicate(entry)) { + out.push(full); + } + } + return out; +} + +/** Diagnostic codes the language server can report for Java sources and configuration files, sorted. */ +export function collectCodes(root) { + const codes = new Set(); + const files = PROBLEM_TYPES_DIRS.flatMap((dir) => walk(join(root, dir), (n) => n.endsWith('ProblemType.java'))); + for (const file of files) { + const text = readFileSync(file, 'utf8'); + if (!/enum\s+\w+\s+implements\s+ProblemType/.test(text)) { + continue; + } + for (const match of text.matchAll(/^\s+([A-Z][A-Z0-9_]+)\(/gm)) { + codes.add(match[1]); + } + } + const validator = join(root, VERSION_VALIDATOR_FILE); + if (existsSync(validator)) { + const match = readFileSync(validator, 'utf8').match(/BOOT_VERSION_VALIDATION_CODE\s*=\s*"([A-Z0-9_]+)"/); + if (match) { + codes.add(match[1]); + } + } + const yamlReconciler = join(root, YAML_AST_RECONCILER_FILE); + const yamlSchemaProblems = join(root, YAML_SCHEMA_PROBLEMS_FILE); + if (existsSync(yamlReconciler) && existsSync(yamlSchemaProblems) + && /\bNodeMergeSupport\b/.test(readFileSync(yamlReconciler, 'utf8'))) { + const match = readFileSync(yamlSchemaProblems, 'utf8').match(/SCHEMA_PROBLEM\s*=\s*problemType\(\s*"([A-Za-z0-9_]+)"/); + if (match) { + codes.add(match[1]); + } + } + return [...codes].sort(); +} + +/** Playbook codes present on disk (file names without .md), sorted. */ +export function collectPlaybooks(root) { + const dir = join(root, EXPLANATIONS_DIR); + if (!existsSync(dir)) { + return []; + } + return readdirSync(dir).filter((n) => n.endsWith('.md')).map((n) => n.slice(0, -3)).sort(); +} + +/** Structural rules for one playbook; returns human readable error strings. */ +export function checkPlaybook(code, text, knownCodes, playbooks) { + const errors = []; + const lines = text.split(/\r?\n/); + const firstLine = lines.find((l) => l.trim().length > 0) ?? ''; + if (firstLine.trim() !== '## Explanations') { + errors.push(`${code}: must start with '## Explanations' (found '${firstLine.trim().slice(0, 40)}')`); + } + if (!lines.some((l) => l.trim() === '## Fixes')) { + errors.push(`${code}: missing '## Fixes' section`); + } + const fences = lines.filter((l) => l.trimStart().startsWith('```')).length; + if (fences % 2 !== 0) { + errors.push(`${code}: unbalanced code fences (${fences})`); + } + if (!DOC_LINK.test(text)) { + errors.push(`${code}: no link to docs.spring.io, spring.io or github.com/spring-projects`); + } + const prose = text.replace(/```[\s\S]*?```/g, '').replace(/`[^`\n]*`/g, ''); + if (/(^|\s)(TODO|FIXME)\s*:|[[(<](TODO|TBD|FIXME)[\])>]|\bTBD\b/m.test(prose)) { + errors.push(`${code}: contains placeholder text (TODO:/TBD/FIXME:) outside code spans`); + } + for (const match of text.matchAll(CROSS_REF)) { + const ref = match[1]; + if (ref !== code && knownCodes.has(ref) && !playbooks.has(ref)) { + errors.push(`${code}: references code ${ref} which has no playbook`); + } + } + return errors; +} + +/** Parses the coverage table: code -> true (has playbook mark) / false. */ +export function parseTodoTable(text) { + const result = new Map(); + for (const line of text.split(/\r?\n/)) { + const match = line.match(/^\|\s*`([A-Za-z][A-Za-z0-9_]+)`\s*\|.*\|\s*(✅|❌)\s*\|\s*$/); + if (match) { + result.set(match[1], match[2] === '✅'); + } + } + return result; +} + +function duplicateTodoRows(text) { + const seen = new Set(); + const duplicates = new Set(); + for (const line of text.split(/\r?\n/)) { + const match = line.match(/^\|\s*`([A-Za-z][A-Za-z0-9_]+)`\s*\|.*\|\s*(✅|❌)\s*\|\s*$/); + if (!match) continue; + if (seen.has(match[1])) duplicates.add(match[1]); + seen.add(match[1]); + } + return [...duplicates].sort(); +} + +export function extractUrls(text) { + const urls = new Set(); + // URLs inside examples are code, not references readers should follow. + const prose = text.replace(/^```[\s\S]*?^```\s*/gm, '').replace(/`[^`]*`/g, ''); + for (const match of prose.matchAll(URL_PATTERN)) { + let url = match[0].replace(/[).,;:]+$/, ''); + url = url.split('#')[0]; + urls.add(url); + } + return [...urls]; +} + +/** Only check links to the documentation hosts maintained by Spring and its project repos. */ +export function allowedDocumentationUrl(value) { + try { + const url = new URL(value); + const host = url.hostname.toLowerCase(); + if (url.protocol !== 'https:' || !LINK_HOSTS.has(host)) return false; + if (host === 'github.com') return url.pathname.startsWith('/spring-projects/'); + if (host === 'raw.githubusercontent.com') return url.pathname.startsWith('/spring-projects/spring-security/'); + if (host === 'openjdk.org') return url.pathname.startsWith('/jeps/'); + if (host === 'yaml.org') return url.pathname.startsWith('/spec/') || url.pathname.startsWith('/type/'); + if (host === 'docs.oracle.com') return url.pathname.startsWith('/en/java/javase/'); + if (host === 'enterprise.spring.io') return url.pathname === '/'; + return true; + } catch { + return false; + } +} + +async function fetchStatus(url) { + try { + let target = url; + for (let redirects = 0; redirects <= 5; redirects++) { + const response = await fetch(target, { + method: 'GET', + redirect: 'manual', + headers: { 'user-agent': 'Mozilla/5.0 (spring-tools explanations check)' }, + signal: AbortSignal.timeout(30000), + }); + if (![301, 302, 303, 307, 308].includes(response.status)) { + await response.body?.cancel().catch(() => undefined); + return response.status; + } + const location = response.headers.get('location'); + await response.body?.cancel().catch(() => undefined); + if (!location || redirects === 5) return response.status; + const next = new URL(location, target).href; + if (!allowedDocumentationUrl(next)) { + return `blocked redirect ${next}`; + } + target = next; + } + return 'too many redirects'; + } catch (error) { + return `ERR ${error.name ?? ''} ${error.message ?? ''}`.trim(); + } +} + +export async function checkLinks(urls, concurrency = 4) { + const failures = []; + const queue = []; + for (const url of urls) { + if (allowedDocumentationUrl(url)) queue.push(url); + else failures.push(`blocked URL (outside approved documentation hosts) ${url}`); + } + async function worker() { + while (queue.length) { + const url = queue.shift(); + const status = await fetchStatus(url); + if (status !== 200) { + failures.push(`${status} ${url}`); + } + } + } + await Promise.all(Array.from({ length: Math.min(concurrency, urls.length || 1) }, worker)); + return failures.sort(); +} + +/** + * Runs all offline checks. + * @param {{root: string, codes?: string[], requireAll?: boolean, todo?: boolean}} options + */ +export function runChecks(options) { + const root = resolve(options.root); + const errors = []; + const warnings = []; + const knownCodes = new Set(collectCodes(root)); + if (knownCodes.size === 0) { + errors.push(`no problem-type codes found under ${PROBLEM_TYPES_DIRS.join(', ')}`); + } + const playbooks = new Set(collectPlaybooks(root)); + + for (const orphan of [...playbooks].filter((p) => !knownCodes.has(p))) { + errors.push(`orphan playbook without a matching problem-type code: ${orphan}.md`); + } + + const selected = options.codes?.length ? [...new Set(options.codes)] : [...playbooks]; + for (const code of selected) { + if (!/^(?:[A-Z][A-Z0-9_]+|YamlSchemaProblem)$/.test(code)) { + errors.push(`invalid diagnostic code '${code}'`); + continue; + } + const file = join(root, EXPLANATIONS_DIR, `${code}.md`); + if (!existsSync(file)) { + errors.push(`missing playbook: ${EXPLANATIONS_DIR}/${code}.md`); + continue; + } + errors.push(...checkPlaybook(code, readFileSync(file, 'utf8'), knownCodes, playbooks)); + } + + const missing = [...knownCodes].filter((c) => !playbooks.has(c)); + if (missing.length) { + const message = `${missing.length} code(s) without playbook: ${missing.join(', ')}`; + (options.requireAll ? errors : warnings).push(message); + } + + if (options.todo) { + const todoFile = join(root, TODO_FILE); + if (!existsSync(todoFile)) { + errors.push(`missing coverage table ${TODO_FILE}`); + } else { + const todoText = readFileSync(todoFile, 'utf8'); + const table = parseTodoTable(todoText); + for (const code of duplicateTodoRows(todoText)) { + errors.push(`${TODO_FILE}: duplicate row for code ${code}`); + } + for (const code of knownCodes) { + if (!table.has(code)) { + errors.push(`${TODO_FILE}: no row for code ${code}`); + } else if (table.get(code) !== playbooks.has(code)) { + errors.push(`${TODO_FILE}: playbook mark for ${code} is ${table.get(code) ? '✅' : '❌'} but file ${playbooks.has(code) ? 'exists' : 'is missing'}`); + } + } + for (const code of table.keys()) { + if (!knownCodes.has(code)) { + errors.push(`${TODO_FILE}: row for unknown code ${code}`); + } + } + } + } + + const urls = new Set(); + for (const code of selected) { + const file = join(root, EXPLANATIONS_DIR, `${code}.md`); + if (existsSync(file)) { + extractUrls(readFileSync(file, 'utf8')).forEach((u) => urls.add(u)); + } + } + + return { errors, warnings, codes: knownCodes.size, playbooks: playbooks.size, selected: selected.length, urls: [...urls].sort() }; +} + +function parseArgs(argv) { + const options = { root: undefined, codes: [], requireAll: false, todo: false, links: false, help: false }; + for (let i = 0; i < argv.length; i++) { + const arg = argv[i]; + if (arg === '--root') options.root = argv[++i]; + else if (arg === '--codes') options.codes = (argv[++i] ?? '').split(',').map((c) => c.trim()).filter(Boolean); + else if (arg === '--require-all') options.requireAll = true; + else if (arg === '--todo') options.todo = true; + else if (arg === '--links') options.links = true; + else if (arg === '--help' || arg === '-h') options.help = true; + else throw new Error(`unknown argument: ${arg}`); + } + return options; +} + +const USAGE = `usage: node check-explanations.mjs [--root DIR] [--codes A,B] [--require-all] [--todo] [--links] + + --root DIR repository root (default: two levels above this script) + --codes A,B restrict structure and link checks to these codes (files must exist) + --require-all fail when a problem-type code has no playbook + --todo verify ${TODO_FILE} lists every code with the right playbook mark + --links fetch every documentation URL of the selected playbooks (needs network)`; + +async function main() { + const options = parseArgs(process.argv.slice(2)); + if (options.help) { + console.log(USAGE); + return 0; + } + const root = options.root ?? join(dirname(fileURLToPath(import.meta.url)), '..', '..'); + const result = runChecks({ ...options, root }); + for (const warning of result.warnings) console.log(`WARN ${warning}`); + for (const error of result.errors) console.log(`ERROR ${error}`); + if (result.errors.length) { + console.log(`explanations check FAILED with ${result.errors.length} error(s)`); + return 1; + } + console.log(`explanations check passed (${result.codes} codes, ${result.playbooks} playbooks, ${result.selected} checked)`); + if (options.links) { + const failures = await checkLinks(result.urls); + for (const failure of failures) console.log(`ERROR link ${failure}`); + if (failures.length) { + console.log(`links check FAILED with ${failures.length} unreachable URL(s)`); + return 1; + } + console.log(`links check passed (${result.urls.length} urls)`); + } + return 0; +} + +if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) { + main().then((code) => process.exit(code), (error) => { + console.error(error.message); + process.exit(2); + }); +} diff --git a/agent-plugins/tools/check-plugin-config.mjs b/agent-plugins/tools/check-plugin-config.mjs new file mode 100644 index 0000000000..5c23ec8829 --- /dev/null +++ b/agent-plugins/tools/check-plugin-config.mjs @@ -0,0 +1,156 @@ +#!/usr/bin/env node +/******************************************************************************* + * Copyright (c) 2026 Broadcom + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * https://www.eclipse.org/legal/epl-v10.html + * + * Contributors: + * Broadcom - initial API and implementation + *******************************************************************************/ + +// Checks the plugin configuration against the language server's MCP tools: +// node agent-plugins/tools/check-plugin-config.mjs [--cross-host] +// Fails when a hook, skill or agent references an unknown tool, when a skill/agent is malformed, +// or when an MCP tool is unreachable through skills/hooks or lacks positive behavioral eval coverage. +// --cross-host also validates Copilot, OpenCode and Codex adapters and manifests. + +import { existsSync, readdirSync, readFileSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { + COPILOT_DIR, + PLUGIN_DIR, + checkAgent, + checkAgentPluginManifest, + checkCodexMarketplace, + checkCopilotAgent, + checkCopilotHooks, + checkEvals, + checkHooks, + checkManifest, + checkSkill, + collectMcpTools, + uncoveredTools, +} from './lib/plugin-config.mjs'; + +const here = dirname(fileURLToPath(import.meta.url)); +const repoRoot = resolve(here, '..', '..'); +const pluginDir = join(repoRoot, PLUGIN_DIR); +const crossHost = process.argv.includes('--cross-host'); + +const errors = []; +const tools = collectMcpTools(repoRoot); +if (tools.size === 0) { + errors.push('no @Tool methods found in the language server sources'); +} + +const hooks = JSON.parse(readFileSync(join(pluginDir, 'hooks', 'hooks.json'), 'utf8')); +errors.push(...checkHooks(hooks, tools, repoRoot)); + +const skillsDir = join(pluginDir, 'skills'); +const skillNames = readdirSync(skillsDir).filter((d) => existsSync(join(skillsDir, d, 'SKILL.md'))); +const skillTexts = []; +for (const name of readdirSync(skillsDir)) { + const file = join(skillsDir, name, 'SKILL.md'); + if (!existsSync(file)) { + errors.push(`${name}: skill directory without SKILL.md`); + continue; + } + const text = readFileSync(file, 'utf8'); + skillTexts.push(text); + errors.push(...checkSkill(name, text, tools, skillNames)); +} + +const agentsDir = join(pluginDir, 'agents'); +const agentFiles = existsSync(agentsDir) ? readdirSync(agentsDir).filter((f) => f.endsWith('.md')) : []; +for (const file of agentFiles) { + errors.push(...checkAgent(file, readFileSync(join(agentsDir, file), 'utf8'), tools)); +} + +const sharedReviewSkill = readFileSync(join(skillsDir, 'spring-review', 'SKILL.md'), 'utf8'); +const reviewBody = (text) => text.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n/, '').trim(); +const claudeReviewer = join(agentsDir, 'spring-reviewer.md'); +if (!existsSync(claudeReviewer)) { + errors.push('Claude reviewer: adapter is missing'); +} else if (reviewBody(readFileSync(claudeReviewer, 'utf8')) !== reviewBody(sharedReviewSkill)) { + errors.push('Claude reviewer: instructions differ from skills/spring-review/SKILL.md; run sync-agent-adapters.mjs --write'); +} + +if (crossHost) { + const copilotAgentsDir = join(pluginDir, COPILOT_DIR, 'agents'); + const copilotAgentFiles = existsSync(copilotAgentsDir) ? readdirSync(copilotAgentsDir).filter((f) => f.endsWith('.agent.md')) : []; + for (const file of agentFiles) { + if (!copilotAgentFiles.includes(`${file.replace(/\.md$/, '')}.agent.md`)) { + errors.push(`${COPILOT_DIR}/agents: no copy of agents/${file}, Copilot would fall back to the Claude front matter`); + } + } + for (const file of copilotAgentFiles) { + const claudeFile = join(agentsDir, file.replace(/\.agent\.md$/, '.md')); + errors.push(...checkCopilotAgent(file, readFileSync(join(copilotAgentsDir, file), 'utf8'), existsSync(claudeFile) ? readFileSync(claudeFile, 'utf8') : undefined)); + } + + for (const [label, file] of [ + ['Copilot reviewer', join(copilotAgentsDir, 'spring-reviewer.agent.md')], + ['OpenCode reviewer', join(pluginDir, 'opencode', 'agents', 'spring-reviewer.md')], + ]) { + if (!existsSync(file)) { + errors.push(`${label}: adapter is missing`); + } else if (reviewBody(readFileSync(file, 'utf8')) !== reviewBody(sharedReviewSkill)) { + errors.push(`${label}: instructions differ from skills/spring-review/SKILL.md; run sync-agent-adapters.mjs --write`); + } + } + + const openCodeConfig = JSON.parse(readFileSync(join(pluginDir, 'opencode', 'opencode.jsonc'), 'utf8')); + const openCodeServer = openCodeConfig.mcp?.['spring-tools']; + if (openCodeServer?.type !== 'local' || openCodeServer.command?.[0] !== 'node' || openCodeServer.command?.[1] !== '{env:SPRING_TOOLS_PLUGIN_ROOT}/launcher.js') { + errors.push('opencode/opencode.jsonc must launch the shared launcher.js through SPRING_TOOLS_PLUGIN_ROOT'); + } + if (openCodeServer?.timeout !== 120000 || openCodeServer?.enabled !== true) { + errors.push('opencode/opencode.jsonc must enable Spring Tools with a 120000 ms startup timeout'); + } + const openCodeAgent = readFileSync(join(pluginDir, 'opencode', 'agents', 'spring-reviewer.md'), 'utf8'); + if (!/^mode:\s*subagent$/m.test(openCodeAgent) || !/^\s+edit:\s*deny$/m.test(openCodeAgent) || !/spring-tools_\*"?:\s*allow/.test(openCodeAgent)) { + errors.push('OpenCode reviewer must be a read-only subagent with access to spring-tools MCP tools'); + } + + const copilotHooks = JSON.parse(readFileSync(join(pluginDir, COPILOT_DIR, 'hooks', 'hooks.json'), 'utf8')); + errors.push(...checkCopilotHooks(copilotHooks, repoRoot)); +} + +const pluginJson = JSON.parse(readFileSync(join(pluginDir, '.claude-plugin', 'plugin.json'), 'utf8')); +const marketplace = JSON.parse(readFileSync(join(repoRoot, 'agent-plugins', '.claude-plugin', 'marketplace.json'), 'utf8')); +errors.push(...checkManifest(pluginJson, marketplace, repoRoot)); +if (crossHost) { + errors.push(...checkCodexMarketplace( + JSON.parse(readFileSync(join(repoRoot, '.agents', 'plugins', 'marketplace.json'), 'utf8')), + repoRoot, + )); + errors.push(...checkAgentPluginManifest( + JSON.parse(readFileSync(join(pluginDir, 'plugin.json'), 'utf8')), + JSON.parse(readFileSync(join(pluginDir, 'mcp.json'), 'utf8')), + pluginJson, + repoRoot, + )); +} + +const uncovered = uncoveredTools(tools, skillTexts, hooks); +if (uncovered.length) { + errors.push(`MCP tools reachable through no skill and no hook: ${uncovered.join(', ')}`); +} + +const evalsDir = join(pluginDir, 'evals'); +errors.push(...checkEvals(evalsDir, tools, skillNames, agentFiles.map((f) => f.replace(/\.md$/, '')))); +const evalCases = existsSync(evalsDir) ? readdirSync(evalsDir).filter((d) => existsSync(join(evalsDir, d, 'prompt.md'))).length : 0; + +if (errors.length) { + for (const error of errors) { + console.error(`ERROR ${error}`); + } + console.error(`plugin config FAILED: ${errors.length} problem(s)`); + process.exit(1); +} +const agentSummary = crossHost ? `${agentFiles.length} agent(s) with Copilot copies` : `${agentFiles.length} agent(s)`; +console.log(`plugin config OK (${tools.size} tools, ${skillNames.length} skills, ${agentSummary}, ${evalCases} eval case(s), every tool covered by a skill or hook and behavioral eval)`); diff --git a/agent-plugins/tools/evals/mock-mcp-server.mjs b/agent-plugins/tools/evals/mock-mcp-server.mjs new file mode 100644 index 0000000000..579c0c7d4a --- /dev/null +++ b/agent-plugins/tools/evals/mock-mcp-server.mjs @@ -0,0 +1,128 @@ +#!/usr/bin/env node +/******************************************************************************* + * Copyright (c) 2026 Broadcom + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * https://www.eclipse.org/legal/epl-v10.html + * + * Contributors: + * Broadcom - initial API and implementation + *******************************************************************************/ + +// Stdio MCP server that replays the recorded responses in evals/mocks instead of starting the +// language server. Claude Code mocks MCP servers itself during `claude plugin eval`; Copilot CLI +// has no such feature, so run-with-copilot.mjs points a copy of the plugin at this script. +// +// EVAL_MOCK_DIRS os.delimiter separated mock directories, searched in order +// EVAL_TRACE file the tool calls are appended to (one JSON object per line) +// EVAL_INSTRUCTIONS optional server instructions to report from `initialize` + +import { appendFileSync, existsSync, readFileSync } from 'node:fs'; +import { delimiter, join } from 'node:path'; + +const mockDirs = (process.env.EVAL_MOCK_DIRS ?? '').split(delimiter).filter(Boolean); +const tracePath = process.env.EVAL_TRACE; + +/** First mock file for `name` in the configured directories, or null. */ +function findMock(name) { + // Tool names become file names below. Refuse arbitrary names so a malformed MCP request + // cannot make this local replay server read files outside the selected mock directories. + if (typeof name !== 'string' || !/^[A-Za-z][A-Za-z0-9]*$/.test(name)) { + return null; + } + for (const dir of mockDirs) { + const file = join(dir, `${name}.md`); + if (existsSync(file)) { + return readFileSync(file, 'utf8'); + } + } + return null; +} + +/** Recorded mocks may carry a front matter block with the expected arguments; the body is the response. */ +function mockBody(text) { + return text.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n/, ''); +} + +function send(message) { + process.stdout.write(`${JSON.stringify(message)}\n`); +} + +function trace(entry) { + if (tracePath) { + appendFileSync(tracePath, `${JSON.stringify(entry)}\n`); + } +} + +function handle(request) { + const { id, method, params } = request; + if (method === 'initialize') { + return { + protocolVersion: params?.protocolVersion ?? '2024-11-05', + capabilities: { tools: { listChanged: false } }, + serverInfo: { name: 'spring-tools-mcp', version: '0.0.0-eval' }, + ...(process.env.EVAL_INSTRUCTIONS ? { instructions: process.env.EVAL_INSTRUCTIONS } : {}), + }; + } + if (method === 'ping') { + return {}; + } + if (method === 'tools/list') { + for (const dir of mockDirs) { + const file = join(dir, '_tools.json'); + if (existsSync(file)) { + return JSON.parse(readFileSync(file, 'utf8')); + } + } + throw new Error(`no recorded tools/list (_tools.json) in ${mockDirs.join(delimiter)}`); + } + if (method === 'tools/call') { + const name = params?.name; + trace({ tool: name, input: JSON.stringify(params?.arguments ?? {}) }); + if (typeof name !== 'string' || !/^[A-Za-z][A-Za-z0-9]*$/.test(name)) { + return { isError: true, content: [{ type: 'text', text: 'invalid tool name' }] }; + } + const mock = findMock(name); + if (mock === null) { + return { isError: true, content: [{ type: 'text', text: `no recorded response for ${name}` }] }; + } + return { content: [{ type: 'text', text: mockBody(mock).trim() }] }; + } + if (method?.startsWith('notifications/')) { + return null; + } + const error = new Error(`unsupported method ${method}`); + error.code = -32601; + throw error; +} + +let buffer = ''; +process.stdin.setEncoding('utf8'); +process.stdin.on('data', (chunk) => { + buffer += chunk; + let newline; + while ((newline = buffer.indexOf('\n')) !== -1) { + const line = buffer.slice(0, newline).trim(); + buffer = buffer.slice(newline + 1); + if (!line) { + continue; + } + let request; + try { + request = JSON.parse(line); + } catch { + continue; + } + try { + const result = handle(request); + if (request.id !== undefined && result !== null) { + send({ jsonrpc: '2.0', id: request.id, result }); + } + } catch (error) { + if (request.id !== undefined) { + send({ jsonrpc: '2.0', id: request.id, error: { code: error.code ?? -32603, message: error.message } }); + } + } + } +}); diff --git a/agent-plugins/tools/evals/record-mocks.mjs b/agent-plugins/tools/evals/record-mocks.mjs new file mode 100644 index 0000000000..6a8b0debe2 --- /dev/null +++ b/agent-plugins/tools/evals/record-mocks.mjs @@ -0,0 +1,214 @@ +#!/usr/bin/env node +/******************************************************************************* + * Copyright (c) 2026 Broadcom + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * https://www.eclipse.org/legal/epl-v10.html + * + * Contributors: + * Broadcom - initial API and implementation + *******************************************************************************/ + +// Records the MCP mocks for the plugin's behavioral eval suite (claude plugin eval) from the real +// language server: starts launcher.js over MCP stdio against a copy of the sf7-validation test +// project, calls every tool the eval cases may use and writes the answers as +// spring-tools/evals/mocks/spring-tools-mcp/.md plus the saved tools/list as _tools.json. +// Workspace paths are rewritten to /workspace so the recordings are stable across machines. +// Needs the language server JAR in the plugin (agent-plugins/update-local-jars.sh) or +// SPRING_TOOLS_LS_JAR, a Java 21+ runtime and network access (spring.io, Maven Central). +// +// node agent-plugins/tools/evals/record-mocks.mjs [--project-src ] + +import { spawn } from 'node:child_process'; +import { cpSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir, homedir } from 'node:os'; +import { basename, dirname, join, resolve } from 'node:path'; +import { createInterface } from 'node:readline'; +import { fileURLToPath } from 'node:url'; +import { createRequire } from 'node:module'; + +const here = dirname(fileURLToPath(import.meta.url)); +const repoRoot = resolve(here, '..', '..', '..'); +const pluginDir = join(repoRoot, 'agent-plugins', 'spring-tools'); +const { defaultInstallDir } = createRequire(import.meta.url)(join(pluginDir, 'install.js')); +const mocksDir = join(pluginDir, 'evals', 'mocks', 'spring-tools-mcp'); +// Case-level override: the fixture only yields version diagnostics by itself, so the validate case +// gets a recording taken after a redundant @Autowired constructor was added to the application class. +const validateCaseMocksDir = join(pluginDir, 'evals', 'validate-finds-spring-problems', 'mocks', 'spring-tools-mcp'); +const JAR_NAME = 'spring-boot-language-server-standalone-exec.jar'; + +const args = process.argv.slice(2); +const option = (name) => (args.includes(name) ? args[args.indexOf(name) + 1] : undefined); +const projectSrc = resolve(option('--project-src') ?? join(repoRoot, 'headless-services/spring-boot-language-server/src/test/resources/test-projects/sf7-validation')); + +// Prefer a fresh test build; otherwise read the same versioned cache as launcher.js. +const localBuildJar = join(pluginDir, 'language-server', JAR_NAME); +const jarPath = process.env.SPRING_TOOLS_LS_JAR || (existsSync(localBuildJar) ? localBuildJar : join(defaultInstallDir(pluginDir), JAR_NAME)); +if (!existsSync(jarPath)) { + console.error(`language server JAR not found at ${jarPath}; build the local test JAR with agent-plugins/update-local-jars.sh or set SPRING_TOOLS_LS_JAR`); + process.exit(2); +} + +const stagingRoot = mkdtempSync(join(pluginDir, 'evals', '.record-mocks-')); +const stagedMocksDir = join(stagingRoot, 'suite'); +const stagedValidateMocksDir = join(stagingRoot, 'validate'); +mkdirSync(stagedMocksDir, { recursive: true }); +mkdirSync(stagedValidateMocksDir, { recursive: true }); + +const work = mkdtempSync(join(tmpdir(), 'spring-tools-eval-record-')); +const workspace = join(work, 'workspace'); +const dataDir = join(work, 'data'); +const projectName = basename(projectSrc); +mkdirSync(dataDir, { recursive: true }); +cpSync(projectSrc, join(workspace, projectName), { recursive: true, filter: (src) => !/[\\/](target|build|\.git)$/.test(src) }); + +const child = spawn(process.execPath, [join(pluginDir, 'launcher.js')], { + env: { ...process.env, CLAUDE_PROJECT_DIR: workspace, CLAUDE_PLUGIN_DATA: dataDir, CLAUDE_PLUGIN_ROOT: pluginDir }, + stdio: ['pipe', 'pipe', 'inherit'], +}); +const childExited = new Promise((resolvePromise) => child.once('exit', resolvePromise)); +const pending = new Map(); +let nextId = 1; +createInterface({ input: child.stdout }).on('line', (line) => { + let message; + try { message = JSON.parse(line); } catch { return; } + if (message.id !== undefined && pending.has(message.id)) { + pending.get(message.id)(message); + pending.delete(message.id); + } +}); +const request = (method, params, timeoutMs = 120000) => new Promise((resolvePromise, reject) => { + const id = nextId++; + const timer = setTimeout(() => reject(new Error(`timeout waiting for ${method}`)), timeoutMs); + pending.set(id, (message) => { clearTimeout(timer); resolvePromise(message); }); + child.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', id, method, params })}\n`); +}); +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +const call = async (name, toolArgs = {}) => { + const response = await request('tools/call', { name, arguments: toolArgs }); + if (response.error) throw new Error(`${name}: ${JSON.stringify(response.error)}`); + return (response.result?.content ?? []).map((c) => c.text ?? JSON.stringify(c)).join('\n'); +}; +async function pollUntil(fn, predicate, attempts, intervalMs) { + let value; + for (let i = 0; i < attempts; i++) { + value = await fn(); + if (predicate(value)) return value; + await sleep(intervalMs); + } + return value; +} + +const stable = (text) => text + .replaceAll(`file://${workspace}`, 'file:///workspace') + .replaceAll(workspace, '/workspace') + .replaceAll(pluginDir, '/plugin') + .replaceAll(`${dataDir}/language-server`, '/plugin/language-server') + .replaceAll(homedir(), '~'); +const pretty = (text) => { + try { return JSON.stringify(JSON.parse(text), null, 2); } catch { return text; } +}; +const yaml = (value) => (Array.isArray(value) ? `[${value.join(', ')}]` : value); +function writeMock(tool, body, expect = {}, dir = stagedMocksDir) { + const front = Object.keys(expect).length + ? `---\nexpect:\n${Object.entries(expect).map(([k, v]) => ` ${k}: ${yaml(v)}`).join('\n')}\n---\n` + : ''; + writeFileSync(join(dir, `${tool}.md`), `${front}${pretty(stable(body)).trim()}\n`); + console.log(`recorded ${tool} (${body.length} chars)${dir === mocksDir ? '' : ` -> ${dir}`}`); +} +const exact = { projectName }; + +try { + await request('initialize', { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'record-mocks', version: '0' } }); + child.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', method: 'notifications/initialized', params: {} })}\n`); + + const tools = await request('tools/list', {}); + writeFileSync(join(stagedMocksDir, '_tools.json'), `${JSON.stringify({ tools: tools.result.tools }, null, 2)}\n`); + + const projectList = await pollUntil(() => call('getProjectList'), (t) => t.includes(`"${projectName}"`), 120, 3000); + if (!projectList.includes(`"${projectName}"`)) throw new Error(`project ${projectName} was not discovered`); + writeMock('getProjectList', projectList); + + const diagnostics = await pollUntil(() => call('getProjectDiagnostics', exact), (t) => /"code"/.test(t), 40, 3000); + writeMock('getProjectDiagnostics', diagnostics, exact); + writeMock('getSpringBootVersion', await call('getSpringBootVersion', exact), exact); + writeMock('getJavaVersion', await call('getJavaVersion', exact), exact); + writeMock('getResolvedProjectClasspath', await call('getResolvedProjectClasspath', exact), exact); + + const mappings = await pollUntil(() => call('getRequestMappings', exact), (t) => t.includes('/greeting'), 20, 2000); + writeMock('getRequestMappings', mappings, exact); + writeMock('findRequestMappingsByMethod', await call('findRequestMappingsByMethod', { ...exact, httpMethod: 'GET' }), { ...exact, httpMethod: 'string' }); + + const beans = await call('getBeanDetails', exact); + writeMock('getBeanDetails', beans, exact); + const beanName = beans.match(/"name"\s*:\s*"([^"]+)"/)?.[1]; + const beanType = beans.match(/"type"\s*:\s*"([^"]+)"/)?.[1]; + if (!beanName || !beanType) throw new Error('no bean in getBeanDetails output'); + writeMock('getBeanUsageInfo', await call('getBeanUsageInfo', { ...exact, beanName }), { ...exact, beanName: 'string' }); + writeMock('findBeansByType', await call('findBeansByType', { ...exact, typeName: beanType }), { ...exact, typeName: 'string' }); + + const stereotypes = await call('getStereotypesList', exact); + writeMock('getStereotypesList', stereotypes, exact); + writeMock('getListOfComponentsAndTheirStereotypes', await call('getListOfComponentsAndTheirStereotypes', exact), exact); + const stereotypeName = stereotypes.match(/"name"\s*:\s*"([^"]+)"/)?.[1] ?? 'Controller'; + writeMock('findComponentsByStereotype', await call('findComponentsByStereotype', { ...exact, stereotypeName }), { ...exact, stereotypeName: 'string' }); + writeMock('getLogicalStructure', await call('getLogicalStructure', exact), exact); + writeMock('getStructureDependencies', await call('getStructureDependencies', exact), exact); + writeMock('captureLogicalStructureBaseline', await call('captureLogicalStructureBaseline', exact), exact); + writeMock('getLogicalStructureChanges', await call('getLogicalStructureChanges', exact), exact); + writeMock('getLogicalStructureBaselineHistory', await call('getLogicalStructureBaselineHistory', exact), exact); + writeMock('clearLogicalStructureBaseline', await call('clearLogicalStructureBaseline', exact), exact); + + writeMock('getLatestReleaseInformation', await call('getLatestReleaseInformation', { projectName: 'spring-boot' }), { projectName: ['spring-boot', 'spring-framework', 'spring-security', 'spring-data-jpa', 'spring-cloud'] }); + writeMock('getLatestBootVersionsFromMavenRepo', await call('getLatestBootVersionsFromMavenRepo', exact), exact); + writeMock('getReleases', await call('getReleases', { project: 'spring-boot' }), { project: 'string' }); + writeMock('getGenerations', await call('getGenerations', { project: 'spring-boot' }), { project: 'string' }); + writeMock('getUpcomingReleases', await call('getUpcomingReleases')); + + writeMock('refreshWorkspace', (await call('refreshWorkspace')) || 'OK'); + writeMock('fileChanged', (await call('fileChanged', { filePath: `${workspace}/${projectName}/pom.xml` })) || 'OK', { filePath: 'string' }); + writeMock('fileDeleted', (await call('fileDeleted', { filePath: `${workspace}/${projectName}/Missing.java` })) || 'OK', { filePath: 'string' }); + + const application = join(workspace, projectName, 'src/main/java/com/example/demo/Sf7ValidationApplication.java'); + const original = readFileSync(application, 'utf8'); + writeFileSync(application, original.replace(/class (\w+) \{/, 'class $1 {\n\t@org.springframework.beans.factory.annotation.Autowired\n\tpublic $1() {}\n')); + await call('fileChanged', { filePath: application }); + const withJavaDiagnostic = await pollUntil(() => call('getProjectDiagnostics', exact), (t) => t.includes('JAVA_AUTOWIRED_CONSTRUCTOR'), 30, 1000); + if (!withJavaDiagnostic.includes('JAVA_AUTOWIRED_CONSTRUCTOR')) throw new Error('JAVA_AUTOWIRED_CONSTRUCTOR did not appear after the edit'); + writeMock('getProjectDiagnostics', withJavaDiagnostic, exact, stagedValidateMocksDir); + writeFileSync(application, original); + + const replaceDirectory = (staged, target) => { + mkdirSync(dirname(target), { recursive: true }); + const backup = `${target}.backup-${process.pid}`; + rmSync(backup, { recursive: true, force: true }); + if (existsSync(target)) renameSync(target, backup); + try { + renameSync(staged, target); + rmSync(backup, { recursive: true, force: true }); + } catch (error) { + if (existsSync(backup) && !existsSync(target)) renameSync(backup, target); + throw error; + } + }; + replaceDirectory(stagedMocksDir, mocksDir); + replaceDirectory(stagedValidateMocksDir, validateCaseMocksDir); + + console.log(`${readdirSync(mocksDir).length} files written to ${mocksDir}`); +} finally { + child.kill('SIGTERM'); + const exited = await new Promise((resolvePromise) => { + const timer = setTimeout(() => resolvePromise(false), 10000); + childExited.then(() => { + clearTimeout(timer); + resolvePromise(true); + }); + }); + if (!exited) { + child.kill('SIGKILL'); + await childExited; + } + rmSync(work, { recursive: true, force: true }); + rmSync(stagingRoot, { recursive: true, force: true }); +} diff --git a/agent-plugins/tools/evals/run-with-copilot.mjs b/agent-plugins/tools/evals/run-with-copilot.mjs new file mode 100644 index 0000000000..346555e6b5 --- /dev/null +++ b/agent-plugins/tools/evals/run-with-copilot.mjs @@ -0,0 +1,214 @@ +#!/usr/bin/env node +/******************************************************************************* + * Copyright (c) 2026 Broadcom + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * https://www.eclipse.org/legal/epl-v10.html + * + * Contributors: + * Broadcom - initial API and implementation + *******************************************************************************/ + +// Runs the plugin's eval suite (agent-plugins/spring-tools/evals) with GitHub Copilot CLI, the +// same way `claude plugin eval` runs it with Claude Code: +// +// node agent-plugins/tools/evals/run-with-copilot.mjs [--case ] [--model ] [--keep] +// +// Each case gets a throwaway workspace with the sf7-validation fixture and a throwaway copy of the +// plugin whose MCP server is mock-mcp-server.mjs, so no language server and no network are needed. +// The trace is read back from the Copilot debug log (tool calls) and the mock server (MCP calls), +// then the recorded graders are applied: `tool_used` and `regex` locally, `llm` through one extra +// Copilot call that answers PASS or FAIL. + +import { spawnSync } from 'node:child_process'; +import { appendFileSync, cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { delimiter, dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { PLUGIN_DIR, MCP_SERVER_NAME, parseFrontMatter } from '../lib/plugin-config.mjs'; +import { combineToolTraces, copilotAgentId, toolGradePasses } from '../lib/eval-grading.mjs'; + +const here = dirname(fileURLToPath(import.meta.url)); +const repoRoot = resolve(here, '..', '..', '..'); +const pluginDir = join(repoRoot, PLUGIN_DIR); +const evalsDir = join(pluginDir, 'evals'); +const fixture = join(repoRoot, 'headless-services/spring-boot-language-server/src/test/resources/test-projects/sf7-validation'); +const mcpToolNames = new Set(JSON.parse(readFileSync(join(evalsDir, 'mocks', MCP_SERVER_NAME, '_tools.json'), 'utf8')).tools.map(({ name }) => name)); + +const args = process.argv.slice(2); +const only = valueOf('--case'); +const model = valueOf('--model'); +const keep = args.includes('--keep'); + +function valueOf(flag) { + const index = args.indexOf(flag); + return index === -1 ? undefined : args[index + 1]; +} + +function readCase(name) { + const text = readFileSync(join(evalsDir, name, 'prompt.md'), 'utf8'); + const front = parseFrontMatter(text) ?? {}; + const prompt = text.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n/, '').trim(); + const gradersDir = join(evalsDir, name, 'graders'); + const graders = readdirSync(gradersDir).filter((f) => f.endsWith('.md')).map((file) => { + const graderText = readFileSync(join(gradersDir, file), 'utf8'); + return { file, ...parseFrontMatter(graderText), rubric: graderText.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n/, '').trim() }; + }); + return { name, front, prompt, graders }; +} + +/** Copy of the plugin whose MCP server replays the recorded mocks of this case. */ +function stagePlugin(root, caseName, tracePath) { + const staged = join(root, 'plugin'); + cpSync(pluginDir, staged, { recursive: true, filter: (src) => !src.includes(`${join(PLUGIN_DIR, 'language-server')}`) }); + const caseMocks = join(evalsDir, caseName, 'mocks', MCP_SERVER_NAME); + const mockDirs = [existsSync(caseMocks) ? caseMocks : null, join(evalsDir, 'mocks', MCP_SERVER_NAME)].filter(Boolean); + const mcp = JSON.parse(readFileSync(join(staged, 'mcp.json'), 'utf8')); + mcp.mcpServers[MCP_SERVER_NAME] = { + type: 'stdio', + command: 'node', + args: [join(here, 'mock-mcp-server.mjs')], + env: { EVAL_MOCK_DIRS: mockDirs.join(delimiter), EVAL_TRACE: tracePath }, + }; + writeFileSync(join(staged, 'mcp.json'), `${JSON.stringify(mcp, null, 2)}\n`); + // The session hook only downloads the language server, which the mocked run never starts. + rmSync(join(staged, 'com.github.copilot', 'hooks'), { recursive: true, force: true }); + return staged; +} + +/** Runs one Copilot prompt and returns { answer, trace }. */ +function runCopilot({ prompt, plugin, workspace, logs, agent }) { + const copilotArgs = [ + '--plugin-dir', plugin, + '--allow-all-tools', '--allow-all-mcp-server-instructions', + '--no-ask-user', '--no-custom-instructions', '--disable-builtin-mcps', + '--log-level', 'debug', '--log-dir', logs, + '-p', prompt, '--silent', + ]; + if (agent) { + copilotArgs.push('--agent', agent); + } + if (model) { + copilotArgs.push('--model', model); + } + const result = spawnSync('copilot', copilotArgs, { cwd: workspace, encoding: 'utf8', timeout: 600000, env: { ...process.env, COPILOT_DISABLE_TELEMETRY: '1' } }); + return { answer: result.stdout ?? '', stderr: result.stderr ?? '', status: result.status }; +} + +/** Tool calls Copilot made, read back from its debug log. */ +function logTrace(logs) { + const entries = []; + for (const file of existsSync(logs) ? readdirSync(logs) : []) { + const text = readFileSync(join(logs, file), 'utf8'); + for (const match of text.matchAll(/"name":\s*"([\w.-]+)",\s*"arguments":\s*"((?:[^"\\]|\\.)*)"/g)) { + entries.push({ tool: match[1], input: match[2].replace(/\\"/g, '"').replace(/\\\\/g, '\\') }); + } + } + return entries; +} + +/** A second, cheap Copilot call that judges the answer against the rubric of an `llm` grader. */ +function llmVerdict(grader, answer, workspace) { + const prompt = [ + 'You are grading the answer of an AI assistant against a rubric. Do not use any tools.', + 'Reply with exactly one word: PASS or FAIL.', + '', '## Rubric', grader.rubric, + '', '## Answer', answer.slice(0, 12000), + ].join('\n'); + // Prompt mode requires an explicit permission mode. Keep the judge read-only even then. + const result = spawnSync('copilot', ['--allow-all-tools', '--available-tools=view', '--no-ask-user', '--no-custom-instructions', '--disable-builtin-mcps', '-p', prompt, '--silent'], { cwd: workspace, encoding: 'utf8', timeout: 300000 }); + const verdict = (result.stdout ?? '').trim().toUpperCase(); + return { pass: result.status === 0 && verdict === 'PASS', raw: verdict.slice(0, 200) }; +} + +function gradeCase(testCase, answer, trace, workspace) { + const results = []; + for (const grader of testCase.graders) { + const weight = Number(grader.weight ?? 1); + if (grader.type === 'tool_used') { + results.push({ file: grader.file, weight, pass: toolGradePasses(grader, trace), detail: grader.tool }); + } else if (grader.type === 'regex') { + const flags = grader.flags ?? ''; + results.push({ file: grader.file, weight, pass: new RegExp(grader.pattern, flags).test(answer), detail: grader.pattern }); + } else if (grader.type === 'llm') { + const verdict = llmVerdict(grader, answer, workspace); + results.push({ file: grader.file, weight, pass: verdict.pass, detail: verdict.raw }); + } else { + results.push({ file: grader.file, weight, pass: false, detail: `unsupported grader type '${grader.type}'` }); + } + } + return results; +} + +const caseNames = readdirSync(evalsDir) + .filter((d) => existsSync(join(evalsDir, d, 'prompt.md'))) + .filter((d) => !only || d === only) + .sort(); +if (caseNames.length === 0) { + console.error(only ? `no eval case named '${only}'` : 'no eval cases found'); + process.exit(2); +} +if (spawnSync('copilot', ['--version'], { encoding: 'utf8' }).status !== 0) { + console.error('GitHub Copilot CLI is not available on the PATH'); + process.exit(2); +} + +const root = mkdtempSync(join(tmpdir(), 'spring-tools-evals-')); +const summary = []; +let failed = 0; +try { + for (const name of caseNames) { + const testCase = readCase(name); + const caseRoot = join(root, name); + const workspace = join(caseRoot, 'workspace'); + const logs = join(caseRoot, 'logs'); + mkdirSync(workspace, { recursive: true }); + mkdirSync(logs, { recursive: true }); + cpSync(fixture, join(workspace, 'sf7-validation'), { recursive: true, filter: (src) => !/[/\\](target|build)([/\\]|$)/.test(src) }); + const tracePath = join(caseRoot, 'mcp-trace.jsonl'); + writeFileSync(tracePath, ''); + const plugin = stagePlugin(caseRoot, name, tracePath); + + // Copilot selects a custom agent through --agent instead of a delegation tool. + const agentGrader = testCase.graders.find((g) => g.type === 'tool_used' && g.tool === 'Agent'); + // Copilot custom-agent IDs are derived from the agent filename, not plugin-qualified. + const agent = agentGrader ? copilotAgentId(agentGrader.input_match) : undefined; + + const { answer, stderr, status } = runCopilot({ prompt: testCase.prompt, plugin, workspace, logs, agent }); + const mockTrace = readFileSync(tracePath, 'utf8').split('\n').filter(Boolean) + .map((line) => JSON.parse(line)); + const trace = [ + ...combineToolTraces(logTrace(logs), mockTrace, mcpToolNames), + ...(agent ? [{ tool: 'agent', input: JSON.stringify({ agent }) }] : []), + ]; + writeFileSync(join(caseRoot, 'answer.txt'), answer); + const results = status === 0 + ? gradeCase(testCase, answer, trace, workspace) + : [{ file: 'run', weight: 1, pass: false, detail: `copilot exited with ${status}: ${stderr.trim().slice(-300)}` }]; + + const total = results.reduce((sum, r) => sum + r.weight, 0); + const scored = results.filter((r) => r.pass).reduce((sum, r) => sum + r.weight, 0); + const pass = results.every((r) => r.pass); + failed += pass ? 0 : 1; + summary.push({ name, pass, scored, total, results, dir: caseRoot }); + console.log(`${pass ? 'PASS' : 'FAIL'} ${name} (${scored}/${total})`); + for (const result of results.filter((r) => !r.pass)) { + console.log(` - ${result.file}: ${result.detail}`); + } + } +} finally { + if (!keep) { + rmSync(root, { recursive: true, force: true }); + } else { + console.log(`\nartifacts: ${root}`); + } +} + +if (process.env.GITHUB_STEP_SUMMARY) { + const rows = summary.map((s) => `| ${s.name} | ${s.pass ? 'pass' : 'fail'} | ${s.scored}/${s.total} |`).join('\n'); + appendFileSync(process.env.GITHUB_STEP_SUMMARY, `\n### Copilot CLI evals\n\n| case | result | score |\n| --- | --- | --- |\n${rows}\n`); +} +console.log(`\n${summary.length - failed}/${summary.length} cases passed`); +process.exit(failed === 0 ? 0 : 1); diff --git a/agent-plugins/tools/lib/eval-grading.mjs b/agent-plugins/tools/lib/eval-grading.mjs new file mode 100644 index 0000000000..b768e7e538 --- /dev/null +++ b/agent-plugins/tools/lib/eval-grading.mjs @@ -0,0 +1,52 @@ +/******************************************************************************* + * Copyright (c) 2026 Broadcom + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * https://www.eclipse.org/legal/epl-v10.html + * + * Contributors: + * Broadcom - initial API and implementation + *******************************************************************************/ + +import { MCP_SERVER_NAME, SCOPED_TOOL_PREFIX } from './plugin-config.mjs'; + +const TOOL_ALIASES = { Read: ['view'], Skill: ['skill'], Glob: ['glob'], Grep: ['grep'], Bash: ['bash'], Agent: ['agent'] }; + +/** Copilot selects plugin-provided agents by the file-derived id, without a plugin namespace. */ +export function copilotAgentId(inputMatch) { + return typeof inputMatch === 'string' && /^[\w-]+$/.test(inputMatch) ? inputMatch : undefined; +} + +/** Count trace entries matching a grader's tool name and optional JSON-input pattern. */ +export function countToolMatches(grader, trace) { + const wanted = grader.tool.startsWith(SCOPED_TOOL_PREFIX) + ? [`${MCP_SERVER_NAME}-${grader.tool.slice(SCOPED_TOOL_PREFIX.length)}`] + : (TOOL_ALIASES[grader.tool] ?? [grader.tool]); + const pattern = grader.input_match ? new RegExp(grader.input_match) : null; + return trace.filter((entry) => wanted.includes(entry.tool) && (!pattern || pattern.test(entry.input))).length; +} + +/** Apply the eval grader's inclusive min/max occurrence bounds (default: at least one). */ +export function toolGradePasses(grader, trace) { + const count = countToolMatches(grader, trace); + const min = grader.min === undefined ? 1 : Number(grader.min); + const max = grader.max === undefined ? Number.POSITIVE_INFINITY : Number(grader.max); + return Number.isFinite(min) && min >= 0 && !Number.isNaN(max) && max >= min && count >= min && count <= max; +} + +/** Prefer the mock server's authoritative MCP trace over duplicate MCP entries in Copilot's logs. */ +export function combineToolTraces(logEntries, mockEntries, mcpToolNames) { + const isMcpToolLogEntry = (tool) => { + const candidates = [ + tool, + tool.startsWith(`${MCP_SERVER_NAME}-`) ? tool.slice(MCP_SERVER_NAME.length + 1) : null, + tool.split('__').at(-1), + ]; + return candidates.some((candidate) => mcpToolNames.has(candidate)); + }; + return [ + ...logEntries.filter((entry) => !isMcpToolLogEntry(entry.tool)), + ...mockEntries.map((entry) => ({ ...entry, tool: `${MCP_SERVER_NAME}-${entry.tool}` })), + ]; +} diff --git a/agent-plugins/tools/lib/plugin-config.mjs b/agent-plugins/tools/lib/plugin-config.mjs new file mode 100644 index 0000000000..8b4390785d --- /dev/null +++ b/agent-plugins/tools/lib/plugin-config.mjs @@ -0,0 +1,693 @@ +/******************************************************************************* + * Copyright (c) 2026 Broadcom + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * https://www.eclipse.org/legal/epl-v10.html + * + * Contributors: + * Broadcom - initial API and implementation + *******************************************************************************/ + +// Consistency rules between the plugin configuration (manifests, hooks, skills, +// agents) and the MCP tools implemented by the language server. The plugin shares configuration across hosts: +// Claude Code reads `.claude-plugin/plugin.json`, `hooks/hooks.json` and `agents/`, +// GitHub Copilot CLI and Codex read the Agent Plugins 1.0 manifests (`plugin.json`, `mcp.json`) and the +// `com.github.copilot/` directory supplies Copilot-specific adapters. Shared fields must stay in sync. + +import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'; +import { basename, join } from 'node:path'; + +export const MCP_SOURCES_DIR = 'headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/mcp'; +export const PLUGIN_DIR = 'agent-plugins/spring-tools'; +export const MCP_SERVER_NAME = 'spring-tools-mcp'; +export const PLUGIN_NAME = 'spring-tools'; +export const HOOK_SERVER = `plugin:${PLUGIN_NAME}:${MCP_SERVER_NAME}`; +export const SCOPED_TOOL_PREFIX = `mcp__plugin_${PLUGIN_NAME}_${MCP_SERVER_NAME}__`; +export const COPILOT_DIR = 'com.github.copilot'; +export const PLUGIN_SCHEMA = 'https://agent-plugins.org/schemas/1.0.0/plugin.schema.json'; +export const MCP_SCHEMA = 'https://agent-plugins.org/schemas/1.0.0/mcp.schema.json'; + +/** + * Reads the `@Tool` annotated methods of the language server's MCP components. + * @returns {Map} tool name -> declared parameter names + */ +export function collectMcpTools(root) { + const tools = new Map(); + const dir = join(root, MCP_SOURCES_DIR); + if (!existsSync(dir)) { + return tools; + } + for (const entry of readdirSync(dir)) { + const file = join(dir, entry); + if (!entry.endsWith('.java') || statSync(file).isDirectory()) { + continue; + } + const text = readFileSync(file, 'utf8'); + let index = 0; + while ((index = text.indexOf('@Tool(', index)) !== -1) { + const rest = text.slice(index); + const method = rest.match(/\n\s*public\s+[\w<>\[\],.? ]+?\s+(\w+)\s*\(/); + if (method) { + const start = index + method.index + method[0].length; + const params = splitTopLevel(text.slice(start, matchingParen(text, start))); + tools.set(method[1], params.map((p) => p.trim().match(/(\w+)$/)[1])); + } + index += '@Tool('.length; + } + } + return tools; +} + +/** Index of the `)` closing the parenthesis opened just before `start`, ignoring parens inside string literals. */ +function matchingParen(text, start) { + let depth = 1; + let inString = false; + for (let i = start; i < text.length; i++) { + const c = text[i]; + if (inString) { + if (c === '\\') { + i++; + } else if (c === '"') { + inString = false; + } + } else if (c === '"') { + inString = true; + } else if (c === '(') { + depth++; + } else if (c === ')' && --depth === 0) { + return i; + } + } + throw new Error('unbalanced parentheses in MCP tool signature'); +} + +/** Splits a parameter list on commas that are not nested in parentheses, brackets or string literals. */ +function splitTopLevel(params) { + const result = []; + let depth = 0; + let inString = false; + let current = ''; + for (let i = 0; i < params.length; i++) { + const c = params[i]; + if (inString) { + if (c === '\\') { + current += c + params[++i]; + continue; + } + if (c === '"') { + inString = false; + } + } else if (c === '"') { + inString = true; + } else if (c === '(' || c === '<' || c === '[') { + depth++; + } else if (c === ')' || c === '>' || c === ']') { + depth--; + } else if (c === ',' && depth === 0) { + result.push(current); + current = ''; + continue; + } + current += c; + } + if (current.trim()) { + result.push(current); + } + return result; +} + +/** Minimal YAML front matter reader for SKILL.md files (scalars and `- item` lists). */ +export function parseFrontMatter(text) { + const match = text.match(/^---\r?\n([\s\S]*?)\r?\n---/); + if (!match) { + return null; + } + const data = {}; + const lines = match[1].split(/\r?\n/); + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + const kv = line.match(/^([A-Za-z][\w-]*):\s*(.*)$/); + if (!kv) { + continue; + } + const [, key, rawValue] = kv; + let value = rawValue.trim(); + if (value === '') { + const items = []; + while (i + 1 < lines.length && /^\s+-\s+/.test(lines[i + 1])) { + items.push(unquote(lines[++i].replace(/^\s+-\s+/, ''))); + } + data[key] = items; + } else if (value.startsWith('[') && value.endsWith(']')) { + data[key] = value.slice(1, -1).split(',').map((v) => unquote(v.trim())).filter(Boolean); + } else { + data[key] = unquote(value); + } + } + return data; +} + +function unquote(value) { + return value.replace(/^"(.*)"$/, '$1').replace(/^'(.*)'$/, '$1'); +} + +function hookEntries(hooksJson) { + const result = []; + for (const [event, groups] of Object.entries(hooksJson.hooks ?? {})) { + for (const group of groups) { + for (const hook of group.hooks ?? []) { + result.push({ event, matcher: group.matcher, hook }); + } + } + } + return result; +} + +/** Validates hooks.json against the MCP tool inventory (and, given the repo root, the plugin files); returns error strings. */ +export function checkHooks(hooksJson, tools, root) { + const errors = []; + const entries = hookEntries(hooksJson); + if (entries.length === 0) { + errors.push('hooks.json declares no hooks'); + } + for (const { event, hook } of entries) { + const where = `${event} hook '${hook.if ?? hook.tool ?? hook.command}'`; + if (hook.if !== undefined && !/^(Bash|PowerShell|Edit|Read|WebFetch|Skill|Agent|mcp__[\w-]+)\(.+\)$/.test(hook.if)) { + errors.push(`${where}: 'if' must be a single permission rule like Edit(*.java) or Bash(git *)`); + } + if (hook.type === 'command') { + if (typeof hook.command !== 'string' || !hook.command) { + errors.push(`${where}: command hook without a command`); + } else if (hook.command.includes('${CLAUDE_PLUGIN_ROOT}') && !Array.isArray(hook.args)) { + errors.push(`${where}: path placeholders belong in exec-form 'args', not in a shell-form command`); + } + for (const arg of hook.args ?? []) { + const prefix = '${CLAUDE_PLUGIN_ROOT}/'; + if (root && arg.startsWith(prefix) && !existsSync(join(root, PLUGIN_DIR, arg.slice(prefix.length)))) { + errors.push(`${where}: '${arg}' does not exist in the plugin directory`); + } + } + continue; + } + if (hook.type !== 'mcp_tool') { + continue; + } + if (event === 'SessionStart' || event === 'Setup') { + errors.push(`${where}: Claude Code skips mcp_tool hooks on ${event} at launch (no MCP client context yet); use a command hook`); + } + if (hook.server !== HOOK_SERVER) { + errors.push(`${where}: server must be '${HOOK_SERVER}', found '${hook.server}'`); + } + if (!tools.has(hook.tool)) { + errors.push(`${where}: unknown MCP tool '${hook.tool}' (known: ${[...tools.keys()].sort().join(', ')})`); + continue; + } + const params = tools.get(hook.tool); + for (const key of Object.keys(hook.input ?? {})) { + if (!params.includes(key)) { + errors.push(`${where}: input '${key}' is not a parameter of ${hook.tool}(${params.join(', ')})`); + } + } + for (const param of params) { + if (!(hook.input ?? {})[param]) { + errors.push(`${where}: parameter '${param}' of ${hook.tool} is not provided`); + } + } + } + return errors; +} + +/** Validates one SKILL.md against its directory, the MCP tools and the other skills. */ +export function checkSkill(dirName, text, tools, skillNames) { + const errors = []; + const front = parseFrontMatter(text); + if (!front) { + return [`${dirName}: SKILL.md has no YAML front matter`]; + } + if (front.name !== dirName) { + errors.push(`${dirName}: front matter name '${front.name}' differs from directory name`); + } + if (!front.description) { + errors.push(`${dirName}: missing description`); + } + const allowed = Array.isArray(front['allowed-tools']) ? front['allowed-tools'] : (front['allowed-tools'] ? String(front['allowed-tools']).split(',').map((s) => s.trim()) : []); + for (const entry of allowed) { + if (entry.startsWith('mcp__')) { + if (!entry.startsWith(SCOPED_TOOL_PREFIX)) { + errors.push(`${dirName}: MCP tool '${entry}' is not scoped with '${SCOPED_TOOL_PREFIX}'`); + } else if (!tools.has(entry.slice(SCOPED_TOOL_PREFIX.length))) { + errors.push(`${dirName}: unknown MCP tool '${entry.slice(SCOPED_TOOL_PREFIX.length)}'`); + } + } + // Skill(plugin:name) only matches an argument-less call; Skill(plugin:name *) also covers arguments. + const skillRef = entry.match(/^Skill\(([^:)\s]+):([^)\s]+)( \*)?\)$/); + if (skillRef) { + if (skillRef[1] !== PLUGIN_NAME) { + errors.push(`${dirName}: skill reference '${entry}' uses plugin '${skillRef[1]}'`); + } else if (!skillNames.includes(skillRef[2])) { + errors.push(`${dirName}: skill reference '${entry}' points to a missing skill`); + } + } else if (entry.startsWith('Skill(')) { + errors.push(`${dirName}: skill reference '${entry}' must look like Skill(${PLUGIN_NAME}:) or Skill(${PLUGIN_NAME}: *)`); + } + } + for (const match of text.matchAll(/`((?:get|file|refresh|find|capture|clear)[A-Z]\w*)`/g)) { + if (!tools.has(match[1])) { + errors.push(`${dirName}: body mentions MCP tool '${match[1]}' which does not exist`); + } + } + return errors; +} + +/** MCP tool names referenced by a skill's allowed-tools (scoped entries only). */ +export function skillToolNames(text) { + const front = parseFrontMatter(text); + const allowed = Array.isArray(front?.['allowed-tools']) ? front['allowed-tools'] : (front?.['allowed-tools'] ? String(front['allowed-tools']).split(',').map((s) => s.trim()) : []); + return allowed.filter((e) => e.startsWith(SCOPED_TOOL_PREFIX)).map((e) => e.slice(SCOPED_TOOL_PREFIX.length)); +} + +/** + * Validates one agents/.md file (Claude Code sub-agent). Agents shipped in a plugin need a + * name matching the file, a description, and may only grant MCP tools of the plugin's own server. + */ +export function checkAgent(fileName, text, tools) { + const errors = []; + const expectedName = basename(fileName).replace(/\.md$/, ''); + const front = parseFrontMatter(text); + if (!front) { + return [`${fileName}: agent file has no YAML front matter`]; + } + if (front.name !== expectedName) { + errors.push(`${fileName}: front matter name '${front.name}' differs from file name '${expectedName}'`); + } + if (!front.description || String(front.description).length < 20) { + errors.push(`${fileName}: missing or too short description (Claude uses it to decide when to delegate)`); + } + for (const key of ['hooks', 'mcpServers', 'permissionMode']) { + if (front[key] !== undefined) { + errors.push(`${fileName}: '${key}' is ignored for agents shipped in a plugin`); + } + } + const granted = Array.isArray(front.tools) ? front.tools : (front.tools ? String(front.tools).split(',').map((s) => s.trim()) : []); + for (const entry of granted) { + if (!entry.startsWith('mcp__')) { + continue; + } + if (entry === `${SCOPED_TOOL_PREFIX}*` || entry === SCOPED_TOOL_PREFIX.slice(0, -2)) { + continue; + } + if (!entry.startsWith(SCOPED_TOOL_PREFIX)) { + errors.push(`${fileName}: MCP tool '${entry}' is not scoped with '${SCOPED_TOOL_PREFIX}'`); + } else if (!tools.has(entry.slice(SCOPED_TOOL_PREFIX.length))) { + errors.push(`${fileName}: unknown MCP tool '${entry.slice(SCOPED_TOOL_PREFIX.length)}'`); + } + } + for (const match of text.matchAll(/`((?:get|file|refresh|find|capture|clear)[A-Z]\w*)`/g)) { + if (!tools.has(match[1])) { + errors.push(`${fileName}: body mentions MCP tool '${match[1]}' which does not exist`); + } + } + return errors; +} + +/** + * Every MCP tool the language server exposes must be reachable through a documented path: an + * allowed-tools entry of some skill or an mcp_tool hook. Returns the tool names that are not. + */ +export function uncoveredTools(tools, skillTexts, hooksJson) { + const covered = new Set(); + for (const text of skillTexts) { + for (const name of skillToolNames(text)) { + covered.add(name); + } + } + for (const { hook } of hookEntries(hooksJson ?? {})) { + if (hook.type === 'mcp_tool' && hook.tool) { + covered.add(hook.tool); + } + } + return [...tools.keys()].filter((name) => !covered.has(name)).sort(); +} + +/** + * Consistency of the behavioral eval suite (claude plugin eval) with the rest of the plugin: every + * mock answers a real tool, every case has a prompt and at least one grader, graders reference + * only real tools/skills/agents, every MCP tool has a positive behavioral grader, and every skill + * and agent has at least one case that checks it. + * Returns error strings. + */ +export function checkEvals(evalsDir, tools, skillNames, agentNames) { + const errors = []; + if (!existsSync(evalsDir)) { + return [`evals directory ${evalsDir} is missing`]; + } + const mockDirs = [join(evalsDir, 'mocks', MCP_SERVER_NAME)]; + const cases = readdirSync(evalsDir).filter((d) => !['mocks', 'results'].includes(d) && statSync(join(evalsDir, d)).isDirectory()); + if (cases.length === 0) { + errors.push('evals: no cases'); + } + const coveredSkills = new Set(); + const coveredAgents = new Set(); + const coveredMcpTools = new Set(); + for (const name of cases) { + const caseDir = join(evalsDir, name); + const promptFile = join(caseDir, 'prompt.md'); + if (!existsSync(promptFile)) { + errors.push(`evals/${name}: prompt.md is missing`); + continue; + } + const prompt = readFileSync(promptFile, 'utf8'); + if (!prompt.replace(/^---[\s\S]*?\n---/, '').trim()) { + errors.push(`evals/${name}: prompt body is empty`); + } + const gradersDir = join(caseDir, 'graders'); + const graders = existsSync(gradersDir) ? readdirSync(gradersDir).filter((f) => f.endsWith('.md')) : []; + if (graders.length === 0) { + errors.push(`evals/${name}: no graders`); + } + for (const grader of graders) { + const graderText = readFileSync(join(gradersDir, grader), 'utf8'); + const data = parseFrontMatter(graderText); + if (!data?.type) { + errors.push(`evals/${name}/graders/${grader}: no type in front matter`); + continue; + } + const graderWhere = `evals/${name}/graders/${grader}`; + if (!['tool_used', 'regex', 'llm'].includes(data.type)) { + errors.push(`${graderWhere}: unsupported grader type '${data.type}'`); + continue; + } + const weight = Number(data.weight ?? 1); + if (!Number.isFinite(weight) || weight <= 0) { + errors.push(`${graderWhere}: weight must be a positive number`); + } + if (data.type === 'regex') { + if (typeof data.pattern !== 'string' || !data.pattern) { + errors.push(`${graderWhere}: regex grader needs a non-empty pattern`); + } else { + try { new RegExp(data.pattern, data.flags ?? ''); } catch (error) { + errors.push(`${graderWhere}: invalid regex (${error.message})`); + } + } + } + if (data.type === 'llm' && !graderText.replace(/^---[\s\S]*?\r?\n---\r?\n/, '').trim()) { + errors.push(`${graderWhere}: llm grader rubric is empty`); + } + if (data.type !== 'tool_used') { + continue; + } + const tool = data.tool ?? ''; + if (!tool) { + errors.push(`${graderWhere}: tool_used grader needs a tool`); + } + if (tool.startsWith('mcp__') && !tool.startsWith(SCOPED_TOOL_PREFIX)) { + errors.push(`${graderWhere}: MCP tool '${tool}' is not scoped with '${SCOPED_TOOL_PREFIX}'`); + } + if (tool.startsWith(SCOPED_TOOL_PREFIX) && !tools.has(tool.slice(SCOPED_TOOL_PREFIX.length))) { + errors.push(`${graderWhere}: MCP tool '${tool}' does not exist`); + } + if (data.input_match !== undefined) { + try { new RegExp(data.input_match); } catch (error) { + errors.push(`${graderWhere}: invalid input_match regex (${error.message})`); + } + } + const min = data.min === undefined ? 1 : Number(data.min); + const max = data.max === undefined ? Number.POSITIVE_INFINITY : Number(data.max); + if (!Number.isFinite(min) || min < 0 || Number.isNaN(max) || max < min) { + errors.push(`${graderWhere}: min/max must define non-negative occurrence bounds with max >= min`); + } + if (tool.startsWith(SCOPED_TOOL_PREFIX) && tools.has(tool.slice(SCOPED_TOOL_PREFIX.length)) && min > 0) { + coveredMcpTools.add(tool.slice(SCOPED_TOOL_PREFIX.length)); + } + if (tool === 'Skill' && data.input_match) { + const skill = data.input_match.match(/\)\?([\w-]+)"/)?.[1]; + if (skill && !skillNames.includes(skill)) { + errors.push(`evals/${name}/graders/${grader}: skill '${skill}' does not exist`); + } + if (skill && data.max !== '0') { + coveredSkills.add(skill); + } + } + if (tool === 'Agent' && data.input_match) { + if (!agentNames.includes(data.input_match)) { + errors.push(`evals/${name}/graders/${grader}: agent '${data.input_match}' does not exist`); + } + coveredAgents.add(data.input_match); + } + } + const caseMocks = join(caseDir, 'mocks', MCP_SERVER_NAME); + if (existsSync(caseMocks)) { + mockDirs.push(caseMocks); + } + } + for (const dir of mockDirs) { + if (!existsSync(dir)) { + errors.push(`evals: mock directory ${dir} is missing`); + continue; + } + for (const file of readdirSync(dir).filter((f) => f.endsWith('.md'))) { + const tool = basename(file, '.md'); + if (!tools.has(tool)) { + errors.push(`evals: mock ${file} answers unknown tool '${tool}'`); + } + } + } + const suiteMocks = mockDirs[0]; + if (existsSync(suiteMocks)) { + const mocked = new Set(readdirSync(suiteMocks).filter((f) => f.endsWith('.md')).map((f) => basename(f, '.md'))); + const unmocked = [...tools.keys()].filter((t) => !mocked.has(t)).sort(); + if (unmocked.length) { + errors.push(`evals: tools without a suite-wide mock (re-run agent-plugins/tools/evals/record-mocks.mjs): ${unmocked.join(', ')}`); + } + const toolsJson = join(suiteMocks, '_tools.json'); + if (!existsSync(toolsJson)) { + errors.push('evals: mocks/_tools.json (saved tools/list) is missing'); + } else { + const listed = new Set((JSON.parse(readFileSync(toolsJson, 'utf8')).tools ?? []).map((t) => t.name)); + const stale = [...tools.keys()].filter((t) => !listed.has(t)).concat([...listed].filter((t) => !tools.has(t))).sort(); + if (stale.length) { + errors.push(`evals: mocks/_tools.json differs from the language server's tools (re-record): ${stale.join(', ')}`); + } + } + } + for (const skill of skillNames.filter((s) => !coveredSkills.has(s))) { + errors.push(`evals: skill '${skill}' has no case asserting it fires`); + } + const missingMcpTools = [...tools.keys()].filter((tool) => !coveredMcpTools.has(tool)).sort(); + if (missingMcpTools.length) { + errors.push(`evals: no positive behavioral grader for MCP tools: ${missingMcpTools.join(', ')}`); + } + for (const agent of agentNames.filter((a) => !coveredAgents.has(a))) { + errors.push(`evals: agent '${agent}' has no case asserting it is used`); + } + return errors; +} + +/** Validates this repository's Codex catalog against the shared portable plugin. */ +export function checkCodexMarketplace(catalog, root) { + const errors = []; + const entries = (catalog.plugins ?? []).filter((entry) => entry.name === PLUGIN_NAME); + if (entries.length !== 1) { + errors.push(`Codex marketplace must contain exactly one '${PLUGIN_NAME}' entry`); + return errors; + } + const source = entries[0].source; + if (source?.source !== 'local' || source.path !== `./${PLUGIN_DIR}`) { + errors.push(`Codex marketplace source must be local with path './${PLUGIN_DIR}'`); + return errors; + } + const manifestPath = join(root, PLUGIN_DIR, 'plugin.json'); + if (!existsSync(manifestPath)) { + errors.push('Codex marketplace source has no portable plugin.json'); + } else if (JSON.parse(readFileSync(manifestPath, 'utf8')).name !== entries[0].name) { + errors.push('Codex marketplace plugin name differs from portable plugin.json'); + } + return errors; +} + +/** Validates plugin.json and the local marketplace; returns error strings. */ +export function checkManifest(pluginJson, marketplaceJson, root) { + const errors = []; + if (pluginJson.name !== PLUGIN_NAME) { + errors.push(`plugin.json name must be '${PLUGIN_NAME}'`); + } + if (!/^\d+\.\d+\.\d+(?:-[\w.]+)?$/.test(pluginJson.version ?? '')) { + errors.push(`plugin.json version '${pluginJson.version}' is not X.Y.Z[-qualifier]`); + } + const server = pluginJson.mcpServers?.[MCP_SERVER_NAME]; + if (!server) { + errors.push(`plugin.json must declare mcpServers.${MCP_SERVER_NAME}`); + } else { + if (server.command !== 'node') { + errors.push(`mcpServers.${MCP_SERVER_NAME}.command must be 'node'`); + } + const launcher = (server.args ?? []).find((a) => a.endsWith('/launcher.js')); + if (!launcher || !launcher.startsWith('${CLAUDE_PLUGIN_ROOT}/')) { + errors.push(`mcpServers.${MCP_SERVER_NAME}.args must launch \${CLAUDE_PLUGIN_ROOT}/launcher.js`); + } else if (!existsSync(join(root, PLUGIN_DIR, 'launcher.js'))) { + errors.push('launcher.js is missing from the plugin directory'); + } + } + const entry = (marketplaceJson.plugins ?? []).find((p) => p.name === PLUGIN_NAME); + if (!entry) { + errors.push(`marketplace.json has no plugin named '${PLUGIN_NAME}'`); + } else if (typeof entry.source !== 'string' || !existsSync(join(root, 'agent-plugins', entry.source, '.claude-plugin', 'plugin.json'))) { + errors.push(`marketplace.json source '${entry.source}' does not point at a plugin directory`); + } + return errors; +} + +// The Agent Plugins 1.0 plugin schema is closed: anything else (for example displayName) is rejected. +const AGENT_PLUGIN_FIELDS = ['$schema', 'name', 'version', 'description', 'author', 'homepage', 'repository', 'license', 'keywords', 'extensions']; + +/** + * Validates the Agent Plugins 1.0 manifests that GitHub Copilot CLI reads (plugin.json and + * mcp.json in the plugin root) and keeps their shared fields in sync with the Claude Code manifest. + */ +export function checkAgentPluginManifest(pluginJson, mcpJson, claudeJson, root) { + const errors = []; + if (pluginJson.$schema !== PLUGIN_SCHEMA) { + errors.push(`plugin.json $schema must be '${PLUGIN_SCHEMA}'`); + } + for (const key of Object.keys(pluginJson)) { + if (!AGENT_PLUGIN_FIELDS.includes(key)) { + errors.push(`plugin.json: '${key}' is not allowed by the Agent Plugins schema (allowed: ${AGENT_PLUGIN_FIELDS.join(', ')})`); + } + } + if (pluginJson.name !== PLUGIN_NAME) { + errors.push(`plugin.json name must be '${PLUGIN_NAME}'`); + } + if (typeof pluginJson.author !== 'object' || !pluginJson.author?.name) { + errors.push('plugin.json author must be an object with a name'); + } + for (const key of ['name', 'version', 'description']) { + if (pluginJson[key] !== claudeJson[key]) { + errors.push(`plugin.json ${key} differs from .claude-plugin/plugin.json ('${pluginJson[key]}' vs '${claudeJson[key]}')`); + } + } + + if (mcpJson.$schema !== MCP_SCHEMA) { + errors.push(`mcp.json $schema must be '${MCP_SCHEMA}'`); + } + for (const key of Object.keys(mcpJson)) { + if (key !== '$schema' && key !== 'mcpServers') { + errors.push(`mcp.json: unexpected key '${key}'`); + } + } + const server = mcpJson.mcpServers?.[MCP_SERVER_NAME]; + if (!server) { + errors.push(`mcp.json must declare mcpServers.${MCP_SERVER_NAME}`); + return errors; + } + if (server.type !== 'stdio') { + errors.push(`mcp.json ${MCP_SERVER_NAME}.type must be 'stdio'`); + } + if (server.command !== 'node') { + errors.push(`mcp.json ${MCP_SERVER_NAME}.command must be 'node' (the command is a single token and is not placeholder-expanded)`); + } + const launcher = (server.args ?? []).find((a) => a.endsWith('/launcher.js')); + if (!launcher || !launcher.startsWith('${PLUGIN_ROOT}/')) { + errors.push(`mcp.json ${MCP_SERVER_NAME}.args must launch \${PLUGIN_ROOT}/launcher.js (only \${PLUGIN_ROOT} and \${PLUGIN_DATA} are expanded)`); + } else if (root && !existsSync(join(root, PLUGIN_DIR, 'launcher.js'))) { + errors.push('launcher.js is missing from the plugin directory'); + } + for (const key of Object.keys(server.env ?? {})) { + if (key === 'PLUGIN_ROOT' || key === 'PLUGIN_DATA') { + errors.push(`mcp.json ${MCP_SERVER_NAME}.env must not redefine '${key}' (the client provides it)`); + } + } + return errors; +} + +// Copilot hook events are lowerCamelCase; the PascalCase spellings are the Claude Code ones. +const COPILOT_HOOK_EVENTS = ['sessionStart', 'userPromptSubmit', 'preToolUse', 'postToolUse', 'sessionEnd']; + +/** + * Validates com.github.copilot/hooks/hooks.json. Copilot only understands command hooks - a Claude + * `mcp_tool` hook or a PascalCase event name makes it reject the whole file at startup. + */ +export function checkCopilotHooks(hooksJson, root) { + const errors = []; + if (hooksJson.version !== 1) { + errors.push(`${COPILOT_DIR}/hooks/hooks.json: version must be 1`); + } + const events = Object.entries(hooksJson.hooks ?? {}); + if (events.length === 0) { + errors.push(`${COPILOT_DIR}/hooks/hooks.json declares no hooks`); + } + for (const [event, entries] of events) { + if (!COPILOT_HOOK_EVENTS.includes(event)) { + errors.push(`${COPILOT_DIR}/hooks/hooks.json: unknown event '${event}' (expected one of ${COPILOT_HOOK_EVENTS.join(', ')})`); + continue; + } + if (!Array.isArray(entries)) { + errors.push(`${COPILOT_DIR}/hooks/hooks.json: '${event}' must be an array of hooks`); + continue; + } + for (const hook of entries) { + const where = `${COPILOT_DIR}/hooks/hooks.json ${event}`; + if (hook.type !== 'command') { + errors.push(`${where}: type '${hook.type}' is not supported, Copilot only runs 'command' hooks`); + continue; + } + if (typeof hook.bash !== 'string' || !hook.bash) { + errors.push(`${where}: command hook without a 'bash' command`); + } + if (typeof hook.powershell !== 'string' || !hook.powershell) { + errors.push(`${where}: command hook without a 'powershell' command, it would not run on Windows`); + } + for (const command of [hook.bash, hook.powershell]) { + for (const match of String(command ?? '').matchAll(/\$(?:env:)?PLUGIN_ROOT\/([\w./-]+)/g)) { + if (root && !existsSync(join(root, PLUGIN_DIR, match[1]))) { + errors.push(`${where}: '${match[1]}' does not exist in the plugin directory`); + } + } + } + } + } + return errors; +} + +/** + * Validates the Copilot copy of an agent against the Claude Code original: same instructions, and + * front matter restricted to supported fields. Copilot custom agents support a `tools` allowlist. + */ +export function checkCopilotAgent(fileName, copilotText, claudeText) { + const errors = []; + const expectedName = basename(fileName).replace(/\.agent\.md$/, ''); + const front = parseFrontMatter(copilotText); + if (!front) { + return [`${COPILOT_DIR}/agents/${fileName}: agent file has no YAML front matter`]; + } + const where = `${COPILOT_DIR}/agents/${fileName}`; + if (front.name !== expectedName) { + errors.push(`${where}: front matter name '${front.name}' differs from file name '${expectedName}'`); + } + for (const key of Object.keys(front)) { + if (!['name', 'description', 'tools'].includes(key)) { + errors.push(`${where}: '${key}' is not supported by Copilot custom agents`); + } + } + if (front.tools !== undefined + && !(typeof front.tools === 'string' || (Array.isArray(front.tools) && front.tools.every((tool) => typeof tool === 'string')))) { + errors.push(`${where}: 'tools' must be a string or a list of strings`); + } + if (claudeText === undefined) { + errors.push(`${where}: no matching agents/${expectedName}.md for Claude Code`); + return errors; + } + const claudeFront = parseFrontMatter(claudeText); + if (front.description !== claudeFront?.description) { + errors.push(`${where}: description differs from agents/${expectedName}.md`); + } + if (agentBody(copilotText) !== agentBody(claudeText)) { + errors.push(`${where}: instructions differ from agents/${expectedName}.md, the two copies must stay identical`); + } + return errors; +} + +/** The instructions of an agent file, i.e. everything below the front matter. */ +function agentBody(text) { + return text.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n/, '').trim(); +} diff --git a/agent-plugins/tools/package.json b/agent-plugins/tools/package.json new file mode 100644 index 0000000000..7c94584181 --- /dev/null +++ b/agent-plugins/tools/package.json @@ -0,0 +1,15 @@ +{ + "name": "spring-tools-agent-plugin-tools", + "version": "0.0.0", + "private": true, + "description": "Development checks for the Spring Tools agent plugin (explanation playbooks, plugin configuration). Not part of the published plugin.", + "license": "EPL-1.0", + "engines": { + "node": ">=20" + }, + "scripts": { + "check": "node check-explanations.mjs --require-all --todo && node check-plugin-config.mjs", + "check:links": "node check-explanations.mjs --require-all --links", + "test": "node --test --test-reporter=tap \"test/*.test.mjs\"" + } +} diff --git a/agent-plugins/tools/smoke/mcp-smoke.mjs b/agent-plugins/tools/smoke/mcp-smoke.mjs new file mode 100644 index 0000000000..0b2c5b1192 --- /dev/null +++ b/agent-plugins/tools/smoke/mcp-smoke.mjs @@ -0,0 +1,292 @@ +#!/usr/bin/env node +/******************************************************************************* + * Copyright (c) 2026 Broadcom + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * https://www.eclipse.org/legal/epl-v10.html + * + * Contributors: + * Broadcom - initial API and implementation + *******************************************************************************/ + +// End-to-end smoke test for the Claude Code plugin: drives launcher.js over MCP stdio exactly +// the way Claude Code does, against a workspace that holds a copy of a Spring Boot test project. +// Needs the language server JAR in the plugin (agent-plugins/update-local-jars.sh) or +// SPRING_TOOLS_LS_JAR, and a Java 21+ runtime. +// +// node agent-plugins/tools/smoke/mcp-smoke.mjs [--project-src ] [--keep] +// +// Prints one PASS/FAIL line per check and ends with "SMOKE PASSED" only when every check passed. + +import { execFileSync, spawn } from 'node:child_process'; +import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { basename, dirname, join, resolve } from 'node:path'; +import { createInterface } from 'node:readline'; +import { fileURLToPath } from 'node:url'; +import { createRequire } from 'node:module'; + +const here = dirname(fileURLToPath(import.meta.url)); +const repoRoot = resolve(here, '..', '..', '..'); +const pluginDir = join(repoRoot, 'agent-plugins', 'spring-tools'); +const JAR_NAME = 'spring-boot-language-server-standalone-exec.jar'; +const { defaultInstallDir } = createRequire(import.meta.url)(join(pluginDir, 'install.js')); + +const args = process.argv.slice(2); +const option = (name) => (args.includes(name) ? args[args.indexOf(name) + 1] : undefined); +const projectSrc = resolve(option('--project-src') ?? join(repoRoot, 'headless-services/spring-boot-language-server/src/test/resources/test-projects/sf7-validation')); +const keep = args.includes('--keep'); + +// Prefer the freshly built test artifact so CI always exercises the source it just compiled; +// otherwise reuse the normal versioned persistent install cache used by launcher.js. +const localBuildJar = join(pluginDir, 'language-server', JAR_NAME); +const jarPath = process.env.SPRING_TOOLS_LS_JAR || (existsSync(localBuildJar) ? localBuildJar : join(defaultInstallDir(pluginDir), JAR_NAME)); +if (!existsSync(jarPath)) { + console.error(`language server JAR not found at ${jarPath}; build the local test JAR with agent-plugins/update-local-jars.sh or set SPRING_TOOLS_LS_JAR`); + process.exit(2); +} +if (!existsSync(join(projectSrc, 'pom.xml')) && !existsSync(join(projectSrc, 'build.gradle'))) { + console.error(`--project-src ${projectSrc} is not a Maven/Gradle project`); + process.exit(2); +} + +// Workspace layout mirrors "a folder with several services": the test project is one subdirectory, +// and a second project appears later to exercise discovery after startup. +const work = mkdtempSync(join(tmpdir(), 'spring-tools-smoke-')); +const workspace = join(work, 'workspace'); +const dataDir = join(work, 'data'); +const projectDir = join(workspace, basename(projectSrc)); +mkdirSync(dataDir, { recursive: true }); +cpSync(projectSrc, projectDir, { recursive: true, filter: (src) => !/[\\/](target|build|\.git)$/.test(src) }); + +const results = []; +const check = (cond, label) => { + results.push(Boolean(cond)); + console.log(`${cond ? 'PASS' : 'FAIL'} ${label}`); +}; +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +const childPids = (parentPid) => { + try { + return execFileSync('pgrep', ['-P', String(parentPid)], { encoding: 'utf8' }) + .trim() + .split(/\s+/) + .filter(Boolean) + .map(Number); + } catch (error) { + if (error.status === 1) { + return []; + } + throw error; + } +}; +const isProcessAlive = (pid) => { + try { + process.kill(pid, 0); + return true; + } catch (error) { + if (error.code === 'ESRCH') { + return false; + } + throw error; + } +}; + +const child = spawn(process.execPath, [join(pluginDir, 'launcher.js')], { + env: { ...process.env, CLAUDE_PROJECT_DIR: workspace, CLAUDE_PLUGIN_DATA: dataDir, CLAUDE_PLUGIN_ROOT: pluginDir }, + stdio: ['pipe', 'pipe', 'pipe'], +}); +let stderr = ''; +child.stderr.on('data', (d) => { stderr += d; }); + +const pending = new Map(); +let nextId = 1; +const strayStdout = []; +createInterface({ input: child.stdout }).on('line', (line) => { + if (!line.trim()) return; + let message; + try { message = JSON.parse(line); } catch { strayStdout.push(line); return; } + if (message.id !== undefined && pending.has(message.id)) { + pending.get(message.id)(message); + pending.delete(message.id); + } +}); + +function request(method, params, timeoutMs = 120000) { + const id = nextId++; + return new Promise((resolvePromise, reject) => { + const timer = setTimeout(() => reject(new Error(`timeout waiting for ${method} (id ${id})`)), timeoutMs); + pending.set(id, (message) => { clearTimeout(timer); resolvePromise(message); }); + child.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', id, method, params })}\n`); + }); +} +const notify = (method, params) => child.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', method, params })}\n`); +const call = async (name, toolArgs = {}) => { + const response = await request('tools/call', { name, arguments: toolArgs }); + return { response, text: (response.result?.content ?? []).map((c) => c.text ?? JSON.stringify(c)).join('\n') }; +}; +const projectNames = (text) => [...text.replace(/\\"/g, '"').matchAll(/"projectName"\s*:\s*"([^"]+)"/g)].map((m) => m[1]); +const codesOf = (text) => [...new Set([...text.matchAll(/"code"\s*:\s*"([A-Za-z_0-9]+)"/g)].map((m) => m[1]))]; +function javaFilesUnder(directory) { + if (!existsSync(directory)) return []; + return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { + const path = join(directory, entry.name); + return entry.isDirectory() ? javaFilesUnder(path) : (entry.isFile() && entry.name.endsWith('.java') ? [path] : []); + }); +} + +async function pollUntil(fn, predicate, attempts, intervalMs) { + let value; + for (let i = 0; i < attempts; i++) { + value = await fn(); + if (predicate(value)) return value; + await sleep(intervalMs); + } + return value; +} + +const started = Date.now(); +try { + const init = await request('initialize', { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'smoke', version: '0' } }); + check(init.result?.serverInfo?.name, `initialize answered by ${JSON.stringify(init.result?.serverInfo)} after ${Date.now() - started} ms`); + check(typeof init.result?.instructions === 'string' && init.result.instructions.includes('getProjectList'), 'server instructions (used by Claude Code tool search) are advertised'); + notify('notifications/initialized', {}); + + const tools = await request('tools/list', {}); + const names = (tools.result?.tools ?? []).map((t) => t.name).sort(); + console.log(`tools (${names.length}): ${names.join(', ')}`); + for (const name of ['getProjectList', 'getProjectDiagnostics', 'getSpringBootVersion', 'fileChanged', 'fileDeleted', 'refreshWorkspace']) { + check(names.includes(name), `tool '${name}' advertised`); + } + check(names.length >= 26, `the full tool inventory is advertised (${names.length} tools)`); + const undocumented = (tools.result?.tools ?? []).filter((t) => !t.description || t.description.trim().length < 20).map((t) => t.name); + check(undocumented.length === 0, `every tool carries a description${undocumented.length ? ` (missing: ${undocumented.join(', ')})` : ''}`); + const fileChanged = tools.result.tools.find((t) => t.name === 'fileChanged'); + check(fileChanged && Object.keys(fileChanged.inputSchema?.properties ?? {}).join() === 'filePath', "fileChanged takes exactly {filePath} (what hooks.json sends)"); + + const prompts = await request('prompts/list', {}); + const promptNames = (prompts.result?.prompts ?? []).map((p) => p.name); + check(promptNames.length > 0, `MCP prompts advertised (${promptNames.join(', ') || 'none'})`); + + const discovery = Date.now(); + const list = await pollUntil(() => call('getProjectList'), (r) => projectNames(r.text).length > 0, 120, 3000); + const projectName = projectNames(list.text)[0]; + console.log(`getProjectList after ${Date.now() - discovery} ms: ${list.text.slice(0, 200)}`); + check(projectName === basename(projectSrc), `project '${projectName}' discovered under CLAUDE_PROJECT_DIR`); + const location = list.text.replace(/\\"/g, '"').match(/"location"\s*:\s*"([^"]+)"/)?.[1]; + check(location && realpathSync(location) === realpathSync(projectDir), `getProjectList reports the project location (${location})`); + + const bootVersion = await call('getSpringBootVersion', { projectName }); + check(/"major"\s*:\s*\d+/.test(bootVersion.text), `getSpringBootVersion -> ${bootVersion.text.slice(0, 80)}`); + const javaVersion = await call('getJavaVersion', { projectName }); + check(/\d/.test(javaVersion.text) && !javaVersion.response.result?.isError, `getJavaVersion -> ${javaVersion.text.slice(0, 40)}`); + const classpath = await call('getResolvedProjectClasspath', { projectName }); + check(/spring-boot-[\w.-]*\.jar/.test(classpath.text), 'getResolvedProjectClasspath lists the Spring Boot JARs'); + + const baseline = await pollUntil(() => call('getProjectDiagnostics', { projectName }), (r) => codesOf(r.text).length > 0, 40, 3000); + console.log(`baseline diagnostic codes: ${codesOf(baseline.text).join(', ') || '(none)'}`); + check(codesOf(baseline.text).length > 0, 'getProjectDiagnostics reports Spring diagnostics for the sample project'); + + // The analysis tools behind the beans/endpoints/architecture skills, against the indexed fixture. + const mappings = await pollUntil(() => call('getRequestMappings', { projectName }), (r) => r.text.includes('/greeting'), 20, 2000); + check(mappings.text.includes('/greeting'), 'getRequestMappings lists the fixture endpoint /greeting'); + const gets = await call('findRequestMappingsByMethod', { projectName, httpMethod: 'GET' }); + check(gets.text.includes('/greeting'), 'findRequestMappingsByMethod GET finds the same endpoint'); + const stereotypes = await call('getListOfComponentsAndTheirStereotypes', { projectName }); + check(/Controller/i.test(stereotypes.text), 'getListOfComponentsAndTheirStereotypes classifies the controller'); + const structure = await call('getLogicalStructure', { projectName }); + check(!structure.response.error && !structure.response.result?.isError && structure.text.length > 20, `getLogicalStructure returns a tree (${structure.text.length} chars)`); + const beans = await call('getBeanDetails', { projectName }); + check(/TestController|Config/.test(beans.text), 'getBeanDetails lists the fixture beans'); + + // Same input shape as the PostToolUse Edit|Write hook: edit a Java file, then fileChanged {filePath}. + const javaFiles = javaFilesUnder(join(projectDir, 'src/main/java')); + const target = javaFiles.find((f) => /class \w+ \{/.test(readFileSync(f, 'utf8'))); + const original = readFileSync(target, 'utf8'); + writeFileSync(target, original.replace(/class (\w+) \{/, 'class $1 {\n\t@org.springframework.beans.factory.annotation.Autowired\n\tpublic $1() {}\n')); + const changed = await call('fileChanged', { filePath: target }); + check(!changed.response.error && !changed.response.result?.isError, `fileChanged accepted the hook payload (${changed.text.slice(0, 60)})`); + const edited = Date.now(); + const afterEdit = await pollUntil(() => call('getProjectDiagnostics', { projectName }), (r) => r.text.includes('JAVA_AUTOWIRED_CONSTRUCTOR'), 20, 2000); + check(afterEdit.text.includes('JAVA_AUTOWIRED_CONSTRUCTOR'), `JAVA_AUTOWIRED_CONSTRUCTOR reported ${Date.now() - edited} ms after fileChanged on ${basename(target)}`); + writeFileSync(target, original); + + const refreshed = await call('refreshWorkspace'); + check(!refreshed.response.error && !refreshed.response.result?.isError, `refreshWorkspace accepted {} (${refreshed.text.slice(0, 60)})`); + const reverted = await pollUntil(() => call('getProjectDiagnostics', { projectName }), (r) => codesOf(r.text).join() === codesOf(baseline.text).join(), 20, 2000); + check(codesOf(reverted.text).join() === codesOf(baseline.text).join(), 'diagnostics back to baseline after reverting the file and refreshWorkspace'); + + // The server watches the workspace itself: an edit made without any hook or tool call (an + // external editor, a code generator) must reach the index on its own. + const watched = Date.now(); + writeFileSync(target, original.replace(/class (\w+) \{/, 'class $1 {\n\t@org.springframework.beans.factory.annotation.Autowired\n\tpublic $1() {}\n')); + const afterWatch = await pollUntil(() => call('getProjectDiagnostics', { projectName }), (r) => r.text.includes('JAVA_AUTOWIRED_CONSTRUCTOR'), 20, 1000); + check(afterWatch.text.includes('JAVA_AUTOWIRED_CONSTRUCTOR'), `file watcher: external edit indexed ${Date.now() - watched} ms after writing ${basename(target)} without fileChanged`); + writeFileSync(target, original); + const afterRevert = await pollUntil(() => call('getProjectDiagnostics', { projectName }), (r) => codesOf(r.text).join() === codesOf(baseline.text).join(), 20, 1000); + check(codesOf(afterRevert.text).join() === codesOf(baseline.text).join(), 'file watcher: reverting the file on disk restores the baseline without any notification'); + const external = join(dirname(target), 'WatchedExtra.java'); + writeFileSync(external, `package ${original.match(/package ([\w.]+);/)[1]};\n\n@org.springframework.web.bind.annotation.RestController\npublic class WatchedExtra {\n\t@org.springframework.web.bind.annotation.GetMapping("/watched")\n\tpublic String watched() { return "ok"; }\n}\n`); + const created = Date.now(); + const withExtra = await pollUntil(() => call('getRequestMappings', { projectName }), (r) => r.text.includes('/watched'), 20, 1000); + check(withExtra.text.includes('/watched'), `file watcher: new controller indexed ${Date.now() - created} ms after creation`); + rmSync(external); + const withoutExtra = await pollUntil(() => call('getRequestMappings', { projectName }), (r) => !r.text.includes('/watched'), 20, 1000); + check(!withoutExtra.text.includes('/watched'), 'file watcher: deleting the file removes its endpoint without fileDeleted'); + + // A project created after startup (create-spring-boot-project skill, git checkout) must be + // discovered by the watcher alone; refreshWorkspace stays available as the manual fallback. + const generated = join(workspace, 'generated-app'); + mkdirSync(join(generated, 'src/main/java/com/example'), { recursive: true }); + writeFileSync(join(generated, 'pom.xml'), ` + 4.0.0 + com.example + generated-app + 0.0.1-SNAPSHOT + 21 + +`); + writeFileSync(join(generated, 'src/main/java/com/example/GeneratedApp.java'), 'package com.example;\n\npublic class GeneratedApp {\n}\n'); + const rediscovered = Date.now(); + const after = await pollUntil(() => call('getProjectList'), (r) => projectNames(r.text).includes('generated-app'), 30, 1000); + check(projectNames(after.text).includes('generated-app'), `file watcher: project created after startup discovered ${Date.now() - rediscovered} ms later without refreshWorkspace (${projectNames(after.text).join(', ')})`); + check(projectNames(after.text).includes(projectName), 'the original project is still listed after the discovery'); + const refreshedAgain = await call('refreshWorkspace'); + const afterRefresh = await pollUntil(() => call('getProjectList'), (r) => projectNames(r.text).includes('generated-app') && projectNames(r.text).includes(projectName), 20, 1000); + check(!refreshedAgain.response.error && projectNames(afterRefresh.text).length === 2, 'refreshWorkspace keeps both projects (manual fallback still works)'); + + check(existsSync(join(dataDir, 'boot-ls.log')), `log written to CLAUDE_PLUGIN_DATA (${join(dataDir, 'boot-ls.log')})`); + check(strayStdout.length === 0, `stdout carried only JSON-RPC (${strayStdout.length} stray line(s))`); + if (strayStdout.length) console.log(`stray stdout: ${strayStdout.slice(0, 5).join(' | ')}`); + check(/\[spring-tools\] Using Java (\d+) from/.test(stderr), 'launcher reported the selected Java runtime on stderr'); + const serverLog = readFileSync(join(dataDir, 'boot-ls.log'), 'utf8'); + check(/file watcher started for .* directories, \d+ ms quiet period/.test(serverLog), 'server log confirms the file watcher started for the workspace'); +} catch (error) { + check(false, error.message); +} finally { + const killed = Date.now(); + const serverPids = process.platform === 'win32' ? [] : childPids(child.pid); + child.kill('SIGTERM'); + if (process.platform !== 'win32') { + // The JVM must not outlive the launcher; graceful shutdown takes about a second. + let alive = serverPids.filter(isProcessAlive); + for (let i = 0; i < 30; i++) { + alive = alive.filter(isProcessAlive); + if (alive.length === 0) break; + await sleep(1000); + } + check(alive.length === 0, `no orphaned JVM after SIGTERM to the launcher (${Date.now() - killed} ms${alive.length ? `, survivors: ${alive.join(', ')}` : ''})`); + for (const pid of alive) { + process.kill(pid, 'SIGKILL'); + } + } + const failed = results.filter((r) => !r).length; + if (failed) { + console.log(`--- launcher stderr (tail) ---\n${stderr.slice(-3000)}`); + console.log(`SMOKE FAILED: ${failed} of ${results.length} checks failed (workspace kept at ${work})`); + process.exitCode = 1; + } else { + console.log(`SMOKE PASSED: ${results.length} checks in ${Math.round((Date.now() - started) / 1000)} s`); + if (!keep) rmSync(work, { recursive: true, force: true }); + } +} diff --git a/agent-plugins/tools/sync-agent-adapters.mjs b/agent-plugins/tools/sync-agent-adapters.mjs new file mode 100644 index 0000000000..4289775905 --- /dev/null +++ b/agent-plugins/tools/sync-agent-adapters.mjs @@ -0,0 +1,69 @@ +#!/usr/bin/env node +/******************************************************************************* + * Copyright (c) 2026 Broadcom + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * https://www.eclipse.org/legal/epl-v10.html + *******************************************************************************/ + +// Regenerates host-specific reviewer wrappers from the portable spring-review skill. +// Run with --write after changing skills/spring-review/SKILL.md. + +import { readFileSync, writeFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const here = dirname(fileURLToPath(import.meta.url)); +const plugin = join(here, '..', 'spring-tools'); +const write = process.argv.includes('--write'); + +function splitFrontMatter(text, file) { + const match = text.match(/^(---\r?\n[\s\S]*?\r?\n---\r?\n)([\s\S]*)$/); + if (!match) throw new Error(`${file} has no YAML front matter`); + return { frontMatter: match[1], body: match[2].trim() }; +} + +const skill = splitFrontMatter(readFileSync(join(plugin, 'skills/spring-review/SKILL.md'), 'utf8'), 'spring-review/SKILL.md'); +const claudeFile = join(plugin, 'agents/spring-reviewer.md'); +const copilotFile = join(plugin, 'com.github.copilot/agents/spring-reviewer.agent.md'); +const openCodeFile = join(plugin, 'opencode/agents/spring-reviewer.md'); +const claude = splitFrontMatter(readFileSync(claudeFile, 'utf8'), 'agents/spring-reviewer.md'); +const copilot = splitFrontMatter(readFileSync(copilotFile, 'utf8'), 'Copilot spring-reviewer.agent.md'); + +const openCodeFrontMatter = `--- +description: Reviews Spring Boot projects with Spring Tools diagnostics, bean wiring, endpoints and architecture. Read-only reviewer subagent. +mode: subagent +permission: + read: allow + glob: allow + grep: allow + edit: deny + bash: deny + "spring-tools_*": allow +--- +`; + +const outputs = [ + [claudeFile, claude.frontMatter, skill.body], + [copilotFile, copilot.frontMatter, skill.body], + [openCodeFile, openCodeFrontMatter, skill.body], +]; +const mismatches = []; +for (const [file, frontMatter, body] of outputs) { + const expected = `${frontMatter}${body}\n`; + let actual; + try { actual = readFileSync(file, 'utf8'); } catch { actual = ''; } + if (actual !== expected) { + if (write) { + writeFileSync(file, expected); + } else { + mismatches.push(file.slice(plugin.length + 1)); + } + } +} +if (mismatches.length) { + console.error(`reviewer adapters drifted from skills/spring-review/SKILL.md: ${mismatches.join(', ')}`); + process.exit(1); +} +console.log(write ? 'reviewer adapters generated' : 'reviewer adapters match shared spring-review skill'); diff --git a/agent-plugins/tools/test/eval-grading.test.mjs b/agent-plugins/tools/test/eval-grading.test.mjs new file mode 100644 index 0000000000..13897d63f1 --- /dev/null +++ b/agent-plugins/tools/test/eval-grading.test.mjs @@ -0,0 +1,31 @@ +/******************************************************************************* + * Copyright (c) 2026 Broadcom + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * https://www.eclipse.org/legal/epl-v10.html + * + * Contributors: + * Broadcom - initial API and implementation + *******************************************************************************/ + +import assert from 'node:assert/strict'; +import { test } from 'node:test'; + +import { combineToolTraces, countToolMatches } from '../lib/eval-grading.mjs'; + +test('MCP calls from Copilot logs are not counted twice when the mock server traced them', () => { + const trace = combineToolTraces( + [ + { tool: 'spring-tools-mcp-getProjectList', input: '{"page":1}' }, + { tool: 'view', input: '{"file_path":"README.md"}' }, + ], + [{ tool: 'getProjectList', input: '{"page":1}' }], + new Set(['getProjectList']), + ); + + assert.equal(countToolMatches({ + tool: 'mcp__plugin_spring-tools_spring-tools-mcp__getProjectList', + }, trace), 1); + assert.equal(trace.filter((entry) => entry.tool === 'view').length, 1); +}); diff --git a/agent-plugins/tools/test/explanations.test.mjs b/agent-plugins/tools/test/explanations.test.mjs new file mode 100644 index 0000000000..c40baf457c --- /dev/null +++ b/agent-plugins/tools/test/explanations.test.mjs @@ -0,0 +1,117 @@ +/******************************************************************************* + * Copyright (c) 2026 Broadcom + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * https://www.eclipse.org/legal/epl-v10.html + * + * Contributors: + * Broadcom - initial API and implementation + *******************************************************************************/ + +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { test } from 'node:test'; + +import { + EXPLANATIONS_DIR, + checkPlaybook, + collectCodes, + collectPlaybooks, + allowedDocumentationUrl, + extractUrls, + parseTodoTable, + runChecks, +} from '../check-explanations.mjs'; + +const here = dirname(fileURLToPath(import.meta.url)); +const repoRoot = join(here, '..', '..', '..'); +const fixtureRoot = join(here, 'fixtures', 'bad-repo'); + +test('collects the diagnostic codes from every ProblemType enum, the version validation constant and the inherited YAML merge-key code', () => { + const codes = collectCodes(repoRoot); + for (const expected of ['JAVA_PUBLIC_BEAN_METHOD', 'MODULITH_APPLICATION_MODULE_LISTENER', 'API_VERSIONING_NOT_CONFIGURED', + 'JAVA_CONCRETE_BEAN_TYPE', 'SPRING_AI_TOOL_MISSING_DESCRIPTION', 'JAVA_SPEL_EXPRESSION_SYNTAX', 'CRON_SYNTAX', 'CRON_FIELD', + 'JPQL_SYNTAX', 'HQL_SYNTAX', 'SQL_SYNTAX', 'BOOT_VERSION_VALIDATION_CODE', + 'PROP_UNKNOWN_PROPERTY', 'PROP_SYNTAX_ERROR', 'YAML_UNKNOWN_PROPERTY', 'YAML_SHOULD_ESCAPE', 'YamlSchemaProblem']) { + assert.ok(codes.includes(expected), `missing code ${expected}`); + } + assert.ok(codes.length >= 60, `only ${codes.length} codes found`); + assert.ok(!codes.includes('DEFAULT_TOOL_DESCRIPTION_MIN_LENGTH'), 'constants must not be mistaken for codes'); + assert.ok(!codes.includes('UNSUPPORTED_OSS_VERSION'), 'version-validation preference keys are not diagnostic codes'); + assert.ok(!codes.includes('YamlSyntaxProblem'), 'only the commons-yaml code the Boot YAML reconciler can emit is collected'); +}); + +test('every playbook on disk is structurally valid and belongs to a known code', () => { + const result = runChecks({ root: repoRoot }); + assert.deepEqual(result.errors, []); + assert.ok(result.playbooks >= 15); + assert.ok(result.urls.length > 0, 'playbooks must link to documentation'); +}); + +test('file names of playbooks are exactly the codes (case sensitive, no spaces)', () => { + for (const code of collectPlaybooks(repoRoot)) { + assert.match(code, /^(?:[A-Z][A-Z0-9_]+|YamlSchemaProblem)$/, `${EXPLANATIONS_DIR}/${code}.md`); + } +}); + +test('negative control: the checker fails on the fixture with an orphan, a malformed playbook, an unresolved cross-reference and a stale coverage table', () => { + const result = runChecks({ root: fixtureRoot, requireAll: true, todo: true }); + const joined = result.errors.join('\n'); + assert.match(joined, /orphan playbook .*ORPHAN_CODE\.md/); + assert.match(joined, /REF_TARGET: missing '## Fixes' section/); + assert.match(joined, /GOOD_CODE: references code MISSING_CODE which has no playbook/); + assert.match(joined, /1 code\(s\) without playbook: MISSING_CODE/); + assert.match(joined, /playbook mark for MISSING_CODE is ✅ but file is missing/); + assert.match(joined, /no row for code REF_TARGET/); + assert.match(joined, /row for unknown code STALE_CODE/); +}); + +test('negative control: a valid fixture playbook passes while the same text without a doc link fails', () => { + const known = new Set(['GOOD_CODE', 'OTHER']); + const files = new Set(['GOOD_CODE', 'OTHER']); + const good = readFileSync(join(fixtureRoot, EXPLANATIONS_DIR, 'GOOD_CODE.md'), 'utf8').replace('`MISSING_CODE`', '`OTHER`'); + assert.deepEqual(checkPlaybook('GOOD_CODE', good, known, files), []); + const noLink = good.replace(/https:\/\/docs\.spring\.io\S+/g, 'https://example.com/'); + assert.match(checkPlaybook('GOOD_CODE', noLink, known, files).join('\n'), /no link to docs\.spring\.io/); + const unbalanced = good + '\n```java\n'; + assert.match(checkPlaybook('GOOD_CODE', unbalanced, known, files).join('\n'), /unbalanced code fences/); + const descriptive = good.replace('It refers to', 'The quick fix inserts a TODO comment. It refers to'); + assert.deepEqual(checkPlaybook('GOOD_CODE', descriptive, known, files), [], 'describing an inserted TODO comment is fine'); + const placeholder = good.replace('It refers to', 'TODO: describe the fix. It refers to'); + assert.match(checkPlaybook('GOOD_CODE', placeholder, known, files).join('\n'), /placeholder text/); +}); + +test('coverage table parser reads code rows and playbook marks', () => { + const table = parseTodoTable('| `A_CODE` | desc | fix | ✅ |\n| `B_CODE` | desc | fix | ❌ |\n| not a code | x | y | ✅ |'); + assert.deepEqual([...table.entries()], [['A_CODE', true], ['B_CODE', false]]); +}); + +test('url extraction strips anchors and trailing punctuation and de-duplicates', () => { + const urls = extractUrls('see https://docs.spring.io/a.html#x, https://docs.spring.io/a.html) and (https://spring.io/b).'); + assert.deepEqual(urls, ['https://docs.spring.io/a.html', 'https://spring.io/b']); + assert.deepEqual(extractUrls('Example: `https://example.com`\n```java\nString url = "https://example.com";\n```'), []); +}); + +test('documentation link checks reject non-documentation hosts and unsafe URL schemes', () => { + assert.equal(allowedDocumentationUrl('https://docs.spring.io/spring-boot/reference/'), true); + assert.equal(allowedDocumentationUrl('https://github.com/spring-projects/spring-boot'), true); + assert.equal(allowedDocumentationUrl('https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/util/Properties.html'), true); + assert.equal(allowedDocumentationUrl('https://openjdk.org/jeps/486'), true); + assert.equal(allowedDocumentationUrl('https://yaml.org/spec/1.2.2/'), true); + assert.equal(allowedDocumentationUrl('https://raw.githubusercontent.com/spring-projects/spring-security/5.8.x/docs/example.adoc'), true); + assert.equal(allowedDocumentationUrl('https://github.com/attacker/repo'), false); + assert.equal(allowedDocumentationUrl('https://raw.githubusercontent.com/attacker/repo/main/file'), false); + assert.equal(allowedDocumentationUrl('https://openjdk.org/'), false); + assert.equal(allowedDocumentationUrl('https://127.0.0.1/admin'), false); + assert.equal(allowedDocumentationUrl('http://spring.io/'), false); + assert.equal(allowedDocumentationUrl('javascript:alert(1)'), false); +}); + +test('selected diagnostic codes cannot escape the explanations directory', () => { + const result = runChecks({ root: fixtureRoot, codes: ['../../TODO_quickfixes'] }); + assert.ok(result.errors.some((error) => /invalid diagnostic code/.test(error))); + assert.ok(!result.errors.some((error) => /missing playbook/.test(error))); +}); diff --git a/agent-plugins/tools/test/fixtures/bad-agent.md b/agent-plugins/tools/test/fixtures/bad-agent.md new file mode 100644 index 0000000000..c71dcce1ba --- /dev/null +++ b/agent-plugins/tools/test/fixtures/bad-agent.md @@ -0,0 +1,9 @@ +--- +name: spring-review +description: Reviews. +tools: Read, mcp__spring-tools-mcp__getProjectList, mcp__plugin_spring-tools_spring-tools-mcp__getDiagnostics +hooks: + PostToolUse: [] +--- + +Call `getDiagnostics` for every project. diff --git a/agent-plugins/tools/test/fixtures/bad-hooks.json b/agent-plugins/tools/test/fixtures/bad-hooks.json new file mode 100644 index 0000000000..1e70d66b20 --- /dev/null +++ b/agent-plugins/tools/test/fixtures/bad-hooks.json @@ -0,0 +1,46 @@ +{ + "hooks": { + "SessionStart": [ + { + "hooks": [ + { + "type": "mcp_tool", + "server": "plugin:spring-tools:spring-tools-mcp", + "tool": "refreshWorkspace", + "input": {} + }, + { + "type": "command", + "command": "node ${CLAUDE_PLUGIN_ROOT}/install.js" + }, + { + "type": "command", + "command": "node", + "args": ["${CLAUDE_PLUGIN_ROOT}/does-not-exist.js"] + } + ] + } + ], + "PostToolUse": [ + { + "matcher": "Edit|Write", + "hooks": [ + { + "type": "mcp_tool", + "if": "Edit(*.java)", + "server": "plugin:spring-tools:spring-tools-mcp", + "tool": "fileChangd", + "input": { "filePath": "${tool_input.file_path}" } + }, + { + "type": "mcp_tool", + "if": "Edit(*.kt)", + "server": "spring-tools-mcp", + "tool": "fileChanged", + "input": { "path": "${tool_input.file_path}" } + } + ] + } + ] + } +} diff --git a/agent-plugins/tools/test/fixtures/bad-repo/agent-plugins/research-notes/TODO_quickfixes.md b/agent-plugins/tools/test/fixtures/bad-repo/agent-plugins/research-notes/TODO_quickfixes.md new file mode 100644 index 0000000000..09e5347a50 --- /dev/null +++ b/agent-plugins/tools/test/fixtures/bad-repo/agent-plugins/research-notes/TODO_quickfixes.md @@ -0,0 +1,7 @@ +# Fixture coverage table + +| Problem Type Code | Description | Quick Fix Implementation | Playbook | +| :--- | :--- | :--- | :---: | +| `GOOD_CODE` | valid playbook | *None* | ✅ | +| `MISSING_CODE` | wrongly marked as covered | *None* | ✅ | +| `STALE_CODE` | no longer exists in the enum | *None* | ❌ | diff --git a/agent-plugins/tools/test/fixtures/bad-repo/agent-plugins/spring-tools/explanations/GOOD_CODE.md b/agent-plugins/tools/test/fixtures/bad-repo/agent-plugins/spring-tools/explanations/GOOD_CODE.md new file mode 100644 index 0000000000..415bf1fb4b --- /dev/null +++ b/agent-plugins/tools/test/fixtures/bad-repo/agent-plugins/spring-tools/explanations/GOOD_CODE.md @@ -0,0 +1,18 @@ +## Explanations +This fixture playbook is structurally valid. It refers to `MISSING_CODE`, which has no playbook in this fixture. + +For more details, see: +- [Fixture documentation](https://docs.spring.io/spring-boot/reference/index.html) + +## Fixes +**Fix 1: Do nothing** + +*Before:* +```java +class A {} +``` + +*After:* +```java +class A {} +``` diff --git a/agent-plugins/tools/test/fixtures/bad-repo/agent-plugins/spring-tools/explanations/ORPHAN_CODE.md b/agent-plugins/tools/test/fixtures/bad-repo/agent-plugins/spring-tools/explanations/ORPHAN_CODE.md new file mode 100644 index 0000000000..1bbb683e64 --- /dev/null +++ b/agent-plugins/tools/test/fixtures/bad-repo/agent-plugins/spring-tools/explanations/ORPHAN_CODE.md @@ -0,0 +1,8 @@ +## Explanations +This fixture playbook has no matching problem-type code, which the checker must report as an orphan. + +For more details, see: +- [Fixture documentation](https://docs.spring.io/spring-boot/reference/index.html) + +## Fixes +**Fix 1: Delete this file** diff --git a/agent-plugins/tools/test/fixtures/bad-repo/agent-plugins/spring-tools/explanations/REF_TARGET.md b/agent-plugins/tools/test/fixtures/bad-repo/agent-plugins/spring-tools/explanations/REF_TARGET.md new file mode 100644 index 0000000000..02afcd4006 --- /dev/null +++ b/agent-plugins/tools/test/fixtures/bad-repo/agent-plugins/spring-tools/explanations/REF_TARGET.md @@ -0,0 +1,5 @@ +## Explanations +This fixture playbook has no Fixes section, which the checker must report. + +For more details, see: +- [Fixture documentation](https://docs.spring.io/spring-boot/reference/index.html) diff --git a/agent-plugins/tools/test/fixtures/bad-repo/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/java/FakeProblemType.java b/agent-plugins/tools/test/fixtures/bad-repo/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/java/FakeProblemType.java new file mode 100644 index 0000000000..8b0b7331e5 --- /dev/null +++ b/agent-plugins/tools/test/fixtures/bad-repo/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/java/FakeProblemType.java @@ -0,0 +1,32 @@ +/******************************************************************************* + * Copyright (c) 2026 Broadcom, Inc. + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * https://www.eclipse.org/legal/epl-v10.html + * + * Contributors: + * Broadcom, Inc. - initial API and implementation + *******************************************************************************/ +package org.springframework.ide.vscode.boot.java; + +// Test fixture for agent-plugins/tools: a minimal problem-type enum with three codes. +public enum FakeProblemType implements ProblemType { + + GOOD_CODE(WARNING, "A code with a valid playbook", "Good code"), + MISSING_CODE(WARNING, "A code without a playbook", "Missing code"), + REF_TARGET(INFO, "A code with a malformed playbook", "Ref target"); + + public static final int NOT_A_CODE = 30; + + private final String description; + + private FakeProblemType(Object severity, String description, String label) { + this.description = description; + } + + @Override + public String getCode() { + return name(); + } +} diff --git a/agent-plugins/tools/test/fixtures/bad-skill.md b/agent-plugins/tools/test/fixtures/bad-skill.md new file mode 100644 index 0000000000..41c5bafb4c --- /dev/null +++ b/agent-plugins/tools/test/fixtures/bad-skill.md @@ -0,0 +1,12 @@ +--- +name: validat +description: Fixture skill with several configuration mistakes. +allowed-tools: + - Read + - mcp__spring-tools-mcp__getProjectList + - mcp__plugin_spring-tools_spring-tools-mcp__getDiagnostics + - Skill(spring-tools:nope) + - Skill(quickfix) +--- + +Use the `getDiagnostics` tool from the spring tools MCP server. diff --git a/agent-plugins/tools/test/install.test.mjs b/agent-plugins/tools/test/install.test.mjs new file mode 100644 index 0000000000..11bc90ff70 --- /dev/null +++ b/agent-plugins/tools/test/install.test.mjs @@ -0,0 +1,313 @@ +/******************************************************************************* + * Copyright (c) 2026 Broadcom + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * https://www.eclipse.org/legal/epl-v10.html + * + * Contributors: + * Broadcom - initial API and implementation + *******************************************************************************/ + +// Exercises the plugin's JAR installer against a local HTTP server standing in for the CDN. + +import assert from 'node:assert/strict'; +import { execFile, spawnSync } from 'node:child_process'; +import { createHash, randomBytes } from 'node:crypto'; +import { chmodSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { createServer } from 'node:http'; +import { createRequire } from 'node:module'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { after, afterEach, before, beforeEach, test } from 'node:test'; + +const require = createRequire(import.meta.url); +const here = dirname(fileURLToPath(import.meta.url)); +const pluginRoot = join(here, '..', '..', 'spring-tools'); +const installer = require(join(pluginRoot, 'install.js')); +const { JAR_NAME, ensureJar, downloadUrls, assertAllowedUrl, proxyFor, hostMatchesNoProxy, pluginVersion, defaultInstallDir, parseArgs, createTunnelAgent } = installer; + +const version = pluginVersion(pluginRoot); +// Download-failure tests must not resolve the build fallback against this file's own checkout +// (findSourceRoot would find it and kick off a real, multi-minute Maven build); give them a +// pluginRoot whose parent directories hold no source checkout. +const isolatedRoot = mkdtempSync(join(tmpdir(), 'spring-tools-isolated-')); +const isolatedPluginRoot = join(isolatedRoot, 'agent-plugins', 'spring-tools'); +mkdirSync(isolatedPluginRoot, { recursive: true }); +writeFileSync(join(isolatedPluginRoot, 'plugin.json'), JSON.stringify({ version })); +const jarBytes = randomBytes(3 * 1024 * 1024 + 123); +const jarDigest = createHash('sha256').update(jarBytes).digest('hex'); +const state = { mode: 'ok', requests: [] }; +let server; +let base; +let loopbackUnavailable = false; +const quiet = () => {}; + +test('installer defaults to persistent client data and rejects a missing --dest value', () => { + assert.equal(defaultInstallDir(pluginRoot, { PLUGIN_DATA: '/client/data' }, '/home/test'), join('/client/data', 'language-server', version)); + assert.equal(defaultInstallDir(pluginRoot, {}, '/home/test'), join('/home/test/.spring-tools/data', 'language-server', version)); + assert.throws(() => parseArgs(['--dest']), /requires a directory path/); + assert.throws(() => parseArgs(['--dest', '--if-missing']), /requires a directory path/); + assert.equal(parseArgs(['--dest', 'cache']).dest, join(process.cwd(), 'cache')); +}); + +test('plugin versions used in download paths cannot escape the plugin data directory', () => { + const root = mkdtempSync(join(tmpdir(), 'spring-tools-version-')); + try { + mkdirSync(join(root, '.claude-plugin')); + writeFileSync(join(root, '.claude-plugin', 'plugin.json'), JSON.stringify({ version: '../../outside' })); + assert.throws(() => pluginVersion(root), /valid version/); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +before(async () => { + server = createServer((req, res) => { + state.requests.push(req.url); + let { pathname } = new URL(req.url, 'http://127.0.0.1'); + if (state.mode === 'missing') { + res.writeHead(404); + res.end('not found'); + return; + } + const jarPath = `/spring-tools/release/language-server/spring-boot/${version}/${JAR_NAME}`; + if (state.mode === 'redirect' && pathname.startsWith(jarPath)) { + res.writeHead(302, { location: pathname.replace(jarPath, `/spring-tools/moved/${JAR_NAME}`) }); + res.end(); + return; + } + pathname = pathname.replace(`/spring-tools/moved/${JAR_NAME}`, jarPath); + if (pathname === `${jarPath}.sha256`) { + const digest = state.mode === 'bad-sum' ? 'f'.repeat(64) : jarDigest; + res.writeHead(200, { 'content-type': 'text/plain' }); + res.end(state.mode === 'sha256sum-format' ? `${digest} ${JAR_NAME}\n` : `${digest}\n`); + } else if (pathname === jarPath) { + res.writeHead(200, { 'content-type': 'application/java-archive', 'content-length': jarBytes.length }); + res.end(jarBytes); + } else { + res.writeHead(404); + res.end('not found'); + } + }); + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(0, '127.0.0.1', resolve); + }).catch((error) => { + if (error.code !== 'EPERM') throw error; + loopbackUnavailable = true; + }); + if (loopbackUnavailable) return; + base = `http://127.0.0.1:${server.address().port}/spring-tools`; + process.env.SPRING_TOOLS_LS_DOWNLOAD_BASE = base; + // This test file runs inside a real Spring Tools checkout, which the build fallback would + // otherwise find and use to run a real Maven build; point it at a directory with no checkout + // so download-failure tests see a clean failure instead of a multi-minute reactor build. + process.env.SPRING_TOOLS_SOURCE_DIR = join(tmpdir(), 'spring-tools-no-source-checkout'); +}); + +after(() => { + if (server?.listening) server.close(); + delete process.env.SPRING_TOOLS_LS_DOWNLOAD_BASE; + delete process.env.SPRING_TOOLS_SOURCE_DIR; +}); + +let dest; +beforeEach(() => { + state.mode = 'ok'; + state.requests = []; + dest = mkdtempSync(join(tmpdir(), 'spring-tools-install-')); +}); + +afterEach(() => { + if (dest) { + rmSync(dest, { recursive: true, force: true }); + dest = undefined; + } +}); + +const leftovers = () => readdirSync(dest).filter((n) => n !== JAR_NAME); +const skipWithoutLoopback = (t) => { + if (!loopbackUnavailable) return false; + t.skip('sandbox denies loopback server binding'); + return true; +}; + +test('downloads the JAR for the plugin version, verifies it and installs it atomically', async (t) => { + if (skipWithoutLoopback(t)) return; + const result = await ensureJar({ pluginRoot, dest, log: quiet }); + assert.equal(result.status, 'downloaded'); + assert.equal(result.jarPath, join(dest, JAR_NAME)); + assert.ok(readFileSync(result.jarPath).equals(jarBytes), 'installed bytes must match the download'); + assert.deepEqual(leftovers(), [], 'no .part or .lock files may remain'); + assert.deepEqual(state.requests.map((u) => u.endsWith('.sha256')), [true, false], 'checksum first, then the JAR'); +}); + +test('--if-missing is a no-op when the JAR is already there', async () => { + writeFileSync(join(dest, JAR_NAME), 'existing'); + const result = await ensureJar({ pluginRoot, dest, ifMissing: true, log: quiet }); + assert.equal(result.status, 'present'); + assert.equal(readFileSync(join(dest, JAR_NAME), 'utf8'), 'existing'); + assert.deepEqual(state.requests, [], 'must not contact the server'); +}); + +test('negative control: a checksum mismatch leaves no JAR and no partial file behind', async (t) => { + if (skipWithoutLoopback(t)) return; + state.mode = 'bad-sum'; + await assert.rejects(ensureJar({ pluginRoot: isolatedPluginRoot, dest, log: quiet }), /Checksum mismatch/); + assert.ok(!existsSync(join(dest, JAR_NAME)), 'a JAR that failed verification must not be installed'); + assert.deepEqual(leftovers(), []); +}); + +test('accepts the sha256sum "digest filename" checksum format', async (t) => { + if (skipWithoutLoopback(t)) return; + state.mode = 'sha256sum-format'; + await ensureJar({ pluginRoot: isolatedPluginRoot, dest, log: quiet }); + assert.ok(readFileSync(join(dest, JAR_NAME)).equals(jarBytes)); +}); + +test('follows redirects that stay on an allowed host', async (t) => { + if (skipWithoutLoopback(t)) return; + state.mode = 'redirect'; + await ensureJar({ pluginRoot, dest, log: quiet }); + assert.ok(readFileSync(join(dest, JAR_NAME)).equals(jarBytes)); + assert.ok(state.requests.some((u) => u.includes('/moved/')), 'the moved locations must have been fetched'); +}); + +test('concurrent installers serialize on the lock and the second one reuses the first download', async (t) => { + if (skipWithoutLoopback(t)) return; + const [a, b] = await Promise.all([ + ensureJar({ pluginRoot, dest, ifMissing: true, log: quiet }), + ensureJar({ pluginRoot, dest, ifMissing: true, log: quiet }), + ]); + assert.deepEqual([a.status, b.status].sort(), ['downloaded', 'present']); + assert.equal(state.requests.filter((u) => !u.endsWith('.sha256')).length, 1, 'the JAR must be downloaded once'); + assert.deepEqual(leftovers(), []); +}); + +test('stale partial downloads from an interrupted run are removed', async (t) => { + if (skipWithoutLoopback(t)) return; + writeFileSync(join(dest, `${JAR_NAME}.4242.part`), 'truncated'); + await ensureJar({ pluginRoot, dest, log: quiet }); + assert.deepEqual(leftovers(), []); +}); + +test('falls back to building the standalone JAR from an available source checkout', async (t) => { + if (skipWithoutLoopback(t)) return; + state.mode = 'missing'; + const root = mkdtempSync(join(tmpdir(), 'spring-tools-source-')); + const localPlugin = join(root, 'agent-plugins', 'spring-tools'); + const target = join(root, 'headless-services', 'spring-boot-language-server-standalone', 'target'); + mkdirSync(localPlugin, { recursive: true }); + mkdirSync(target, { recursive: true }); + writeFileSync(join(localPlugin, 'plugin.json'), JSON.stringify({ version })); + writeFileSync(join(root, 'headless-services', 'pom.xml'), ''); + writeFileSync(join(root, 'headless-services', 'mvnw'), `#!/bin/sh\nprintf "Maven build output\\n"\nmkdir -p spring-boot-language-server-standalone/target\nprintf built > spring-boot-language-server-standalone/target/test-standalone-exec.jar\n`); + chmodSync(join(root, 'headless-services', 'mvnw'), 0o755); + try { + const script = `require(${JSON.stringify(join(pluginRoot, 'install.js'))}).ensureJar({ + pluginRoot: ${JSON.stringify(localPlugin)}, dest: ${JSON.stringify(dest)} + }).then(result => process.stdout.write(JSON.stringify(result))).catch(error => { + console.error(error); process.exitCode = 1; + });`; + const output = await new Promise((resolve, reject) => { + execFile(process.execPath, ['-e', script], { + env: { ...process.env, SPRING_TOOLS_LS_DOWNLOAD_BASE: base, SPRING_TOOLS_SOURCE_DIR: root }, + }, (error, stdout, stderr) => error ? reject(error) : resolve({ stdout, stderr })); + }); + const result = JSON.parse(output.stdout); + assert.equal(result.status, 'built'); + assert.match(output.stderr, /Maven build output/); + assert.equal(readFileSync(result.jarPath, 'utf8'), 'built'); + assert.deepEqual(leftovers(), []); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test('the CLI reports a download on stdout for the SessionStart hook and stays silent when nothing was needed', async (t) => { + if (skipWithoutLoopback(t)) return; + const probe = spawnSync(process.execPath, ['-e', ''], { encoding: 'utf8' }); + if (probe.error?.code === 'EPERM') { + t.skip('sandbox denies child process creation'); + return; + } + const env = { ...process.env, SPRING_TOOLS_LS_DOWNLOAD_BASE: base }; + delete env.SPRING_TOOLS_LS_JAR; + // Must stay asynchronous: the mock server lives in this process and has to answer the child. + const run = (args) => new Promise((resolve) => { + execFile(process.execPath, [join(pluginRoot, 'install.js'), ...args], { env, encoding: 'utf8' }, (error, stdout, stderr) => { + resolve({ status: error ? error.code : 0, stdout, stderr }); + }); + }); + const first = await run(['--if-missing', '--dest', dest]); + assert.equal(first.status, 0, first.stderr); + assert.match(first.stdout, /downloaded its language server/); + assert.match(first.stderr, /Installed Spring Boot Language Server/); + const second = await run(['--if-missing', '--dest', dest]); + assert.equal(second.status, 0); + assert.equal(second.stdout, ''); + const unknown = await run(['--bogus']); + assert.equal(unknown.status, 1); + assert.match(unknown.stderr, /unknown argument/); +}); + +test('download URLs follow the CDN layout for releases and snapshots', () => { + assert.equal(downloadUrls('2.5.0', 'https://cdn.spring.io/spring-tools').jarUrl, + `https://cdn.spring.io/spring-tools/release/language-server/spring-boot/2.5.0/${JAR_NAME}`); + assert.equal(downloadUrls('2.5.0-SNAPSHOT', 'https://cdn.spring.io/spring-tools/').jarUrl, + `https://cdn.spring.io/spring-tools/snapshot/language-server/spring-boot/${JAR_NAME}`); + assert.equal(downloadUrls('2.5.0').sha256Url, `${downloadUrls('2.5.0').jarUrl}.sha256`); +}); + +test('negative control: downloads are refused from untrusted hosts and over plain HTTP to non-loopback hosts', () => { + assert.throws(() => assertAllowedUrl('https://evil.example.com/x.jar', 'https://cdn.spring.io/spring-tools'), /untrusted host/); + assert.throws(() => assertAllowedUrl('http://cdn.spring.io/x.jar', 'https://cdn.spring.io/spring-tools'), /insecure/); + assert.throws(() => assertAllowedUrl('http://mirror.example.com/x.jar', 'http://mirror.example.com/spring-tools'), /insecure/); + assert.equal(assertAllowedUrl('https://cdn.spring.io/spring-tools/x.jar', 'https://mirror.example.com/spring-tools').hostname, 'cdn.spring.io'); + assert.equal(assertAllowedUrl('https://mirror.example.com/spring-tools/x.jar', 'https://mirror.example.com/spring-tools').hostname, 'mirror.example.com'); + assert.equal(assertAllowedUrl('http://127.0.0.1:9/x.jar', 'http://127.0.0.1:9/spring-tools').hostname, '127.0.0.1'); +}); + +test('proxy selection follows HTTPS_PROXY, ALL_PROXY and NO_PROXY like curl', () => { + const target = 'https://cdn.spring.io/spring-tools/x.jar'; + assert.equal(proxyFor(target, {}), null); + assert.equal(proxyFor(target, { HTTPS_PROXY: 'http://proxy.example.com:3128' }).href, 'http://proxy.example.com:3128/'); + assert.equal(proxyFor(target, { https_proxy: 'proxy.example.com:3128' }).port, '3128', 'a bare host:port is an http proxy'); + assert.equal(proxyFor(target, { ALL_PROXY: 'http://user:pw@proxy.example.com:8080' }).username, 'user'); + assert.equal(proxyFor(target, { HTTP_PROXY: 'http://proxy.example.com:3128' }), null, 'HTTP_PROXY does not apply to https targets'); + assert.equal(proxyFor(target, { HTTPS_PROXY: 'http://proxy.example.com:3128', NO_PROXY: 'localhost,.spring.io' }), null); + assert.equal(proxyFor(target, { HTTPS_PROXY: 'http://proxy.example.com:3128', no_proxy: '*' }), null); + assert.equal(proxyFor('http://127.0.0.1:8080/x', { HTTP_PROXY: 'http://proxy.example.com:3128' }), null, 'loopback never goes through a proxy'); + assert.throws(() => proxyFor(target, { HTTPS_PROXY: 'socks5://proxy.example.com:1080' }), /only http:\/\/ proxies/); + assert.equal(hostMatchesNoProxy('cdn.spring.io', 'example.com, spring.io:443'), true); + assert.equal(hostMatchesNoProxy('cdn.spring.io', 'notspring.io'), false); +}); + +test('HTTPS requests reuse the established CONNECT tunnel', () => { + const socket = {}; + const agent = createTunnelAgent(socket); + assert.strictEqual(agent.createConnection({}), socket); + agent.destroy(); +}); + +test('recovers immediately when the installer that owns the lock has exited', async (t) => { + if (skipWithoutLoopback(t)) return; + const exited = spawnSync(process.execPath, ['-e', 'process.stdout.write(String(process.pid))'], { encoding: 'utf8' }); + assert.equal(exited.status, 0, exited.stderr); + const deadPid = Number(exited.stdout); + assert.ok(Number.isSafeInteger(deadPid) && deadPid > 0); + + const lockPath = join(dest, `${JAR_NAME}.lock`); + writeFileSync(lockPath, `${deadPid} ${new Date().toISOString()}\n`); + const result = await ensureJar({ pluginRoot, dest, log: quiet }); + + assert.equal(result.status, 'downloaded'); + assert.ok(readFileSync(result.jarPath).equals(jarBytes)); + assert.deepEqual(leftovers(), []); +}); + +after(() => { + rmSync(isolatedRoot, { recursive: true, force: true }); +}); diff --git a/agent-plugins/tools/test/launcher.test.mjs b/agent-plugins/tools/test/launcher.test.mjs new file mode 100644 index 0000000000..7bd7d692a1 --- /dev/null +++ b/agent-plugins/tools/test/launcher.test.mjs @@ -0,0 +1,207 @@ +/******************************************************************************* + * Copyright (c) 2026 Broadcom + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * https://www.eclipse.org/legal/epl-v10.html + * + * Contributors: + * Broadcom - initial API and implementation + *******************************************************************************/ + +// Unit tests for the pure parts of launcher.js (Java selection, JVM arguments). The end-to-end +// behaviour against a real JVM is covered by tools/smoke/mcp-smoke.mjs. + +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { mkdtempSync, existsSync, mkdirSync, writeFileSync, rmSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { test } from 'node:test'; + +const require = createRequire(import.meta.url); +const here = dirname(fileURLToPath(import.meta.url)); +const pluginRoot = join(here, '..', '..', 'spring-tools'); +const launcher = require(join(pluginRoot, 'launcher.js')); +const { defaultInstallDir, pluginVersion } = require(join(pluginRoot, 'install.js')); +const { MIN_JAVA_MAJOR, SERVER_INSTRUCTIONS, serverInstructions, parseJavaMajor, javaCandidates, pickJava, splitOpts, watchEnabled, buildJavaArgs, resolveProjectDir, resolveDataDir } = launcher; + +test('parses the major version from java -version output of old and new JDKs', () => { + assert.equal(parseJavaMajor('openjdk version "21.0.2" 2024-01-16\nOpenJDK Runtime Environment'), 21); + assert.equal(parseJavaMajor('java version "1.8.0_392"\nJava(TM) SE Runtime Environment'), 8); + assert.equal(parseJavaMajor('openjdk version "25" 2025-09-16'), 25); + assert.equal(parseJavaMajor('openjdk version "17.0.10" 2024-01-16 LTS'), 17); + assert.equal(parseJavaMajor(''), null); + assert.equal(parseJavaMajor('bash: java: command not found'), null); +}); + +test('persistent data directory prefers explicit and host-provided locations then falls back to the user home', () => { + assert.equal(resolveDataDir({ env: { SPRING_TOOLS_DATA_DIR: '/custom/data', CLAUDE_PLUGIN_DATA: '/claude' }, home: '/home/test' }), '/custom/data'); + assert.equal(resolveDataDir({ env: { CLAUDE_PLUGIN_DATA: '/claude', PLUGIN_DATA: '/plugin' }, home: '/home/test' }), '/claude'); + assert.equal(resolveDataDir({ env: { PLUGIN_DATA: '/plugin' }, home: '/home/test' }), '/plugin'); + assert.equal(resolveDataDir({ env: { SPRING_TOOLS_DATA_DIR: 'relative/data' }, home: '/home/test' }), join(process.cwd(), 'relative/data')); + assert.equal(resolveDataDir({ env: {}, home: '/home/test' }), '/home/test/.spring-tools/data'); +}); + +test('language server JAR is resolved under persistent data rather than the plugin installation', () => { + const dataDir = resolveDataDir({ env: { PLUGIN_DATA: '/client/data' }, home: '/home/test' }); + assert.equal(defaultInstallDir(pluginRoot, { PLUGIN_DATA: '/client/data' }, '/home/test'), join(dataDir, 'language-server', pluginVersion(pluginRoot))); +}); + +test('candidates are tried most specific first: SPRING_TOOLS_JAVA, JAVA_HOME, then PATH', () => { + const all = javaCandidates({ SPRING_TOOLS_JAVA: '/opt/jdk/bin/java', JAVA_HOME: '/usr/lib/jvm/17' }, 'linux'); + assert.deepEqual(all.map((c) => c.source), ['SPRING_TOOLS_JAVA', 'JAVA_HOME', 'PATH']); + assert.equal(all[1].java, join('/usr/lib/jvm/17', 'bin', 'java')); + assert.deepEqual(javaCandidates({}, 'linux').map((c) => c.java), ['java']); + assert.ok(javaCandidates({ JAVA_HOME: 'C:\\jdk' }, 'win32')[0].java.endsWith('java.exe')); +}); + +test('picks the first candidate that is Java 21 or newer and skips older or broken ones', () => { + const versions = { '/old/bin/java': 'openjdk version "17.0.10"', java: 'openjdk version "21.0.2"', '/new/bin/java': 'openjdk version "25"' }; + const probe = (java) => (java in versions ? { output: versions[java] } : { output: '', error: 'ENOENT' }); + const picked = pickJava(javaCandidates({ JAVA_HOME: '/old', SPRING_TOOLS_JAVA: '/missing/java' }, 'linux'), probe); + assert.equal(picked.java, 'java'); + assert.equal(picked.major, 21); + assert.deepEqual(picked.tried.map((t) => [t.source, t.major, t.error ?? null]), [ + ['SPRING_TOOLS_JAVA', null, 'ENOENT'], + ['JAVA_HOME', 17, null], + ['PATH', 21, null], + ]); + assert.equal(pickJava(javaCandidates({ SPRING_TOOLS_JAVA: '/new/bin/java' }, 'linux'), probe).major, 25); +}); + +test('negative control: no usable Java yields no pick but a full record of what was tried', () => { + const picked = pickJava(javaCandidates({ JAVA_HOME: '/old' }, 'linux'), () => ({ output: 'openjdk version "17.0.1"' })); + assert.equal(picked.java, null); + assert.equal(picked.tried.length, 2); + assert.ok(picked.tried.every((t) => t.major === 17 && t.major < MIN_JAVA_MAJOR)); +}); + +test('splits SPRING_TOOLS_JAVA_OPTS like a shell would', () => { + assert.deepEqual(splitOpts(undefined), []); + assert.deepEqual(splitOpts(' -Xmx2g -XX:+UseZGC '), ['-Xmx2g', '-XX:+UseZGC']); + assert.deepEqual(splitOpts('-Dfoo="a b" -Dbar=\'c d\''), ['-Dfoo=a b', '-Dbar=c d']); +}); + +test('JVM arguments put user options after the defaults so they win, and end with -jar ', () => { + const args = buildJavaArgs({ jarPath: '/p/ls.jar', dataDir: '/data', projectDir: '/proj', extraOpts: ['-Xmx2g'] }); + assert.ok(args.indexOf('-Xmx1024m') < args.indexOf('-Xmx2g')); + assert.deepEqual(args.slice(-2), ['-jar', '/p/ls.jar']); + assert.ok(args.includes('-Dspring.ai.mcp.server.stdio=true')); + assert.ok(args.includes('-Dlanguageserver.enabled=false')); + assert.ok(args.includes('-Dspring.boot.ls.project.dir=/proj')); + assert.ok(args.includes(`-Dlogging.file.name=${join('/data', 'boot-ls.log')}`)); + assert.ok(args.includes(`-Dspring.ai.mcp.server.instructions=${serverInstructions()}`)); +}); + +test('the file watcher is on by default and SPRING_TOOLS_LS_WATCH=false turns it off', () => { + const base = { jarPath: '/p/ls.jar', dataDir: '/data', projectDir: '/proj' }; + assert.ok(buildJavaArgs(base).includes('-Dspring.boot.ls.project.watch=true')); + assert.ok(buildJavaArgs({ ...base, watch: false }).includes('-Dspring.boot.ls.project.watch=false')); + for (const value of [undefined, '', 'true', 'yes', '1']) { + assert.equal(watchEnabled(value), true, `watchEnabled(${JSON.stringify(value)})`); + } + for (const value of ['false', 'FALSE', ' 0 ', 'no', 'off']) { + assert.equal(watchEnabled(value), false, `watchEnabled(${JSON.stringify(value)})`); + } + // The property must precede user options so SPRING_TOOLS_JAVA_OPTS can still override it. + const args = buildJavaArgs({ ...base, extraOpts: ['-Dspring.boot.ls.project.watch=false'] }); + assert.ok(args.indexOf('-Dspring.boot.ls.project.watch=true') < args.lastIndexOf('-Dspring.boot.ls.project.watch=false')); +}); + +test('server instructions name entry-point tools, use host-neutral skill guidance, and stay under 2 KB', () => { + for (const tool of ['getProjectList', 'getProjectDiagnostics', 'fileChanged', 'refreshWorkspace', 'getBeanDetails', 'getRequestMappings']) { + assert.ok(SERVER_INSTRUCTIONS.includes(tool), `${tool} should be mentioned`); + } + // Each host-neutral skill name must map to an installed skill in this package. + for (const skill of ['quickfix', 'validate']) { + assert.ok(existsSync(join(pluginRoot, 'skills', skill, 'SKILL.md')), `skill ${skill} referenced by the instructions is missing`); + } + assert.ok(SERVER_INSTRUCTIONS.includes('watches'), 'clients should know they need not notify the server about every edit'); + assert.ok(!SERVER_INSTRUCTIONS.includes('/spring-tools:'), 'host-specific slash invocations do not work on every client'); + assert.ok(SERVER_INSTRUCTIONS.includes('explanations/.md')); + assert.ok(Buffer.byteLength(SERVER_INSTRUCTIONS, 'utf8') < 2048); + assert.ok(!/["\n]/.test(SERVER_INSTRUCTIONS), 'quotes and newlines would complicate argv quoting on Windows'); +}); + +test('the instructions carry the absolute playbook directory for clients that do not expand path placeholders', () => { + const instructions = serverInstructions('/plugins/spring-tools'); + assert.ok(instructions.startsWith(SERVER_INSTRUCTIONS)); + assert.ok(instructions.includes(join('/plugins/spring-tools', 'explanations'))); + assert.ok(instructions.includes('.md')); + assert.ok(Buffer.byteLength(serverInstructions(pluginRoot), 'utf8') < 2048); + assert.ok(!/["\n]/.test(instructions)); + // The directory must really exist, otherwise every client is pointed at a dangling path. + assert.ok(existsSync(join(pluginRoot, 'explanations'))); +}); + +test('the project directory falls back from the explicit override down to the Copilot session state', () => { + const root = mkdtempSync(join(tmpdir(), 'spring-tools-projectdir-')); + try { + const plugin = join(root, 'plugin'); + const workspace = join(root, 'workspace'); + const other = join(root, 'other'); + const home = join(root, 'home'); + const session = join(home, '.copilot', 'session-state', 'sess-1'); + for (const dir of [plugin, workspace, other, session]) { + mkdirSync(dir, { recursive: true }); + } + writeFileSync(join(session, 'workspace.yaml'), `version: 1\ncwd: ${workspace}\nmodel: gpt\n`); + const resolve = (env, cwd) => resolveProjectDir({ env, cwd, pluginRoot: plugin, home }); + + // An explicit override always wins, even over a perfectly good working directory. + assert.deepEqual(resolve({ SPRING_TOOLS_PROJECT_DIR: other, CLAUDE_PROJECT_DIR: workspace }, workspace), { dir: other, source: 'SPRING_TOOLS_PROJECT_DIR', detected: true }); + // Claude Code exports the project directory; Copilot does not but starts the server in the plugin. + assert.deepEqual(resolve({ CLAUDE_PROJECT_DIR: workspace }, plugin), { dir: workspace, source: 'CLAUDE_PROJECT_DIR', detected: true }); + assert.deepEqual(resolve({}, workspace), { dir: workspace, source: 'working directory', detected: true }); + assert.deepEqual(resolve({ COPILOT_AGENT_SESSION_ID: 'sess-1' }, plugin), { dir: workspace, source: 'Copilot session state', detected: true }); + assert.deepEqual(resolve({ PWD: workspace }, plugin), { dir: workspace, source: 'PWD', detected: true }); + + assert.throws(() => resolve({ SPRING_TOOLS_PROJECT_DIR: join(root, 'gone'), PWD: workspace }, plugin), /SPRING_TOOLS_PROJECT_DIR is not an existing directory/); + + // Nothing usable: never index the plugin itself, and say that the detection failed. + assert.deepEqual(resolve({ CLAUDE_PROJECT_DIR: join(root, 'gone'), PWD: plugin, COPILOT_AGENT_SESSION_ID: 'nope' }, plugin), { dir: plugin, source: 'working directory', detected: false }); + // A session id must not be able to escape the session-state directory. + assert.equal(resolve({ COPILOT_AGENT_SESSION_ID: '../../..' }, plugin).detected, false); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test('the launcher fails fast with an actionable message when no Java 21+ runtime is available', (t) => { + const emptyBin = mkdtempSync(join(tmpdir(), 'spring-tools-nojava-')); + try { + const result = spawnSync(process.execPath, [join(pluginRoot, 'launcher.js')], { + encoding: 'utf8', + timeout: 30000, + env: { PATH: emptyBin, HOME: emptyBin, CLAUDE_PROJECT_DIR: emptyBin, CLAUDE_PLUGIN_DATA: emptyBin }, + }); + if (result.error?.code === 'EPERM') { + t.skip('sandbox denies child process creation'); + return; + } + assert.equal(result.status, 1); + assert.match(result.stderr, /No Java 21\+ runtime found/); + assert.match(result.stderr, /JAVA_HOME or SPRING_TOOLS_JAVA/); + assert.equal(result.stdout, '', 'stdout is the MCP channel and must stay silent'); + } finally { + rmSync(emptyBin, { recursive: true, force: true }); + } +}); + + +test('a cached plugin without a workspace exits before downloading and keeps MCP stdout clean', () => { + const result = spawnSync(process.execPath, [join(pluginRoot, 'launcher.js')], { + cwd: pluginRoot, + encoding: 'utf8', + timeout: 10000, + env: { PATH: process.env.PATH, PWD: pluginRoot }, + }); + assert.equal(result.status, 1, result.stderr); + assert.match(result.stderr, /refusing to index the plugin directory/); + assert.match(result.stderr, /SPRING_TOOLS_PROJECT_DIR/); + assert.equal(result.stdout, ''); + assert.doesNotMatch(result.stderr, /Downloading/); +}); diff --git a/agent-plugins/tools/test/plugin-config.test.mjs b/agent-plugins/tools/test/plugin-config.test.mjs new file mode 100644 index 0000000000..4f0c57e301 --- /dev/null +++ b/agent-plugins/tools/test/plugin-config.test.mjs @@ -0,0 +1,433 @@ +/******************************************************************************* + * Copyright (c) 2026 Broadcom + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * https://www.eclipse.org/legal/epl-v10.html + * + * Contributors: + * Broadcom - initial API and implementation + *******************************************************************************/ + +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { test } from 'node:test'; + +import { + COPILOT_DIR, + HOOK_SERVER, + MCP_SCHEMA, + PLUGIN_DIR, + PLUGIN_SCHEMA, + SCOPED_TOOL_PREFIX, + checkAgent, + checkAgentPluginManifest, + checkCodexMarketplace, + checkCopilotAgent, + checkCopilotHooks, + checkEvals, + checkHooks, + checkManifest, + checkSkill, + collectMcpTools, + parseFrontMatter, + skillToolNames, + uncoveredTools, +} from '../lib/plugin-config.mjs'; +import { copilotAgentId, countToolMatches, toolGradePasses } from '../lib/eval-grading.mjs'; + +const here = dirname(fileURLToPath(import.meta.url)); +const repoRoot = join(here, '..', '..', '..'); +const pluginDir = join(repoRoot, PLUGIN_DIR); +const tools = collectMcpTools(repoRoot); +const skillsDir = join(pluginDir, 'skills'); +const skillNames = readdirSync(skillsDir); +const skillText = (name) => readFileSync(join(skillsDir, name, 'SKILL.md'), 'utf8'); +const agentsDir = join(pluginDir, 'agents'); +const agentFiles = readdirSync(agentsDir).filter((f) => f.endsWith('.md')); + +test('the language server exposes the MCP tools the plugin relies on', () => { + for (const name of ['getProjectList', 'getProjectDiagnostics', 'fileChanged', 'fileDeleted', 'refreshWorkspace', 'getSpringBootVersion']) { + assert.ok(tools.has(name), `MCP tool ${name} not found in the language server sources`); + } + assert.deepEqual(tools.get('fileChanged'), ['filePath']); + assert.deepEqual(tools.get('getProjectDiagnostics'), ['projectName']); +}); + +test('hooks.json only calls existing MCP tools through the plugin-scoped server with matching parameters', () => { + const hooks = JSON.parse(readFileSync(join(pluginDir, 'hooks', 'hooks.json'), 'utf8')); + assert.deepEqual(checkHooks(hooks, tools, repoRoot), []); + assert.equal(hooks.hooks.PostToolUse[0].hooks[0].server, HOOK_SERVER); +}); + +test('hooks.json pre-downloads the JAR at session start and refreshes the index after commands that rewrite the tree', () => { + const hooks = JSON.parse(readFileSync(join(pluginDir, 'hooks', 'hooks.json'), 'utf8')); + const start = hooks.hooks.SessionStart.flatMap((g) => g.hooks); + assert.equal(start.length, 1); + assert.equal(start[0].type, 'command'); + assert.equal(start[0].command, 'node'); + assert.deepEqual(start[0].args, ['${CLAUDE_PLUGIN_ROOT}/install.js', '--if-missing']); + assert.ok(start[0].timeout >= 300, 'the first-run download needs more than the default hook budget on slow links'); + + const post = hooks.hooks.PostToolUse.flatMap((g) => g.hooks); + const refreshRules = post.filter((h) => h.tool === 'refreshWorkspace').map((h) => h.if); + for (const rule of ['Bash(git *)', 'Bash(rm *)', 'Bash(mv *)', 'Bash(tar *)', 'Bash(unzip *)', 'PowerShell(git *)', 'PowerShell(Remove-Item *)']) { + assert.ok(refreshRules.includes(rule), `${rule} should trigger refreshWorkspace`); + } + const fileRules = post.filter((h) => h.tool === 'fileChanged').map((h) => h.if); + for (const ext of ['java', 'kt', 'xml', 'properties', 'yml', 'yaml', 'factories', 'gradle', 'kts']) { + assert.ok(fileRules.includes(`Edit(*.${ext})`), `edits of *.${ext} should trigger fileChanged`); + } + assert.ok(post.filter((h) => h.tool === 'fileChanged').every((h) => h.input.filePath === '${tool_input.file_path}')); +}); + +test('every skill has a SKILL.md whose name matches its directory and whose tools exist', () => { + for (const dirName of skillNames) { + const file = join(skillsDir, dirName, 'SKILL.md'); + assert.ok(existsSync(file), `${dirName} has no SKILL.md`); + assert.deepEqual(checkSkill(dirName, readFileSync(file, 'utf8'), tools, skillNames), [], dirName); + } +}); + +test('every MCP tool has positive behavioral eval coverage', () => { + assert.deepEqual(checkEvals(join(pluginDir, 'evals'), tools, skillNames, agentFiles.map((file) => file.replace(/\.md$/, ''))), []); +}); + +test('the quickfix skill reads explanations by diagnostic code and may look up the Spring Boot version', () => { + const text = readFileSync(join(skillsDir, 'quickfix', 'SKILL.md'), 'utf8'); + const front = parseFrontMatter(text); + // Named arguments expand to an empty string when omitted; $ARGUMENTS[2] would stay literal. + assert.deepEqual(front.arguments, ['code', 'file', 'range']); + assert.ok(text.includes('${CLAUDE_PLUGIN_ROOT}/explanations/.md')); + assert.ok(text.includes('`../../explanations/.md` relative to this skill directory')); + assert.ok(!/\$ARGUMENTS\[/.test(text), 'quickfix should use the named placeholders only'); + assert.ok(front['allowed-tools'].includes(`${SCOPED_TOOL_PREFIX}getSpringBootVersion`)); +}); + +test('the validate skill pre-approves quickfix invocations that carry arguments', () => { + const front = parseFrontMatter(readFileSync(join(skillsDir, 'validate', 'SKILL.md'), 'utf8')); + assert.ok(front['allowed-tools'].includes('Skill(spring-tools:quickfix *)')); + assert.ok(!front['allowed-tools'].includes('Skill(spring-tools:quickfix)'), 'the exact-match form only covers an argument-less call'); +}); + +test('the create-spring-boot-project skill only grants the start.spring.io lookup and registers the new project', () => { + const text = readFileSync(join(skillsDir, 'create-spring-boot-project', 'SKILL.md'), 'utf8'); + const allowed = parseFrontMatter(text)['allowed-tools']; + assert.ok(allowed.includes('Bash(curl -sS https://start.spring.io)')); + assert.ok(!allowed.some((rule) => /^Bash\(curl -sS \*\)$/.test(rule)), 'no blanket curl grant'); + assert.ok(text.includes('curl -sS https://start.spring.io | grep'), 'the body must use exactly the granted lookup command'); + assert.ok(allowed.includes(`${SCOPED_TOOL_PREFIX}refreshWorkspace`)); + assert.match(text, /refreshWorkspace/); +}); + +test('plugin.json and the local marketplace are consistent with the plugin directory', () => { + const pluginJson = JSON.parse(readFileSync(join(pluginDir, '.claude-plugin', 'plugin.json'), 'utf8')); + const marketplace = JSON.parse(readFileSync(join(repoRoot, 'agent-plugins', '.claude-plugin', 'marketplace.json'), 'utf8')); + assert.deepEqual(checkManifest(pluginJson, marketplace, repoRoot), []); +}); + +test('Codex marketplace resolves to the shared portable plugin', () => { + const catalog = JSON.parse(readFileSync(join(repoRoot, '.agents', 'plugins', 'marketplace.json'), 'utf8')); + assert.deepEqual(checkCodexMarketplace(catalog, repoRoot), []); +}); + +test('negative control: Codex catalog rejects missing, duplicate and stale entries', () => { + const entry = { name: 'spring-tools', source: { source: 'local', path: `./${PLUGIN_DIR}` } }; + assert.match(checkCodexMarketplace({ plugins: [] }, repoRoot).join('\n'), /exactly one/); + assert.match(checkCodexMarketplace({ plugins: [entry, entry] }, repoRoot).join('\n'), /exactly one/); + for (const source of [{ source: 'local', path: './missing-plugin' }, { source: 'git', path: `./${PLUGIN_DIR}` }]) { + assert.match(checkCodexMarketplace({ plugins: [{ ...entry, source }] }, repoRoot).join('\n'), /source must be local with path/); + } +}); + +test('negative control: Codex catalog detects a missing or differently named portable manifest', () => { + const root = mkdtempSync(join(tmpdir(), 'codex-catalog-')); + const catalog = { plugins: [{ name: 'spring-tools', source: { source: 'local', path: `./${PLUGIN_DIR}` } }] }; + try { + assert.match(checkCodexMarketplace(catalog, root).join('\n'), /no portable plugin.json/); + mkdirSync(join(root, PLUGIN_DIR), { recursive: true }); + writeFileSync(join(root, PLUGIN_DIR, 'plugin.json'), JSON.stringify({ name: 'other-plugin' })); + assert.match(checkCodexMarketplace(catalog, root).join('\n'), /name differs/); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test('negative control: a misspelled MCP tool name in a hook is detected', () => { + const hooks = JSON.parse(readFileSync(join(here, 'fixtures', 'bad-hooks.json'), 'utf8')); + const errors = checkHooks(hooks, tools, repoRoot); + assert.match(errors.join('\n'), /unknown MCP tool 'fileChangd'/); + assert.match(errors.join('\n'), /server must be 'plugin:spring-tools:spring-tools-mcp'/); + assert.match(errors.join('\n'), /input 'path' is not a parameter of fileChanged\(filePath\)/); + assert.match(errors.join('\n'), /skips mcp_tool hooks on SessionStart/); + assert.match(errors.join('\n'), /does not exist in the plugin directory/); + assert.match(errors.join('\n'), /path placeholders belong in exec-form 'args'/); +}); + +test('negative control: a skill with an unscoped or unknown tool and a wrong name is detected', () => { + const text = readFileSync(join(here, 'fixtures', 'bad-skill.md'), 'utf8'); + const errors = checkSkill('validate', text, tools, skillNames); + assert.match(errors.join('\n'), /name 'validat' differs from directory name/); + assert.match(errors.join('\n'), /MCP tool 'mcp__spring-tools-mcp__getProjectList' is not scoped/); + assert.match(errors.join('\n'), /unknown MCP tool 'getDiagnostics'/); + assert.match(errors.join('\n'), /skill reference 'Skill\(spring-tools:nope\)' points to a missing skill/); + assert.match(errors.join('\n'), /skill reference 'Skill\(quickfix\)' must look like/); + assert.match(errors.join('\n'), /body mentions MCP tool 'getDiagnostics' which does not exist/); +}); + +test('every MCP tool of the language server is reachable through a skill or a hook', () => { + const hooks = JSON.parse(readFileSync(join(pluginDir, 'hooks', 'hooks.json'), 'utf8')); + assert.deepEqual(uncoveredTools(tools, skillNames.map(skillText), hooks), []); + assert.ok(tools.size >= 26, `expected the full tool inventory, found ${tools.size}`); + assert.ok(skillNames.length >= 9, `expected at least 9 skills, found ${skillNames.length}`); +}); + +test('the analysis skills grant exactly the tools their body walks through', () => { + const expectations = { + beans: ['getProjectList', 'getBeanDetails', 'findBeansByType', 'getBeanUsageInfo'], + endpoints: ['getProjectList', 'getRequestMappings', 'findRequestMappingsByMethod'], + architecture: ['getProjectList', 'getLogicalStructure', 'getStructureDependencies', 'getStereotypesList', 'findComponentsByStereotype', 'getListOfComponentsAndTheirStereotypes', + 'captureLogicalStructureBaseline', 'getLogicalStructureChanges', 'getLogicalStructureBaselineHistory', 'clearLogicalStructureBaseline'], + 'spring-versions': ['getProjectList', 'getSpringBootVersion', 'getLatestReleaseInformation', 'getLatestBootVersionsFromMavenRepo', 'getReleases', 'getGenerations', 'getUpcomingReleases'], + 'project-info': ['getProjectList', 'getJavaVersion', 'getSpringBootVersion', 'getResolvedProjectClasspath'], + refresh: ['refreshWorkspace', 'fileChanged', 'fileDeleted'], + }; + for (const [skill, expected] of Object.entries(expectations)) { + const text = skillText(skill); + assert.deepEqual(skillToolNames(text).sort(), [...expected].sort(), skill); + for (const name of expected) { + assert.ok(text.includes(`\`${name}\``), `${skill} body should explain when to use ${name}`); + } + } + // Skills that ask questions of the index must not be allowed to change files. + for (const skill of ['beans', 'endpoints', 'architecture', 'spring-versions', 'project-info']) { + const allowed = parseFrontMatter(skillText(skill))['allowed-tools']; + assert.ok(!allowed.some((rule) => /^(Edit|Write|Bash|NotebookEdit)\b/.test(rule)), `${skill} must stay read-only`); + } +}); + +test('the spring-versions skill separates workspace project names from Spring IO slugs', () => { + const text = skillText('spring-versions'); + assert.match(text, /spring-boot.*spring-framework/s, 'names the Spring IO slugs'); + assert.match(text, /getLatestBootVersionsFromMavenRepo[\s\S]*Gradle/, 'documents the Maven-only limitation'); +}); + +test('agents/ ships a read-only Spring reviewer whose MCP grant is scoped to the plugin server', () => { + assert.ok(agentFiles.includes('spring-reviewer.md')); + for (const file of agentFiles) { + const text = readFileSync(join(agentsDir, file), 'utf8'); + assert.deepEqual(checkAgent(file, text, tools), [], file); + } + const reviewer = parseFrontMatter(readFileSync(join(agentsDir, 'spring-reviewer.md'), 'utf8')); + const granted = String(reviewer.tools).split(',').map((s) => s.trim()); + assert.ok(granted.includes(`${SCOPED_TOOL_PREFIX}*`), 'the reviewer needs every plugin MCP tool'); + assert.ok(!granted.some((t) => /^(Edit|Write|Bash|NotebookEdit)$/.test(t)), 'a reviewer must not edit or run commands'); + assert.ok(reviewer.description.length <= 250, 'agent descriptions are shown in the agent list; keep them short'); +}); + +test('negative control: a malformed plugin agent is detected', () => { + const errors = checkAgent('spring-reviewer.md', readFileSync(join(here, 'fixtures', 'bad-agent.md'), 'utf8'), tools); + assert.match(errors.join('\n'), /name 'spring-review' differs from file name 'spring-reviewer'/); + assert.match(errors.join('\n'), /too short description/); + assert.match(errors.join('\n'), /'hooks' is ignored for agents shipped in a plugin/); + assert.match(errors.join('\n'), /MCP tool 'mcp__spring-tools-mcp__getProjectList' is not scoped/); + assert.match(errors.join('\n'), /unknown MCP tool 'getDiagnostics'/); + assert.match(errors.join('\n'), /body mentions MCP tool 'getDiagnostics'/); +}); + +test('negative control: a tool nobody grants shows up as uncovered', () => { + const extended = new Map(tools); + extended.set('getSomethingNew', ['projectName']); + const hooks = JSON.parse(readFileSync(join(pluginDir, 'hooks', 'hooks.json'), 'utf8')); + assert.deepEqual(uncoveredTools(extended, skillNames.map(skillText), hooks), ['getSomethingNew']); + assert.deepEqual(uncoveredTools(new Map([['fileChanged', ['filePath']]]), [], hooks), [], 'hooks count as coverage'); +}); + +test('check-plugin-config.mjs reports the inventory it validated', (t) => { + const result = spawnSync(process.execPath, [join(here, '..', 'check-plugin-config.mjs'), '--cross-host'], { encoding: 'utf8' }); + if (result.error?.code === 'EPERM') { + t.skip('sandbox denies child process creation'); + return; + } + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /^plugin config OK \(\d+ tools, \d+ skills, \d+ agent\(s\) with Copilot copies, \d+ eval case\(s\), every tool covered by a skill or hook and behavioral eval\)/); +}); + +const agentPluginManifest = () => JSON.parse(readFileSync(join(pluginDir, 'plugin.json'), 'utf8')); +const agentPluginMcp = () => JSON.parse(readFileSync(join(pluginDir, 'mcp.json'), 'utf8')); +const claudeManifest = () => JSON.parse(readFileSync(join(pluginDir, '.claude-plugin', 'plugin.json'), 'utf8')); + +test('the Agent Plugins manifests Copilot reads stay within the closed schema and in sync with the Claude manifest', () => { + const manifest = agentPluginManifest(); + const mcp = agentPluginMcp(); + assert.deepEqual(checkAgentPluginManifest(manifest, mcp, claudeManifest(), repoRoot), []); + assert.equal(manifest.$schema, PLUGIN_SCHEMA); + assert.equal(mcp.$schema, MCP_SCHEMA); + // Copilot expands ${PLUGIN_ROOT} in args only, so the command has to be a bare executable. + assert.equal(mcp.mcpServers['spring-tools-mcp'].command, 'node'); + assert.ok(mcp.mcpServers['spring-tools-mcp'].args.some((a) => a === '${PLUGIN_ROOT}/launcher.js')); +}); + +test('negative control: schema violations and drift between the two manifests are reported', () => { + const claude = claudeManifest(); + const broken = { ...agentPluginManifest(), displayName: 'Spring Tools', version: '9.9.9', author: 'Spring Tools' }; + const errors = checkAgentPluginManifest(broken, agentPluginMcp(), claude, repoRoot); + assert.ok(errors.some((e) => e.includes("'displayName' is not allowed"))); + assert.ok(errors.some((e) => e.includes('version differs'))); + assert.ok(errors.some((e) => e.includes('author must be an object'))); + const badMcp = { $schema: MCP_SCHEMA, mcpServers: { 'spring-tools-mcp': { type: 'stdio', command: '${PLUGIN_ROOT}/node', args: ['launcher.js'], env: { PLUGIN_ROOT: '/x' } } } }; + const mcpErrors = checkAgentPluginManifest(agentPluginManifest(), badMcp, claude, repoRoot); + assert.ok(mcpErrors.some((e) => e.includes("command must be 'node'"))); + assert.ok(mcpErrors.some((e) => e.includes('${PLUGIN_ROOT}/launcher.js'))); + assert.ok(mcpErrors.some((e) => e.includes("must not redefine 'PLUGIN_ROOT'"))); +}); + +test('the Copilot hooks only use command hooks with both shells and existing scripts', () => { + const hooks = JSON.parse(readFileSync(join(pluginDir, COPILOT_DIR, 'hooks', 'hooks.json'), 'utf8')); + assert.deepEqual(checkCopilotHooks(hooks, repoRoot), []); + assert.ok(hooks.hooks.sessionStart[0].bash.includes('install.js')); +}); + +test('negative control: Claude hook spellings in the Copilot hooks file are rejected', () => { + const pascalCase = { version: 1, hooks: { SessionStart: [{ type: 'command', bash: 'node x.js', powershell: 'node x.js' }] } }; + assert.ok(checkCopilotHooks(pascalCase, null).some((e) => e.includes("unknown event 'SessionStart'"))); + const mcpTool = { version: 1, hooks: { postToolUse: [{ type: 'mcp_tool', tool: 'fileChanged' }] } }; + assert.ok(checkCopilotHooks(mcpTool, null).some((e) => e.includes("only runs 'command' hooks"))); + const missingShell = { version: 1, hooks: { sessionStart: [{ type: 'command', bash: 'node x.js' }] } }; + assert.ok(checkCopilotHooks(missingShell, null).some((e) => e.includes('powershell'))); + const missingScript = { version: 1, hooks: { sessionStart: [{ type: 'command', bash: 'node "$PLUGIN_ROOT/nope.js"', powershell: 'node "$env:PLUGIN_ROOT/nope.js"' }] } }; + assert.ok(checkCopilotHooks(missingScript, repoRoot).some((e) => e.includes('nope.js'))); +}); + +test('the Copilot agent copies keep the instructions of their Claude originals', () => { + const copilotAgentsDir = join(pluginDir, COPILOT_DIR, 'agents'); + const copies = readdirSync(copilotAgentsDir).filter((f) => f.endsWith('.agent.md')); + assert.deepEqual(copies.sort(), agentFiles.map((f) => f.replace(/\.md$/, '.agent.md')).sort()); + for (const file of copies) { + const claudeText = readFileSync(join(agentsDir, file.replace(/\.agent\.md$/, '.md')), 'utf8'); + assert.deepEqual(checkCopilotAgent(file, readFileSync(join(copilotAgentsDir, file), 'utf8'), claudeText), []); + // Copilot supports `tools` as an allowlist, but not Claude-only `model` metadata. + assert.deepEqual(Object.keys(parseFrontMatter(readFileSync(join(copilotAgentsDir, file), 'utf8'))), ['name', 'description', 'tools']); + } +}); + +test('Claude, Copilot and OpenCode reviewer adapters are generated from the portable spring-review skill', () => { + const shared = skillText('spring-review').replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n/, '').trim(); + const adapters = [ + readFileSync(join(agentsDir, 'spring-reviewer.md'), 'utf8'), + readFileSync(join(pluginDir, COPILOT_DIR, 'agents', 'spring-reviewer.agent.md'), 'utf8'), + readFileSync(join(pluginDir, 'opencode', 'agents', 'spring-reviewer.md'), 'utf8'), + ]; + for (const text of adapters) { + assert.equal(text.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n/, '').trim(), shared); + } + const openCode = JSON.parse(readFileSync(join(pluginDir, 'opencode', 'opencode.jsonc'), 'utf8')); + assert.deepEqual(openCode.mcp['spring-tools'].command, ['node', '{env:SPRING_TOOLS_PLUGIN_ROOT}/launcher.js']); + assert.equal(openCode.mcp['spring-tools'].timeout, 120000); + assert.match(adapters[2], /mode: subagent[\s\S]*edit: deny[\s\S]*spring-tools_\*.*allow/); +}); + +test('snapshot workflow updates the portable and Claude plugin manifest versions together', () => { + const workflow = readFileSync(join(repoRoot, '.github', 'workflows', 'snapshot-standalone-ls.yml'), 'utf8'); + assert.match(workflow, /jq --arg v "\$version" '\.version = \$v' agent-plugins\/spring-tools\/\.claude-plugin\/plugin\.json/); + assert.match(workflow, /jq --arg v "\$version" '\.version = \$v' agent-plugins\/spring-tools\/plugin\.json/); + assert.match(workflow, /git add agent-plugins\/spring-tools\/\.claude-plugin\/plugin\.json agent-plugins\/spring-tools\/plugin\.json/); +}); + +test('negative control: a drifted or over-specified Copilot agent copy is reported', () => { + const claudeText = readFileSync(join(agentsDir, 'spring-reviewer.md'), 'utf8'); + const drifted = '---\nname: spring-reviewer\ndescription: Something else entirely that is long enough.\nmodel: inherit\n---\n\nDo something different.\n'; + const errors = checkCopilotAgent('spring-reviewer.agent.md', drifted, claudeText); + assert.ok(errors.some((e) => e.includes("'model' is not supported by Copilot custom agents"))); + assert.ok(errors.some((e) => e.includes('description differs'))); + assert.ok(errors.some((e) => e.includes('instructions differ'))); + assert.ok(checkCopilotAgent('ghost.agent.md', '---\nname: ghost\ndescription: x\n---\n\nbody\n', undefined).some((e) => e.includes('no matching agents/ghost.md'))); +}); + +test('the eval suite mocks every MCP tool with the recorded tools/list and covers every skill and agent with a case', () => { + const agentNames = agentFiles.map((f) => f.replace(/\.md$/, '')); + assert.deepEqual(checkEvals(join(pluginDir, 'evals'), tools, skillNames, agentNames), []); + const mocks = readdirSync(join(pluginDir, 'evals', 'mocks', 'spring-tools-mcp')); + assert.ok(mocks.includes('_tools.json')); + assert.equal(mocks.filter((f) => f.endsWith('.md')).length, tools.size); + // The validate and reviewer cases get a diagnostics recording that contains a Java diagnostic. + for (const name of ['validate-finds-spring-problems', 'reviewer-agent-reviews-project']) { + const override = readFileSync(join(pluginDir, 'evals', name, 'mocks', 'spring-tools-mcp', 'getProjectDiagnostics.md'), 'utf8'); + assert.match(override, /JAVA_AUTOWIRED_CONSTRUCTOR/); + assert.match(override, /expect:\n\s+projectName: sf7-validation/); + } + // Recordings must not leak the machine they were taken on. + for (const file of mocks.filter((f) => f.endsWith('.md'))) { + assert.doesNotMatch(readFileSync(join(pluginDir, 'evals', 'mocks', 'spring-tools-mcp', file), 'utf8'), /\/tmp\/|\/home\//, `${file} contains a local path`); + } +}); + +test('negative control: a broken eval suite is rejected', () => { + const dir = mkdtempSync(join(tmpdir(), 'spring-tools-evals-')); + try { + mkdirSync(join(dir, 'mocks', 'spring-tools-mcp'), { recursive: true }); + writeFileSync(join(dir, 'mocks', 'spring-tools-mcp', 'getEverything.md'), 'nope\n'); + writeFileSync(join(dir, 'mocks', 'spring-tools-mcp', '_tools.json'), JSON.stringify({ tools: [{ name: 'getProjectList' }] })); + mkdirSync(join(dir, 'no-graders')); + writeFileSync(join(dir, 'no-graders', 'prompt.md'), '---\nmax_turns: 5\n---\n\nHello\n'); + mkdirSync(join(dir, 'bad-refs', 'graders'), { recursive: true }); + writeFileSync(join(dir, 'bad-refs', 'prompt.md'), 'Do the thing\n'); + writeFileSync(join(dir, 'bad-refs', 'graders', 'tool.md'), `---\ntype: tool_used\ntool: ${SCOPED_TOOL_PREFIX}getNothing\n---\n`); + writeFileSync(join(dir, 'bad-refs', 'graders', 'skill.md'), '---\ntype: tool_used\ntool: Skill\ninput_match: \'"skill"\\s*:\\s*"(?:[\\w-]+:)?nonexistent"\'\n---\n'); + writeFileSync(join(dir, 'bad-refs', 'graders', 'agent.md'), '---\ntype: tool_used\ntool: Agent\ninput_match: ghost-agent\n---\n'); + writeFileSync(join(dir, 'bad-refs', 'graders', 'untyped.md'), 'PASS if anything.\n'); + const errors = checkEvals(dir, tools, ['validate'], ['spring-reviewer']); + const expect = (pattern) => assert.ok(errors.some((e) => pattern.test(e)), `expected an error matching ${pattern}, got:\n${errors.join('\n')}`); + expect(/mock getEverything\.md answers unknown tool/); + expect(/_tools\.json differs/); + expect(/tools without a suite-wide mock/); + expect(/no-graders: no graders/); + expect(/getNothing' does not exist/); + expect(/skill 'nonexistent' does not exist/); + expect(/agent 'ghost-agent' does not exist/); + expect(/untyped\.md: no type/); + expect(/skill 'validate' has no case/); + expect(/agent 'spring-reviewer' has no case/); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test('the mock MCP server rejects tool names that could traverse out of its mock directory', (t) => { + const server = join(here, '..', 'evals', 'mock-mcp-server.mjs'); + const result = spawnSync(process.execPath, [server], { + encoding: 'utf8', + input: `${JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name: '../../etc/passwd', arguments: {} } })}\n`, + env: { ...process.env, EVAL_MOCK_DIRS: join(here, 'fixtures') }, + }); + if (result.error?.code === 'EPERM') { + t.skip('sandbox denies child process creation'); + return; + } + assert.equal(result.status, 0, result.stderr); + const reply = JSON.parse(result.stdout.trim()); + assert.equal(reply.result.isError, true); + assert.equal(reply.result.content[0].text, 'invalid tool name'); +}); + +test('Copilot eval tool graders honor positive and negative occurrence bounds', () => { + const trace = [ + { tool: 'spring-tools-mcp-getProjectList', input: '{}' }, + { tool: 'skill', input: '{"skill":"spring-tools:validate"}' }, + ]; + assert.equal(countToolMatches({ tool: 'mcp__plugin_spring-tools_spring-tools-mcp__getProjectList' }, trace), 1); + assert.equal(toolGradePasses({ type: 'tool_used', tool: 'mcp__plugin_spring-tools_spring-tools-mcp__getProjectList' }, trace), true); + assert.equal(toolGradePasses({ type: 'tool_used', tool: 'Skill', min: 0, max: 0 }, trace), false); + assert.equal(toolGradePasses({ type: 'tool_used', tool: 'Skill', input_match: 'nope', min: 0, max: 0 }, trace), true); + assert.equal(toolGradePasses({ type: 'tool_used', tool: 'Skill', min: 2, max: 3 }, trace), false); +}); + +test('Copilot evals select custom agents using the file-derived ID without plugin namespacing', () => { + assert.equal(copilotAgentId('spring-reviewer'), 'spring-reviewer'); + assert.equal(copilotAgentId('spring-tools:spring-reviewer'), undefined); + assert.equal(copilotAgentId('spring-reviewer --evil'), undefined); +}); diff --git a/claude-plugins/update-local-jars.sh b/agent-plugins/update-local-jars.sh similarity index 86% rename from claude-plugins/update-local-jars.sh rename to agent-plugins/update-local-jars.sh index bb6708a425..7c35634511 100755 --- a/claude-plugins/update-local-jars.sh +++ b/agent-plugins/update-local-jars.sh @@ -1,15 +1,15 @@ #!/bin/bash set -e -# Go to the directory of this script (claude-plugins) +# Go to the directory of this script (agent-plugins) cd "$(dirname "$0")" echo "Building standalone language server..." cd ../headless-services ./mvnw -f pom.xml -pl spring-boot-language-server-standalone -am -DskipTests clean package -cd ../claude-plugins +cd ../agent-plugins -echo "Updating local JARs for Claude Code plugins..." +echo "Updating local JARs for agent plugins..." # 1. Update spring-tools plugin echo "Updating spring-tools plugin..." diff --git a/claude-plugins/spring-tools/.gitignore b/claude-plugins/spring-tools/.gitignore deleted file mode 100644 index 178e8c412f..0000000000 --- a/claude-plugins/spring-tools/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -language-server/ -node_modules/ \ No newline at end of file diff --git a/claude-plugins/spring-tools/README.md b/claude-plugins/spring-tools/README.md deleted file mode 100644 index 13a5c38c25..0000000000 --- a/claude-plugins/spring-tools/README.md +++ /dev/null @@ -1,172 +0,0 @@ -# Spring Tools Language Server — Claude Code Plugin - -A [Claude Code](https://code.claude.com) plugin that contributes the Spring Tools Language Server, exposing Spring Boot diagnostics, bean/request-mapping lookups, and other project insights to Claude Code via MCP tools. - -Unlike the VS Code extension, this plugin uses the **standalone** variant of the language server which operates **without** JDT Language Server. Project classpath is computed directly via Maven and Gradle tooling; type indexing uses Jandex. - -## Requirements - -- Java 21+ on `PATH` -- Maven or Gradle projects in your workspace - -## Usage - -### 1. Add the Marketplace - -First, add either the Release or Snapshot marketplace to Claude Code: - -**To use the stable release:** -```bash -claude plugin marketplace add https://cdn.spring.io/spring-tools/release/claude-plugins/marketplace.json -``` - -**To use the bleeding-edge snapshot:** -```bash -claude plugin marketplace add https://cdn.spring.io/spring-tools/snapshot/claude-plugins/marketplace.json -``` - -### 2. Install the Plugin - -Once the marketplace is added, install the plugin: - -**If you added the stable release marketplace:** -```bash -claude plugin install spring-tools@spring-tools-marketplace -``` - -**If you added the snapshot marketplace:** -```bash -claude plugin install spring-tools@spring-tools-snapshots -``` - -### 3. Update the Plugin - -When new versions of the plugin are published to the marketplace, update it by running: - -```bash -claude plugin marketplace update -claude plugin update spring-tools -``` - -### 4. Testing the Plugin - -To verify that the Spring Boot Language Server is correctly booting up and serving MCP tools to Claude Code, you must run Claude Code **interactively** (don't use the `-p` single-shot flag, as it will kill the CLI before the language server finishes initializing). - -Open a Spring Boot project and start Claude Code: -```bash -claude -``` - -Then, ask Claude a test query to verify the MCP integration. For example: -> "Check the project diagnostics for CoffeeController.java." - -Claude will wait for the language server to initialize, call the `getProjectDiagnostics` MCP tool, and summarize the exact Spring Boot warnings and quick fixes it reports. - -### 5. Local Testing - -We maintain a local marketplace configuration (`claude-plugins/.claude-plugin/marketplace.json`) to make testing the plugin directly from the source tree easy. - -1. Run the update script to build the standalone language server JAR and copy it into this plugin's directory (run this from the `claude-plugins` directory): - ```bash - ./update-local-jars.sh - ``` -2. Add the local `claude-plugins` directory as a marketplace (run this from the `sts4` root directory): - ```bash - claude plugin marketplace add ./claude-plugins - ``` -3. Install the plugin from your new local marketplace: - ```bash - claude plugin install spring-tools@spring-tools-local - ``` - -## Configuring language server preferences - -You can customize validation severities and other language server settings on a per-project basis by placing a settings file inside the `.claude/` directory of your project. Two formats are supported and may coexist — the properties file provides the base values and the JSON file overrides them. - -### Properties format (`.claude/spring-tools.properties`) - -Flat `key=value` format where each key is the full dot-separated settings path. This is the simplest format to get started with: - -```properties -# Category enablement toggles (AUTO / ON / OFF) -boot-java.validation.java.boot2=OFF -boot-java.validation.java.boot3=ON -boot-java.validation.spel.on=ON -boot-java.validation.java.version-validation=OFF - -# Per-problem severity overrides (IGNORE / HINT / INFO / WARNING / ERROR) -spring-boot.ls.problem.boot2.JAVA_PUBLIC_BEAN_METHOD=IGNORE -spring-boot.ls.problem.boot2.JAVA_AUTOWIRED_CONSTRUCTOR=IGNORE -``` - -### JSON format (`.claude/spring-tools.json`) - -Nested JSON matching the VSCode `boot-java` / `spring-boot` configuration structure. Useful when you want to express several settings for the same category together: - -```json -{ - "boot-java": { - "validation": { - "java": { - "boot2": "OFF", - "boot3": "ON" - }, - "spel": { "on": "ON" }, - "version-validation": { "on": "OFF" } - } - }, - "spring-boot": { - "ls": { - "problem": { - "boot2": { - "JAVA_PUBLIC_BEAN_METHOD": "IGNORE", - "JAVA_AUTOWIRED_CONSTRUCTOR": "IGNORE" - } - } - } - } -} -``` - -### Available settings - -**Category enablement toggles** (`boot-java.validation.*`) accept `AUTO`, `ON`, or `OFF`. - -**Per-problem severity overrides** (`spring-boot.ls.problem..`) accept `IGNORE`, `HINT`, `INFO`, `WARNING`, or `ERROR`. - -The full list of available categories and problem codes is embedded in the language server JAR as `problem-types.json`. They are the same keys used in the VSCode extension's settings. - -Settings are applied once at startup. You must restart the language server (and therefore Claude Code) for changes to take effect. - -## What the language server provides - -Via MCP tools: - -- **Diagnostics** — Spring-specific warnings and quick fixes (missing annotations, incorrect bean wiring, etc.), including version validation results -- **Project insight** — bean, component, and request-mapping lookups; resolved project classpath - -Via hooks (`hooks/hooks.json`), the plugin also tracks file and project changes on disk to keep its internal index up to date, and exposes a command to refresh the index manually. These hooks are gated to Java/Kotlin/Groovy source files and build/config files (`.java`, `.kt`, `.kts`, `.groovy`, `.xml`, `.properties`, `.yml`, `.yaml`, `.gradle`) — edits to unrelated files don't trigger them. The workspace-refresh hook fires only on `git` commands. - -## Plugin structure - -``` -spring-tools/ -├── .claude-plugin/ -│ └── plugin.json # Plugin manifest (MCP server config) -├── launcher.js # Node.js script that downloads the JAR (if missing) and starts Java -├── install.js # Node.js script that downloads the JAR -├── hooks/ # Hooks that notify the language server of file/project changes -├── language-server/ # Populated by install.js on first run (gitignored) -│ └── spring-boot-language-server-standalone-exec.jar -├── skills/ # Claude Code skills -│ ├── validate/ -│ └── quickfix/ -├── explanations/ # Markdown files with problem explanations and fixes -└── README.md -``` - -## How it works - -Claude Code parses the MCP configuration in `plugin.json` at startup. This triggers `launcher.js`, which checks if the heavy Java JAR is downloaded. If not, it executes `install.js` to download it from Spring's CDN. Then it boots the standalone Spring Tools Language Server, instructing it to expose its MCP tools over `stdio` (the language server's own LSP socket transport is disabled, since nothing in this plugin connects to it). - -The launcher sets `languageserver.standalone.project-dir` to the current working directory. The standalone server uses that Spring Boot property for project discovery, settings-file loading, and file watching. Watcher behavior can be configured with `languageserver.standalone.filewatcher.enabled`, `languageserver.standalone.filewatcher.debounce-ms`, and `languageserver.standalone.filewatcher.max-directories`. diff --git a/claude-plugins/spring-tools/commands/refresh.md b/claude-plugins/spring-tools/commands/refresh.md deleted file mode 100644 index f9b3514f2a..0000000000 --- a/claude-plugins/spring-tools/commands/refresh.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -description: Refresh the Spring Tools workspace so the language server re-indexes files on disk (use after /rewind, git checkout, external edits, etc.). -allowed-tools: - - mcp__spring-tools-mcp__refreshWorkspace ---- - -Call the `refreshWorkspace` tool on the `spring-tools-mcp` MCP server to force the Spring Tools language server to re-read project state from disk. Do not do anything else. After the tool returns, briefly confirm to the user that the Spring Tools workspace was refreshed. diff --git a/claude-plugins/spring-tools/evals/mcp-tools-covered/graders/capture-logical-structure-baseline.md b/claude-plugins/spring-tools/evals/mcp-tools-covered/graders/capture-logical-structure-baseline.md new file mode 100644 index 0000000000..d2dcab6358 --- /dev/null +++ b/claude-plugins/spring-tools/evals/mcp-tools-covered/graders/capture-logical-structure-baseline.md @@ -0,0 +1,4 @@ +--- +type: tool_used +tool: mcp__plugin_spring-tools_spring-tools-mcp__captureLogicalStructureBaseline +--- diff --git a/claude-plugins/spring-tools/evals/mcp-tools-covered/graders/clear-logical-structure-baseline.md b/claude-plugins/spring-tools/evals/mcp-tools-covered/graders/clear-logical-structure-baseline.md new file mode 100644 index 0000000000..170dd5ccb6 --- /dev/null +++ b/claude-plugins/spring-tools/evals/mcp-tools-covered/graders/clear-logical-structure-baseline.md @@ -0,0 +1,4 @@ +--- +type: tool_used +tool: mcp__plugin_spring-tools_spring-tools-mcp__clearLogicalStructureBaseline +--- diff --git a/claude-plugins/spring-tools/evals/mcp-tools-covered/graders/file-changed.md b/claude-plugins/spring-tools/evals/mcp-tools-covered/graders/file-changed.md new file mode 100644 index 0000000000..6618d1afa7 --- /dev/null +++ b/claude-plugins/spring-tools/evals/mcp-tools-covered/graders/file-changed.md @@ -0,0 +1,4 @@ +--- +type: tool_used +tool: mcp__plugin_spring-tools_spring-tools-mcp__fileChanged +--- diff --git a/claude-plugins/spring-tools/evals/mcp-tools-covered/graders/file-deleted.md b/claude-plugins/spring-tools/evals/mcp-tools-covered/graders/file-deleted.md new file mode 100644 index 0000000000..29ea0fa0f8 --- /dev/null +++ b/claude-plugins/spring-tools/evals/mcp-tools-covered/graders/file-deleted.md @@ -0,0 +1,4 @@ +--- +type: tool_used +tool: mcp__plugin_spring-tools_spring-tools-mcp__fileDeleted +--- diff --git a/claude-plugins/spring-tools/evals/mcp-tools-covered/graders/find-beans-by-type.md b/claude-plugins/spring-tools/evals/mcp-tools-covered/graders/find-beans-by-type.md new file mode 100644 index 0000000000..0347b762ab --- /dev/null +++ b/claude-plugins/spring-tools/evals/mcp-tools-covered/graders/find-beans-by-type.md @@ -0,0 +1,4 @@ +--- +type: tool_used +tool: mcp__plugin_spring-tools_spring-tools-mcp__findBeansByType +--- diff --git a/claude-plugins/spring-tools/evals/mcp-tools-covered/graders/find-components-by-stereotype.md b/claude-plugins/spring-tools/evals/mcp-tools-covered/graders/find-components-by-stereotype.md new file mode 100644 index 0000000000..a66c7bf3ad --- /dev/null +++ b/claude-plugins/spring-tools/evals/mcp-tools-covered/graders/find-components-by-stereotype.md @@ -0,0 +1,4 @@ +--- +type: tool_used +tool: mcp__plugin_spring-tools_spring-tools-mcp__findComponentsByStereotype +--- diff --git a/claude-plugins/spring-tools/evals/mcp-tools-covered/graders/find-request-mappings-by-method.md b/claude-plugins/spring-tools/evals/mcp-tools-covered/graders/find-request-mappings-by-method.md new file mode 100644 index 0000000000..136a64d818 --- /dev/null +++ b/claude-plugins/spring-tools/evals/mcp-tools-covered/graders/find-request-mappings-by-method.md @@ -0,0 +1,4 @@ +--- +type: tool_used +tool: mcp__plugin_spring-tools_spring-tools-mcp__findRequestMappingsByMethod +--- diff --git a/claude-plugins/spring-tools/evals/mcp-tools-covered/graders/get-bean-details.md b/claude-plugins/spring-tools/evals/mcp-tools-covered/graders/get-bean-details.md new file mode 100644 index 0000000000..f9fcc6c85a --- /dev/null +++ b/claude-plugins/spring-tools/evals/mcp-tools-covered/graders/get-bean-details.md @@ -0,0 +1,4 @@ +--- +type: tool_used +tool: mcp__plugin_spring-tools_spring-tools-mcp__getBeanDetails +--- diff --git a/claude-plugins/spring-tools/evals/mcp-tools-covered/graders/get-generations.md b/claude-plugins/spring-tools/evals/mcp-tools-covered/graders/get-generations.md new file mode 100644 index 0000000000..45872f1f99 --- /dev/null +++ b/claude-plugins/spring-tools/evals/mcp-tools-covered/graders/get-generations.md @@ -0,0 +1,4 @@ +--- +type: tool_used +tool: mcp__plugin_spring-tools_spring-tools-mcp__getGenerations +--- diff --git a/claude-plugins/spring-tools/evals/mcp-tools-covered/graders/get-latest-boot-versions.md b/claude-plugins/spring-tools/evals/mcp-tools-covered/graders/get-latest-boot-versions.md new file mode 100644 index 0000000000..322d78f92a --- /dev/null +++ b/claude-plugins/spring-tools/evals/mcp-tools-covered/graders/get-latest-boot-versions.md @@ -0,0 +1,4 @@ +--- +type: tool_used +tool: mcp__plugin_spring-tools_spring-tools-mcp__getLatestBootVersionsFromMavenRepo +--- diff --git a/claude-plugins/spring-tools/evals/mcp-tools-covered/graders/get-list-components-and-stereotypes.md b/claude-plugins/spring-tools/evals/mcp-tools-covered/graders/get-list-components-and-stereotypes.md new file mode 100644 index 0000000000..3f69a0ed31 --- /dev/null +++ b/claude-plugins/spring-tools/evals/mcp-tools-covered/graders/get-list-components-and-stereotypes.md @@ -0,0 +1,4 @@ +--- +type: tool_used +tool: mcp__plugin_spring-tools_spring-tools-mcp__getListOfComponentsAndTheirStereotypes +--- diff --git a/claude-plugins/spring-tools/evals/mcp-tools-covered/graders/get-logical-structure-baseline-history.md b/claude-plugins/spring-tools/evals/mcp-tools-covered/graders/get-logical-structure-baseline-history.md new file mode 100644 index 0000000000..9ba5d2348b --- /dev/null +++ b/claude-plugins/spring-tools/evals/mcp-tools-covered/graders/get-logical-structure-baseline-history.md @@ -0,0 +1,4 @@ +--- +type: tool_used +tool: mcp__plugin_spring-tools_spring-tools-mcp__getLogicalStructureBaselineHistory +--- diff --git a/claude-plugins/spring-tools/evals/mcp-tools-covered/graders/get-logical-structure-changes.md b/claude-plugins/spring-tools/evals/mcp-tools-covered/graders/get-logical-structure-changes.md new file mode 100644 index 0000000000..966f78af48 --- /dev/null +++ b/claude-plugins/spring-tools/evals/mcp-tools-covered/graders/get-logical-structure-changes.md @@ -0,0 +1,4 @@ +--- +type: tool_used +tool: mcp__plugin_spring-tools_spring-tools-mcp__getLogicalStructureChanges +--- diff --git a/claude-plugins/spring-tools/evals/mcp-tools-covered/graders/get-logical-structure.md b/claude-plugins/spring-tools/evals/mcp-tools-covered/graders/get-logical-structure.md new file mode 100644 index 0000000000..c1c984d795 --- /dev/null +++ b/claude-plugins/spring-tools/evals/mcp-tools-covered/graders/get-logical-structure.md @@ -0,0 +1,4 @@ +--- +type: tool_used +tool: mcp__plugin_spring-tools_spring-tools-mcp__getLogicalStructure +--- diff --git a/claude-plugins/spring-tools/evals/mcp-tools-covered/graders/get-project-list.md b/claude-plugins/spring-tools/evals/mcp-tools-covered/graders/get-project-list.md new file mode 100644 index 0000000000..36a7ae4b7b --- /dev/null +++ b/claude-plugins/spring-tools/evals/mcp-tools-covered/graders/get-project-list.md @@ -0,0 +1,4 @@ +--- +type: tool_used +tool: mcp__plugin_spring-tools_spring-tools-mcp__getProjectList +--- diff --git a/claude-plugins/spring-tools/evals/mcp-tools-covered/graders/get-releases.md b/claude-plugins/spring-tools/evals/mcp-tools-covered/graders/get-releases.md new file mode 100644 index 0000000000..7b076b677b --- /dev/null +++ b/claude-plugins/spring-tools/evals/mcp-tools-covered/graders/get-releases.md @@ -0,0 +1,4 @@ +--- +type: tool_used +tool: mcp__plugin_spring-tools_spring-tools-mcp__getReleases +--- diff --git a/claude-plugins/spring-tools/evals/mcp-tools-covered/graders/get-resolved-project-classpath.md b/claude-plugins/spring-tools/evals/mcp-tools-covered/graders/get-resolved-project-classpath.md new file mode 100644 index 0000000000..d28e8430a7 --- /dev/null +++ b/claude-plugins/spring-tools/evals/mcp-tools-covered/graders/get-resolved-project-classpath.md @@ -0,0 +1,4 @@ +--- +type: tool_used +tool: mcp__plugin_spring-tools_spring-tools-mcp__getResolvedProjectClasspath +--- diff --git a/claude-plugins/spring-tools/evals/mcp-tools-covered/graders/get-spring-boot-version.md b/claude-plugins/spring-tools/evals/mcp-tools-covered/graders/get-spring-boot-version.md new file mode 100644 index 0000000000..e65867a865 --- /dev/null +++ b/claude-plugins/spring-tools/evals/mcp-tools-covered/graders/get-spring-boot-version.md @@ -0,0 +1,4 @@ +--- +type: tool_used +tool: mcp__plugin_spring-tools_spring-tools-mcp__getSpringBootVersion +--- diff --git a/claude-plugins/spring-tools/evals/mcp-tools-covered/graders/get-stereotypes-list.md b/claude-plugins/spring-tools/evals/mcp-tools-covered/graders/get-stereotypes-list.md new file mode 100644 index 0000000000..ba53b0d7cf --- /dev/null +++ b/claude-plugins/spring-tools/evals/mcp-tools-covered/graders/get-stereotypes-list.md @@ -0,0 +1,4 @@ +--- +type: tool_used +tool: mcp__plugin_spring-tools_spring-tools-mcp__getStereotypesList +--- diff --git a/claude-plugins/spring-tools/evals/mcp-tools-covered/graders/get-structure-dependencies.md b/claude-plugins/spring-tools/evals/mcp-tools-covered/graders/get-structure-dependencies.md new file mode 100644 index 0000000000..bd93ef5c1c --- /dev/null +++ b/claude-plugins/spring-tools/evals/mcp-tools-covered/graders/get-structure-dependencies.md @@ -0,0 +1,4 @@ +--- +type: tool_used +tool: mcp__plugin_spring-tools_spring-tools-mcp__getStructureDependencies +--- diff --git a/claude-plugins/spring-tools/evals/mcp-tools-covered/graders/get-upcoming-releases.md b/claude-plugins/spring-tools/evals/mcp-tools-covered/graders/get-upcoming-releases.md new file mode 100644 index 0000000000..06d5446aca --- /dev/null +++ b/claude-plugins/spring-tools/evals/mcp-tools-covered/graders/get-upcoming-releases.md @@ -0,0 +1,4 @@ +--- +type: tool_used +tool: mcp__plugin_spring-tools_spring-tools-mcp__getUpcomingReleases +--- diff --git a/claude-plugins/spring-tools/evals/mcp-tools-covered/prompt.md b/claude-plugins/spring-tools/evals/mcp-tools-covered/prompt.md new file mode 100644 index 0000000000..86ca10aaac --- /dev/null +++ b/claude-plugins/spring-tools/evals/mcp-tools-covered/prompt.md @@ -0,0 +1,14 @@ +--- +max_turns: 40 +allowed_tools: [Read, Glob, Grep, Skill] +tags: [smoke, mcp-coverage] +expected_outcome: The agent calls every listed Spring Tools MCP tool at least once and summarizes the checked areas without editing files. +--- + +This eval runs against an isolated mock Spring Tools server. Call every MCP tool listed below at least once, using `sf7-validation` as the IDE project name and `spring-boot` as the Spring IO project slug where those parameters apply. For required type, bean, HTTP method, stereotype, and file-path arguments, use `org.example.Service`, `service`, `GET`, `Controller`, and `/tmp/spring-tools-eval/Probe.java` as appropriate. Use each tool's schema for any optional arguments. + +Call the project discovery, diagnostics, information, component, endpoint, structure, Spring release, workspace refresh, and file-notification tools, including the tools that capture or clear a logical-structure baseline. The mocked calls are isolated and are safe for this eval. Do not edit project files. After the calls, briefly summarize which categories you checked. + +The required tool calls are: + +`getProjectList`, `getProjectDiagnostics`, `getSpringBootVersion`, `getJavaVersion`, `getResolvedProjectClasspath`, `getBeanDetails`, `getBeanUsageInfo`, `findBeansByType`, `getRequestMappings`, `findRequestMappingsByMethod`, `getStereotypesList`, `getListOfComponentsAndTheirStereotypes`, `findComponentsByStereotype`, `getLogicalStructure`, `getStructureDependencies`, `captureLogicalStructureBaseline`, `clearLogicalStructureBaseline`, `getLogicalStructureChanges`, `getLogicalStructureBaselineHistory`, `getReleases`, `getGenerations`, `getUpcomingReleases`, `getLatestReleaseInformation`, `getLatestBootVersionsFromMavenRepo`, `fileChanged`, `fileDeleted`, and `refreshWorkspace`. diff --git a/claude-plugins/spring-tools/hooks/hooks.json b/claude-plugins/spring-tools/hooks/hooks.json deleted file mode 100644 index 79fa3798ba..0000000000 --- a/claude-plugins/spring-tools/hooks/hooks.json +++ /dev/null @@ -1,88 +0,0 @@ -{ - "hooks": { - "PostToolUse": [ - { - "matcher": "Write", - "hooks": [ - { - "type": "mcp_tool", - "if": "Write(**/*.{java,kt,kts,groovy,xml,properties,yml,yaml,gradle})", - "server": "plugin:spring-tools:spring-tools-mcp", - "tool": "fileChanged", - "input": { - "filePath": "${tool_input.file_path}" - } - } - ] - }, - { - "matcher": "StrReplace", - "hooks": [ - { - "type": "mcp_tool", - "if": "StrReplace(**/*.{java,kt,kts,groovy,xml,properties,yml,yaml,gradle})", - "server": "plugin:spring-tools:spring-tools-mcp", - "tool": "fileChanged", - "input": { - "filePath": "${tool_input.file_path}" - } - } - ] - }, - { - "matcher": "Edit", - "hooks": [ - { - "type": "mcp_tool", - "if": "Edit(**/*.{java,kt,kts,groovy,xml,properties,yml,yaml,gradle})", - "server": "plugin:spring-tools:spring-tools-mcp", - "tool": "fileChanged", - "input": { - "filePath": "${tool_input.file_path}" - } - } - ] - }, - { - "matcher": "Replace", - "hooks": [ - { - "type": "mcp_tool", - "if": "Replace(**/*.{java,kt,kts,groovy,xml,properties,yml,yaml,gradle})", - "server": "plugin:spring-tools:spring-tools-mcp", - "tool": "fileChanged", - "input": { - "filePath": "${tool_input.file_path}" - } - } - ] - }, - { - "matcher": "Delete", - "hooks": [ - { - "type": "mcp_tool", - "if": "Delete(**/*.{java,kt,kts,groovy,xml,properties,yml,yaml,gradle})", - "server": "plugin:spring-tools:spring-tools-mcp", - "tool": "fileDeleted", - "input": { - "filePath": "${tool_input.file_path}" - } - } - ] - }, - { - "matcher": "Bash", - "hooks": [ - { - "type": "mcp_tool", - "if": "Bash(git *)", - "server": "plugin:spring-tools:spring-tools-mcp", - "tool": "refreshWorkspace", - "input": {} - } - ] - } - ] - } -} diff --git a/claude-plugins/spring-tools/install.js b/claude-plugins/spring-tools/install.js deleted file mode 100644 index 349c43c963..0000000000 --- a/claude-plugins/spring-tools/install.js +++ /dev/null @@ -1,102 +0,0 @@ -const fs = require('fs'); -const path = require('path'); -const https = require('https'); -const crypto = require('crypto'); - -const pluginJsonPath = path.join(__dirname, '.claude-plugin', 'plugin.json'); -const pluginData = JSON.parse(fs.readFileSync(pluginJsonPath, 'utf8')); -const version = pluginData.version; - -const JAR_NAME = 'spring-boot-language-server-standalone-exec.jar'; -const jarDir = path.join(__dirname, 'language-server'); -const jarPath = path.join(jarDir, JAR_NAME); - -const ALLOWED_HOST = 'cdn.spring.io'; -const MAX_REDIRECTS = 5; - -if (!fs.existsSync(jarDir)) { - fs.mkdirSync(jarDir, { recursive: true }); -} - -function get(url, redirectCount = 0) { - return new Promise((resolve, reject) => { - let parsed; - try { - parsed = new URL(url); - } catch (err) { - reject(new Error(`Invalid URL: ${url}`)); - return; - } - if (parsed.protocol !== 'https:' || parsed.hostname !== ALLOWED_HOST) { - reject(new Error(`Refusing to fetch from untrusted host: ${url}`)); - return; - } - https.get(url, (res) => { - if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) { - res.resume(); - if (redirectCount >= MAX_REDIRECTS) { - reject(new Error(`Too many redirects while fetching ${url}`)); - return; - } - resolve(get(new URL(res.headers.location, url).toString(), redirectCount + 1)); - } else if (res.statusCode === 200) { - resolve(res); - } else { - reject(new Error(`Failed to fetch ${url}: ${res.statusCode}`)); - } - }).on('error', reject); - }); -} - -async function fetchText(url) { - const res = await get(url); - return new Promise((resolve, reject) => { - let data = ''; - res.on('data', chunk => data += chunk); - res.on('end', () => resolve(data.trim())); - res.on('error', reject); - }); -} - -async function downloadFile(url, dest) { - console.error(`Downloading: ${url}`); - const res = await get(url); - const hash = crypto.createHash('sha256'); - const fileStream = fs.createWriteStream(dest); - return new Promise((resolve, reject) => { - res.on('data', chunk => hash.update(chunk)); - res.on('error', reject); - fileStream.on('error', reject); - fileStream.on('finish', () => resolve(hash.digest('hex'))); - res.pipe(fileStream); - }); -} - -async function install() { - console.error(`Installing Spring Boot Language Server v${version}...`); - - const isSnapshot = version.includes('-'); - const baseUrl = isSnapshot - ? `https://cdn.spring.io/spring-tools/snapshot/language-server/spring-boot/${JAR_NAME}` - : `https://cdn.spring.io/spring-tools/release/language-server/spring-boot/${version}/${JAR_NAME}`; - const sha256Url = `${baseUrl}.sha256`; - - try { - const expectedSha256 = await fetchText(sha256Url); - const actualSha256 = await downloadFile(baseUrl, jarPath); - - if (actualSha256.toLowerCase() !== expectedSha256.toLowerCase()) { - fs.unlinkSync(jarPath); - throw new Error(`Checksum mismatch for ${JAR_NAME}: expected ${expectedSha256}, got ${actualSha256}`); - } - - console.error(`Successfully installed version ${version} to ${jarPath}`); - } catch (err) { - throw new Error(`Failed to download JAR from ${baseUrl}\nError: ${err.message}`); - } -} - -install().catch(err => { - console.error("Installation failed:", err); - process.exit(1); -}); diff --git a/claude-plugins/spring-tools/launcher.js b/claude-plugins/spring-tools/launcher.js deleted file mode 100644 index 4e974f9fd6..0000000000 --- a/claude-plugins/spring-tools/launcher.js +++ /dev/null @@ -1,65 +0,0 @@ -/* Copyright (c) 2026 Broadcom, Inc. */ - -const fs = require('fs'); -const path = require('path'); -const { spawn, spawnSync } = require('child_process'); - -const jarDir = path.join(__dirname, 'language-server'); -const jarPath = path.join(jarDir, 'spring-boot-language-server-standalone-exec.jar'); - -async function start() { - // 1. Download the JAR if it doesn't exist - if (!fs.existsSync(jarPath)) { - console.error("Spring Boot Language Server JAR not found. Downloading on first run..."); - try { - spawnSync('node', [path.join(__dirname, 'install.js')], { stdio: 'inherit' }); - } catch (e) { - console.error("Failed to download JAR:", e); - process.exit(1); - } - } - - // 2. Launch the Java process. The plugin only exposes MCP tools over stdio - - // the LSP socket transport is disabled since nothing connects to it. - const javaArgs = [ - "-Xmx1024m", - "-Djdk.util.zip.disableZip64ExtraFieldValidation=true", - "-Dspring.config.location=classpath:/application.properties", - "-Dspring.profiles.active=file-logging", - `-Dlogging.file.name=${path.join(__dirname, 'boot-ls.log')}`, - "-Dlogging.level.root=INFO", - "-Dspring.ai.mcp.server.stdio=true", - "-Dlanguageserver.enabled=false", - `-Dlanguageserver.standalone.project-dir=${process.cwd()}`, - "-jar", - jarPath - ]; - - const child = spawn('java', javaArgs, { stdio: 'inherit' }); - - // When Claude Code exits, it sends a termination signal to this Node script. - // We must catch these signals and manually kill the heavy Java child process - // so it doesn't remain active as an orphan in the background. - ['SIGINT', 'SIGTERM', 'SIGQUIT'].forEach(signal => { - process.on(signal, () => { - if (!child.killed) { - child.kill('SIGTERM'); - } - process.exit(0); - }); - }); - - child.on('error', (err) => { - console.error('Failed to start Java process:', err); - process.exit(1); - }); - - child.on('close', (code) => { - process.exit(code); - }); -} - -start().catch(err => { - console.error("Failed to start launcher:", err); - process.exit(1); -}); \ No newline at end of file diff --git a/nohttp-whitelist.txt b/nohttp-whitelist.txt index ab1cb85c13..3155fc0645 100644 --- a/nohttp-whitelist.txt +++ b/nohttp-whitelist.txt @@ -1,3 +1,5 @@ http://timestamp\.digicert\.com http://sha256timestamp\.ws\.symantec\.com/sha256/timestamp http://org\.eclipse\.ui\.intro/.* +http://spring\.io/ +http://cdn\.spring\.io/x\.jar