diff --git a/.github/workflows/maven-pr.yml b/.github/workflows/maven-pr.yml index 223e9286..e09fa270 100644 --- a/.github/workflows/maven-pr.yml +++ b/.github/workflows/maven-pr.yml @@ -11,14 +11,18 @@ jobs: build: runs-on: ubuntu-latest + strategy: + matrix: + java-version: [ 17, 25 ] + steps: - name: Check out Git repository uses: actions/checkout@v4 - - name: Set up JDK 11 + - name: Set up JDK ${{ matrix.java-version }} uses: actions/setup-java@v4 with: - java-version: 17 + java-version: ${{ matrix.java-version }} distribution: 'zulu' - name: Build With Maven diff --git a/.github/workflows/maven.yml b/.github/workflows/maven.yml index 868833e3..8a2ae20d 100644 --- a/.github/workflows/maven.yml +++ b/.github/workflows/maven.yml @@ -11,14 +11,18 @@ jobs: build: runs-on: ubuntu-latest + strategy: + matrix: + java-version: [ 17, 25 ] + steps: - name: Check out Git repository uses: actions/checkout@v4 - - name: Set up JDK 11 + - name: Set up JDK ${{ matrix.java-version }} uses: actions/setup-java@v4 with: - java-version: 17 + java-version: ${{ matrix.java-version }} distribution: 'zulu' - name: Build With Maven diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index dec7def4..dee8cbae 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -25,10 +25,14 @@ jobs: uses: actions/checkout@v3 with: fetch-depth: 0 - - name: Set up JDK 11 + - name: Set up JDK 25 + # Release artifacts must be built on JDK 25+ so the JEP 238 + # META-INF/versions/25 entries (Java 25 parser activation) are + # compiled into the codebase-graph-builder jar; the jdk25-multi-release + # build profile is inactive on older build JDKs. uses: actions/setup-java@v3 with: - java-version: '17' + java-version: '25' distribution: 'temurin' cache: maven - name: Set release version @@ -43,6 +47,16 @@ jobs: - name: Stage release run: mvn --no-transfer-progress --batch-mode -Ppublish clean deploy -DaltDeploymentRepository=local::default::file://`pwd`/target/staging-deploy + - name: Verify multi-release jar contains Java 25 entries + # Release guard: published jars must carry the JEP 238 versioned + # factory so JDK 25 users actually get the Java 25 parser. + run: | + jar=$(ls codebase-graph-builder/target/codebase-graph-builder-*.jar | grep -v -e sources -e javadoc | head -1) + unzip -l "$jar" | grep "META-INF/versions/25/org/hjug/graphbuilder/graphbuilder/Java25ParserFactory.class" >/dev/null \ + || { echo "FATAL: $jar is missing META-INF/versions/25 entries"; exit 1; } + unzip -p "$jar" META-INF/MANIFEST.MF | grep -q "Multi-Release: true" \ + || { echo "FATAL: $jar manifest lacks 'Multi-Release: true'"; exit 1; } + - name: Run JReleaser uses: jreleaser/release-action@v2 with: diff --git a/AGENTS.md b/AGENTS.md index 17a7a884..98089b15 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,6 +17,64 @@ - **Anonymous/synthetic classes are first-class graph members.** Java `Outer$N`/`Outer$` (anonymous/synthetic inner classes) and the Kotlin literal `""` FQN are **not** sieved out by `GraphDependencyCollector`; they genuinely participate in cycles and can harbour antipatterns, so they are vertices in the class graph and rendered with `$` as the enclosing-class separator. `GraphDependencyCollector` keeps only the `from == to` self-edge guard, plus a degenerate-package guard so a packageless `""` source never creates an `""` package-graph vertex. **Sink-only** anonymous/synthetic vertices (those with no outgoing edges) are suppressed only at render time in `HtmlReport.isSinkAnonymousOrSyntheticVertex` to keep the Class/Cycle Map DOT graph readable; active ones still render. - **Anonymous DOT node ids are source-file derived.** OpenRewrite attributes a Kotlin anonymous object / function-literal type with {@code ""} as the trailing simple-name segment of its FQN: standalone ({@code ""}) or, in real graphs (e.g. FXGL), prefixed by the enclosing class/package ({@code "dev.DeveloperWASDControl."}). {@code HtmlReport.isAnonymousFqn(vertex)} detects a vertex when its trailing segment starts with {@code <}. {@code HtmlReport.renderSafeNodeId(vertex, codebaseGraphDTO)} then derives the enclosing owner from the vertex's mapped source-file path in {@code CodebaseGraphDTO.classToSourceFilePathMapping} (file base name without extension, e.g. {@code DeveloperWASDControl.kt} -> {@code DeveloperWASDControl}). The DOT node id renders as {@code DeveloperWASDControl_anonymous} and the human-readable label as {@code DeveloperWASDControl\$anonymous} ({@code $} escaped as {@code \$} for DOT). When no source path is mapped (or DTO is null) it degrades to the reversible {@code lt_}/{@code _gt} {@code <}/{@code >} encoding. The renderer is responsible for DOT/HTML-safe encoding of the literal {@code ""} FQN ({@code <}/{@code >} are illegal in Graphviz node ids; {@code <}/{@code >} escaping in HTML table labels). +## Java 25 analysis (JEP 238 multi-release jar) + +`rewrite-java-25` is a **required** (BOM-managed, no explicit version) compile +dependency of `codebase-graph-builder` and ships in every distributed artifact +(Maven plugin, CLI fat jar). Its class files are Java 25 (class-file 69.0) but +are inert on Java 17/21 classpaths: the jar declares no `META-INF/services` +entries, and no base-level class in the module references it. + +Activation uses a **JEP 238 multi-release jar**, not reflection or version +detection. `codebase-graph-builder` declares `Multi-Release: true` and ships +two variants of `org.hjug.graphbuilder.graphbuilder.Java25ParserFactory`: + +- the base variant (`src/main/java`, `--release 17`) returns + `Optional.empty()`; +- the Java 25 variant (`src/main/java25`, compiled `--release 25` into + `META-INF/versions/25` by the auto-activated `jdk25-multi-release` profile) + calls `Java25Parser.builder().build()` directly, wrapped in a + `catch (Throwable)` graceful-degradation guard. + +On JDK 25+ runtimes the JVM's versioned jar lookup shadows the base variant — +parser selection is fully runtime-driven with no configuration surface. +`JavaSourceFileGraphBuilder.createJavaParser(config)` is just +`Java25ParserFactory.createJava25Parser().orElseGet(...fromJavaVersion...)`. +Independently, OpenRewrite's `JavaParser.fromJavaVersion()` itself reflectively +elevates to `Java25Parser` on JDK 25+ whenever `rewrite-java-25` is on the +classpath, so exploded-directory classpaths (e.g. surefire against +`target/classes`, where JEP 238 shadowing does not apply) still get the +Java 25 parser on JDK 25. `Java25ParserFactoryTest` covers the factory +contract (including public-API parity of the two variants); +`MultiReleaseJarIT` verifies the packaged jar's manifest, versioned entries, +and real jar shadowing. + +Constraints: + +- **Never reference `rewrite-java-25` (or any class-file-69 code) from base + sources** — only from `src/main/java25`. +- The versioned class must keep exactly the same public API as the base class. +- Release artifacts must be built on JDK 25+ (see `release.yml`, which also + asserts the versioned entries exist); a build on JDK 17 succeeds but + silently lacks `META-INF/versions/25`. +- JaCoCo excludes `META-INF/versions/**` (duplicate class names break its + report goal); the CLI shade config re-adds `Multi-Release: true` to the fat + jar manifest. +- `refactor-first-maven-plugin` uses `maven-plugin-plugin` / + `maven-plugin-annotations` 3.16.0 because descriptor generation scans + dependency archives with ASM and older versions cannot read class-file 69. + +**JDK 25 build notes:** +- Spotless 3.10.2 with `palantir-java-format` 2.71.0 runs on JDK 17, 21 and + 25 with no `--add-exports` workaround (older Spotless 2.x / + palantir-java-format builds failed on JDK 21+ without javac exports and + could not run on JDK 25 at all). +- CI (`maven.yml`, `maven-pr.yml`) builds a JDK 17 + JDK 25 matrix. +- `CostBenefitCalculatorTest.testCostBenefitCalculation` runs on all JDKs; + its former `@DisabledOnJre(JRE.JAVA_25)` (OpenRewrite 8.90.4 fixture + failure, issue #8712 family) no longer reproduces now that + `rewrite-java-25` is always on the classpath — verified passing on JDK 25. + ## Kotlin analysis (hard dependency) `rewrite-kotlin` (`org.openrewrite:rewrite-kotlin`) is a **non-optional @@ -37,11 +95,12 @@ and the CLI fat-jar bundle the Kotlin compiler — `kotlin-script-runtime` / `kotlin-daemon-embeddable` / `kotlinx-coroutines-core-jvm` transitives — into **every** consumer's runtime, including pure-Java projects that never contain a `.kt` file. As of this branch -the CLI fat-jar is `cli/target/cli-.jar` and measures **~144 MB** -(verified via `du -sh cli/target/cli-0.10.0.jar` after -`mvn clean install -DskipTests`); the Kotlin compiler and its transitives are a -material fraction of that. A pure-Java consumer therefore pays this size/cost -(the dependency is always on the classpath regardless). +the CLI fat-jar is `cli/target/cli-.jar` and measures **~145 MB** +(verified via `du -sh cli/target/cli-0.11.0-SNAPSHOT.jar` after +`mvn clean install`); the Kotlin compiler and its transitives — and, since the +JEP 238 change, the non-optional `rewrite-java-25` — are a material fraction of +that. A pure-Java consumer therefore pays this size/cost (the dependency is +always on the classpath regardless). **No opt-out:** Kotlin analysis runs unconditionally — there is no `analyzeKotlin` switch on `GraphBuilderConfig`. The Kotlin parser is always @@ -78,6 +137,9 @@ Configuration options (most important): - `excludeTests`: Exclude test classes (default: true) - `minifyHtml`: Minify HTML report (default: false) +(Java 25 parser activation is automatic on JDK 25+ runtimes via the JEP 238 +multi-release jar — there is no longer any configuration flag for it.) + ## CVE Pinning Transitive dependencies surfaced by an OWASP dependency-check are pinned centrally in the parent `pom.xml` `` so child modules reference them by bare diff --git a/CLAUDE.md b/CLAUDE.md index 240089de..437c55cc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -49,7 +49,7 @@ test-resources (shared fixtures) coverage (JaCoCo aggregation) ``` -**codebase-graph-builder** — The most complex module. Uses OpenRewrite to parse Java source across versions (11/17/21), builds class and package dependency graphs with JGraphT, detects cycle-breaking candidates using two graph algorithms, and returns everything in `CodebaseGraphDTO`. Entry point: `JavaGraphBuilder.getCodebaseGraphDTO()`. +**codebase-graph-builder** — The most complex module. Uses OpenRewrite to parse Java source across versions (11/17/21, and 25 when running on a Java 25+ runtime — see "Java 25 analysis" below), builds class and package dependency graphs with JGraphT, detects cycle-breaking candidates using two graph algorithms, and returns everything in `CodebaseGraphDTO`. Entry point: `JavaGraphBuilder.getCodebaseGraphDTO()`. **graph-algorithms** — Two cycle-decomposition algorithms used by `JavaGraphBuilder`: - *Directed Feedback Vertex Set* (`org.hjug.feedback.vertex.kernelized`) — kernelized algorithm; identifies the minimum vertex set to remove to break all cycles. @@ -92,6 +92,10 @@ Mutation testing via PIT (`pitest-maven`) is configured but not part of the defa ## Java & Toolchain -- Source/target: Java 11 minimum; OpenRewrite parser supports 11, 17, 21. +- Source/target: Java 17 minimum; OpenRewrite parser supports 11, 17, 21 — plus 25 when running on a Java 25+ runtime. - Logging: SLF4J; use `log.debug()` for verbose per-class output, `log.info()` sparingly. - Spotless enforces Palantir Java format — run `mvn spotless:apply` before committing. + +## Java 25 analysis + +`rewrite-java-25` is a **required** compile dependency of `codebase-graph-builder` and ships in every distribution (Maven plugin, CLI fat jar). Its class files are compiled for class-file version 69 but are inert on Java 17/21 classpaths (no `META-INF/services` entries, no base-level references). Activation uses a **JEP 238 multi-release jar** rather than reflection or runtime-version detection: `codebase-graph-builder` declares `Multi-Release: true` and carries two variants of `org.hjug.graphbuilder.graphbuilder.Java25ParserFactory` — the base variant (release 17, returns `Optional.empty()`) and a Java 25 variant compiled from `src/main/java25` into `META-INF/versions/25` (directly constructs `Java25Parser`, with a `catch (Throwable)` graceful-degradation guard). On JDK 25+ runtimes the JVM's versioned jar lookup shadows the base variant, so `JavaSourceFileGraphBuilder.createJavaParser(config)` is just `Java25ParserFactory.createJava25Parser().orElseGet(JavaParser::fromJavaVersion...)`. OpenRewrite's `fromJavaVersion()` additionally elevates to `Java25Parser` on its own when the runtime is 25+ and `rewrite-java-25` is on the classpath (covering exploded-directory classpaths where jar shadowing does not apply). The former `forceJava25Parser` escape hatch has been removed everywhere; there is no configuration surface for parser selection. Build constraints: never reference class-file-69 code from base sources (only `src/main/java25`); the versioned class must keep the base's exact public API; release artifacts must be built with JDK 25 (`release.yml` asserts the versioned entries exist); JaCoCo excludes `META-INF/versions/**`; the CLI shade config re-adds `Multi-Release: true` to the fat jar manifest; `refactor-first-maven-plugin` requires `maven-plugin-plugin`/`maven-plugin-annotations` 3.16.0 (ASM 9.10.1) so descriptor generation can scan class-file-69 entries. See `plans/jep-238-plan.md` for the design record. diff --git a/README.md b/README.md index effa9c20..a1cf2c4c 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ Code map viewers are powered by [3D Force Graph](https://vasturiano.github.io/3d
If there are more than 4000 classes + relationships, a simplified 3D viewer will be available to avoid page load slowdowns. Features will be toggleable in the 3D UI in a future release. ## How to Use RefactorFirst Quickly -Run the command below in your Java project's top-level directory. You'll need Git, Java 11 (or newer) and Maven 3 installed. This command will analyze Maven and non-Maven projects: +Run the command below in your Java project's top-level directory. You'll need Git, Java 17 (or newer) and Maven 3 installed. This command will analyze Maven and non-Maven projects: ```bash mvn org.hjug.refactorfirst.plugin:refactor-first-maven-plugin:0.10.0:htmlReport ``` @@ -43,7 +43,8 @@ The graphs generated in the report will look similar to this one: ![image info](./RefactorFirst_Sample_Report.png) ## Please Note: Java 17 (or newer) required to run RefactorFirst -**Java 25 codebase analysis is supported!** +**Java 25 codebase analysis is supported!** Run RefactorFirst on a Java 25+ runtime and the Java 25 parser is used automatically; on older runtimes analysis falls back to the parser matching your runtime. +The Maven and Gradle plugins themselves still only require a Java 17 runtime. Please use a recent JDK release of the Java version you are using. If you use an old JDK release of your chosen Java version, you may encounter issues during analysis. @@ -118,6 +119,8 @@ Specify with -D if running on the command line. e.g. ```-DbackEdgeAnalysisCount |projectVersion|The version of your project to be displayed on the report| Your Maven project version | |outputDirectory|The location the project report will be written| ```${projectDir}/target/site/refactor-first-report.html``` +The Java 25 parser activates automatically when running on a JDK 25+ runtime (JEP 238 multi-release jar); there is no configuration flag for it. + ## But I'm using Gradle / my project layout isn't typical! I plan to create a Gradle plugin and (possibly) support non-conventional project structures in the future, but in the meantime you can create a dummy POM file in the same directory as your .git directory to show your project's name in the report: diff --git a/change-proneness-ranker/src/test/java/org/hjug/git/GitLogReaderTest.java b/change-proneness-ranker/src/test/java/org/hjug/git/GitLogReaderTest.java index 7a2d8558..0b7d53a8 100644 --- a/change-proneness-ranker/src/test/java/org/hjug/git/GitLogReaderTest.java +++ b/change-proneness-ranker/src/test/java/org/hjug/git/GitLogReaderTest.java @@ -7,6 +7,8 @@ import static org.mockito.Mockito.when; import java.io.*; +import java.nio.file.Files; +import java.nio.file.NoSuchFileException; import java.util.*; import org.eclipse.jgit.api.Git; import org.eclipse.jgit.api.LogCommand; @@ -190,8 +192,6 @@ void testFileLogReturnsOnlyVerifiedPartialPathHistoryWhenFilteredWalkYieldsNothi void testFileLogReturnsOnlyVerifiedPartialPathHistoryWithMissingTree() throws Exception { // Simulates a shallow clone whose tree objects are missing: the filtered walk // may yield zero results due to missing objects, but should not fall back to total history. - GitLogReader gitLogReader = new GitLogReader(git); - String attributeHandler = "AttributeHandler.java"; InputStream resourceAsStream = getClass().getClassLoader().getResourceAsStream(attributeHandler); String contents = convertInputStreamToString(resourceAsStream); @@ -208,11 +208,19 @@ void testFileLogReturnsOnlyVerifiedPartialPathHistoryWithMissingTree() throws Ex deleteLooseObject(secondCommit.getTree()); - ScmLogInfo scmLogInfo = Assertions.assertDoesNotThrow(() -> gitLogReader.fileLog(attributeHandler)); - - // With missing tree objects, the filtered walk may yield zero verified partial results - // rather than falling back to total repository history - Assertions.assertEquals(0, scmLogInfo.getCommitCount()); + // Read through a freshly opened repository: the commits above populated JGit's + // in-memory loose-object cache (UnpackedObjectCache, one per ObjectDirectory + // instance), which could otherwise serve the deleted tree from memory and let + // the walk succeed with 2 commits instead of failing. A new Repository instance + // gets an empty cache, so the missing object is really exercised on every OS. + try (Git freshGit = Git.open(repository.getDirectory())) { + GitLogReader gitLogReader = new GitLogReader(freshGit); + ScmLogInfo scmLogInfo = Assertions.assertDoesNotThrow(() -> gitLogReader.fileLog(attributeHandler)); + + // With missing tree objects, the filtered walk may yield zero verified partial results + // rather than falling back to total repository history + Assertions.assertEquals(0, scmLogInfo.getCommitCount()); + } } @Test @@ -232,16 +240,39 @@ void testFileLogDoesNotFabricateHistoryForFileNeverCommitted() throws Exception Assertions.assertEquals(0, scmLogInfo.getCommitCount()); } - private void deleteLooseObject(ObjectId objectId) throws IOException { + private void deleteLooseObject(ObjectId objectId) throws IOException, InterruptedException { String objectName = objectId.getName(); File looseObject = new File( new File(repository.getDirectory(), "objects"), objectName.substring(0, 2) + '/' + objectName.substring(2)); org.junit.jupiter.api.Assumptions.assumeTrue( looseObject.exists(), "loose object " + objectName + " should exist in a fresh repository"); - if (!looseObject.delete()) { - throw new IOException("Unable to delete loose object " + looseObject); + + // JGit writes loose objects with the read-only attribute set (as C git + // does). POSIX unlink ignores the read-only bit, but Windows refuses to + // delete a read-only file and every retry would fail with + // AccessDeniedException — so the attribute must be cleared first. + if (!looseObject.setWritable(true) && !looseObject.canWrite()) { + throw new IOException("Unable to clear read-only flag on loose object " + looseObject); + } + + // On Windows, antivirus or the search indexer can also hold a newly + // created file open briefly, making an immediate delete fail + // spuriously. Retry for a short window before giving up (harmless on + // Linux/macOS, where the first attempt succeeds). + IOException lastError = null; + for (int attempt = 0; attempt < 50; attempt++) { + try { + Files.delete(looseObject.toPath()); + return; + } catch (NoSuchFileException alreadyGone) { + return; + } catch (IOException lockedOrBusy) { + lastError = lockedOrBusy; + Thread.sleep(50); + } } + throw new IOException("Unable to delete loose object " + looseObject, lastError); } @Test diff --git a/cli/pom.xml b/cli/pom.xml index 426c1844..7322f0f1 100644 --- a/cli/pom.xml +++ b/cli/pom.xml @@ -55,6 +55,12 @@ org.hjug.refactorfirst.Main + + + true + diff --git a/codebase-graph-builder/pom.xml b/codebase-graph-builder/pom.xml index d6e04f97..3ceafd95 100644 --- a/codebase-graph-builder/pom.xml +++ b/codebase-graph-builder/pom.xml @@ -38,6 +38,18 @@ io.micrometer micrometer-core + + + org.openrewrite + rewrite-java-25 + org.openrewrite rewrite-java-21 @@ -66,4 +78,75 @@ rewrite-kotlin + + + + + org.apache.maven.plugins + maven-jar-plugin + 3.4.1 + + + + + true + + + + + + org.apache.maven.plugins + maven-failsafe-plugin + 3.5.3 + + + + integration-test + verify + + + + + + + + + + + jdk25-multi-release + + [25,) + + + + + org.apache.maven.plugins + maven-compiler-plugin + + + compile-java25-multirelease + compile + + compile + + + 25 + + ${project.basedir}/src/main/java25 + + + ${project.build.outputDirectory}/META-INF/versions/25 + + + + + + + + diff --git a/codebase-graph-builder/src/main/java/org/hjug/graphbuilder/graphbuilder/Java25ParserFactory.java b/codebase-graph-builder/src/main/java/org/hjug/graphbuilder/graphbuilder/Java25ParserFactory.java new file mode 100644 index 00000000..b3cc08e5 --- /dev/null +++ b/codebase-graph-builder/src/main/java/org/hjug/graphbuilder/graphbuilder/Java25ParserFactory.java @@ -0,0 +1,43 @@ +package org.hjug.graphbuilder.graphbuilder; + +import java.util.Optional; +import org.openrewrite.java.JavaParser; + +/** + * JEP 238 (multi-release jar) seam for the OpenRewrite Java 25 parser. + * + *

