Skip to content

Commit 18e4000

Browse files
Merge pull request #218 from refactorfirst/#190-add-java25-support
#190 Add Java 25 Support
2 parents 0f10da8 + 9bcdbe5 commit 18e4000

26 files changed

Lines changed: 1915 additions & 32 deletions

File tree

‎.github/workflows/maven-pr.yml‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,14 +11,18 @@ jobs:
1111
build:
1212
runs-on: ubuntu-latest
1313

14+
strategy:
15+
matrix:
16+
java-version: [ 17, 25 ]
17+
1418
steps:
1519
- name: Check out Git repository
1620
uses: actions/checkout@v4
1721

18-
- name: Set up JDK 11
22+
- name: Set up JDK ${{ matrix.java-version }}
1923
uses: actions/setup-java@v4
2024
with:
21-
java-version: 17
25+
java-version: ${{ matrix.java-version }}
2226
distribution: 'zulu'
2327

2428
- name: Build With Maven

‎.github/workflows/maven.yml‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,14 +11,18 @@ jobs:
1111
build:
1212
runs-on: ubuntu-latest
1313

14+
strategy:
15+
matrix:
16+
java-version: [ 17, 25 ]
17+
1418
steps:
1519
- name: Check out Git repository
1620
uses: actions/checkout@v4
1721

18-
- name: Set up JDK 11
22+
- name: Set up JDK ${{ matrix.java-version }}
1923
uses: actions/setup-java@v4
2024
with:
21-
java-version: 17
25+
java-version: ${{ matrix.java-version }}
2226
distribution: 'zulu'
2327

2428
- name: Build With Maven

‎.github/workflows/release.yml‎

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,10 +25,14 @@ jobs:
2525
uses: actions/checkout@v3
2626
with:
2727
fetch-depth: 0
28-
- name: Set up JDK 11
28+
- name: Set up JDK 25
29+
# Release artifacts must be built on JDK 25+ so the JEP 238
30+
# META-INF/versions/25 entries (Java 25 parser activation) are
31+
# compiled into the codebase-graph-builder jar; the jdk25-multi-release
32+
# build profile is inactive on older build JDKs.
2933
uses: actions/setup-java@v3
3034
with:
31-
java-version: '17'
35+
java-version: '25'
3236
distribution: 'temurin'
3337
cache: maven
3438
- name: Set release version
@@ -43,6 +47,16 @@ jobs:
4347
- name: Stage release
4448
run: mvn --no-transfer-progress --batch-mode -Ppublish clean deploy -DaltDeploymentRepository=local::default::file://`pwd`/target/staging-deploy
4549

50+
- name: Verify multi-release jar contains Java 25 entries
51+
# Release guard: published jars must carry the JEP 238 versioned
52+
# factory so JDK 25 users actually get the Java 25 parser.
53+
run: |
54+
jar=$(ls codebase-graph-builder/target/codebase-graph-builder-*.jar | grep -v -e sources -e javadoc | head -1)
55+
unzip -l "$jar" | grep "META-INF/versions/25/org/hjug/graphbuilder/graphbuilder/Java25ParserFactory.class" >/dev/null \
56+
|| { echo "FATAL: $jar is missing META-INF/versions/25 entries"; exit 1; }
57+
unzip -p "$jar" META-INF/MANIFEST.MF | grep -q "Multi-Release: true" \
58+
|| { echo "FATAL: $jar manifest lacks 'Multi-Release: true'"; exit 1; }
59+
4660
- name: Run JReleaser
4761
uses: jreleaser/release-action@v2
4862
with:

‎AGENTS.md‎

Lines changed: 67 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,64 @@
1717
- **Anonymous/synthetic classes are first-class graph members.** Java `Outer$N`/`Outer$` (anonymous/synthetic inner classes) and the Kotlin literal `"<anonymous>"` 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 `"<anonymous>"` 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.
1818
- **Anonymous DOT node ids are source-file derived.** OpenRewrite attributes a Kotlin anonymous object / function-literal type with {@code "<anonymous>"} as the trailing simple-name segment of its FQN: standalone ({@code "<anonymous>"}) or, in real graphs (e.g. FXGL), prefixed by the enclosing class/package ({@code "dev.DeveloperWASDControl.<anonymous>"}). {@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 "<anonymous>"} FQN ({@code <}/{@code >} are illegal in Graphviz node ids; {@code <}/{@code >} escaping in HTML table labels).
1919

