Skip to content
Merged
8 changes: 6 additions & 2 deletions .github/workflows/maven-pr.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 6 additions & 2 deletions .github/workflows/maven.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
18 changes: 16 additions & 2 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand Down
72 changes: 67 additions & 5 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,64 @@
- **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.
- **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).

## 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
Expand All @@ -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-<version>.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-<version>.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
Expand Down Expand Up @@ -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` `<dependencyManagement>` so child modules reference them by bare
Expand Down
8 changes: 6 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
7 changes: 5 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ Code map viewers are powered by [3D Force Graph](https://vasturiano.github.io/3d
<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.

## 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
```
Expand Down Expand Up @@ -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.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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.

Expand Down Expand Up @@ -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:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand All @@ -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
Expand All @@ -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
Expand Down
Loading
Loading