From e39902eedb089408855dbf7425f01ce26f2e3fe2 Mon Sep 17 00:00:00 2001 From: Patrick Baumgartner Date: Fri, 9 Oct 2026 20:12:29 +0200 Subject: [PATCH] GH-1998: Add consistency checks for explanation playbooks and plugin config Fixes gh-1998 Signed-off-by: Patrick Baumgartner --- .../research-notes/TODO_quickfixes.md | 1 + .../explanations/MODEL_ATTRIBUTE_NAMES.md | 3 + claude-plugins/tools/check-explanations.mjs | 363 ++++++++++ claude-plugins/tools/lib/plugin-config.mjs | 659 ++++++++++++++++++ claude-plugins/tools/package.json | 15 + .../tools/test/explanations.test.mjs | 117 ++++ .../tools/test/fixtures/bad-hooks.json | 46 ++ .../research-notes/TODO_quickfixes.md | 7 + .../spring-tools/explanations/GOOD_CODE.md | 18 + .../spring-tools/explanations/ORPHAN_CODE.md | 8 + .../spring-tools/explanations/REF_TARGET.md | 5 + .../ide/vscode/boot/java/FakeProblemType.java | 32 + .../tools/test/fixtures/bad-skill.md | 12 + .../tools/test/plugin-config.test.mjs | 123 ++++ nohttp-whitelist.txt | 1 + 15 files changed, 1410 insertions(+) create mode 100644 claude-plugins/tools/check-explanations.mjs create mode 100644 claude-plugins/tools/lib/plugin-config.mjs create mode 100644 claude-plugins/tools/package.json create mode 100644 claude-plugins/tools/test/explanations.test.mjs create mode 100644 claude-plugins/tools/test/fixtures/bad-hooks.json create mode 100644 claude-plugins/tools/test/fixtures/bad-repo/claude-plugins/research-notes/TODO_quickfixes.md create mode 100644 claude-plugins/tools/test/fixtures/bad-repo/claude-plugins/spring-tools/explanations/GOOD_CODE.md create mode 100644 claude-plugins/tools/test/fixtures/bad-repo/claude-plugins/spring-tools/explanations/ORPHAN_CODE.md create mode 100644 claude-plugins/tools/test/fixtures/bad-repo/claude-plugins/spring-tools/explanations/REF_TARGET.md create mode 100644 claude-plugins/tools/test/fixtures/bad-repo/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/java/FakeProblemType.java create mode 100644 claude-plugins/tools/test/fixtures/bad-skill.md create mode 100644 claude-plugins/tools/test/plugin-config.test.mjs diff --git a/claude-plugins/research-notes/TODO_quickfixes.md b/claude-plugins/research-notes/TODO_quickfixes.md index 1a66a3395d..cc8879cc17 100644 --- a/claude-plugins/research-notes/TODO_quickfixes.md +++ b/claude-plugins/research-notes/TODO_quickfixes.md @@ -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/explanations/MODEL_ATTRIBUTE_NAMES.md b/claude-plugins/spring-tools/explanations/MODEL_ATTRIBUTE_NAMES.md index faa15e9aa7..b1565db0c3 100644 --- a/claude-plugins/spring-tools/explanations/MODEL_ATTRIBUTE_NAMES.md +++ b/claude-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/tools/check-explanations.mjs b/claude-plugins/tools/check-explanations.mjs new file mode 100644 index 0000000000..7b93ca12a8 --- /dev/null +++ b/claude-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 = 'claude-plugins/spring-tools/explanations'; +export const TODO_FILE = 'claude-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/claude-plugins/tools/lib/plugin-config.mjs b/claude-plugins/tools/lib/plugin-config.mjs new file mode 100644 index 0000000000..f6794d0d9f --- /dev/null +++ b/claude-plugins/tools/lib/plugin-config.mjs @@ -0,0 +1,659 @@ +/******************************************************************************* + * 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 is loaded by two +// clients: Claude Code reads `.claude-plugin/plugin.json`, `hooks/hooks.json` and `agents/`, +// GitHub Copilot CLI reads the Agent Plugins 1.0 manifests (`plugin.json`, `mcp.json`) and the +// `com.github.copilot/` directory. Both sets 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 = 'claude-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, 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(); + 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(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 === '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 claude-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`); + } + 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 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, 'claude-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/claude-plugins/tools/package.json b/claude-plugins/tools/package.json new file mode 100644 index 0000000000..58191e9b92 --- /dev/null +++ b/claude-plugins/tools/package.json @@ -0,0 +1,15 @@ +{ + "name": "spring-tools-claude-plugin-tools", + "version": "0.0.0", + "private": true, + "description": "Development checks for the Spring Tools Claude Code 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", + "check:links": "node check-explanations.mjs --require-all --links", + "test": "node --test --test-reporter=tap \"test/*.test.mjs\"" + } +} diff --git a/claude-plugins/tools/test/explanations.test.mjs b/claude-plugins/tools/test/explanations.test.mjs new file mode 100644 index 0000000000..c40baf457c --- /dev/null +++ b/claude-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/claude-plugins/tools/test/fixtures/bad-hooks.json b/claude-plugins/tools/test/fixtures/bad-hooks.json new file mode 100644 index 0000000000..1e70d66b20 --- /dev/null +++ b/claude-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/claude-plugins/tools/test/fixtures/bad-repo/claude-plugins/research-notes/TODO_quickfixes.md b/claude-plugins/tools/test/fixtures/bad-repo/claude-plugins/research-notes/TODO_quickfixes.md new file mode 100644 index 0000000000..09e5347a50 --- /dev/null +++ b/claude-plugins/tools/test/fixtures/bad-repo/claude-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/claude-plugins/tools/test/fixtures/bad-repo/claude-plugins/spring-tools/explanations/GOOD_CODE.md b/claude-plugins/tools/test/fixtures/bad-repo/claude-plugins/spring-tools/explanations/GOOD_CODE.md new file mode 100644 index 0000000000..415bf1fb4b --- /dev/null +++ b/claude-plugins/tools/test/fixtures/bad-repo/claude-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/claude-plugins/tools/test/fixtures/bad-repo/claude-plugins/spring-tools/explanations/ORPHAN_CODE.md b/claude-plugins/tools/test/fixtures/bad-repo/claude-plugins/spring-tools/explanations/ORPHAN_CODE.md new file mode 100644 index 0000000000..1bbb683e64 --- /dev/null +++ b/claude-plugins/tools/test/fixtures/bad-repo/claude-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/claude-plugins/tools/test/fixtures/bad-repo/claude-plugins/spring-tools/explanations/REF_TARGET.md b/claude-plugins/tools/test/fixtures/bad-repo/claude-plugins/spring-tools/explanations/REF_TARGET.md new file mode 100644 index 0000000000..02afcd4006 --- /dev/null +++ b/claude-plugins/tools/test/fixtures/bad-repo/claude-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/claude-plugins/tools/test/fixtures/bad-repo/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/java/FakeProblemType.java b/claude-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..0d203c7890 --- /dev/null +++ b/claude-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 claude-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/claude-plugins/tools/test/fixtures/bad-skill.md b/claude-plugins/tools/test/fixtures/bad-skill.md new file mode 100644 index 0000000000..41c5bafb4c --- /dev/null +++ b/claude-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/claude-plugins/tools/test/plugin-config.test.mjs b/claude-plugins/tools/test/plugin-config.test.mjs new file mode 100644 index 0000000000..612eb52dea --- /dev/null +++ b/claude-plugins/tools/test/plugin-config.test.mjs @@ -0,0 +1,123 @@ +/******************************************************************************* + * 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 { existsSync, readFileSync, readdirSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { test } from 'node:test'; + +import { + HOOK_SERVER, + PLUGIN_DIR, + SCOPED_TOOL_PREFIX, + checkHooks, + checkManifest, + checkSkill, + collectMcpTools, + parseFrontMatter, +} from '../lib/plugin-config.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'); + +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('checkHooks accepts an existing plugin MCP tool for a valid hook', () => { + const hooks = { + hooks: { + PostToolUse: [{ + matcher: 'Edit', + hooks: [{ + type: 'mcp_tool', + if: 'Edit(*.java)', + server: HOOK_SERVER, + tool: 'fileChanged', + input: { filePath: '${tool_input.file_path}' }, + }], + }], + }, + }; + assert.deepEqual(checkHooks(hooks, tools), []); +}); + +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('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); + 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, 'claude-plugins', '.claude-plugin', 'marketplace.json'), 'utf8')); + assert.deepEqual(checkManifest(pluginJson, marketplace, repoRoot), []); +}); + +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/); +}); diff --git a/nohttp-whitelist.txt b/nohttp-whitelist.txt index ab1cb85c13..d48974db18 100644 --- a/nohttp-whitelist.txt +++ b/nohttp-whitelist.txt @@ -1,3 +1,4 @@ http://timestamp\.digicert\.com http://sha256timestamp\.ws\.symantec\.com/sha256/timestamp http://org\.eclipse\.ui\.intro/.* +http://spring\.io/