diff --git a/.github/workflows/release-agent-plugin.yml b/.github/workflows/release-agent-plugin.yml index 3db36eb3..118a9fdd 100644 --- a/.github/workflows/release-agent-plugin.yml +++ b/.github/workflows/release-agent-plugin.yml @@ -35,6 +35,11 @@ jobs: with: fetch-depth: 0 + - uses: actions/setup-java@v6 + with: + distribution: zulu + java-version: '8.0.492+9' + - name: Determine release version id: version env: @@ -101,9 +106,27 @@ jobs: - name: Validate plugin working-directory: agent-plugins/modern-java-development run: | - python3 -m unittest \ - skills/modern-java/scripts/test_detect_java_version.py \ - skills/modern-java/scripts/test_reference_coverage.py + classes=$(mktemp -d) + trap 'rm -rf "$classes"' EXIT + mkdir "$classes/source" "$classes/shipped" "$classes/tests" + javac -source 8 -target 8 -Xlint:-options \ + -d "$classes/source" \ + skills/modern-java/scripts/DetectJavaVersion.java + ( + cd "$classes/shipped" + jar xf "$GITHUB_WORKSPACE/agent-plugins/modern-java-development/skills/modern-java/scripts/detect-java-version.jar" + ) + rm -rf "$classes/shipped/META-INF" + diff --recursive "$classes/source" "$classes/shipped" + javac -source 8 -target 8 -Xlint:-options \ + -cp skills/modern-java/scripts/detect-java-version.jar \ + -d "$classes/tests" \ + skills/modern-java/scripts/PluginValidationTest.java + java -cp "$classes/tests:skills/modern-java/scripts/detect-java-version.jar" \ + PluginValidationTest ../.. + javap -verbose \ + -classpath skills/modern-java/scripts/detect-java-version.jar \ + DetectJavaVersion | grep --quiet 'major version: 52' jq --exit-status \ --arg version "${{ steps.version.outputs.version }}" \ '.version == $version' plugin.json >/dev/null diff --git a/agent-plugins/modern-java-development/README.md b/agent-plugins/modern-java-development/README.md index fe6b286f..ae45eae2 100644 --- a/agent-plugins/modern-java-development/README.md +++ b/agent-plugins/modern-java-development/README.md @@ -21,9 +21,11 @@ modern-java-development/ │ ├── enterprise-practices.md │ └── release-practices.md └── scripts/ - ├── detect_java_version.py - ├── test_reference_coverage.py - └── test_detect_java_version.py + ├── DetectJavaVersion.java + ├── PluginValidationTest.java + ├── detect-java-version.cmd + ├── detect-java-version.jar + └── detect-java-version.sh ``` ## Install @@ -91,14 +93,22 @@ Agent Plugins manifest. When the skill is active, the agent runs the detector from the Java project root: -```bash -python3 skills/modern-java/scripts/detect_java_version.py . +```console +# macOS and Linux +skills/modern-java/scripts/detect-java-version.sh . + +# Windows +skills\modern-java\scripts\detect-java-version.cmd . ``` Pass an explicit target when build metadata is unavailable: -```bash -python3 skills/modern-java/scripts/detect_java_version.py . --java-version 21 +```console +# macOS and Linux +skills/modern-java/scripts/detect-java-version.sh . --java-version 21 + +# Windows +skills\modern-java\scripts\detect-java-version.cmd . --java-version 21 ``` The detector emits JSON so an agent can distinguish the selected target from @@ -107,9 +117,24 @@ lower-confidence runtime evidence and report conflicting build configuration. ## Validate ```bash -python3 -m unittest \ - skills/modern-java/scripts/test_detect_java_version.py \ - skills/modern-java/scripts/test_reference_coverage.py +set -e +classes=$(mktemp -d) +trap 'rm -rf "$classes"' EXIT +jarfile="$PWD/skills/modern-java/scripts/detect-java-version.jar" +mkdir "$classes/source" "$classes/shipped" "$classes/tests" +javac -source 8 -target 8 -Xlint:-options \ + -d "$classes/source" skills/modern-java/scripts/DetectJavaVersion.java +( + cd "$classes/shipped" + jar xf "$jarfile" +) +rm -rf "$classes/shipped/META-INF" +diff -r "$classes/source" "$classes/shipped" +javac -source 8 -target 8 -Xlint:-options \ + -cp "$jarfile" \ + -d "$classes/tests" skills/modern-java/scripts/PluginValidationTest.java +java -cp "$classes/tests:$jarfile" \ + PluginValidationTest ../.. ``` ## Release diff --git a/agent-plugins/modern-java-development/skills/modern-java/SKILL.md b/agent-plugins/modern-java-development/skills/modern-java/SKILL.md index 6bed098c..4542b6de 100644 --- a/agent-plugins/modern-java-development/skills/modern-java/SKILL.md +++ b/agent-plugins/modern-java-development/skills/modern-java/SKILL.md @@ -2,7 +2,7 @@ name: modern-java description: Detects a project's effective Java version and provides release-appropriate guidance for writing, reviewing, refactoring, upgrading, and modernizing Java code. Use for Java implementation, architecture, code review, build configuration, migration, performance, concurrency, testing, or API design tasks. license: MIT -compatibility: Requires Python 3 to run the bundled detector; an agent may inspect the same project files directly when Python is unavailable. +compatibility: Requires a Java 8 or newer runtime to run the bundled detector. metadata: author: "@brunoborges" version: "1.0.0" @@ -18,8 +18,16 @@ features or APIs the build accepts. 1. From the project or module root, run: + On macOS or Linux: + ```bash - python3 /scripts/detect_java_version.py . + /scripts/detect-java-version.sh . + ``` + + On Windows: + + ```bat + \scripts\detect-java-version.cmd . ``` If the user explicitly supplied Java version X, pass diff --git a/agent-plugins/modern-java-development/skills/modern-java/references/core-practices.md b/agent-plugins/modern-java-development/skills/modern-java/references/core-practices.md index 03beb372..dfff89f2 100644 --- a/agent-plugins/modern-java-development/skills/modern-java/references/core-practices.md +++ b/agent-plugins/modern-java-development/skills/modern-java/references/core-practices.md @@ -49,8 +49,12 @@ syntax available at the detected compilation target. cancellation, timeout, interruption, and failure propagation behavior. - Preserve interruption (`Thread.currentThread().interrupt()`) when an `InterruptedException` cannot be propagated. +- Replace unsafe forced thread termination with cooperative cancellation and + explicit lifecycle ownership. + - Use concurrent collections and high-level synchronization utilities before hand-written locking. Document invariants protected by locks. + - Measure before selecting executors, pool sizes, lock-free structures, or virtual-thread migration. CPU-bound and I/O-bound workloads need different strategies. diff --git a/agent-plugins/modern-java-development/skills/modern-java/references/enterprise-practices.md b/agent-plugins/modern-java-development/skills/modern-java/references/enterprise-practices.md index 348c2674..f7a4eeab 100644 --- a/agent-plugins/modern-java-development/skills/modern-java/references/enterprise-practices.md +++ b/agent-plugins/modern-java-development/skills/modern-java/references/enterprise-practices.md @@ -88,7 +88,7 @@ configuration before recommending a migration. explicit `@Configuration` over large XML bean graphs in modern Spring. Preserve XML where externalized wiring or legacy integration makes it useful; do not rely on broad component scanning that obscures ownership. - + - On Spring Framework 7, use native API version conditions when they match the public versioning strategy. Keep version negotiation centralized, document deprecation and compatibility policy, and avoid merging unrelated versions diff --git a/agent-plugins/modern-java-development/skills/modern-java/references/release-practices.md b/agent-plugins/modern-java-development/skills/modern-java/references/release-practices.md index c54d3491..ec6a06ad 100644 --- a/agent-plugins/modern-java-development/skills/modern-java/references/release-practices.md +++ b/agent-plugins/modern-java-development/skills/modern-java/references/release-practices.md @@ -7,6 +7,12 @@ on the exact runtime and module graph. ## Java 7 and older maintenance targets +- Use generics instead of raw collections, `Deque` instead of legacy `Stack`, + and unsynchronized collections unless synchronization is part of the contract. + +- Use `ProcessBuilder` instead of `Runtime.exec`, construct URLs through `URI`, + and specify charsets explicitly at text/byte boundaries. + - Use multi-catch when handlers have identical behavior and neither alternative needs a more specific type. Preserve separate catches when recovery differs. @@ -19,7 +25,11 @@ on the exact runtime and module graph. - Use lambdas and method references when they clarify behavior, and streams for side-effect-free transformations and reductions. Use `stream.toArray(Type[]::new)` when a typed array is the required API boundary. - + +- Prefer collection bulk operations, `Map.compute`/`merge`, comparator factories, + and the standard Base64 codecs over hand-written equivalents when their + contracts match the required behavior. + - Use `java.time`, `DateTimeFormatter`, `Duration`, and `Period` instead of mutable `Date`, `Calendar`, `SimpleDateFormat`, or unitless millisecond math. @@ -51,7 +61,11 @@ on the exact runtime and module graph. - Use `ProcessBuilder` to start processes and `ProcessHandle` to inspect or manage them. Drain output, bound waits, and handle process-tree termination. - + +- Use `StackWalker` for controlled stack inspection, `Cleaner` or explicit + resource ownership instead of finalization, and + `getDeclaredConstructor().newInstance()` instead of `Class.newInstance()`. + - Preserve nanosecond `Instant` precision through storage and serialization; do not silently truncate to epoch milliseconds. @@ -89,7 +103,7 @@ on the exact runtime and module graph. - Prefer `java.net.http.HttpClient` for JDK-native HTTP. Reuse clients, configure connect/request timeouts, handle interruption, and validate status and body limits. - + - Use `String.isBlank`, `strip`, `lines`, and `repeat` instead of hand-written equivalents. `strip` is Unicode-aware; `lines` recognizes multiple line terminators and does not retain them. @@ -177,6 +191,9 @@ on the exact runtime and module graph. - Treat strong encapsulation of JDK internals as a migration requirement, not something to bypass permanently with `--add-opens`. +- Plan migrations away from the deprecated Security Manager around explicit + process, container, module, and application security boundaries. + ## Java 18-20 @@ -189,6 +206,9 @@ on the exact runtime and module graph. - Use try-with-resources for locally owned `ExecutorService` lifetimes. Define cancellation, graceful shutdown, timeout, and forced-shutdown behavior. +- Use `Locale.of` instead of deprecated locale constructors, while preserving + language, region, variant, and BCP 47 semantics. + - Record patterns, pattern switch, and virtual threads are not final before Java 21. @@ -245,6 +265,10 @@ on the exact runtime and module graph. ## Java 24 +- Use the Class-File API for class-file parsing, generation, and transformation + when its typed model fits; preserve unknown attributes and verify emitted + bytecode when interoperability matters. + - Use stream gatherers for reusable stateful intermediate operations when standard operations cannot express the transformation clearly. Respect integrator state, short-circuiting, parallel-combiner, and finisher semantics. diff --git a/agent-plugins/modern-java-development/skills/modern-java/scripts/DetectJavaVersion.java b/agent-plugins/modern-java-development/skills/modern-java/scripts/DetectJavaVersion.java new file mode 100644 index 00000000..2a4bd847 --- /dev/null +++ b/agent-plugins/modern-java-development/skills/modern-java/scripts/DetectJavaVersion.java @@ -0,0 +1,518 @@ +import java.io.IOException; +import java.io.PrintStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.FileVisitResult; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.nio.file.SimpleFileVisitor; +import java.nio.file.attribute.BasicFileAttributes; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.Comparator; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import javax.xml.XMLConstants; +import javax.xml.parsers.DocumentBuilderFactory; +import org.w3c.dom.Element; +import org.w3c.dom.Node; +import org.w3c.dom.NodeList; + +/** Detects a Java project's effective compilation target. */ +public final class DetectJavaVersion { + private static final Set IGNORED_DIRS = new HashSet<>(Arrays.asList( + ".git", ".gradle", ".idea", ".mvn", ".vscode", "build", + "node_modules", "out", "target")); + + private static final Map SOURCE_PRIORITY = new HashMap<>(); + + static { + SOURCE_PRIORITY.put("explicit", 100); + SOURCE_PRIORITY.put("maven-release", 90); + SOURCE_PRIORITY.put("gradle-release", 90); + SOURCE_PRIORITY.put("maven-toolchain", 80); + SOURCE_PRIORITY.put("gradle-toolchain", 80); + SOURCE_PRIORITY.put("maven-source", 70); + SOURCE_PRIORITY.put("gradle-source", 70); + SOURCE_PRIORITY.put("java-version-file", 60); + SOURCE_PRIORITY.put("sdkman", 60); + SOURCE_PRIORITY.put("asdf", 60); + SOURCE_PRIORITY.put("ci", 50); + SOURCE_PRIORITY.put("environment", 40); + SOURCE_PRIORITY.put("runtime", 10); + } + + private DetectJavaVersion() { + } + + static final class Candidate { + final int version; + final String source; + final String location; + final String raw; + final int priority; + + Candidate(int version, String source, String location, String raw) { + this.version = version; + this.source = source; + this.location = location; + this.raw = raw; + this.priority = SOURCE_PRIORITY.get(source); + } + + String key() { + return version + "\0" + source + "\0" + location + "\0" + raw; + } + } + + static final class Result { + final Path root; + final Candidate selected; + final boolean ambiguous; + final List conflicts; + final List candidates; + + Result( + Path root, + Candidate selected, + boolean ambiguous, + List conflicts, + List candidates) { + this.root = root; + this.selected = selected; + this.ambiguous = ambiguous; + this.conflicts = conflicts; + this.candidates = candidates; + } + } + + static Integer normalizeVersion(String value) { + if (value == null) { + return null; + } + String text = value.trim().replaceAll("^[\"']|[\"']$", ""); + Matcher match = Pattern.compile( + "(?= 5 ? version : null; + } + + private static void addCandidate( + List candidates, String value, String source, Object location) { + Integer version = normalizeVersion(value); + if (version != null) { + candidates.add(new Candidate( + version, source, String.valueOf(location), value.trim())); + } + } + + private static String localName(Node node) { + String name = node.getLocalName(); + return name != null ? name : node.getNodeName().replaceFirst("^.*:", ""); + } + + private static String resolveMavenValue(String value, Map properties) { + Set seen = new HashSet<>(); + String current = value.trim(); + while (true) { + Matcher match = Pattern.compile("^\\$\\{([^}]+)}$").matcher(current); + if (!match.matches() || !seen.add(match.group(1))) { + return current; + } + current = properties.getOrDefault(match.group(1), current).trim(); + } + } + + private static void inspectMaven(Path path, List candidates) { + try { + DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); + factory.setNamespaceAware(true); + factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true); + factory.setFeature("http://xml.org/sax/features/external-general-entities", false); + factory.setFeature("http://xml.org/sax/features/external-parameter-entities", false); + factory.setAttribute(XMLConstants.ACCESS_EXTERNAL_DTD, ""); + factory.setAttribute(XMLConstants.ACCESS_EXTERNAL_SCHEMA, ""); + Element root = factory.newDocumentBuilder().parse(path.toFile()).getDocumentElement(); + + Map properties = new HashMap<>(); + NodeList all = root.getElementsByTagName("*"); + for (int index = 0; index < all.getLength(); index++) { + Node node = all.item(index); + if ("properties".equals(localName(node))) { + NodeList children = node.getChildNodes(); + for (int childIndex = 0; childIndex < children.getLength(); childIndex++) { + Node child = children.item(childIndex); + if (child.getNodeType() == Node.ELEMENT_NODE) { + properties.put(localName(child), child.getTextContent().trim()); + } + } + } + } + + for (int index = 0; index < all.getLength(); index++) { + Node node = all.item(index); + String name = localName(node); + String value = resolveMavenValue(node.getTextContent(), properties); + if ("jdkToolchain".equals(name)) { + NodeList children = ((Element) node).getElementsByTagName("*"); + for (int childIndex = 0; childIndex < children.getLength(); childIndex++) { + Node child = children.item(childIndex); + if ("version".equals(localName(child))) { + addCandidate( + candidates, + resolveMavenValue(child.getTextContent(), properties), + "maven-toolchain", + path); + } + } + } else if ("maven.compiler.release".equals(name) || "release".equals(name)) { + addCandidate(candidates, value, "maven-release", path); + } else if ("maven.compiler.source".equals(name) || "source".equals(name)) { + addCandidate(candidates, value, "maven-source", path); + } + } + + for (String key : Arrays.asList("java.version", "jdk.version")) { + if (properties.containsKey(key)) { + addCandidate(candidates, properties.get(key), "maven-source", path); + } + } + } catch (Exception ignored) { + // Malformed or unreadable build metadata is not evidence of a Java target. + } + } + + private static void inspectGradle(Path path, List candidates) { + String text; + try { + text = new String(Files.readAllBytes(path), StandardCharsets.UTF_8); + } catch (IOException exception) { + return; + } + Map patterns = new LinkedHashMap<>(); + patterns.put("gradle-release", Pattern.compile( + "(?:options\\.)?release(?:\\.set)?\\s*\\(?\\s*(\\d{1,3})")); + patterns.put("gradle-toolchain", Pattern.compile( + "JavaLanguageVersion\\.of\\s*\\(\\s*(\\d{1,3})\\s*\\)")); + patterns.put("gradle-source", Pattern.compile( + "(?:sourceCompatibility|targetCompatibility)\\s*=\\s*" + + "(?:JavaVersion\\.VERSION_)?[\"']?(?:1[_.])?(\\d{1,3})")); + for (Map.Entry entry : patterns.entrySet()) { + Matcher matcher = entry.getValue().matcher(text); + while (matcher.find()) { + addCandidate(candidates, matcher.group(1), entry.getKey(), path); + } + } + } + + private static void inspectVersionFiles(Path root, List candidates) { + Map files = new LinkedHashMap<>(); + files.put(".java-version", "java-version-file"); + files.put(".sdkmanrc", "sdkman"); + files.put(".tool-versions", "asdf"); + for (Map.Entry entry : files.entrySet()) { + Path path = root.resolve(entry.getKey()); + if (!Files.isRegularFile(path)) { + continue; + } + try { + String text = new String(Files.readAllBytes(path), StandardCharsets.UTF_8); + Matcher matcher; + String value = null; + if (".sdkmanrc".equals(entry.getKey())) { + matcher = Pattern.compile("(?m)^\\s*java\\s*=\\s*(\\S+)").matcher(text); + value = matcher.find() ? matcher.group(1) : null; + } else if (".tool-versions".equals(entry.getKey())) { + matcher = Pattern.compile("(?m)^\\s*java\\s+(\\S+)").matcher(text); + value = matcher.find() ? matcher.group(1) : null; + } else if (!text.isEmpty()) { + value = text.split("\\R", 2)[0]; + } + addCandidate(candidates, value, entry.getValue(), path); + } catch (IOException ignored) { + // Unreadable version-manager files are not evidence of a target. + } + } + } + + private static void inspectCi(Path root, List candidates) { + Path workflows = root.resolve(".github").resolve("workflows"); + if (!Files.isDirectory(workflows)) { + return; + } + try (java.util.stream.Stream paths = Files.list(workflows)) { + paths.filter(path -> { + String name = path.getFileName().toString(); + return name.endsWith(".yml") || name.endsWith(".yaml"); + }) + .sorted() + .forEach(path -> { + try { + String text = new String( + Files.readAllBytes(path), StandardCharsets.UTF_8); + Matcher matcher = Pattern.compile( + "(?m)^\\s*java-version\\s*:\\s*[\"']?([^\"'\\s#]+)") + .matcher(text); + while (matcher.find()) { + addCandidate(candidates, matcher.group(1), "ci", path); + } + } catch (IOException ignored) { + // Continue inspecting other workflows. + } + }); + } catch (IOException ignored) { + // An unreadable workflow directory is not evidence of a target. + } + } + + private static List buildFiles(Path root, int maxDepth) throws IOException { + List result = new ArrayList<>(); + Files.walkFileTree(root, Collections.emptySet(), maxDepth + 1, + new SimpleFileVisitor() { + @Override + public FileVisitResult preVisitDirectory( + Path directory, BasicFileAttributes attributes) { + if (!directory.equals(root) + && IGNORED_DIRS.contains(directory.getFileName().toString())) { + return FileVisitResult.SKIP_SUBTREE; + } + return FileVisitResult.CONTINUE; + } + + @Override + public FileVisitResult visitFile( + Path file, BasicFileAttributes attributes) { + String name = file.getFileName().toString(); + if ("pom.xml".equals(name) + || "build.gradle".equals(name) + || "build.gradle.kts".equals(name)) { + result.add(file); + } + return FileVisitResult.CONTINUE; + } + }); + Collections.sort(result); + return result; + } + + static Result detect( + Path root, + String explicit, + int maxDepth, + String environmentVersion, + String runtimeVersion) throws IOException { + List candidates = new ArrayList<>(); + addCandidate(candidates, explicit, "explicit", "command line"); + for (Path path : buildFiles(root, maxDepth)) { + if ("pom.xml".equals(path.getFileName().toString())) { + inspectMaven(path, candidates); + } else { + inspectGradle(path, candidates); + } + } + inspectVersionFiles(root, candidates); + inspectCi(root, candidates); + addCandidate(candidates, environmentVersion, "environment", "JAVA_VERSION"); + addCandidate(candidates, runtimeVersion, "runtime", "java on PATH"); + + Map unique = new LinkedHashMap<>(); + for (Candidate candidate : candidates) { + unique.put(candidate.key(), candidate); + } + List ordered = new ArrayList<>(unique.values()); + ordered.sort(Comparator + .comparingInt((Candidate candidate) -> candidate.priority).reversed() + .thenComparing(candidate -> candidate.location) + .thenComparingInt(candidate -> candidate.version)); + + Candidate selected = ordered.isEmpty() ? null : ordered.get(0); + Set strongestVersions = new HashSet<>(); + List conflicts = new ArrayList<>(); + if (selected != null) { + for (Candidate candidate : ordered) { + if (candidate.priority == selected.priority) { + strongestVersions.add(candidate.version); + } + if (candidate.version != selected.version && candidate.priority >= 70) { + conflicts.add(candidate); + } + } + } + return new Result( + root, + selected, + strongestVersions.size() > 1, + conflicts, + ordered); + } + + private static String escapeJson(String value) { + StringBuilder escaped = new StringBuilder(); + for (int index = 0; index < value.length(); index++) { + char character = value.charAt(index); + switch (character) { + case '"': + escaped.append("\\\""); + break; + case '\\': + escaped.append("\\\\"); + break; + case '\b': + escaped.append("\\b"); + break; + case '\f': + escaped.append("\\f"); + break; + case '\n': + escaped.append("\\n"); + break; + case '\r': + escaped.append("\\r"); + break; + case '\t': + escaped.append("\\t"); + break; + default: + if (character < 0x20) { + escaped.append(String.format("\\u%04x", (int) character)); + } else { + escaped.append(character); + } + } + } + return escaped.toString(); + } + + private static String candidateJson(Candidate candidate, String indent) { + if (candidate == null) { + return "null"; + } + return "{\n" + + indent + " \"version\": " + candidate.version + ",\n" + + indent + " \"source\": \"" + escapeJson(candidate.source) + "\",\n" + + indent + " \"location\": \"" + escapeJson(candidate.location) + "\",\n" + + indent + " \"raw\": \"" + escapeJson(candidate.raw) + "\",\n" + + indent + " \"priority\": " + candidate.priority + "\n" + + indent + "}"; + } + + private static String candidatesJson(List candidates, String indent) { + if (candidates.isEmpty()) { + return "[]"; + } + StringBuilder json = new StringBuilder("[\n"); + for (int index = 0; index < candidates.size(); index++) { + if (index > 0) { + json.append(",\n"); + } + json.append(indent).append(" ") + .append(candidateJson(candidates.get(index), indent + " ")); + } + return json.append("\n").append(indent).append("]").toString(); + } + + private static String resultJson(Result result) { + return "{\n" + + " \"root\": \"" + escapeJson(result.root.toString()) + "\",\n" + + " \"selected\": " + candidateJson(result.selected, " ") + ",\n" + + " \"ambiguous\": " + result.ambiguous + ",\n" + + " \"conflicts\": " + candidatesJson(result.conflicts, " ") + ",\n" + + " \"candidates\": " + candidatesJson(result.candidates, " ") + "\n" + + "}"; + } + + private static void usage(PrintStream stream) { + stream.println("Usage: detect-java-version [path]" + + " [--java-version VERSION] [--max-depth DEPTH]"); + } + + public static void main(String[] args) { + Path path = Paths.get("."); + String explicit = null; + int maxDepth = 3; + boolean pathSet = false; + boolean optionsEnded = false; + try { + for (int index = 0; index < args.length; index++) { + if (!optionsEnded && "--".equals(args[index])) { + optionsEnded = true; + continue; + } + if (!optionsEnded && args[index].startsWith("--java-version=")) { + explicit = args[index].substring("--java-version=".length()); + continue; + } + if (!optionsEnded && args[index].startsWith("--max-depth=")) { + maxDepth = Math.max(0, Integer.parseInt( + args[index].substring("--max-depth=".length()))); + continue; + } + if (optionsEnded) { + if (pathSet) { + throw new IllegalArgumentException( + "unexpected argument: " + args[index]); + } + path = Paths.get(args[index]); + pathSet = true; + continue; + } + switch (args[index]) { + case "--java-version": + explicit = args[++index]; + break; + case "--max-depth": + maxDepth = Math.max(0, Integer.parseInt(args[++index])); + break; + case "--help": + case "-h": + usage(System.out); + return; + default: + if (args[index].startsWith("-") || pathSet) { + throw new IllegalArgumentException("unexpected argument: " + args[index]); + } + path = Paths.get(args[index]); + pathSet = true; + } + } + + Path root = path.toAbsolutePath().normalize(); + if (!Files.isDirectory(root)) { + System.err.println("{\"error\":\"not a directory: " + + escapeJson(root.toString()) + "\"}"); + System.exit(2); + } + if (explicit != null && normalizeVersion(explicit) == null) { + System.err.println("{\"error\":\"invalid Java version: " + + escapeJson(explicit) + "\"}"); + System.exit(2); + } + Result result = detect( + root, + explicit, + maxDepth, + System.getenv("JAVA_VERSION"), + System.getProperty("java.specification.version")); + System.out.println(resultJson(result)); + if (result.selected == null) { + System.exit(1); + } + } catch (ArrayIndexOutOfBoundsException | NumberFormatException exception) { + usage(System.err); + System.exit(2); + } catch (IllegalArgumentException | IOException exception) { + System.err.println("{\"error\":\"" + escapeJson(exception.getMessage()) + "\"}"); + System.exit(2); + } + } +} diff --git a/agent-plugins/modern-java-development/skills/modern-java/scripts/PluginValidationTest.java b/agent-plugins/modern-java-development/skills/modern-java/scripts/PluginValidationTest.java new file mode 100644 index 00000000..f052956a --- /dev/null +++ b/agent-plugins/modern-java-development/skills/modern-java/scripts/PluginValidationTest.java @@ -0,0 +1,180 @@ +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.Arrays; +import java.util.HashSet; +import java.util.Objects; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Stream; + +/** Dependency-free validation for the modern Java skill. */ +public final class PluginValidationTest { + private int assertions; + + public static void main(String[] args) throws Exception { + PluginValidationTest tests = new PluginValidationTest(); + tests.runDetectorTests(); + tests.validateReferenceCoverage( + args.length == 0 ? Paths.get("../..") : Paths.get(args[0])); + System.out.println("Plugin validation passed (" + tests.assertions + " assertions)"); + } + + private void runDetectorTests() throws Exception { + Path root = Files.createTempDirectory("java-version-detector-test"); + try { + write(root.resolve("pom.xml"), + "17" + + "" + + "${java.version}" + + ""); + DetectJavaVersion.Result result = detect(root, null); + assertEquals(17, result.selected.version, "Maven property release"); + assertEquals("maven-release", result.selected.source, "Maven release source"); + assertFalse(result.ambiguous, "single Maven release is unambiguous"); + + Files.delete(root.resolve("pom.xml")); + write(root.resolve("build.gradle.kts"), + "java { toolchain { languageVersion.set(JavaLanguageVersion.of(21)) } }\n" + + "tasks.withType { options.release.set(17) }\n"); + result = detect(root, null); + assertEquals(17, result.selected.version, "Gradle release beats toolchain"); + assertEquals("gradle-release", result.selected.source, "Gradle release source"); + + Files.delete(root.resolve("build.gradle.kts")); + write(root.resolve("pom.xml"), + "" + + "21" + + ""); + result = detect(root, null); + assertEquals(21, result.selected.version, "Maven toolchain version"); + assertEquals("maven-toolchain", result.selected.source, "Maven toolchain source"); + + Files.delete(root.resolve("pom.xml")); + write(root.resolve(".java-version"), "temurin-21.0.4\n"); + result = detect(root, null); + assertEquals(21, result.selected.version, "version-manager file"); + assertEquals("java-version-file", result.selected.source, "version-manager source"); + + write(root.resolve("pom.xml"), + "17" + + ""); + result = detect(root, "11"); + assertEquals(11, result.selected.version, "explicit version wins"); + assertEquals("explicit", result.selected.source, "explicit source"); + + deleteTree(root); + Files.createDirectories(root.resolve("api")); + Files.createDirectories(root.resolve("app")); + write(root.resolve("api/pom.xml"), + "17" + + ""); + write(root.resolve("app/pom.xml"), + "21" + + ""); + result = detect(root, null); + assertTrue(result.ambiguous, "conflicting module releases are ambiguous"); + Set versions = new HashSet<>(); + for (DetectJavaVersion.Candidate candidate : result.candidates) { + if ("maven-release".equals(candidate.source)) { + versions.add(candidate.version); + } + } + assertEquals( + new HashSet(Arrays.asList(17, 21)), + versions, + "both module releases are reported"); + assertEquals(8, DetectJavaVersion.normalizeVersion("1.8.0_402"), + "legacy version normalization"); + assertEquals(8, DetectJavaVersion.normalizeVersion("jdk1.8.0_402"), + "prefixed legacy version normalization"); + assertEquals(null, DetectJavaVersion.normalizeVersion("1.4.2"), + "unsupported legacy version"); + } finally { + deleteTree(root); + } + } + + private DetectJavaVersion.Result detect(Path root, String explicit) throws IOException { + return DetectJavaVersion.detect(root, explicit, 3, null, "25"); + } + + private void validateReferenceCoverage(Path repository) throws IOException { + Path content = repository.resolve("content"); + if (!Files.isDirectory(content)) { + return; + } + Path references = repository.resolve( + "agent-plugins/modern-java-development/skills/modern-java/references"); + Pattern marker = Pattern.compile(""); + Set covered = new HashSet<>(); + try (Stream paths = Files.list(references)) { + for (Path path : (Iterable) paths.filter( + candidate -> candidate.toString().endsWith(".md"))::iterator) { + Matcher matcher = marker.matcher(read(path)); + while (matcher.find()) { + for (String slug : matcher.group(1).trim().split("\\s+")) { + covered.add(slug); + } + } + } + } + + Set missing = new HashSet<>(); + try (Stream paths = Files.walk(content, 2)) { + for (Path path : (Iterable) paths.filter( + candidate -> candidate.toString().endsWith(".yaml") + && !"template.yaml".equals(candidate.getFileName().toString())) + ::iterator) { + String filename = path.getFileName().toString(); + String slug = filename.substring(0, filename.length() - ".yaml".length()); + if (!covered.contains(slug)) { + missing.add(slug); + } + } + } + assertEquals(new HashSet(), missing, "reference coverage"); + } + + private static void write(Path path, String content) throws IOException { + Files.write(path, content.getBytes(StandardCharsets.UTF_8)); + } + + private static String read(Path path) throws IOException { + return new String(Files.readAllBytes(path), StandardCharsets.UTF_8); + } + + private static void deleteTree(Path root) throws IOException { + if (!Files.exists(root)) { + return; + } + try (Stream paths = Files.walk(root)) { + for (Path path : (Iterable) paths.sorted((left, right) -> + right.getNameCount() - left.getNameCount())::iterator) { + Files.delete(path); + } + } + } + + private void assertTrue(boolean condition, String message) { + assertions++; + if (!condition) { + throw new AssertionError(message); + } + } + + private void assertFalse(boolean condition, String message) { + assertTrue(!condition, message); + } + + private void assertEquals(Object expected, Object actual, String message) { + assertions++; + if (!Objects.equals(expected, actual)) { + throw new AssertionError( + message + ": expected " + expected + ", got " + actual); + } + } +} diff --git a/agent-plugins/modern-java-development/skills/modern-java/scripts/detect-java-version.cmd b/agent-plugins/modern-java-development/skills/modern-java/scripts/detect-java-version.cmd new file mode 100644 index 00000000..a8555cc7 --- /dev/null +++ b/agent-plugins/modern-java-development/skills/modern-java/scripts/detect-java-version.cmd @@ -0,0 +1,12 @@ +@echo off +setlocal DisableDelayedExpansion + +where java >nul 2>&1 +if errorlevel 1 ( + >&2 echo {"error":"java not found on PATH; install Java 8 or newer and configure PATH"} + exit /b 127 +) + +set "SCRIPT_DIR=%~dp0" +java -jar "%SCRIPT_DIR%detect-java-version.jar" %* +exit /b %ERRORLEVEL% diff --git a/agent-plugins/modern-java-development/skills/modern-java/scripts/detect-java-version.jar b/agent-plugins/modern-java-development/skills/modern-java/scripts/detect-java-version.jar new file mode 100644 index 00000000..4df62d2e Binary files /dev/null and b/agent-plugins/modern-java-development/skills/modern-java/scripts/detect-java-version.jar differ diff --git a/agent-plugins/modern-java-development/skills/modern-java/scripts/detect-java-version.sh b/agent-plugins/modern-java-development/skills/modern-java/scripts/detect-java-version.sh new file mode 100755 index 00000000..2e01ddb3 --- /dev/null +++ b/agent-plugins/modern-java-development/skills/modern-java/scripts/detect-java-version.sh @@ -0,0 +1,12 @@ +#!/bin/sh + +set -u + +if ! command -v java >/dev/null 2>&1; then + printf '%s\n' \ + '{"error":"java not found on PATH; install Java 8 or newer and configure PATH"}' >&2 + exit 127 +fi + +script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +java -jar "$script_dir/detect-java-version.jar" "$@" diff --git a/agent-plugins/modern-java-development/skills/modern-java/scripts/detect_java_version.py b/agent-plugins/modern-java-development/skills/modern-java/scripts/detect_java_version.py deleted file mode 100755 index 575bb90e..00000000 --- a/agent-plugins/modern-java-development/skills/modern-java/scripts/detect_java_version.py +++ /dev/null @@ -1,321 +0,0 @@ -#!/usr/bin/env python3 -"""Detect a Java project's effective compilation target.""" - -from __future__ import annotations - -import argparse -import json -import os -import re -import subprocess -import sys -import xml.etree.ElementTree as ET -from dataclasses import asdict, dataclass -from pathlib import Path - - -IGNORED_DIRS = { - ".git", - ".gradle", - ".idea", - ".mvn", - ".vscode", - "build", - "node_modules", - "out", - "target", -} - -SOURCE_PRIORITY = { - "explicit": 100, - "maven-release": 90, - "gradle-release": 90, - "maven-toolchain": 80, - "gradle-toolchain": 80, - "maven-source": 70, - "gradle-source": 70, - "java-version-file": 60, - "sdkman": 60, - "asdf": 60, - "ci": 50, - "environment": 40, - "runtime": 10, -} - - -@dataclass(frozen=True) -class Candidate: - version: int - source: str - location: str - raw: str - priority: int - - -def normalize_version(value: str | int | None) -> int | None: - if value is None: - return None - text = str(value).strip().strip("\"'") - match = re.search(r"(?= 5 else None - - -def add_candidate( - candidates: list[Candidate], - value: str | int | None, - source: str, - location: Path | str, -) -> None: - version = normalize_version(value) - if version is not None: - candidates.append( - Candidate( - version=version, - source=source, - location=str(location), - raw=str(value).strip(), - priority=SOURCE_PRIORITY[source], - ) - ) - - -def local_name(tag: str) -> str: - return tag.rsplit("}", 1)[-1] - - -def resolve_maven_value(value: str, properties: dict[str, str]) -> str: - seen: set[str] = set() - current = value.strip() - while True: - match = re.fullmatch(r"\$\{([^}]+)}", current) - if not match or match.group(1) in seen: - return current - key = match.group(1) - seen.add(key) - current = properties.get(key, current).strip() - - -def inspect_maven(path: Path, candidates: list[Candidate]) -> None: - try: - root = ET.parse(path).getroot() - except (ET.ParseError, OSError): - return - - properties: dict[str, str] = {} - for element in root.iter(): - if local_name(element.tag) == "properties": - for child in element: - if child.text: - properties[local_name(child.tag)] = child.text.strip() - - release_names = {"maven.compiler.release", "release"} - source_names = {"maven.compiler.source", "source"} - for element in root.iter(): - name = local_name(element.tag) - if name == "jdkToolchain": - for child in element.iter(): - if local_name(child.tag) == "version" and child.text: - add_candidate( - candidates, - resolve_maven_value(child.text, properties), - "maven-toolchain", - path, - ) - continue - if not element.text: - continue - value = resolve_maven_value(element.text, properties) - if name in release_names: - add_candidate(candidates, value, "maven-release", path) - elif name in source_names: - add_candidate(candidates, value, "maven-source", path) - - for key in ("java.version", "jdk.version"): - if key in properties: - add_candidate(candidates, properties[key], "maven-source", path) - - -def inspect_gradle(path: Path, candidates: list[Candidate]) -> None: - try: - text = path.read_text(encoding="utf-8") - except (OSError, UnicodeDecodeError): - return - - patterns = [ - ( - "gradle-release", - r"(?:options\.)?release(?:\.set)?\s*\(?\s*(\d{1,3})", - ), - ( - "gradle-toolchain", - r"JavaLanguageVersion\.of\s*\(\s*(\d{1,3})\s*\)", - ), - ( - "gradle-source", - r"(?:sourceCompatibility|targetCompatibility)\s*=\s*" - r"(?:JavaVersion\.VERSION_)?[\"']?(?:1[_.])?(\d{1,3})", - ), - ] - for source, pattern in patterns: - for match in re.finditer(pattern, text): - add_candidate(candidates, match.group(1), source, path) - - -def inspect_version_files(root: Path, candidates: list[Candidate]) -> None: - files = [ - (".java-version", "java-version-file"), - (".sdkmanrc", "sdkman"), - (".tool-versions", "asdf"), - ] - for filename, source in files: - path = root / filename - if not path.is_file(): - continue - try: - text = path.read_text(encoding="utf-8") - except (OSError, UnicodeDecodeError): - continue - if filename == ".sdkmanrc": - match = re.search(r"(?m)^\s*java\s*=\s*(\S+)", text) - value = match.group(1) if match else None - elif filename == ".tool-versions": - match = re.search(r"(?m)^\s*java\s+(\S+)", text) - value = match.group(1) if match else None - else: - value = text.splitlines()[0] if text.splitlines() else None - add_candidate(candidates, value, source, path) - - -def inspect_ci(root: Path, candidates: list[Candidate]) -> None: - workflows = root / ".github" / "workflows" - if not workflows.is_dir(): - return - for path in sorted((*workflows.glob("*.yml"), *workflows.glob("*.yaml"))): - try: - text = path.read_text(encoding="utf-8") - except (OSError, UnicodeDecodeError): - continue - for match in re.finditer( - r"(?m)^\s*java-version\s*:\s*[\"']?([^\"'\s#]+)", text - ): - add_candidate(candidates, match.group(1), "ci", path) - - -def build_files(root: Path, max_depth: int) -> list[Path]: - result: list[Path] = [] - for current, dirs, files in os.walk(root): - current_path = Path(current) - depth = len(current_path.relative_to(root).parts) - dirs[:] = [ - item - for item in dirs - if item not in IGNORED_DIRS and depth < max_depth - ] - for filename in files: - if filename in {"pom.xml", "build.gradle", "build.gradle.kts"}: - result.append(current_path / filename) - return sorted(result) - - -def runtime_version() -> tuple[str | None, str | None]: - try: - process = subprocess.run( - ["java", "-XshowSettings:properties", "-version"], - capture_output=True, - text=True, - timeout=5, - check=False, - ) - except (OSError, subprocess.SubprocessError): - return None, None - output = process.stdout + process.stderr - match = re.search(r"java\.specification\.version\s*=\s*(\S+)", output) - return (match.group(1), "java on PATH") if match else (None, None) - - -def detect(root: Path, explicit: str | None, max_depth: int) -> dict[str, object]: - candidates: list[Candidate] = [] - if explicit: - add_candidate(candidates, explicit, "explicit", "command line") - - for path in build_files(root, max_depth): - if path.name == "pom.xml": - inspect_maven(path, candidates) - else: - inspect_gradle(path, candidates) - - inspect_version_files(root, candidates) - inspect_ci(root, candidates) - add_candidate(candidates, os.environ.get("JAVA_VERSION"), "environment", "JAVA_VERSION") - value, location = runtime_version() - add_candidate(candidates, value, "runtime", location or "java on PATH") - - unique = list( - { - (item.version, item.source, item.location, item.raw): item - for item in candidates - }.values() - ) - ordered = sorted( - unique, - key=lambda item: (-item.priority, item.location, item.version), - ) - selected = ordered[0] if ordered else None - strongest = [item for item in ordered if selected and item.priority == selected.priority] - ambiguous = len({item.version for item in strongest}) > 1 - conflicts = [ - item - for item in ordered - if selected and item.version != selected.version and item.priority >= 70 - ] - - return { - "root": str(root), - "selected": asdict(selected) if selected else None, - "ambiguous": ambiguous, - "conflicts": [asdict(item) for item in conflicts], - "candidates": [asdict(item) for item in ordered], - } - - -def main() -> int: - parser = argparse.ArgumentParser( - description="Detect the effective Java compilation target for a project." - ) - parser.add_argument("path", nargs="?", default=".", help="project root") - parser.add_argument( - "--java-version", - help="explicit target supplied by the user (highest precedence)", - ) - parser.add_argument( - "--max-depth", - type=int, - default=3, - help="maximum build-file search depth (default: 3)", - ) - args = parser.parse_args() - - root = Path(args.path).expanduser().resolve() - if not root.is_dir(): - print(json.dumps({"error": f"not a directory: {root}"}), file=sys.stderr) - return 2 - if args.java_version and normalize_version(args.java_version) is None: - print( - json.dumps({"error": f"invalid Java version: {args.java_version}"}), - file=sys.stderr, - ) - return 2 - - result = detect(root, args.java_version, max(0, args.max_depth)) - print(json.dumps(result, indent=2)) - return 0 if result["selected"] else 1 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/agent-plugins/modern-java-development/skills/modern-java/scripts/test_detect_java_version.py b/agent-plugins/modern-java-development/skills/modern-java/scripts/test_detect_java_version.py deleted file mode 100644 index 7b89ebec..00000000 --- a/agent-plugins/modern-java-development/skills/modern-java/scripts/test_detect_java_version.py +++ /dev/null @@ -1,120 +0,0 @@ -import os -import sys -import tempfile -import unittest -from pathlib import Path -from unittest.mock import patch - -sys.path.insert(0, str(Path(__file__).resolve().parent)) - -import detect_java_version as detector - - -class DetectJavaVersionTests(unittest.TestCase): - def setUp(self): - self.temp = tempfile.TemporaryDirectory() - self.root = Path(self.temp.name) - - def tearDown(self): - self.temp.cleanup() - - def detect(self, explicit=None): - with ( - patch.object(detector, "runtime_version", return_value=("25", "java on PATH")), - patch.dict(os.environ, {"JAVA_VERSION": ""}, clear=False), - ): - return detector.detect(self.root, explicit, 3) - - def test_maven_release_resolves_property_and_beats_runtime(self): - (self.root / "pom.xml").write_text( - """ - - 17 - - ${java.version} - - - """, - encoding="utf-8", - ) - - result = self.detect() - - self.assertEqual(17, result["selected"]["version"]) - self.assertEqual("maven-release", result["selected"]["source"]) - self.assertFalse(result["ambiguous"]) - - def test_gradle_release_beats_toolchain(self): - (self.root / "build.gradle.kts").write_text( - """ - java { toolchain { languageVersion.set(JavaLanguageVersion.of(21)) } } - tasks.withType { options.release.set(17) } - """, - encoding="utf-8", - ) - - result = self.detect() - - self.assertEqual(17, result["selected"]["version"]) - self.assertEqual("gradle-release", result["selected"]["source"]) - - def test_maven_compiler_toolchain_is_detected(self): - (self.root / "pom.xml").write_text( - """ - - 21 - - """, - encoding="utf-8", - ) - - result = self.detect() - - self.assertEqual(21, result["selected"]["version"]) - self.assertEqual("maven-toolchain", result["selected"]["source"]) - - def test_version_manager_beats_runtime(self): - (self.root / ".java-version").write_text("temurin-21.0.4\n", encoding="utf-8") - - result = self.detect() - - self.assertEqual(21, result["selected"]["version"]) - self.assertEqual("java-version-file", result["selected"]["source"]) - - def test_explicit_version_has_highest_precedence(self): - (self.root / "pom.xml").write_text( - "17" - "", - encoding="utf-8", - ) - - result = self.detect("11") - - self.assertEqual(11, result["selected"]["version"]) - self.assertEqual("explicit", result["selected"]["source"]) - - def test_conflicting_module_releases_are_ambiguous(self): - for module, version in (("api", 17), ("app", 21)): - directory = self.root / module - directory.mkdir() - (directory / "pom.xml").write_text( - f"{version}" - "", - encoding="utf-8", - ) - - result = self.detect() - - self.assertTrue(result["ambiguous"]) - self.assertEqual({17, 21}, { - item["version"] - for item in result["candidates"] - if item["source"] == "maven-release" - }) - - def test_legacy_java_version_is_normalized(self): - self.assertEqual(8, detector.normalize_version("1.8.0_402")) - - -if __name__ == "__main__": - unittest.main() diff --git a/agent-plugins/modern-java-development/skills/modern-java/scripts/test_reference_coverage.py b/agent-plugins/modern-java-development/skills/modern-java/scripts/test_reference_coverage.py deleted file mode 100644 index 336fa3aa..00000000 --- a/agent-plugins/modern-java-development/skills/modern-java/scripts/test_reference_coverage.py +++ /dev/null @@ -1,28 +0,0 @@ -import re -import unittest -from pathlib import Path - - -class ReferenceCoverageTests(unittest.TestCase): - def test_every_catalog_pattern_is_covered_by_reference_guidance(self): - repository = Path(__file__).resolve().parents[5] - content = repository / "content" - if not content.is_dir(): - self.skipTest("java.evolved catalog is not present in packaged plugin") - - references = Path(__file__).resolve().parents[1] / "references" - covered = set() - for path in references.glob("*.md"): - for marker in re.findall(r"", path.read_text()): - covered.update(marker.split()) - - patterns = { - path.stem - for path in content.glob("*/*.yaml") - if path.name != "template.yaml" - } - self.assertEqual(set(), patterns - covered) - - -if __name__ == "__main__": - unittest.main() diff --git a/templates/agent-plugin.html b/templates/agent-plugin.html index 9ab0213e..724f796d 100644 --- a/templates/agent-plugin.html +++ b/templates/agent-plugin.html @@ -68,7 +68,7 @@

Modern Java advice,
bounded by your JDK.

version detector
-
$ detect_java_version.py .
+
$ detect-java-version.sh .
source maven.compiler.release
target Java 21