20+
## Java 25 analysis (JEP 238 multi-release jar)
21+
22+
`rewrite-java-25` is a **required** (BOM-managed, no explicit version) compile
23+
dependency of `codebase-graph-builder` and ships in every distributed artifact
24+
(Maven plugin, CLI fat jar). Its class files are Java 25 (class-file 69.0) but
25+
are inert on Java 17/21 classpaths: the jar declares no `META-INF/services`
26+
entries, and no base-level class in the module references it.
27+
28+
Activation uses a **JEP 238 multi-release jar**, not reflection or version
29+
detection. `codebase-graph-builder` declares `Multi-Release: true` and ships
30+
two variants of `org.hjug.graphbuilder.graphbuilder.Java25ParserFactory`:
31+
32+
- the base variant (`src/main/java`, `--release 17`) returns
33+
`Optional.empty()`;
34+
- the Java 25 variant (`src/main/java25`, compiled `--release 25` into
35+
`META-INF/versions/25` by the auto-activated `jdk25-multi-release` profile)
36+
calls `Java25Parser.builder().build()` directly, wrapped in a
37+
`catch (Throwable)` graceful-degradation guard.
38+
39+
On JDK 25+ runtimes the JVM's versioned jar lookup shadows the base variant —
40+
parser selection is fully runtime-driven with no configuration surface.
41+
`JavaSourceFileGraphBuilder.createJavaParser(config)` is just
42+
`Java25ParserFactory.createJava25Parser().orElseGet(...fromJavaVersion...)`.
43+
Independently, OpenRewrite's `JavaParser.fromJavaVersion()` itself reflectively
44+
elevates to `Java25Parser` on JDK 25+ whenever `rewrite-java-25` is on the
45+
classpath, so exploded-directory classpaths (e.g. surefire against
46+
`target/classes`, where JEP 238 shadowing does not apply) still get the
47+
Java 25 parser on JDK 25. `Java25ParserFactoryTest` covers the factory
48+
contract (including public-API parity of the two variants);
49+
`MultiReleaseJarIT` verifies the packaged jar's manifest, versioned entries,
50+
and real jar shadowing.
51+
52+
Constraints:
53+
54+
- **Never reference `rewrite-java-25` (or any class-file-69 code) from base
55+
sources** — only from `src/main/java25`.
56+
- The versioned class must keep exactly the same public API as the base class.
57+
- Release artifacts must be built on JDK 25+ (see `release.yml`, which also
58+
asserts the versioned entries exist); a build on JDK 17 succeeds but
59+
silently lacks `META-INF/versions/25`.
60+
- JaCoCo excludes `META-INF/versions/**` (duplicate class names break its
61+
report goal); the CLI shade config re-adds `Multi-Release: true` to the fat
62+
jar manifest.
63+
- `refactor-first-maven-plugin` uses `maven-plugin-plugin` /
64+
`maven-plugin-annotations` 3.16.0 because descriptor generation scans
65+
dependency archives with ASM and older versions cannot read class-file 69.
66+
67+
**JDK 25 build notes:**
68+
- Spotless 3.10.2 with `palantir-java-format` 2.71.0 runs on JDK 17, 21 and
69+
25 with no `--add-exports` workaround (older Spotless 2.x /
70+
palantir-java-format builds failed on JDK 21+ without javac exports and
71+
could not run on JDK 25 at all).
72+
- CI (`maven.yml`, `maven-pr.yml`) builds a JDK 17 + JDK 25 matrix.
73+
- `CostBenefitCalculatorTest.testCostBenefitCalculation` runs on all JDKs;
74+
its former `@DisabledOnJre(JRE.JAVA_25)` (OpenRewrite 8.90.4 fixture
75+
failure, issue #8712 family) no longer reproduces now that
76+
`rewrite-java-25` is always on the classpath — verified passing on JDK 25.
77+
2078
## Kotlin analysis (hard dependency)
2179

2280
`rewrite-kotlin` (`org.openrewrite:rewrite-kotlin`) is a **non-optional
@@ -37,11 +95,12 @@ and the CLI fat-jar bundle the Kotlin compiler —
3795
`kotlin-script-runtime` / `kotlin-daemon-embeddable` /
3896
`kotlinx-coroutines-core-jvm` transitives — into **every** consumer's runtime,
3997
including pure-Java projects that never contain a `.kt` file. As of this branch
40-
the CLI fat-jar is `cli/target/cli-<version>.jar` and measures **~144 MB**
41-
(verified via `du -sh cli/target/cli-0.10.0.jar` after
42-
`mvn clean install -DskipTests`); the Kotlin compiler and its transitives are a
43-
material fraction of that. A pure-Java consumer therefore pays this size/cost
44-
(the dependency is always on the classpath regardless).
98+
the CLI fat-jar is `cli/target/cli-<version>.jar` and measures **~145 MB**
99+
(verified via `du -sh cli/target/cli-0.11.0-SNAPSHOT.jar` after
100+
`mvn clean install`); the Kotlin compiler and its transitives — and, since the
101+
JEP 238 change, the non-optional `rewrite-java-25` — are a material fraction of
102+
that. A pure-Java consumer therefore pays this size/cost (the dependency is
103+
always on the classpath regardless).
45104

46105
**No opt-out:** Kotlin analysis runs unconditionally — there is no
47106
`analyzeKotlin` switch on `GraphBuilderConfig`. The Kotlin parser is always
@@ -78,6 +137,9 @@ Configuration options (most important):
78137
- `excludeTests`: Exclude test classes (default: true)
79138
- `minifyHtml`: Minify HTML report (default: false)
80139

140+
(Java 25 parser activation is automatic on JDK 25+ runtimes via the JEP 238
141+
multi-release jar — there is no longer any configuration flag for it.)
142+
81143
## CVE Pinning
82144
Transitive dependencies surfaced by an OWASP dependency-check are pinned centrally in the
83145
parent `pom.xml` `<dependencyManagement>` so child modules reference them by bare

‎CLAUDE.md‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ test-resources (shared fixtures)
4949
coverage (JaCoCo aggregation)
5050
```
5151

52-
**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()`.
52+
**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()`.
5353

5454
**graph-algorithms** — Two cycle-decomposition algorithms used by `JavaGraphBuilder`:
5555
- *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
9292

9393
## Java & Toolchain
9494

95-
- Source/target: Java 11 minimum; OpenRewrite parser supports 11, 17, 21.
95+
- Source/target: Java 17 minimum; OpenRewrite parser supports 11, 17, 21 — plus 25 when running on a Java 25+ runtime.
9696
- Logging: SLF4J; use `log.debug()` for verbose per-class output, `log.info()` sparingly.
9797
- Spotless enforces Palantir Java format — run `mvn spotless:apply` before committing.
98+
99+
## Java 25 analysis
100+
101+
`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.

‎README.md‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ Code map viewers are powered by [3D Force Graph](https://vasturiano.github.io/3d
1212
<br>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.
1313

1414
## How to Use RefactorFirst Quickly
15-
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:
15+
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:
1616
```bash
1717
mvn org.hjug.refactorfirst.plugin:refactor-first-maven-plugin:0.10.0:htmlReport
1818
```
@@ -43,7 +43,8 @@ The graphs generated in the report will look similar to this one:
4343
![image info](./RefactorFirst_Sample_Report.png)
4444

4545
## Please Note: Java 17 (or newer) required to run RefactorFirst
46-
**Java 25 codebase analysis is supported!**
46+
**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.
47+
The Maven and Gradle plugins themselves still only require a Java 17 runtime.
4748
Please use a recent JDK release of the Java version you are using.
4849
If you use an old JDK release of your chosen Java version, you may encounter issues during analysis.
4950

@@ -118,6 +119,8 @@ Specify with -D if running on the command line. e.g. ```-DbackEdgeAnalysisCount
118119
|projectVersion|The version of your project to be displayed on the report| Your Maven project version |
119120
|outputDirectory|The location the project report will be written| ```${projectDir}/target/site/refactor-first-report.html```
120121

122+
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.
123+
121124

122125
## But I'm using Gradle / my project layout isn't typical!
123126
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:

‎change-proneness-ranker/src/test/java/org/hjug/git/GitLogReaderTest.java‎

Lines changed: 41 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77
import static org.mockito.Mockito.when;
88

99
import java.io.*;
10+
import java.nio.file.Files;
11+
import java.nio.file.NoSuchFileException;
1012
import java.util.*;
1113
import org.eclipse.jgit.api.Git;
1214
import org.eclipse.jgit.api.LogCommand;
@@ -190,8 +192,6 @@ void testFileLogReturnsOnlyVerifiedPartialPathHistoryWhenFilteredWalkYieldsNothi
190192
void testFileLogReturnsOnlyVerifiedPartialPathHistoryWithMissingTree() throws Exception {
191193
// Simulates a shallow clone whose tree objects are missing: the filtered walk
192194
// may yield zero results due to missing objects, but should not fall back to total history.
193-
GitLogReader gitLogReader = new GitLogReader(git);
194-
195195
String attributeHandler = "AttributeHandler.java";
196196
InputStream resourceAsStream = getClass().getClassLoader().getResourceAsStream(attributeHandler);
197197
String contents = convertInputStreamToString(resourceAsStream);
@@ -208,11 +208,19 @@ void testFileLogReturnsOnlyVerifiedPartialPathHistoryWithMissingTree() throws Ex
208208

209209
deleteLooseObject(secondCommit.getTree());
210210

211-
ScmLogInfo scmLogInfo = Assertions.assertDoesNotThrow(() -> gitLogReader.fileLog(attributeHandler));
212-
213-
// With missing tree objects, the filtered walk may yield zero verified partial results
214-
// rather than falling back to total repository history
215-
Assertions.assertEquals(0, scmLogInfo.getCommitCount());
211+
// Read through a freshly opened repository: the commits above populated JGit's
212+
// in-memory loose-object cache (UnpackedObjectCache, one per ObjectDirectory
213+
// instance), which could otherwise serve the deleted tree from memory and let
214+
// the walk succeed with 2 commits instead of failing. A new Repository instance
215+
// gets an empty cache, so the missing object is really exercised on every OS.
216+
try (Git freshGit = Git.open(repository.getDirectory())) {
217+
GitLogReader gitLogReader = new GitLogReader(freshGit);
218+
ScmLogInfo scmLogInfo = Assertions.assertDoesNotThrow(() -> gitLogReader.fileLog(attributeHandler));
219+
220+
// With missing tree objects, the filtered walk may yield zero verified partial results
221+
// rather than falling back to total repository history
222+
Assertions.assertEquals(0, scmLogInfo.getCommitCount());
223+
}
216224
}
217225

218226
@Test
@@ -232,16 +240,39 @@ void testFileLogDoesNotFabricateHistoryForFileNeverCommitted() throws Exception
232240
Assertions.assertEquals(0, scmLogInfo.getCommitCount());
233241
}
234242

235-
private void deleteLooseObject(ObjectId objectId) throws IOException {
243+
private void deleteLooseObject(ObjectId objectId) throws IOException, InterruptedException {
236244
String objectName = objectId.getName();
237245
File looseObject = new File(
238246
new File(repository.getDirectory(), "objects"),
239247
objectName.substring(0, 2) + '/' + objectName.substring(2));
240248
org.junit.jupiter.api.Assumptions.assumeTrue(
241249
looseObject.exists(), "loose object " + objectName + " should exist in a fresh repository");
242-
if (!looseObject.delete()) {
243-
throw new IOException("Unable to delete loose object " + looseObject);
250+
251+
// JGit writes loose objects with the read-only attribute set (as C git
252+
// does). POSIX unlink ignores the read-only bit, but Windows refuses to
253+
// delete a read-only file and every retry would fail with
254+
// AccessDeniedException — so the attribute must be cleared first.
255+
if (!looseObject.setWritable(true) && !looseObject.canWrite()) {
256+
throw new IOException("Unable to clear read-only flag on loose object " + looseObject);
257+
}
258+
259+
// On Windows, antivirus or the search indexer can also hold a newly
260+
// created file open briefly, making an immediate delete fail
261+
// spuriously. Retry for a short window before giving up (harmless on
262+
// Linux/macOS, where the first attempt succeeds).
263+
IOException lastError = null;
264+
for (int attempt = 0; attempt < 50; attempt++) {
265+
try {
266+
Files.delete(looseObject.toPath());
267+
return;
268+
} catch (NoSuchFileException alreadyGone) {
269+
return;
270+
} catch (IOException lockedOrBusy) {
271+
lastError = lockedOrBusy;
272+
Thread.sleep(50);
273+
}
244274
}
275+
throw new IOException("Unable to delete loose object " + looseObject, lastError);
245276
}
246277

247278
@Test

0 commit comments

Comments
 (0)