The packaged {@code codebase-graph-builder} jar is multi-release: a Java + * 25-compiled variant of this class lives under + * {@code META-INF/versions/25/org/hjug/graphbuilder/graphbuilder/}. On a JDK + * 25+ runtime the JVM's versioned jar lookup shadows this base + * variant with the versioned one, which instantiates + * {@code org.openrewrite.java.Java25Parser} directly. On JDK 17/21 runtimes + * the versioned entry is invisible to the class loader — its class-file + * version (69.0) can never be loaded — and this base variant answers. + * + *

Note: multi-release shadowing only applies when classes are loaded from + * a jar. In exploded-directory classpaths (e.g. unit tests running + * against {@code target/classes}) this base variant answers even on JDK 25. + */ +public final class Java25ParserFactory { + + /** Prevents instantiation of this utility class. */ + private Java25ParserFactory() { + // Utility class + } + + /** + * Creates a Java 25-capable parser. + * + * @return a Java 25 {@link JavaParser} when the multi-release Java 25 + * variant of this class is active (JDK 25+ runtime, classes loaded + * from the packaged jar); otherwise {@link Optional#empty()}. Never + * throws. + */ + public static Optional createJava25Parser() { + // Base variant: Java 25 parser classes are not referenced here, so + // nothing newer than class file 61 exists to load. The versioned + // variant under META-INF/versions/25 shadows this method on JDK 25+. + return Optional.empty(); + } +} diff --git a/codebase-graph-builder/src/main/java/org/hjug/graphbuilder/graphbuilder/JavaSourceFileGraphBuilder.java b/codebase-graph-builder/src/main/java/org/hjug/graphbuilder/graphbuilder/JavaSourceFileGraphBuilder.java index 4dbc7c5f..611071ab 100644 --- a/codebase-graph-builder/src/main/java/org/hjug/graphbuilder/graphbuilder/JavaSourceFileGraphBuilder.java +++ b/codebase-graph-builder/src/main/java/org/hjug/graphbuilder/graphbuilder/JavaSourceFileGraphBuilder.java @@ -39,12 +39,13 @@ @Slf4j public class JavaSourceFileGraphBuilder implements SourceFileGraphBuilder { + /** Builds dependency graphs and metrics from the Java files under the supplied repository path. */ @Override public CodebaseGraphDTO buildGraph(String repositoryPath, String repositoryRoot, GraphBuilderConfig config) throws IOException { File srcDirectory = new File(repositoryPath); - JavaParser javaParser = JavaParser.fromJavaVersion().build(); + JavaParser javaParser = createJavaParser(); ExecutionContext ctx = new InMemoryExecutionContext(e -> log.warn("OpenRewrite parse/visit error", e)); final Graph classReferencesGraph = @@ -94,6 +95,28 @@ public CodebaseGraphDTO buildGraph(String repositoryPath, String repositoryRoot, metricsCollector); } + /** + * Selects the {@link JavaParser} for this build. The JEP 238 multi-release + * variant of {@link Java25ParserFactory} supplies the Java 25 parser when + * the classes were loaded from the packaged jar on a JDK 25+ runtime. The + * fallback {@link JavaParser#fromJavaVersion()} additionally elevates to + * the Java 25 parser on its own whenever the runtime is 25+ and + * {@code rewrite-java-25} is on the classpath. + * + *

Note: JEP 238 shadowing applies only to classes loaded from a jar. + * In exploded-directory classpaths (e.g. unit tests against + * {@code target/classes}) the base factory variant answers even on + * JDK 25; the {@code fromJavaVersion()} elevation still yields a + * Java 25-capable parser there when {@code rewrite-java-25} is present. + */ + static JavaParser createJavaParser() { + return Java25ParserFactory.createJava25Parser().orElseGet(() -> { + log.debug("Using JavaParser.fromJavaVersion() (Java 25 multi-release jar variant not active)"); + return JavaParser.fromJavaVersion().build(); + }); + } + + /** Finalizes collected metrics and assembles the Java codebase graph result. */ static CodebaseGraphDTO finalizeDto( Graph classReferencesGraph, Graph packageReferencesGraph, @@ -127,6 +150,7 @@ static CodebaseGraphDTO finalizeDto( getMethodDisharmonies(detector, metrics)); } + /** Removes graph vertices whose packages were not discovered in the analyzed codebase. */ static void removeClassesNotInCodebase( Set packagesInCodebase, Graph classReferencesGraph) { Set classesToRemove = new HashSet<>(); @@ -138,6 +162,7 @@ static void removeClassesNotInCodebase( classReferencesGraph.removeAllVertices(classesToRemove); } + /** Removes package vertices that were not discovered in the analyzed codebase. */ static void removePackagesNotInCodebase( Set packagesInCodebase, Graph packageReferencesGraph) { Set packagesToRemove = new HashSet<>(); @@ -149,6 +174,7 @@ static void removePackagesNotInCodebase( packageReferencesGraph.removeAllVertices(packagesToRemove); } + /** Returns the package portion of a fully qualified class name. */ static String getPackage(String fqn) { if (!fqn.contains(".")) { return ""; @@ -157,6 +183,7 @@ static String getPackage(String fqn) { return fqn.substring(0, lastIndex); } + /** Collects all method-level disharmonies detected for the supplied metrics. */ private static List getMethodDisharmonies( DisharmonyDetector detector, Collection metrics) { List methodDisharmonies = new ArrayList<>(); @@ -168,6 +195,7 @@ private static List getMethodDisharmonies( return methodDisharmonies; } + /** Collects class-level disharmonies, including Kotlin-specific findings when applicable. */ static List getClassDisharmonies( DisharmonyDetector detector, Collection metrics, boolean hasKotlinMetrics) { List classDisharmonies = new ArrayList<>(); diff --git a/codebase-graph-builder/src/main/java25/org/hjug/graphbuilder/graphbuilder/Java25ParserFactory.java b/codebase-graph-builder/src/main/java25/org/hjug/graphbuilder/graphbuilder/Java25ParserFactory.java new file mode 100644 index 00000000..d842a4ae --- /dev/null +++ b/codebase-graph-builder/src/main/java25/org/hjug/graphbuilder/graphbuilder/Java25ParserFactory.java @@ -0,0 +1,49 @@ +package org.hjug.graphbuilder.graphbuilder; + +import java.util.Optional; +import org.openrewrite.java.Java25Parser; +import org.openrewrite.java.JavaParser; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Java 25 multi-release variant of {@code Java25ParserFactory} (JEP 238). + * + *

This source lives in {@code src/main/java25} and is compiled with + * {@code --release 25} into {@code META-INF/versions/25} of the packaged jar. + * It is loaded only on JDK 25+ runtimes, where it shadows the base variant — + * so it may reference {@link Java25Parser} (class file 69.0) directly, without + * reflection. JEP 238 requires this class to expose exactly the same public + * API as the base variant. + */ +public final class Java25ParserFactory { + + // Plain slf4j rather than Lombok @Slf4j: keep this versioned class free of + // annotation processing so the extra compile execution stays minimal. + private static final Logger log = LoggerFactory.getLogger(Java25ParserFactory.class); + + /** Prevents instantiation of this utility class. */ + private Java25ParserFactory() { + // Utility class + } + + /** + * Creates the Java 25 parser. This variant only ever runs on JDK 25+, so + * the parser should always be constructible; a failure (e.g. + * {@code rewrite-java-25} missing from a hand-rolled classpath) still + * degrades gracefully to {@link Optional#empty()} so callers fall back to + * the standard parser. + * + * @return the Java 25 parser, or {@link Optional#empty()} on failure; never + * throws + */ + public static Optional createJava25Parser() { + try { + log.info("Using Java 25 parser for Java 25 language feature support"); + return Optional.of(Java25Parser.builder().build()); + } catch (Throwable t) { + log.warn("Java 25 parser unavailable on a Java 25+ runtime; falling back to the standard parser", t); + return Optional.empty(); + } + } +} diff --git a/codebase-graph-builder/src/test/java/org/hjug/graphbuilder/Java25AnalysisIntegrationTest.java b/codebase-graph-builder/src/test/java/org/hjug/graphbuilder/Java25AnalysisIntegrationTest.java new file mode 100644 index 00000000..c5c418e0 --- /dev/null +++ b/codebase-graph-builder/src/test/java/org/hjug/graphbuilder/Java25AnalysisIntegrationTest.java @@ -0,0 +1,47 @@ +package org.hjug.graphbuilder; + +import static org.junit.jupiter.api.Assertions.*; + +import java.io.File; +import java.io.IOException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledForJreRange; +import org.junit.jupiter.api.condition.JRE; + +/** + * End-to-end integration test for Java 25 source analysis. The fixture uses + * Java 25-only syntax (JEP 513 flexible constructor bodies — statements before + * an explicit {@code super()} call) which earlier source-level parsers reject. + * + *

On a JDK 25+ runtime a Java 25-capable parser is always selected: either + * by the JEP 238 multi-release variant of {@link Java25ParserFactory} (when + * classes are loaded from the packaged jar — verified by + * {@code MultiReleaseJarIT}) or by {@code JavaParser.fromJavaVersion()}, which + * reflectively elevates to {@code Java25Parser} when {@code rewrite-java-25} + * is on the classpath (always true here and in shipped distributions). The + * versioned-variant construction itself is covered by + * {@code Java25ParserFactoryTest}. + */ +@EnabledForJreRange(min = JRE.JAVA_25) +class Java25AnalysisIntegrationTest { + + private static final String FIXTURE_DIR = "src/test/resources/java25SrcDirectory"; + + @DisplayName("Java 25 source using flexible constructor bodies is parsed and appears in the graph") + @Test + void java25SourceIsAnalyzed() throws IOException { + File srcDirectory = new File(FIXTURE_DIR); + GraphBuilderConfig config = + GraphBuilderConfig.builder().excludeTests(false).build(); + CodebaseGraphDTO dto = new CompositeGraphBuilder().getCodebaseGraphDTO(srcDirectory.getAbsolutePath(), config); + + assertTrue( + dto.getClassReferencesGraph().containsVertex("com.example.java25.FlexibleConstructorBody"), + "Java 25 fixture class must be a vertex in the class graph. Vertices: " + + dto.getClassReferencesGraph().vertexSet()); + assertTrue( + dto.getClassToSourceFilePathMapping().containsKey("com.example.java25.FlexibleConstructorBody"), + "Java 25 fixture class must be attributed to its source file"); + } +} diff --git a/codebase-graph-builder/src/test/java/org/hjug/graphbuilder/graphbuilder/Java25ParserFactoryTest.java b/codebase-graph-builder/src/test/java/org/hjug/graphbuilder/graphbuilder/Java25ParserFactoryTest.java new file mode 100644 index 00000000..e875c6f0 --- /dev/null +++ b/codebase-graph-builder/src/test/java/org/hjug/graphbuilder/graphbuilder/Java25ParserFactoryTest.java @@ -0,0 +1,188 @@ +package org.hjug.graphbuilder.graphbuilder; + +import static org.junit.jupiter.api.Assertions.*; +import static org.junit.jupiter.api.Assumptions.assumeTrue; + +import java.lang.reflect.Method; +import java.lang.reflect.Modifier; +import java.net.URL; +import java.net.URLClassLoader; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.Arrays; +import java.util.List; +import java.util.Optional; +import java.util.Set; +import java.util.TreeSet; +import java.util.stream.Collectors; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledForJreRange; +import org.junit.jupiter.api.condition.JRE; +import org.openrewrite.ExecutionContext; +import org.openrewrite.InMemoryExecutionContext; +import org.openrewrite.SourceFile; +import org.openrewrite.java.JavaParser; +import org.openrewrite.java.tree.JavaSourceFile; + +/** + * Contract tests for {@link Java25ParserFactory}, the JEP 238 multi-release + * seam that activates the OpenRewrite Java 25 parser. + * + *

The packaged {@code codebase-graph-builder} jar carries two variants of + * {@code Java25ParserFactory}: a base variant (Java 17) that always returns + * {@link Optional#empty()}, and a {@code META-INF/versions/25} variant + * (Java 25) that instantiates {@code Java25Parser} directly. The JVM's + * multi-release jar lookup selects the variant. + * + *

Important test-environment note: JEP 238 shadowing only applies to + * classes loaded from a jar. Surefire runs tests against the exploded + * {@code target/classes} directory, so the {@link Java25ParserFactory} symbol + * in this JVM always resolves to the base variant — even on JDK 25. The + * versioned variant is therefore verified by loading it directly from + * {@code target/classes/META-INF/versions/25} in an isolated class loader + * (T2/T3), and the real jar shadowing is verified by + * {@code MultiReleaseJarIT} against the packaged artifact. + */ +class Java25ParserFactoryTest { + + private static final String FACTORY_FQN = "org.hjug.graphbuilder.graphbuilder.Java25ParserFactory"; + private static final String VERSIONED_CLASS_RELATIVE_PATH = + "org/hjug/graphbuilder/graphbuilder/Java25ParserFactory.class"; + private static final Path VERSIONED_CLASSES_DIR = Paths.get("target", "classes", "META-INF", "versions", "25"); + private static final Path JAVA25_FIXTURE_ROOT = Paths.get("src", "test", "resources", "java25SrcDirectory"); + + @DisplayName("T1: base variant returns empty Optional and never throws on pre-25 runtimes") + @Test + void baseVariantReturnsEmpty() { + Optional parser = assertDoesNotThrow(Java25ParserFactory::createJava25Parser); + if (Runtime.version().feature() < 25) { + // No multi-release shadowing is possible on a pre-25 runtime. + assertTrue(parser.isEmpty(), "Pre-25 runtimes must not receive a Java 25 parser"); + } else { + // On JDK 25 surefire runs, target/classes is an exploded directory + // and JEP 238 shadowing does not apply, so the base variant still + // answers here. Jar-shadowed behavior on JDK 25 is covered by + // MultiReleaseJarIT. + assertTrue(parser.isEmpty(), "Exploded-classes test classpath must resolve the base variant"); + } + } + + @DisplayName("T2: versioned variant creates a parser that accepts Java 25 source (JEP 513)") + @EnabledForJreRange(min = JRE.JAVA_25) + @Test + void versionedVariantCreatesWorkingJava25Parser() throws Exception { + Class versionedFactory = loadVersionedFactory(); + Object result = versionedFactory.getMethod("createJava25Parser").invoke(null); + + assertInstanceOf(Optional.class, result); + Optional parserOptional = (Optional) result; + assertTrue(parserOptional.isPresent(), "Versioned variant must create a Java 25 parser on JDK 25+"); + Object parser = parserOptional.get(); + assertTrue( + parser.getClass().getName().contains("Java25"), + "Expected a Java25Parser instance, got: " + parser.getClass().getName()); + + // Prove it really is the Java 25 parser: the fixture uses JEP 513 + // flexible constructor bodies (statements before super()), which the + // Java 21 parser rejects. + ExecutionContext ctx = new InMemoryExecutionContext(t -> fail("Parse failure on Java 25 fixture", t)); + List parsed = ((JavaParser) parser) + .parse( + List.of(JAVA25_FIXTURE_ROOT.resolve( + Paths.get("com", "example", "java25", "FlexibleConstructorBody.java"))), + JAVA25_FIXTURE_ROOT, + ctx) + .collect(Collectors.toList()); + + assertEquals(1, parsed.size(), "Expected exactly one parsed source file"); + assertTrue( + parsed.get(0) instanceof JavaSourceFile, + "Java 25 fixture must parse to a compilation unit, got: " + + parsed.get(0).getClass().getName()); + } + + @DisplayName("T2: versioned variant falls back when the Java 25 parser is unavailable") + @EnabledForJreRange(min = JRE.JAVA_25) + @Test + void versionedVariantFallsBackWhenJava25ParserIsUnavailable() throws Exception { + Class versionedFactory = loadVersionedFactoryWithoutJava25Parser(); + + Object result = assertDoesNotThrow( + () -> versionedFactory.getMethod("createJava25Parser").invoke(null), + "The versioned factory must contain parser linkage failures"); + + assertInstanceOf(Optional.class, result); + assertTrue( + ((Optional) result).isEmpty(), + "A missing Java25Parser must return an empty Optional so the caller can use its standard fallback"); + } + + @DisplayName("T3: versioned and base variants expose identical public API (JEP 238 parity)") + @EnabledForJreRange(min = JRE.JAVA_25) + @Test + void versionedVariantExposesIdenticalPublicApi() throws Exception { + Class versionedFactory = loadVersionedFactory(); + assertEquals( + publicStaticSignatures(Java25ParserFactory.class), + publicStaticSignatures(versionedFactory), + "JEP 238 requires the versioned class to expose the same public API as the base class"); + } + + /** + * Loads the Java 25 variant of the factory from + * {@code target/classes/META-INF/versions/25} in an isolated class loader. + * Skips when the versioned class file does not exist (i.e. the module was + * built on a pre-25 JDK, where the {@code jdk25+} build profile is + * inactive). + */ + private static Class loadVersionedFactory() throws Exception { + return loadVersionedFactory(false); + } + + private static Class loadVersionedFactoryWithoutJava25Parser() throws Exception { + return loadVersionedFactory(true); + } + + private static Class loadVersionedFactory(boolean hideJava25Parser) throws Exception { + assumeTrue( + Files.exists(VERSIONED_CLASSES_DIR.resolve(VERSIONED_CLASS_RELATIVE_PATH)), + "Versioned factory class only exists when built on JDK 25+"); + // Hide org.hjug classes from the parent so the child genuinely loads + // the versioned class from META-INF/versions/25 — plain parent-first + // delegation would resolve the base variant from target/classes. + ClassLoader filteredParent = new ClassLoader(Java25ParserFactoryTest.class.getClassLoader()) { + @Override + protected Class loadClass(String name, boolean resolve) throws ClassNotFoundException { + if (name.startsWith("org.hjug.")) { + throw new ClassNotFoundException(name); + } + return super.loadClass(name, resolve); + } + }; + URLClassLoader loader = + new URLClassLoader(new URL[] {VERSIONED_CLASSES_DIR.toUri().toURL()}, filteredParent) { + @Override + protected Class loadClass(String name, boolean resolve) throws ClassNotFoundException { + if (hideJava25Parser && name.equals("org.openrewrite.java.Java25Parser")) { + throw new ClassNotFoundException(name); + } + return super.loadClass(name, resolve); + } + }; + return Class.forName(FACTORY_FQN, false, loader); + } + + private static Set publicStaticSignatures(Class type) { + return Arrays.stream(type.getDeclaredMethods()) + .filter(m -> Modifier.isPublic(m.getModifiers()) && Modifier.isStatic(m.getModifiers())) + .map(Java25ParserFactoryTest::signatureOf) + .collect(Collectors.toCollection(TreeSet::new)); + } + + private static String signatureOf(Method method) { + return method.getName() + Arrays.toString(method.getParameterTypes()) + ":" + + method.getReturnType().getSimpleName(); + } +} diff --git a/codebase-graph-builder/src/test/java/org/hjug/graphbuilder/graphbuilder/JavaSourceFileGraphBuilderJava25Test.java b/codebase-graph-builder/src/test/java/org/hjug/graphbuilder/graphbuilder/JavaSourceFileGraphBuilderJava25Test.java new file mode 100644 index 00000000..253d24cc --- /dev/null +++ b/codebase-graph-builder/src/test/java/org/hjug/graphbuilder/graphbuilder/JavaSourceFileGraphBuilderJava25Test.java @@ -0,0 +1,79 @@ +package org.hjug.graphbuilder.graphbuilder; + +import static org.junit.jupiter.api.Assertions.*; + +import java.io.File; +import java.io.IOException; +import org.hjug.graphbuilder.CodebaseGraphDTO; +import org.hjug.graphbuilder.GraphBuilderConfig; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledForJreRange; +import org.junit.jupiter.api.condition.JRE; +import org.openrewrite.java.JavaParser; + +/** + * Tests for parser selection in {@link JavaSourceFileGraphBuilder}. + * + *

Two mechanisms select a Java 25-capable parser on JDK 25+ runtimes: the + * JEP 238 shadowed variant of {@link Java25ParserFactory} when classes are + * loaded from the packaged multi-release jar (verified by + * {@code MultiReleaseJarIT}), and OpenRewrite's own + * {@link JavaParser#fromJavaVersion()}, which reflectively elevates to + * {@code Java25Parser} when the runtime is 25+ and {@code rewrite-java-25} is + * on the classpath (as it always is in this module and in the shipped + * distributions). Under surefire's exploded {@code target/classes} classpath + * the factory's base variant answers, so the elevation path is what this test + * exercises on JDK 25. + */ +class JavaSourceFileGraphBuilderJava25Test { + + private static final String FIXTURE_DIR = "src/test/resources/javaSrcDirectory"; + + /** + * The fixture lives under {@code src/test/resources}, so test-source + * exclusion must be disabled or every fixture file is filtered out. + */ + private static GraphBuilderConfig config() { + return GraphBuilderConfig.builder().excludeTests(false).build(); + } + + @DisplayName("Standard parser is used when runtime is older than Java 25") + @Test + void standardParserUsedOnOlderRuntime() { + if (Runtime.version().feature() >= 25) { + return; // covered by the JAVA_25-only test below + } + JavaParser parser = JavaSourceFileGraphBuilder.createJavaParser(); + assertFalse( + parser.getClass().getSimpleName().contains("25"), + "Expected a non-Java-25 parser, got: " + parser.getClass().getName()); + } + + @DisplayName("JDK 25: base factory variant answers on exploded classpath; fromJavaVersion still elevates") + @EnabledForJreRange(min = JRE.JAVA_25) + @Test + void jdk25YieldsJava25CapableParser() { + assertTrue( + Java25ParserFactory.createJava25Parser().isEmpty(), + "Base factory variant must answer when classes are loaded from an exploded classpath " + + "(JEP 238 shadowing applies only to jars)"); + // fromJavaVersion() reflectively elevates to Java25Parser on a 25+ + // runtime because rewrite-java-25 is on the (test) classpath. + JavaParser parser = JavaSourceFileGraphBuilder.createJavaParser(); + assertNotNull(parser, "Parser creation must never fail"); + assertTrue( + parser.getClass().getName().contains("Java25"), + "Expected a Java 25-capable parser on JDK 25, got: " + + parser.getClass().getName()); + } + + @DisplayName("Graph build with default config succeeds (regression guard)") + @Test + void buildGraphWithDefaultConfig() throws IOException { + File srcDirectory = new File(FIXTURE_DIR); + CodebaseGraphDTO dto = + new JavaSourceFileGraphBuilder().buildGraph(srcDirectory.getAbsolutePath(), "", config()); + assertEquals(5, dto.getClassReferencesGraph().vertexSet().size()); + } +} diff --git a/codebase-graph-builder/src/test/java/org/hjug/graphbuilder/graphbuilder/MultiReleaseJarIT.java b/codebase-graph-builder/src/test/java/org/hjug/graphbuilder/graphbuilder/MultiReleaseJarIT.java new file mode 100644 index 00000000..f2963b85 --- /dev/null +++ b/codebase-graph-builder/src/test/java/org/hjug/graphbuilder/graphbuilder/MultiReleaseJarIT.java @@ -0,0 +1,130 @@ +package org.hjug.graphbuilder.graphbuilder; + +import static org.junit.jupiter.api.Assertions.*; +import static org.junit.jupiter.api.Assumptions.assumeTrue; + +import java.io.IOException; +import java.lang.reflect.Method; +import java.lang.reflect.Modifier; +import java.net.URL; +import java.net.URLClassLoader; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.Arrays; +import java.util.Optional; +import java.util.Set; +import java.util.TreeSet; +import java.util.jar.JarEntry; +import java.util.jar.JarFile; +import java.util.stream.Collectors; +import java.util.stream.Stream; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * Failsafe IT (runs after {@code package}) verifying that the packaged + * {@code codebase-graph-builder} jar is a well-formed JEP 238 multi-release + * jar: it carries {@code Multi-Release: true}, contains the Java 25 variant + * of {@code Java25ParserFactory} under {@code META-INF/versions/25}, and the + * JVM's versioned jar lookup actually shadows the base variant on JDK 25. + * + *

Only built artifacts from a JDK 25+ build contain versioned entries (the + * {@code jdk25-multi-release} profile), so these tests assume a JDK 25+ + * runtime and self-skip otherwise. CI's release job pins JDK 25 and fails the + * build if the versioned entries are missing, so a skipped run here on JDK 17 + * is by design. + */ +class MultiReleaseJarIT { + + private static final String FACTORY_FQN = "org.hjug.graphbuilder.graphbuilder.Java25ParserFactory"; + private static final String VERSIONED_ENTRY = + "META-INF/versions/25/org/hjug/graphbuilder/graphbuilder/Java25ParserFactory.class"; + + @DisplayName("T4: packaged jar declares Multi-Release and carries the versioned factory") + @Test + void packagedJarIsMultiReleaseWithVersionedFactory() throws Exception { + Path jar = packagedJar(); + try (JarFile jarFile = new JarFile(jar.toFile())) { + assertEquals( + "true", + jarFile.getManifest().getMainAttributes().getValue("Multi-Release"), + "Packaged jar manifest must declare Multi-Release: true"); + JarEntry versionedEntry = jarFile.getJarEntry(VERSIONED_ENTRY); + assertNotNull(versionedEntry, "Packaged jar must contain " + VERSIONED_ENTRY); + + // The versioned class file must really be class file version 69 + // (Java 25): bytes 6-7 of the class file hold the major version. + byte[] bytes = jarFile.getInputStream(versionedEntry).readAllBytes(); + int major = ((bytes[6] & 0xFF) << 8) | (bytes[7] & 0xFF); + assertEquals(69, major, "Versioned factory must be compiled for Java 25 (class file 69.0)"); + } + } + + @DisplayName("T4: loading the factory from the packaged jar on JDK 25 activates the Java 25 variant") + @Test + void jarShadowingActivatesJava25Variant() throws Exception { + Path jar = packagedJar(); + // The filtered parent hides org.hjug classes so the child genuinely + // loads the factory from the jar (a plain parent-first delegation + // would resolve the base variant from target/classes instead). + ClassLoader filteredParent = new ClassLoader(MultiReleaseJarIT.class.getClassLoader()) { + @Override + protected Class loadClass(String name, boolean resolve) throws ClassNotFoundException { + if (name.startsWith("org.hjug.")) { + throw new ClassNotFoundException(name); + } + return super.loadClass(name, resolve); + } + }; + try (URLClassLoader loader = new URLClassLoader(new URL[] {jar.toUri().toURL()}, filteredParent)) { + Class factoryFromJar = Class.forName(FACTORY_FQN, false, loader); + assertTrue( + factoryFromJar + .getProtectionDomain() + .getCodeSource() + .getLocation() + .getPath() + .contains(".jar"), + "Factory must be loaded from the packaged jar"); + + Method create = factoryFromJar.getMethod("createJava25Parser"); + Object result = create.invoke(null); + assertInstanceOf(Optional.class, result); + assertTrue( + ((Optional) result).isPresent(), + "Multi-release shadowing must activate the Java 25 variant on JDK 25 runtimes"); + + // API parity with the base variant, asserted from the jar-loaded class. + assertEquals( + publicStaticSignatures(Java25ParserFactory.class), + publicStaticSignatures(factoryFromJar), + "JEP 238 requires the versioned class to expose the same public API as the base class"); + } + } + + private static Path packagedJar() throws IOException { + assumeTrue(Runtime.version().feature() >= 25, "Versioned jar entries exist only in artifacts built on JDK 25+"); + Path target = Paths.get("target"); + try (Stream files = Files.list(target)) { + return files.filter(p -> { + String name = p.getFileName().toString(); + return name.startsWith("codebase-graph-builder-") + && name.endsWith(".jar") + && !name.contains("-sources") + && !name.contains("-javadoc"); + }) + .findFirst() + .orElseThrow(() -> new IllegalStateException( + "Packaged codebase-graph-builder jar not found in target/ — ITs must run after package")); + } + } + + private static Set publicStaticSignatures(Class type) { + return Arrays.stream(type.getDeclaredMethods()) + .filter(m -> Modifier.isPublic(m.getModifiers()) && Modifier.isStatic(m.getModifiers())) + .map(m -> m.getName() + Arrays.toString(m.getParameterTypes()) + ":" + + m.getReturnType().getSimpleName()) + .collect(Collectors.toCollection(TreeSet::new)); + } +} diff --git a/codebase-graph-builder/src/test/resources/java25SrcDirectory/com/example/java25/FlexibleConstructorBody.java b/codebase-graph-builder/src/test/resources/java25SrcDirectory/com/example/java25/FlexibleConstructorBody.java new file mode 100644 index 00000000..735d92ac --- /dev/null +++ b/codebase-graph-builder/src/test/resources/java25SrcDirectory/com/example/java25/FlexibleConstructorBody.java @@ -0,0 +1,30 @@ +package com.example.java25; + +/** + * Java 25 language feature (JEP 513, final in 25): flexible constructor + * bodies — statements may appear before an explicit super() invocation. + * Parsers constrained to source level 21 or earlier reject this file. + */ +public class FlexibleConstructorBody { + + private final int value; + + /** + * Creates an instance after validating the constructor argument before delegation. + * + * @param raw the non-negative value to retain + */ + public FlexibleConstructorBody(int raw) { + if (raw < 0) { + throw new IllegalArgumentException("value must not be negative"); + } + // Statement before explicit constructor invocation: Java 25 (JEP 513). + super(); + this.value = raw; + } + + /** Returns the value supplied at construction time. */ + public int getValue() { + return value; + } +} diff --git a/cost-benefit-calculator/src/main/java/org/hjug/cbc/CycleRanker.java b/cost-benefit-calculator/src/main/java/org/hjug/cbc/CycleRanker.java index 65157d16..9a9d912f 100644 --- a/cost-benefit-calculator/src/main/java/org/hjug/cbc/CycleRanker.java +++ b/cost-benefit-calculator/src/main/java/org/hjug/cbc/CycleRanker.java @@ -9,6 +9,7 @@ import org.hjug.dsm.CircularReferenceChecker; import org.hjug.graphbuilder.CodebaseGraphDTO; import org.hjug.graphbuilder.CompositeGraphBuilder; +import org.hjug.graphbuilder.GraphBuilderConfig; import org.jgrapht.Graph; import org.jgrapht.graph.AsSubgraph; import org.jgrapht.graph.DefaultWeightedEdge; @@ -41,14 +42,20 @@ public CycleRanker(String repositoryPath) { */ // TODO: should this method belong in this class? public CodebaseGraphDTO generateClassReferencesGraph(boolean excludeTests, String testSourceDirectory) { + if (repositoryPath == null || repositoryPath.isEmpty()) { + throw new IllegalArgumentException("Source directory cannot be null or empty"); + } try { // Route through CompositeGraphBuilder so Kotlin source files are // also walked and contribute edges/vertices. Kotlin analysis runs // unconditionally; a Kotlin parse/build failure falls back to the // Java-only DTO. CompositeGraphBuilder compositeGraphBuilder = new CompositeGraphBuilder(); - codebaseGraphDTO = compositeGraphBuilder.getCodebaseGraphDTO( - repositoryPath, repositoryRoot, excludeTests, testSourceDirectory); + GraphBuilderConfig config = GraphBuilderConfig.builder() + .excludeTests(excludeTests) + .testSourceDirectory(testSourceDirectory) + .build(); + codebaseGraphDTO = compositeGraphBuilder.getCodebaseGraphDTO(repositoryPath, repositoryRoot, config); } catch (IOException e) { throw new RuntimeException(e); } diff --git a/cost-benefit-calculator/src/test/java/org/hjug/cbc/CostBenefitCalculatorTest.java b/cost-benefit-calculator/src/test/java/org/hjug/cbc/CostBenefitCalculatorTest.java index 80abbbd6..f8f88357 100644 --- a/cost-benefit-calculator/src/test/java/org/hjug/cbc/CostBenefitCalculatorTest.java +++ b/cost-benefit-calculator/src/test/java/org/hjug/cbc/CostBenefitCalculatorTest.java @@ -68,6 +68,12 @@ void testCBOViolation() throws IOException, GitAPIException, InterruptedExceptio Assertions.assertFalse(disharmonies.isEmpty()); } + // Note: this test was previously @DisabledOnJre(JRE.JAVA_25) — OpenRewrite + // 8.90.4 could not convert the UpdateCenter2.java fixture on a JDK 25 + // runtime (Java 25 parser lambda NPE, openrewrite/rewrite#8712 family). + // That failure no longer reproduces now that rewrite-java-25 is always on + // the classpath (JEP 238 packaging): the test passes on JDK 25. If the + // fixture regresses on newer JDKs, check that issue first. @Test void testCostBenefitCalculation() throws IOException, GitAPIException, InterruptedException { diff --git a/cost-benefit-calculator/src/test/java/org/hjug/cbc/CycleRankerGraphGenerationTest.java b/cost-benefit-calculator/src/test/java/org/hjug/cbc/CycleRankerGraphGenerationTest.java new file mode 100644 index 00000000..02e1b6e5 --- /dev/null +++ b/cost-benefit-calculator/src/test/java/org/hjug/cbc/CycleRankerGraphGenerationTest.java @@ -0,0 +1,89 @@ +package org.hjug.cbc; + +import static org.junit.jupiter.api.Assertions.*; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import org.hjug.graphbuilder.CodebaseGraphDTO; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.NullAndEmptySource; + +/** + * Verifies {@link CycleRanker#generateClassReferencesGraph(boolean, String)} + * builds a usable graph. (Replaces a deleted test that existed to exercise + * the removed parser-forcing overload; parser selection is now entirely + * runtime-driven via the JEP 238 multi-release jar design.) + */ +class CycleRankerGraphGenerationTest { + + @TempDir + Path tempDir; + + @Test + @DisplayName("generateClassReferencesGraph builds the graph") + void generatesClassReferencesGraph() throws IOException { + Path pkg = tempDir.resolve("com/example"); + Files.createDirectories(pkg); + Files.writeString( + pkg.resolve("Plain.java"), + """ + package com.example; + public class Plain { + public void hello() {} + } + """); + + CycleRanker ranker = new CycleRanker(tempDir.toString(), tempDir.toString()); + CodebaseGraphDTO dto = ranker.generateClassReferencesGraph(false, ""); + + assertNotNull(dto); + assertTrue(dto.getClassReferencesGraph().containsVertex("com.example.Plain")); + } + + @ParameterizedTest + @NullAndEmptySource + @DisplayName("generateClassReferencesGraph rejects a missing source directory") + void rejectsMissingSourceDirectory(String repositoryPath) { + CycleRanker ranker = new CycleRanker(repositoryPath, tempDir.toString()); + + IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, () -> ranker.generateClassReferencesGraph(false, "src/test")); + + assertEquals("Source directory cannot be null or empty", exception.getMessage()); + assertNull(ranker.getCodebaseGraphDTO(), "A rejected request must not populate the graph DTO"); + } + + @ParameterizedTest(name = "excludeTests={0} should include custom test source={1}") + @CsvSource({"true, false", "false, true"}) + @DisplayName("generateClassReferencesGraph forwards test exclusion configuration") + void forwardsTestExclusionConfiguration(boolean excludeTests, boolean expectTestSource) throws IOException { + writeJava(tempDir.resolve("src/main/java/com/example/ProductionClass.java"), "com.example", "ProductionClass"); + writeJava(tempDir.resolve("custom-tests/com/example/FixtureClass.java"), "com.example", "FixtureClass"); + + CycleRanker ranker = new CycleRanker(tempDir.toString(), tempDir.toString()); + CodebaseGraphDTO dto = ranker.generateClassReferencesGraph(excludeTests, "custom-tests"); + + assertTrue(dto.getClassReferencesGraph().containsVertex("com.example.ProductionClass")); + assertEquals( + expectTestSource, + dto.getClassReferencesGraph().containsVertex("com.example.FixtureClass"), + "The custom test source directory must follow the caller's exclusion setting"); + assertSame(dto, ranker.getCodebaseGraphDTO(), "The generated DTO must remain available to cycle ranking"); + } + + private static void writeJava(Path path, String packageName, String className) throws IOException { + Files.createDirectories(path.getParent()); + Files.writeString( + path, + """ + package %s; + public class %s {} + """ + .formatted(packageName, className)); + } +} diff --git a/plans/add-java-25-analysis.md b/plans/add-java-25-analysis.md new file mode 100644 index 00000000..55d7c38b --- /dev/null +++ b/plans/add-java-25-analysis.md @@ -0,0 +1,431 @@ +# Plan: Add Java 25 Analysis Support to RefactorFirst + +## Executive Summary + +This plan outlines the approach to add support for analyzing Java 25 codebases to RefactorFirst while maintaining backward compatibility with Java 17 runtime requirements for the Maven and Gradle plugins. The solution uses conditional class loading to detect Java 25 runtime and dynamically load the `org.openrewrite:rewrite-java-25` dependency only when available. + +## Background + +### Current State +- RefactorFirst currently supports Java 11, 17, and 21 analysis via OpenRewrite parsers +- The project is compiled for Java 17 (parent pom.xml: `maven.compiler.source/target = 17`) +- Maven and Gradle plugins require Java 17 runtime +- OpenRewrite provides `rewrite-java-25` artifact for Java 25 source parsing + +### The Challenge +- `rewrite-java-25` is compiled with Java 25 class file version (69.0) +- It will fail to load on Java 17/21 runtimes with `UnsupportedClassVersionError` +- OpenRewrite's own `JavaParser.fromJavaVersion()` logic checks runtime version, not source version +- Per OpenRewrite issue #6686, `rewrite-java-25` only works on Java 25+ runtime + +### Key Constraint +- Maven and Gradle plugins must continue to require only Java 17 runtime +- Users with Java 25 codebases should get enhanced analysis when running on Java 25 runtime +- Users on Java 17 runtime should continue to work with Java 17/21 codebases + +## Technical Approach + +Use test-driven development (TDD) approach to implement the solution. + +### 1. Dependency Management +b +#### 1.1 Add Optional Java 25 Dependency +Add `rewrite-java-25` as an optional dependency in `../codebase-graph-builder/pom.xml`: + +```xml + + org.openrewrite + rewrite-java-25 + ${rewrite.version} + true + +``` + +**Rationale:** Marking it as `optional` prevents it from being transitively included in the Maven/Gradle plugins, keeping their Java 17 runtime requirement. + +#### 1.2 Update Parent POM Dependency Management +Add the version to parent `../pom.xml` `` section aligned with the current OpenRewrite BOM version (8.90.4): + +```xml + + org.openrewrite + rewrite-java-25 + 8.90.4 + +``` + +### 2. Runtime Detection Mechanism + +#### 2.1 Create Java Version Detection Utility +Create `org.hjug.graphbuilder.JavaRuntimeDetector` utility class: + +```java +package org.hjug.graphbuilder; + +public class JavaRuntimeDetector { + + private static final int JAVA_25_VERSION = 25; + + /** + * Detects if the current JVM runtime is Java 25 or higher. + * Uses java.specification.version system property which returns + * the feature version (e.g., "25" for Java 25). + */ + public static boolean isJava25OrHigher() { + String javaVersion = System.getProperty("java.specification.version"); + if (javaVersion == null) { + return false; + } + + try { + int version = Integer.parseInt(javaVersion); + return version >= JAVA_25_VERSION; + } catch (NumberFormatException e) { + // Fallback to Runtime.version() for edge cases + return Runtime.version().feature() >= JAVA_25_VERSION; + } + } + + /** + * Gets the current Java runtime feature version. + */ + public static int getRuntimeVersion() { + String javaVersion = System.getProperty("java.specification.version"); + if (javaVersion != null) { + try { + return Integer.parseInt(javaVersion); + } catch (NumberFormatException e) { + // Fall through to Runtime.version() + } + } + return Runtime.version().feature(); + } +} +``` + +**Rationale:** Uses `java.specification.version` which is stable and documented as returning the feature version per Java 25 system properties documentation. + +### 3. Conditional Parser Loading + +#### 3.1 Create Java 25 Parser Wrapper +Create `org.hjug.graphbuilder.graphbuilder.Java25ParserWrapper` to handle conditional loading: + +```java +package org.hjug.graphbuilder.graphbuilder; + +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import lombok.extern.slf4j.Slf4j; +import org.openrewrite.ExecutionContext; +import org.openrewrite.java.JavaParser; + +/** + * Wrapper for conditionally loading Java 25 parser. + * Uses reflection to avoid compile-time dependency on rewrite-java-25. + */ +@Slf4j +public class Java25ParserWrapper { + + private static final String JAVA25_PARSER_CLASS = "org.openrewrite.java.Java25Parser"; + private static final String JAVA_PARSER_BUILDER_CLASS = "org.openrewrite.java.JavaParser$Builder"; + + /** + * Attempts to create a Java 25 parser using reflection. + * Returns null if rewrite-java-25 is not available or loading fails. + */ + public static JavaParser tryCreateJava25Parser(ExecutionContext ctx) { + if (!JavaRuntimeDetector.isJava25OrHigher()) { + log.debug("Java 25 runtime not detected, skipping Java 25 parser"); + return null; + } + + try { + // Load Java25Parser class + Class java25ParserClass = Class.forName(JAVA25_PARSER_CLASS); + + // Get the builder method + Method builderMethod = java25ParserClass.getMethod("builder"); + Object builder = builderMethod.invoke(null); + + // Build the parser + Method buildMethod = builder.getClass().getMethod("build"); + return (JavaParser) buildMethod.invoke(builder); + + } catch (ClassNotFoundException e) { + log.debug("rewrite-java-25 not available on classpath, falling back to standard parser"); + return null; + } catch (NoSuchMethodException | IllegalAccessException | InvocationTargetException e) { + log.warn("Failed to create Java 25 parser via reflection", e); + return null; + } + } +} +``` + +**Rationale:** +- Reflection avoids compile-time dependency on `rewrite-java-25` +- Graceful fallback to standard parser when Java 25 not available +- Only attempts loading when Java 25 runtime is detected + +#### 3.2 Update JavaSourceFileGraphBuilder +Modify `JavaSourceFileGraphBuilder.buildGraph()` to conditionally use Java 25 parser: + +```java +@Override +public CodebaseGraphDTO buildGraph(String repositoryPath, String repositoryRoot, GraphBuilderConfig config) + throws IOException { + File srcDirectory = new File(repositoryPath); + + // Try Java 25 parser first if runtime supports it + JavaParser javaParser = Java25ParserWrapper.tryCreateJava25Parser( + new InMemoryExecutionContext(e -> log.warn("OpenRewrite parse/visit error", e))); + + // Fall back to standard parser if Java 25 not available + if (javaParser == null) { + javaParser = JavaParser.fromJavaVersion().build(); + log.debug("Using standard Java parser (version: {})", JavaRuntimeDetector.getRuntimeVersion()); + } else { + log.info("Using Java 25 parser for enhanced Java 25 feature support"); + } + + ExecutionContext ctx = new InMemoryExecutionContext(e -> log.warn("OpenRewrite parse/visit error", e)); + + // ... rest of existing implementation unchanged +} +``` + +**Rationale:** +- Attempts Java 25 parser first when runtime supports it +- Graceful fallback maintains backward compatibility +- Logging provides visibility into which parser is being used + +### 4. Configuration Updates + +#### 4.1 Update GraphBuilderConfig +Add optional Java 25 configuration field to `GraphBuilderConfig`: + +```java +@Value +@Builder +public class GraphBuilderConfig { + + @Builder.Default + boolean excludeTests = true; + + @Builder.Default + String testSourceDirectory = "src/test"; + + @Builder.Default + String kotlinLanguageLevel = "KOTLIN_2_4"; + + @Builder.Default + String repositoryRoot = ""; + + /** + * Whether to force use of Java 25 parser even when runtime detection fails. + * For advanced use cases where user knows Java 25 parser is available. + * Default: false (rely on runtime detection). + */ + @Builder.Default + boolean forceJava25Parser = false; + + public static GraphBuilderConfig defaultConfig() { + return GraphBuilderConfig.builder().build(); + } +} +``` + +**Rationale:** Provides escape hatch for edge cases where runtime detection might fail but parser is available. + +#### 4.2 Update Maven Plugin Configuration +Add configuration parameter to `RefactorFirstMavenReport`: + +```java +@Parameter(property = "forceJava25Parser") +private boolean forceJava25Parser = false; +``` + +Pass this through to the graph builder configuration. + +### 5. Testing Strategy + +#### 5.1 Unit Tests +Create `JavaRuntimeDetectorTest`: +- Test detection on various runtime versions (mocked system properties) +- Test edge cases (null values, malformed version strings) + +Create `Java25ParserWrapperTest`: +- Test successful parser creation when Java 25 available +- Test graceful fallback when not available +- Test reflection failure scenarios + +#### 5.2 Integration Tests +Create `Java25AnalysisIntegrationTest`: +- Test with actual Java 25 source code (requires Java 25 runtime in CI) +- Verify Java 25 features are properly parsed +- Test fallback behavior on Java 17 runtime + +#### 5.3 Test Resources +Add Java 25 test fixtures to `test-resources/src/test/resources/`: +- Simple Java 25 class using new features +- Class with Java 25-specific syntax + +#### 5.4 CI Configuration +Update GitHub Actions workflows: +- Add Java 25 runtime matrix for testing +- Ensure Java 17 tests still pass without Java 25 dependency + +### 6. Documentation Updates + +#### 6.1 Update CLAUDE.md +Add section on Java 25 support: +- Explain runtime detection mechanism +- Document optional dependency nature +- Note that Maven/Gradle plugins still require Java 17 + +#### 6.2 Update AGENTS.md +Add Java 25 to key architecture points: +- Document the conditional loading approach +- Explain the reflection-based wrapper + +#### 6.3 Update README.md +Add Java 25 support to features list with runtime requirements note. + +### 7. Migration Path + +#### 7.1 Backward Compatibility +- No breaking changes for existing users +- Java 17 runtime users continue to work as before +- Java 25 users get enhanced analysis automatically + +#### 7.2 Rollout Strategy +1. Implement changes in `codebase-graph-builder` module +2. Add optional dependency to parent POM +3. Update tests to cover both scenarios +4. Release as minor version bump (0.11.0 → 0.12.0) +5. Monitor for issues with Java 25 detection + +### 8. Potential Issues and Mitigations + +#### 8.1 Class Loading Issues +**Risk:** Reflection might fail in certain classloading environments (e.g., OSGi, custom classloaders) + +**Mitigation:** +- Add comprehensive error handling in wrapper +- Provide configuration option to disable Java 25 parser +- Log detailed failure information for debugging + +#### 8.2 Version Detection Edge Cases +**Risk:** `java.specification.version` might not be reliable on all JVMs + +**Mitigation:** +- Use `Runtime.version().feature()` as fallback +- Add unit tests for various version string formats +- Provide manual override via configuration + +#### 8.3 Dependency Conflicts +**Risk:** Users might have conflicting OpenRewrite versions + +**Mitigation:** +- Use parent POM dependency management to ensure consistent versions +- Document that Java 25 support requires OpenRewrite 8.90.4+ +- Add validation in wrapper to check version compatibility + +#### 8.4 Performance Impact +**Risk:** Reflection overhead on every parser creation + +**Mitigation:** +- Cache reflection results (Class objects, Method objects) +- Only perform reflection once at startup +- Benchmark to ensure minimal impact + +### 9. Implementation Order + +1. **Phase 1: Infrastructure** + - Create `JavaRuntimeDetector` utility + - Add unit tests for runtime detection + - Update parent POM with `rewrite-java-25` dependency management + +2. **Phase 2: Parser Loading** + - Create `Java25ParserWrapper` with reflection + - Add unit tests for wrapper + - Update `JavaSourceFileGraphBuilder` to use conditional loading + +3. **Phase 3: Configuration** + - Update `GraphBuilderConfig` with force option + - Update Maven plugin configuration + - Pass configuration through pipeline + +4. **Phase 4: Testing** + - Add Java 25 test fixtures + - Create integration tests + - Update CI workflows for Java 25 testing + +5. **Phase 5: Documentation** + - Update CLAUDE.md + - Update AGENTS.md + - Update README.md + - Add release notes + +### 10. Success Criteria + +- [x] Java 25 codebases can be analyzed when running on Java 25 runtime +- [x] Java 17 runtime users continue to work without errors +- [x] Maven and Gradle plugins still require only Java 17 runtime +- [x] `rewrite-java-25` is not transitively included in plugin distributions +- [x] Unit tests cover both Java 17 and Java 25 scenarios +- [x] Integration tests verify Java 25 feature parsing +- [x] Documentation clearly explains runtime requirements +- [x] No performance regression on Java 17 runtime + +## Alternatives Considered + +### Alternative 1: Separate Java 25 Module +**Approach:** Create a separate `codebase-graph-builder-java25` module that requires Java 25 compilation. + +**Pros:** +- Clean separation of concerns +- No reflection needed +- Type-safe + +**Cons:** +- Increases module complexity +- Makes plugin distribution more complex +- Would require separate Maven/Gradle plugin variants +- Violates requirement that plugins only need Java 17 + +**Decision:** Rejected in favor of conditional loading approach. + +### Alternative 2: Multi-Release JAR +**Approach:** Use Java 9+ multi-release JAR feature to include Java 25-specific classes. + +**Pros:** +- Standard Java mechanism for version-specific code +- No reflection needed + +**Cons:** +- Requires Java 9+ base (already satisfied) +- More complex build configuration +- Still requires careful dependency management +- Multi-release JARs can be confusing for users + +**Decision:** Rejected due to complexity and the need for optional dependency management. + +### Alternative 3: Always Include Java 25 Dependency +**Approach:** Always include `rewrite-java-25` and let it fail gracefully on Java 17. + +**Pros:** +- Simplest implementation +- No conditional logic needed + +**Cons:** +- Violates plugin Java 17 requirement +- Would cause class loading errors on Java 17 +- Increases distribution size unnecessarily +- OpenRewrite issue #6686 shows this breaks on Java 21 + +**Decision:** Rejected due to incompatible with plugin requirements. + +## Conclusion + +This plan provides a pragmatic approach to adding Java 25 support while maintaining backward compatibility. The conditional loading strategy using reflection allows RefactorFirst to leverage Java 25 parsing capabilities when available without requiring Java 25 runtime for the Maven and Gradle plugins themselves. The optional dependency ensures that `rewrite-java-25` is only included when explicitly needed, keeping plugin distributions lean and compatible with Java 17 runtimes. \ No newline at end of file diff --git a/plans/jep-238-plan.md b/plans/jep-238-plan.md new file mode 100644 index 00000000..773f6965 --- /dev/null +++ b/plans/jep-238-plan.md @@ -0,0 +1,432 @@ +# JEP 238 (Multi-Release JAR) Implementation Plan: Activate the OpenRewrite Java 25 Parser and Remove All `forceJava25` Flags + +## 1. Goal + +Replace the reflection-based, detection-gated Java 25 parser activation with a +[JEP 238](https://openjdk.org/jeps/238) Multi-Release (MR) JAR design, and delete +every `forceJava25Parser` / `forceJava25` flag from the codebase. + +After this change: + +- On a **JDK 25+ runtime**, the OpenRewrite `Java25Parser` is activated + automatically by the JVM's own multi-release class lookup — no reflection, no + version-property parsing, no flag. +- On a **JDK 17/21 runtime**, the Java 25 parser classes are never loaded and the + standard `JavaParser.fromJavaVersion()` parser is used. +- The Java 25 parser actually **ships** in the Maven plugin and CLI + distributions (today it does not — see §2, problem 3). +- The public API surface is reduced: `forceJava25Parser` disappears from + `GraphBuilderConfig`, `CycleRanker`, `SimpleHtmlReport`, `JsonGenerator`, + `HtmlReport`, and all four Maven mojos. + +Development follows TDD: every phase writes or updates failing tests first, +then implements until green, on **both** JDK 17 and JDK 25. + +## 2. Current State and Its Problems + +Current mechanism (all in `codebase-graph-builder` unless noted): + +- `rewrite-java-25` is an `` dependency; its class files are + class-file 69.0, so nothing at release 17 may reference it statically. +- `Java25ParserWrapper.tryCreateJava25Parser(boolean force)` reflectively loads + `org.openrewrite.java.Java25Parser`, gated by + `JavaRuntimeDetector.isJava25OrHigher()` (parses + `java.specification.version`, falls back to `Runtime.version()`). +- `forceJava25Parser` is a diagnostic escape hatch threaded through: + - `GraphBuilderConfig.forceJava25Parser` + - `CycleRanker.generateClassReferencesGraph(boolean, String, boolean)` + (cost-benefit-calculator) + - `SimpleHtmlReport.execute(...)` / `generateReport(...)`, + `JsonGenerator.execute(...)` / `generateReportData(...)`, + `HtmlReport.generateReport(...)` (report) + - `@Parameter(property = "forceJava25Parser")` on + `RefactorFirstMavenReport`, `RefactorFirstHtmlReport`, + `RefactorFirstSimpleHtmlReport`, `RefactorFirstMavenJsonGenerator` + (`-DforceJava25Parser=true`) + +Problems this plan fixes: + +1. **Complexity**: reflection glue, a runtime detector, a cached load-state + machine, and a boolean threaded through ~10 methods/4 mojos — all to express + "use this class when the runtime is new enough," which the JVM can express + natively via MR-JAR lookup. +2. **The force flag is semantically dead**: on a sub-25 runtime the class files + physically cannot load (`UnsupportedClassVersionError`), so "force" could + never succeed — it only existed to bypass detection on hypothetical + misreporting JVMs. MR-JAR lookup is driven by the actual runtime version + (`Runtime.version()` semantics inside `JarFile`), which is stricter and more + reliable than any system-property parse, so the escape hatch has no analog + and no purpose. +3. **The parser never ships**: because `rewrite-java-25` is ``, it is + absent from the Maven plugin and CLI fat-jar distributions (verified via + `mvn -pl refactor-first-maven-plugin dependency:tree`). Even a user running + Maven on JDK 25 silently gets the Java 21 parser today. The MR-JAR change + must therefore also fix distribution (Phase 6). + +## 3. Design Overview + +### 3.1 Core idea + +Introduce one seam class with two compiled variants in the same JAR: + +``` +org.hjug.graphbuilder.graphbuilder.Java25ParserFactory +``` + +- **Base variant** (`src/main/java`, compiled `--release 17`): + returns `Optional.empty()`. It contains *no* reference to Java 25 types. +- **Versioned variant** (`src/main/java25`, compiled `--release 25` into + `META-INF/versions/25/…`): directly calls + `org.openrewrite.java.Java25Parser.builder().build()` and returns it wrapped + in `Optional`. + +Per JEP 238, when the JAR is on the classpath of a JDK 25+ runtime **and** the +manifest declares `Multi-Release: true`, the versioned class shadows the base +class. On JDK 17/21 the versioned entry is invisible — it can never be loaded, +verified, or linked, so the class-file-69.0 problem disappears by construction. + +Parser selection in `JavaSourceFileGraphBuilder.createJavaParser` becomes: + +```java +static JavaParser createJavaParser(GraphBuilderConfig config) { + return Java25ParserFactory.createJava25Parser() + .orElseGet(() -> JavaParser.fromJavaVersion().build()); +} +``` + +No config flag, no detector, no reflection, no cache. (`GraphBuilderConfig` +retains its other fields; the parameter stays because the method's other +callers/tests use the config object — only the `forceJava25Parser` use is +dropped.) + +### 3.2 Public-API parity requirement + +JEP 238 requires a versioned class to expose the same public API as the base +class, but we assemble the JAR via the compiler/jar plugins rather than +`jar --release`, so there is **no automatic validation**. Mitigation: + +- Keep the class tiny (one public static method + private constructor). +- Add a contract test (Phase 3, T3) asserting base and versioned variants + expose identical public signatures, run on JDK 25 where both are on disk. + +### 3.3 Runtime behavior matrix (acceptance criteria) + +| Runtime | `rewrite-java-25` present | Result | +|----------------|---------------------------|-----------------------------------------------------| +| JDK 17 / 21 | yes (now a real dep) | Base factory → `Optional.empty()` → standard parser. Version-69 classes never loaded. | +| JDK 25+ | yes | Versioned factory → `Java25Parser` | +| JDK 25+ | no (broken packaging) | Versioned factory catches `Throwable` → `Optional.empty()` → standard parser + `log.warn` | + +The third row's defensive `catch (Throwable)` replaces the graceful-degradation +guarantee the reflection wrapper provided. + +### 3.4 Build mechanics (codebase-graph-builder) + +- New source root `src/main/java25` containing only + `org/hjug/graphbuilder/graphbuilder/Java25ParserFactory.java`. +- A `jdk25+` Maven profile (auto-activated by `[25,)`) adds a second + `maven-compiler-plugin` execution: `release=25`, sources from + `src/main/java25`, output to + `${project.build.outputDirectory}/META-INF/versions/25`. +- `maven-jar-plugin` gets + `true`. +- **Release builds must run on JDK 25+** or the published JAR silently lacks + the versioned entries. `release.yml` currently builds with JDK 17 and must be + bumped to 25, plus a release-time guard (Phase 6, T12) that fails the build + if `META-INF/versions/25` is missing from the packaged JAR. CI keeps the + 17 + 25 matrix: JDK 17 proves the fallback side and still produces a valid + (non-MR) JAR. + +### 3.5 Distribution change + +Flip `rewrite-java-25` from `true` to a normal compile +dependency (still BOM-managed, no explicit version). With the MR-JAR bridge, +nothing on a JDK 17/21 classpath ever loads a version-69 class file, so the +original reason for optionality is gone; keeping it optional would mean the +versioned factory fires on JDK 25 but `Java25Parser` is missing in the very +distributions this feature targets. + +*Alternative considered and rejected:* unpacking `rewrite-java-25`'s classes +into our own `META-INF/versions/25` at package time. It keeps consumers' +classpaths pristine but duplicates/shades a third-party artifact, doubles the +packaging logic, and fights the CLI shade step. The plain dependency plus the +MR bridge is simpler and the version-69 classes on the classpath are provably +inert (verified by T11 smoke tests). + +### 3.6 CLI fat jar + +`maven-shade-plugin` flattens everything; JEP 238 semantics survive only if +the shaded manifest carries `Multi-Release: true` and the +`META-INF/versions/25/**` entries are preserved (they are ordinary +distinct-path resources — no collisions with base entries). Add the manifest +entry to the existing `ManifestResourceTransformer` configuration and verify +by integration test (T10). + +## 4. TDD Execution Phases + +Each phase ends green on both JDKs before the next begins. Run commands: + +``` +mvn clean install # full reactor, current JDK +mvn clean test -pl codebase-graph-builder # module under change +mvn spotless:apply # formatting +``` + +### Phase 1 — RED: contract tests for the new seam + +Write `Java25ParserFactoryTest` +(`codebase-graph-builder/src/test/java/org/hjug/graphbuilder/graphbuilder/`). +All environment-sensitive tests follow the existing pattern of +`@EnabledOnJre(JRE.JAVA_25)` / `Runtime.version().feature()` guards. + +- **T1** `returnsEmptyOnPre25Runtime` — skipped on JDK 25; asserts + `Java25ParserFactory.createJava25Parser()` is empty. *(Fails: class does not + exist → compile error = red.)* +- **T2** `versionedVariantCreatesWorkingJava25Parser` — + `@EnabledForJreRange(min = JRE.JAVA_25)`; loads the Java 25 + `Java25ParserFactory` class from the versioned output in isolation, then + reflectively invokes `createJava25Parser()`. Asserts that the resulting + parser handles the flexible-constructor-body fixture, proving the versioned + variant creates a working Java 25 parser. Packaged-JAR shadowing is covered + separately by `MultiReleaseJarIT`; direct factory invocation belongs to T1. +- **T3** `baseAndVersionedVariantsExposeIdenticalPublicApi` — + `@EnabledOnJre(JRE.JAVA_25)` (only meaningful when the versioned class file + exists on disk): loads + `target/classes/META-INF/versions/25/.../Java25ParserFactory.class` via an + isolated child `URLClassLoader` that also has `rewrite-java-25` on it, + reflectively compares public method signatures against + `Java25ParserFactory.class`, asserts equality. Guards the JEP 238 API-parity + rule (§3.2). + +### Phase 2 — GREEN (base variant only) + +Implement the base `Java25ParserFactory` in `src/main/java` (returns +`Optional.empty()`; `@Slf4j` unused here — keep the base variant dependency- +free and silent). + +- T1 now passes on JDK 17/21. T2/T3 remain **red on JDK 25** — expected; do + not skip them. + +### Phase 3 — GREEN on JDK 25: versioned variant + MR packaging + +1. `codebase-graph-builder/pom.xml`: add `jar` manifest + `Multi-Release: true`; add `jdk25+` profile with the second compiler + execution (§3.4). +2. Add the versioned `Java25ParserFactory` in `src/main/java25` referencing + `Java25Parser` directly, with `catch (Throwable)` → `Optional.empty()` + + `log.warn` fallback. +3. **T4** `packagedJarIsMultiRelease` (failsafe IT, `*IT.java`, JDK 25 + profile): opens `target/codebase-graph-builder-.jar`, asserts manifest + `Multi-Release: true` and presence of + `META-INF/versions/25/org/hjug/graphbuilder/graphbuilder/Java25ParserFactory.class`. +4. Verify T2, T3, T4 green on JDK 25; T1 still green on JDK 17 (and T4 must be + written to be absent-tolerant: on a JDK 17 build the profile is off, so the + IT is skipped via a JUnit assumption that the versions dir exists). + +### Phase 4 — Switch parser selection, retire reflection machinery + +TDD: first update the existing behavioral tests, watch them fail, then refactor. + +1. Rewrite `JavaSourceFileGraphBuilderJava25Test` from "force flag plumbing" + to "factory-driven selection": on JDK 25 assert + `createJavaParser(config)` returns a parser that accepts the module-import + fixture; on 17/21 assert the standard parser is returned (e.g. parsing the + fixture throws / graph build of pre-25 fixture succeeds). *(Red against the + old implementation.)* +2. Implement the new `createJavaParser` body (§3.1). Green. +3. **T5** migration grep-guard (enforced by CI, not JUnit): zero matches for + `tryCreateJava25Parser` in `src/main`. +4. Delete `Java25ParserWrapper` + `Java25ParserWrapperTest`. +5. `JavaRuntimeDetector` is used by `Java25AnalysisIntegrationTest` (assertion + guard) and the removed log line only. Replace the integration-test guard + with `@EnabledOnJre(JRE.JAVA_25)` (or `Runtime.version().feature() >= 25`), + drop the debug-log line in `JavaSourceFileGraphBuilder`, then delete + `JavaRuntimeDetector` + `JavaRuntimeDetectorTest`. *(Deciding: delete rather + than keep-on-spec — re-adding a 45-line utility is trivial if ever needed.)* +6. Run `Java25AnalysisIntegrationTest` on both JDKs: JDK 25 leg must exercise + the versioned factory end-to-end. + +### Phase 5 — Remove all `forceJava25` flags, bottom-up (TDD via compiler) + +Order chosen so each step compiles independently; the *existing full module +test suites are the regression net* — after each step run the module's tests. +Where a test exists solely for the flag, delete it in the same commit as the +flag. + +1. **codebase-graph-builder** + - `GraphBuilderConfig`: remove `forceJava25Parser` field + Javadoc. + - `GraphBuilderConfigTest`: delete `forceJava25Parser_defaultsToFalse` and + `forceJava25Parser_canBeSetViaBuilder`. + - `mvn clean test -pl codebase-graph-builder` → green. +2. **cost-benefit-calculator** + - `CycleRanker`: delete the 3-arg + `generateClassReferencesGraph(boolean, String, boolean)`; the 2-arg + method becomes the only overload and builds `GraphBuilderConfig` without + the flag. + - Delete `CycleRankerForceJava25ParserTest` in the same commit. + - **T6** add/keep a `CycleRanker` test asserting the 2-arg overload builds + the graph (the surviving behavior the deleted test inadvertently covered). + - `mvn clean test -pl cost-benefit-calculator` → green. +3. **report** + - `SimpleHtmlReport`: remove the trailing `boolean forceJava25Parser` + parameter from `execute(...)` and `generateReport(...)`, and the argument + at both `generateClassReferencesGraph` call sites; drop the Javadoc lines. + - `JsonGenerator`: same for `execute(...)` and `generateReportData(...)`. + - `HtmlReport`: remove the trailing parameter from `generateReport(...)` + (reached transitively via `RefactorFirstMavenReport`). + - `JsonGeneratorTest`: update the force-overload test (around the + `forceJava25Parser` overload section) to call the new signature and + assert the same report-data behavior. + - `mvn clean test -pl report` → green. +4. **refactor-first-maven-plugin** + - Remove the `@Parameter(property = "forceJava25Parser")` field (and + Javadoc) from `RefactorFirstMavenReport`, `RefactorFirstHtmlReport`, + `RefactorFirstSimpleHtmlReport`, `RefactorFirstMavenJsonGenerator`, and + the trailing argument at each call into the report classes. + - **T7** built-in regression: `mvn clean test -pl refactor-first-maven-plugin` + regenerates the plugin descriptor/helpmojo; assert (manual or scripted) + the generated `plugin.xml`/help goal no longer mentions + `forceJava25Parser`. +5. **Final repo-wide sweep (T8)**: `grep -ri "forcejava25" .` returns only + historical plan documents (`plans/add-java-25-analysis.md`, this file) before + moving on. + +### Phase 6 — Ship it: dependency distribution + CLI fat jar + release pipeline + +1. `codebase-graph-builder/pom.xml`: remove `true` from + `rewrite-java-25` and update the comment to describe the MR-JAR rationale. + - **T9** `mvn -pl refactor-first-maven-plugin dependency:tree | grep + rewrite-java-25` → now present (inverted regression check vs. today's + state). +2. `cli/pom.xml`: add + `true` to + the shade `ManifestResourceTransformer`. +3. **T10** CLI packaging IT (script or invoker-style check): + `unzip -l cli/target/cli-.jar` shows + `META-INF/versions/25/org/hjug/graphbuilder/graphbuilder/Java25ParserFactory.class`, + and `unzip -p … META-INF/MANIFEST.MF` contains `Multi-Release: true`. +4. **T11** runtime smoke matrix (CI jobs, both against the fat jar and a + `mvn …:htmlReport` invocation on a fixture project): + - JDK 17: runs to completion; log contains standard-parser message; no + `UnsupportedClassVersionError`/`NoClassDefFoundError` anywhere. + - JDK 25: log contains "Using Java 25 parser" (move that `log.info` into + the versioned factory success path) and the module-import fixture is + graphed. +5. `.github/workflows/release.yml`: bump `java-version: '17'` → `'25'` so + published artifacts contain the versioned entries. + - **T12** release guard: add a step (or enforcer rule) failing the release + build if `META-INF/versions/25/.../Java25ParserFactory.class` is absent + from `codebase-graph-builder/target/*.jar`. +6. Re-check `cli` fat-jar size (`du -sh`) and record the new figure in + AGENTS.md (it will grow by the size of `rewrite-java-25`, which was + previously absent). + +### Phase 7 — Documentation updates (same PR) + +- `README.md`: delete the `forceJava25Parser` row from the configuration table; + if a Java 25 section exists, update it to "automatic on JDK 25+ runtimes". +- `AGENTS.md`: rewrite the "Java 25 analysis" section — describe the MR-JAR + design, the `jdk25+` build profile, the JDK-25 release-build requirement, the + no-longer-optional `rewrite-java-25` dependency, and remove `-DforceJava25Parser` + from the mojo option list. (Required by project convention.) +- `CLAUDE.md`: replace the reflection/optional paragraph (line ~101) with the + MR-JAR description mirroring AGENTS.md. +- Leave `plans/add-java-25-analysis.md` untouched (historical record); this + plan supersedes its approach. + +## 5. Complete File Change List + +**codebase-graph-builder** +- `pom.xml` — MR manifest, `jdk25+` profile, drop `` +- `…/graphbuilder/Java25ParserFactory.java` — **new** (base) +- `src/main/java25/org/hjug/graphbuilder/graphbuilder/Java25ParserFactory.java` — **new** (versioned) +- `…/graphbuilder/JavaSourceFileGraphBuilder.java` — new `createJavaParser` body +- `…/GraphBuilderConfig.java` — remove field +- `…/JavaRuntimeDetector.java` — **delete** +- `…/graphbuilder/Java25ParserWrapper.java` — **delete** +- Tests: new `Java25ParserFactoryTest` (+ `Java25ParserFactoryPackagingIT`); + rewrite `JavaSourceFileGraphBuilderJava25Test`; trim `GraphBuilderConfigTest`; + delete `Java25ParserWrapperTest`, `JavaRuntimeDetectorTest`; + update `Java25AnalysisIntegrationTest` guard + +**cost-benefit-calculator** +- `CycleRanker.java` — drop 3-arg overload; delete + `CycleRankerForceJava25ParserTest`, keep/adjust surviving-overload test (T6) + +**report** +- `SimpleHtmlReport.java`, `JsonGenerator.java`, `HtmlReport.java` — remove + parameter; `JsonGeneratorTest` — update overload test + +**refactor-first-maven-plugin** +- 4 mojos — remove `@Parameter` field and call argument + +**cli** +- `pom.xml` — shade `Multi-Release: true` manifest entry + +**build/docs** +- `.github/workflows/release.yml` — JDK 25 + presence guard +- `README.md`, `AGENTS.md`, `CLAUDE.md` + +## 6. Risks & Mitigations + +| Risk | Mitigation | +|---|---| +| JDK 17 build silently produces a JAR without versioned entries | Profile is explicit; T4 IT documents expected layout; T12 fails the *release* build if entries are missing; release.yml pinned to JDK 25. | +| No automatic JEP 238 API-parity validation (we don't use `jar --release`) | T3 reflective signature comparison; class kept to one public method. | +| Shade plugin mangling MR entries in fat jar | T10 IT asserts manifest + entries in the built artifact every build. | +| `rewrite-java-25` (v69 classes) now on JDK 17 plugin/CLI classpaths | Provably inert — only reachable through the versioned factory, which JDK 17 class loaders cannot see; T11 JDK 17 smoke run proves no linkage errors. Defensive `catch (Throwable)` retained. | +| Maven < 3.x / ancient classloaders ignoring MR manifests | Supported runtimes are JDK 17+; `ClassRealm` extends `URLClassLoader`, which honors MR JARs since JDK 9. T11's JDK 25 plugin run verifies activation in the real realm. | +| Removed `-DforceJava25Parser` breaks a user's build script | Flag was inert-by-design (could only ever fail gracefully on <25); note the removal in the changelog/release notes as a **breaking change** for the next minor release. | +| `CostBenefitCalculatorTest.testCostBenefitCalculation` stays disabled on JRE 25 (upstream OpenRewrite bug) | **Resolved during implementation:** with `rewrite-java-25` now always on the classpath, the formerly disabled test passes on JDK 25 (`@DisabledOnJre` removed). The upstream issue remains open but no longer reproduces for this fixture. | + +## Implementation Notes (post-implementation, 2026-09-22) + +Deviations from the plan discovered during implementation: + +1. **OpenRewrite's `JavaParser.fromJavaVersion()` already elevates to + `Java25Parser` on JDK 25** when `rewrite-java-25` is on the classpath + (via `JdkParserBuilderCache`'s internal reflection). With §3.5's + distribution change this guarantees a Java 25-capable parser on JDK 25 even + from exploded classpaths, so `JavaSourceFileGraphBuilderJava25Test` and + `Java25AnalysisIntegrationTest` assert a 25-capable parser on JDK 25 + directly rather than skipping on the factory's base variant. The factory + remains the deterministic selection mechanism for the packaged + (jar-loaded) path, verified by `MultiReleaseJarIT`. +2. **`rewrite-java-25` jars declare no `META-INF/services` entries**, which is + what makes shipping them on Java 17/21 classpaths safe (no ServiceLoader + linkage risk), independent of the MR-JAR bridge. +3. **Unplanned dependency bump:** `maven-plugin-plugin` / + `maven-plugin-annotations` 3.15.1 → **3.16.0** in + `refactor-first-maven-plugin`. Descriptor generation scans dependency + archives with ASM and the 3.15.1-bundled ASM cannot read class-file 69; + 3.16.0 ships ASM 9.10.1. +4. **JaCoCo** gained `META-INF/versions/**` in the parent + pom — its report goal rejects duplicate class names ("Can't add different + class with same name"). +5. `Multi-Release: true` is declared unconditionally (not just in the JDK 25 + profile); it is legal and inert in a jar without versioned entries. +6. The `cli` module is commented out of the reactor (`pom.xml`); the fat jar + was built standalone with `mvn -f cli/pom.xml clean install` for the + T10/T11 verification. +7. Pre-existing, **out of scope but noted**: the CLI entry point crashes in + this checkout because `ReportCommand` declares `--output` on both + `testSourceDirectory` and `outputDirectory` (picocli + `DuplicateOptionAnnotationsException`). Committed code, unrelated to this + change; the T11 smoke tests therefore drove `SimpleHtmlReport` directly + against the shaded fat jar instead of the picocli entry point. + +## 7. Definition of Done + +1. `mvn clean install` green on JDK 17 **and** JDK 25 (CI matrix). +2. `grep -ri "forcejava25" --include="*.java" .` → zero matches. +3. On JDK 25: plugin and CLI runs log Java 25 parser usage and parse the + module-import fixture; on JDK 17: both run with the standard parser and no + linkage errors. +4. Packaged `codebase-graph-builder` JAR and CLI fat jar both contain + `META-INF/versions/25/.../Java25ParserFactory.class` and + `Multi-Release: true`; `refactor-first-maven-plugin` dependency tree includes + `rewrite-java-25`. +5. Release workflow builds on JDK 25 and fails if the versioned entries are + missing. +6. README / AGENTS.md / CLAUDE.md describe the MR-JAR mechanism and no longer + mention the flag. diff --git a/pom.xml b/pom.xml index 05b600ee..dca923fe 100644 --- a/pom.xml +++ b/pom.xml @@ -406,6 +406,15 @@ org.jacoco jacoco-maven-plugin 0.8.15 + + + + META-INF/versions/** + + @@ -505,7 +514,7 @@ com.diffplug.spotless spotless-maven-plugin - 2.44.5 + 3.10.2 @@ -524,7 +533,10 @@ - + + + 2.71.0 + diff --git a/refactor-first-maven-plugin/pom.xml b/refactor-first-maven-plugin/pom.xml index 0f182ee9..52e2b64b 100644 --- a/refactor-first-maven-plugin/pom.xml +++ b/refactor-first-maven-plugin/pom.xml @@ -78,7 +78,10 @@ org.apache.maven.plugin-tools maven-plugin-annotations - 3.15.1 + + 3.16.0 provided @@ -97,7 +100,10 @@ org.apache.maven.plugins maven-plugin-plugin - 3.15.1 + + 3.16.0 refactor-first diff --git a/report/src/main/java/org/hjug/refactorfirst/report/SimpleHtmlReport.java b/report/src/main/java/org/hjug/refactorfirst/report/SimpleHtmlReport.java index 8593c2a9..ec4d5546 100644 --- a/report/src/main/java/org/hjug/refactorfirst/report/SimpleHtmlReport.java +++ b/report/src/main/java/org/hjug/refactorfirst/report/SimpleHtmlReport.java @@ -179,6 +179,9 @@ public class SimpleHtmlReport { .setMinifyCss(true) .build(); + /** + * Generates the HTML report and writes it to {@code outputDirectory}. + */ @SneakyThrows public void execute( int edgeAnalysisCount, @@ -416,12 +419,14 @@ public StringBuilder generateReport( return stringBuilder; } + /** Reads the repository's remote URL from the project Git metadata. */ static String getRepoUrl(String projectBaseDir) throws Exception { try (GitLogReader glr = new GitLogReader(new File(projectBaseDir))) { return glr.getRepoUrl(); } } + /** Builds the report navigation menu for the available graphs, findings, and cycles. */ StringBuilder createMenu( List disharmonySpecs, Map> rankedDisharmoniesByAnchor, @@ -475,10 +480,13 @@ StringBuilder createMenu( return menu; } + /** Extension hook for adding a class-map entry to the navigation menu. */ void renderClassMapMenu(StringBuilder stringBuilder) {} + /** Extension hook for adding a package-map entry to the navigation menu. */ void renderPackageMapMenu(StringBuilder stringBuilder) {} + /** Renders the class-cycle summary and the largest cycle's details. */ private String renderCycles(List rankedCycles, String repoUrl, CodebaseGraphDTO codebaseGraphDTO) { StringBuilder stringBuilder = new StringBuilder(); stringBuilder.append(renderClassCycleSummary(rankedCycles)); @@ -491,6 +499,7 @@ private String renderCycles(List rankedCycles, String repoUrl, Code return stringBuilder.toString(); } + /** Renders the prioritized class relationships whose removal breaks cycles. */ private String renderClassEdgeDisharmonies( List classRelationshipDisharmonies, List packageRelationshipDisharmonies, @@ -545,6 +554,7 @@ private String renderClassEdgeDisharmonies( return stringBuilder.toString(); } + /** Renders the prioritized package relationships whose removal breaks cycles. */ private String renderPackageEdgeDisharmonies( List edgeDisharmonies, String repoUrl, CodebaseGraphDTO codebaseGraphDTO) { StringBuilder stringBuilder = new StringBuilder(); @@ -596,6 +606,7 @@ private String renderPackageEdgeDisharmonies( return stringBuilder.toString(); } + /** Returns column headings for the class-relationship table. */ private String[] getClassRelationshipDisharmonyTableHeadings() { return new String[] { "Class Relationship", @@ -607,6 +618,7 @@ private String[] getClassRelationshipDisharmonyTableHeadings() { }; } + /** Returns column headings for the package-relationship table. */ private String[] getPackageRelationshipDisharmonyTableHeadings() { return new String[] { "Package Relationship", @@ -653,6 +665,7 @@ String[] getPackageRelationshipDisharmony( }; } + /** Renders summary rows for the ranked class cycles. */ private String renderClassCycleSummary(List rankedCycles) { StringBuilder stringBuilder = new StringBuilder(); @@ -701,6 +714,7 @@ String renderClassEdge(DefaultWeightedEdge edge) { .toString(); } + /** Renders a weighted class edge with repository links and removal markers. */ private String renderClassEdge(DefaultWeightedEdge edge, String repoUrl, CodebaseGraphDTO codebaseGraphDTO) { StringBuilder edgesToCut = new StringBuilder(); String[] vertexes = extractVertexes(edge); @@ -727,6 +741,7 @@ private String renderClassEdge(DefaultWeightedEdge edge, String repoUrl, Codebas .toString(); } + /** Renders a weighted package edge with removal markers. */ private String renderPackageEdge(DefaultWeightedEdge edge, String repoUrl, CodebaseGraphDTO codebaseGraphDTO) { StringBuilder edgesToCut = new StringBuilder(); String[] vertexes = extractVertexes(edge); @@ -753,6 +768,7 @@ private String renderPackageEdge(DefaultWeightedEdge edge, String repoUrl, Codeb .toString(); } + /** Links a class label to its source file when a source-path mapping is available. */ String hyperlinkClass(String className, String repoUrl, CodebaseGraphDTO codebaseGraphDTO) { String path = codebaseGraphDTO.getClassToSourceFilePathMapping().get(className); if (path == null || path.isBlank()) { @@ -780,6 +796,7 @@ static String escapeHtmlAttribute(String value) { return escapeHtmlLabel(value).replace("\"", """).replace("'", "'"); } + /** Returns column headings for the class-cycle summary table. */ private String[] getClassCycleSummaryTableHeadings() { return new String[] {"Cycle Name", "Priority", "Class Count", "Relationship Count"}; } @@ -795,6 +812,7 @@ String[] getRankedCycleSummaryData(RankedCycle rankedCycle) { }; } + /** Renders the visualization and relationship table for one class cycle. */ private String renderSingleCycle(RankedCycle cycle, String repoUrl, CodebaseGraphDTO codebaseGraphDTO) { StringBuilder stringBuilder = new StringBuilder(); @@ -875,18 +893,22 @@ private String renderSingleCycle(RankedCycle cycle, String repoUrl, CodebaseGrap return stringBuilder.toString(); } + /** Extension hook for rendering class-graph visuals. */ public String renderClassGraphVisuals(String repoUrl, CodebaseGraphDTO codebaseGraphDTO) { return ""; // empty on purpose } + /** Extension hook for rendering package-graph visuals. */ public String renderPackageGraphVisuals(String repoUrl, CodebaseGraphDTO codebaseGraphDTO) { return ""; // empty on purpose } + /** Extension hook for rendering a class-cycle visualization. */ public String renderClassCycleVisuals(RankedCycle cycle, String repoUrl, CodebaseGraphDTO codebaseGraphDTO) { return ""; // empty on purpose } + /** Renders a table cell, right-aligning numeric and date-like values. */ String drawTableCell(String rowData) { if (isNumber(rowData) || isDateTime(rowData)) { return "" + rowData + "\n"; @@ -895,30 +917,37 @@ String drawTableCell(String rowData) { } } + /** Reports whether a table value is a signed integer or decimal number. */ boolean isNumber(String rowData) { return rowData.matches("-?\\d+(\\.\\d+)?"); } + /** Reports whether a table value uses the localized date-time delimiter. */ boolean isDateTime(String rowData) { return rowData.contains(", "); } + /** Extension hook for rendering the HTML title element. */ public String printTitle(String projectName, String projectVersion) { return ""; // empty on purpose } + /** Extension hook for rendering additional HTML head content. */ public String printHead() { return ""; // empty on purpose } + /** Extension hook for rendering script elements. */ String printScripts() { return ""; // empty on purpose } + /** Returns the opening body tag used by the report page. */ public String printOpenBodyTag() { return " \n"; } + /** Returns the opening banner and breadcrumb markup. */ public String printBreadcrumbs() { return """

\n" + ""; } + /** Renders links for common RefactorFirst GitHub actions. */ String renderGithubButtons() { return """
@@ -962,6 +994,7 @@ String renderGithubButtons() {
"""; } + /** Returns the base filename for the generated report. */ String getOutputName() { // This report will generate simple-report.html when invoked in a project with `mvn site` return "refactor-first-report"; @@ -1096,6 +1129,7 @@ public String renderDisharmonyInfo( return sb.toString(); } + /** Removes package qualifiers and verbose generic bounds from a method signature. */ String getSimpleMethodSignature(String sig) { if (sig == null) { return null; @@ -1146,6 +1180,7 @@ String getSimpleMethodSignature(String sig) { // upWaitQueue(com.tonikelope.megabasterd.Transference) ↔ // TransferenceManager.downWaitQueue(com.tonikelope.megabasterd.Transference) // should become upWaitQueue(Transference) ↔ TransferenceManager.downWaitQueue(Transference) + /** Simplifies fully qualified parameter types in a duplication-partner description. */ String simplifyDuplicatePartners(String duplicationPartners) { if (duplicationPartners == null) { return null; @@ -1172,10 +1207,12 @@ String simplifyDuplicatePartners(String duplicationPartners) { return String.join(" ↔ ", simplifiedParts); } + /** Extension hook for rendering a chart for one disharmony category. */ String renderDisharmonyChart(String anchorId, String title, List ranked, int maxPriority) { return ""; // empty on purpose; overridden in HtmlReport } + /** Returns the simple class name from a fully qualified class name. */ String getClassName(String fqn) { // handle no package if (!fqn.contains(".")) { @@ -1186,6 +1223,7 @@ String getClassName(String fqn) { return fqn.substring(lastIndex + 1); } + /** Extracts the source and target vertex labels from an edge's string representation. */ static String[] extractVertexes(DefaultWeightedEdge edge) { return edge.toString().replace("(", "").replace(")", "").split(":"); } @@ -1198,6 +1236,7 @@ static final class DisharmonySpec { final String problem; final String solution; + /** Creates the display metadata for one supported disharmony category. */ DisharmonySpec( String type, String anchorId, String title, boolean methodLevel, String problem, String solution) { this.type = type; @@ -1208,26 +1247,32 @@ static final class DisharmonySpec { this.solution = solution; } + /** Returns the disharmony type identifier. */ String type() { return type; } + /** Returns the HTML anchor identifier. */ String anchorId() { return anchorId; } + /** Returns the display title. */ String title() { return title; } + /** Reports whether the disharmony describes methods rather than classes. */ boolean methodLevel() { return methodLevel; } + /** Returns the problem description. */ String problem() { return problem; } + /** Returns the recommended remediation. */ String solution() { return solution; } diff --git a/report/src/test/java/org/hjug/refactorfirst/report/JsonGeneratorTest.java b/report/src/test/java/org/hjug/refactorfirst/report/JsonGeneratorTest.java index 40976f2a..1cd5595a 100644 --- a/report/src/test/java/org/hjug/refactorfirst/report/JsonGeneratorTest.java +++ b/report/src/test/java/org/hjug/refactorfirst/report/JsonGeneratorTest.java @@ -194,4 +194,54 @@ public String execute() { // (buildRawClassGraphDot only renders vertices with edges) assertTrue(report.getClassMap().getDot().contains("digraph G")); } + + /** + * Verifies report generation succeeds with parser selection left entirely + * to the runtime (JEP 238 multi-release jar design). + */ + @Test + void testExecuteWithoutParserFlags() throws Exception { + File repoDir = tempDir.toFile(); + new File(repoDir, ".git").mkdirs(); + + File srcDir = new File(repoDir, "src/main/java/com/example"); + srcDir.mkdirs(); + Files.writeString( + new File(srcDir, "SampleService.java").toPath(), + """ + package com.example; + + public class SampleService { + public String execute() { + return "Hello World"; + } + } + """); + + new ProcessBuilder("git", "init").directory(repoDir).start().waitFor(); + new ProcessBuilder("git", "config", "user.email", "test@test.com") + .directory(repoDir) + .start() + .waitFor(); + new ProcessBuilder("git", "config", "user.name", "Test") + .directory(repoDir) + .start() + .waitFor(); + new ProcessBuilder("git", "add", ".").directory(repoDir).start().waitFor(); + new ProcessBuilder("git", "commit", "-m", "initial") + .directory(repoDir) + .start() + .waitFor(); + + new JsonGenerator().execute(50, true, false, true, "src/test", "ForcedProject", "1.0.0", repoDir, null); + + Path jsonFile = tempDir.resolve(".refactorfirst").resolve("refactor-first.json"); + assertTrue(Files.exists(jsonFile)); + + RefactorFirstReportDTO report = objectMapper.readValue(jsonFile.toFile(), RefactorFirstReportDTO.class); + assertNotNull(report); + assertEquals("ForcedProject", report.getProject().getName()); + assertFalse(report.getProject().isAnalysisFailed()); + assertTrue(report.getClassMap().getClassCount() >= 1); + } }