Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4,846 changes: 4,137 additions & 709 deletions .refactorfirst/refactor-first.json

Large diffs are not rendered by default.

105 changes: 103 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,104 @@
- Central DTO: `CodebaseGraphDTO` (JGraphT graphs + disharmony lists + metrics)
- CLI entry point: `org.hjug.refactorfirst.Main` → `ReportCommand`
- Fat jar location: `cli/target/cli-<version>.jar`
- **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).
- **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 an anonymous source never creates an `""` package-graph vertex: the package of a Kotlin anonymous FQN derives from its **enclosing class** (`dev.Owner.<anonymous>` → `dev`, and a packageless `Owner`/bare `"<anonymous>"` → no package edge), never a pseudo-package named after the owner — this keeps the package graph's names aligned with the class hierarchy's package derivation so the break-up lists in the Class and Package graphs match. **Sink-only** anonymous/synthetic vertices (those with no outgoing edges) are suppressed at hierarchy-build time in `HierarchyBuilder` (via the same `HtmlReport.isSinkAnonymousOrSyntheticVertex` static) to keep the Class/Cycle Map DOT graph readable; active ones still render, and edges touching a suppressed vertex are dropped with it.
- **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 <}. `HierarchyBuilder` resolves the id and label once, into the {@code ClassNodeDTO} (calling {@code HtmlReport.renderSafeNodeId(vertex, classGraph, codebaseGraphDTO)} / {@code anonymousOwnerLabel} / {@code getClassName}), deriving 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}; labels are carried unescaped in the DTO and DOT-quoted at emission (the JS-template-literal wrapper additionally escapes {@code $} for the inline HTML report). When no source path is mapped (or DTO is null) it degrades to the reversible {@code lt_}/{@code _gt} {@code <}/{@code >} encoding. Note the render path (the 3-arg `renderSafeNodeId`) emits no hash discriminator, so same-named source files in different packages can collide — a pre-existing wart; the 2-arg overload's hash discriminator is not used for graphs.

## Nested graph hierarchy (HierarchyDTO + DOT synthesis)

Every generated graph (Class Map, Cycle Map, Package Map) renders as a nested
package/class hierarchy. The pipeline is single-sourced:

`VertexInfo` graphs → `HierarchyBuilder` → `HierarchyDTO` → DOT emission.

- **`refactor-first.json` carries the hierarchy, not raw DOT** (0.11.0 breaking
change): `classMap.dot`, `packageMap.dot` and
`classCycles.largestCycle.dot` are gone, replaced by `.hierarchy` objects
(`packages` tree + `edges` referencing pre-resolved node ids). Counts and
gates (`dotThreshold`, `dotThresholdExceeded`, `hasEdges`) keep their names.
There is no schema version and no legacy `.dot` fallback — reports published
with older versions lose their graphs on hierarchy-only viewers until
regenerated.
- **`HierarchyBuilder`** (report module) builds the three hierarchies:
`buildClassHierarchy` (endpoints of every class edge), `buildCycleHierarchy`
(a cycle's members, ids still resolved against the FULL class graph), and
`buildPackageHierarchy` (the package-map hierarchy **data**: relationship
participants and break-up packages join the tree with their node ids,
source paths, and `renderAsNode` leaf flags — rendering is the DOT
builders' job, and `buildPackageDot` draws every participating package as
a flat `shape=ellipse` node, never as rectangles under ancestor clusters).
Nested `$`/Kotlin `<anonymous>` classes resolve to the owning package;
packageless classes land in the synthetic `(default package)` bucket.
Roots/children/classes/edges are sorted for determinism and single-child
ancestor chains collapse into their deepest package (branching ancestors
and content keep their boxes).
- **Ids/labels are resolved once, into the DTO** (`ClassNodeDTO.id`/`label`,
`PackageNodeDTO.id`/`nodeId`) and carried unescaped; renderers escape at
emission. Class ids reuse the `HtmlReport.renderSafeNodeId` rules, package
node ids are `package_<sha24>` (`renderSafePackageNodeId`), cluster ids are
`cluster_<sha24>` (`HierarchyBuilder.clusterId`).
- **`NestedDotBuilder`** emits the nested DOT in two modes.
*Class/cycle maps* (`buildNestedDot(hierarchy, repoUrl, includeHyperlinks)`):
packages as labelled `subgraph cluster_*` blocks (clusters BEFORE edges — in
DOT a node belongs to the cluster where it is first defined), classes as
`shape=ellipse` nodes. Removal colors: classes/edges keep
`color=red style=filled`; flagged clusters fill pink. Every cluster
declares an explicit fill — pink when flagged, `fillcolor=white` otherwise
— because vizdom propagates a styled cluster's fill to its nested cluster
rects; the explicit white keeps child packages of a pink cluster on their
normal fill.
*Package map* (`buildPackageDot(hierarchy)`): the flat relationship graph,
matching the main-branch package map — every package that participates in
at least one relationship (or was selected for removal to break cycles)
renders once as a `shape=ellipse` node carrying its name, and the edges
carry only the relationship weights. Break-up packages fill red
(`color=red style=filled`), removal relationships render red, and the
package map renders through **vizdom** like the class and cycle maps (no
separate engine). Every graph's nodes are **hyperlinked**: class nodes link
to their source files and package nodes to their source directories via
`URL="<repoUrl><path>" target="_blank"` (http(s)-gated — payloads are
untrusted on the page/viewer). vizdom honors node `URL` but drops
`target`, so every render site restores it with a post-render
`svg.replace(/<a href=/g, '<a target="_blank" href=')` (global — a graph
hyperlinks many nodes). The full hierarchy
— package tree, break-up flags, source directories (`path`), and edges
— stays in `refactor-first.json` for LLM consumption; only the
relationship information renders.
`buildRawClassGraphDot`/`buildRawClassCycleDot`/`buildRawPackageGraphDot`
in `HtmlReport` keep their signatures and delegate here, so the classic
inline HTML report renders the graphs too.
- **Render engine**: every graph — class, cycle, and package maps — renders
through vizdom (`@vizdom/vizdom-ts-web@0.1.19`). The package map's flat DOT
(ellipse nodes + edges) parses under the same vizdom path as the nested
class/cycle DOT: the classic report's `printHead` prefetches the module and
`generateDotImage` renders each container; the viewer and the report page
mirror this. vizdom silently ignores `compound`/`ltail`/`lhead`, which is
fine — the flat package map no longer uses them.
- **The Class and Package maps
flag identical break-up lists by construction**: every `packagesToRemove`
member is seeded into both hierarchies (even when its classes/edges do not
render) and flagged packages survive pruning and single-child collapse. The
Cycle Map stays cycle-scoped: it flags only the break-up packages inside the
displayed cycle.
- **Three synthesis implementations must stay byte-identical** (their goldens
mirror each other): Java `NestedDotBuilder` (`NestedDotBuilderTest`), the
inline copy in `report/src/main/resources/viewer/index.html` (self-contained
standalone viewer; pinned by `ViewerScriptTest`), and the report page's
`lib/hierarchy-dot.js` (refactorfirst-page repo, pinned by
`tests/unit/hierarchy-dot.test.js`) — each in both the class/cycle mode
(`buildNestedDot`/`hierarchyToDot`) and the package mode
(`buildPackageDot`/`packageHierarchyToDot`). The page copies add an
`edgeFontColor` option: vizdom bakes label `fontcolor` into fill-only
glyph paths at parse time, so the dark palette re-synthesizes with
`fontcolor = "#9fb0c0"` on edges. Node and cluster labels sit on vizdom's
white shapes and stay default-colored in both palettes. graphlib-dot
creates a **node for every named subgraph** (one per package in the class
map, labelled with the package name), so packages would appear as noise in
the sigma/3D popups: all popup graph builders (2D `renderGraph`/`buildGraphologyGraph`
and 3D `createForceGraph`, in the viewer, the classic report's popup scripts,
and the page) identify subgraph nodes structurally with
`graphlibGraph.children(node).length > 0` and drop them and any edge touching
them, while keeping every class node, including ids such as `cluster_Helper`.

## Java 25 analysis (JEP 238 multi-release jar)

Expand Down Expand Up @@ -131,6 +227,11 @@ is for build failures, not for "Kotlin is absent".
Generate reports directly:
`mvn org.hjug.refactorfirst.plugin:refactor-first-maven-plugin:0.9.0:htmlReport`

Generate the JSON report data (writes `.refactorfirst/refactor-first.json`
at the project root; the dogfooded copy in this repo is regenerated with this
goal on the current `0.11.0-SNAPSHOT`):
`mvn org.hjug.refactorfirst.plugin:refactor-first-maven-plugin:0.11.0-SNAPSHOT:jsonReport`

Configuration options (most important):
- `showDetails`: Shows God Class metrics in table (default: false)
- `backEdgeAnalysisCount`: 0 = analyze all back edges (default: 50)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -132,11 +132,24 @@ public DefaultWeightedEdge addPackageDependency(String fromClassFqn, String toCl
}

protected String getPackageFromFqn(String fqn) {
if (!fqn.contains(".")) {
String name = fqn;
int lastIndex = name.lastIndexOf('.');
// A trailing "<anonymous>"-style segment names a Kotlin anonymous class, not a package
// member: derive the package from the enclosing class so an anonymous class never
// manufactures a package named after its owner (dev.Owner.<anonymous> -> "dev", and
// Owner.<anonymous> -> "" like the bare packageless literal). This keeps the package
// graph's package names aligned with the class hierarchy's package derivation, so the
// break-up packages flagged in the Class and Package graphs match.
if (lastIndex >= 0 && name.substring(lastIndex + 1).startsWith("<")) {
name = name.substring(0, lastIndex);
if (!name.contains(".")) {
return "";
}
lastIndex = name.lastIndexOf('.');
} else if (lastIndex < 0) {
return "";
}
int lastIndex = fqn.lastIndexOf(".");
return fqn.substring(0, lastIndex);
return name.substring(0, lastIndex);
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,66 @@ void addPackageDependency_kotlinAnonymousSource_doesNotPollutePackageGraph() {
assertTrue(pkgGraph.edgeSet().isEmpty());
}

/**
* A prefixed Kotlin anonymous FQN (e.g. {@code dev.DeveloperWASDControl.<anonymous>}) must not
* manufacture a package named after its enclosing class: the package edge derives from the
* enclosing class's package, so the Class and Package graphs agree on what the packages are
* and the break-up lists rendered from them match.
*/
@DisplayName("addPackageDependency derives the enclosing class's package for a prefixed Kotlin <anonymous> source")
@Test
void addPackageDependency_prefixedKotlinAnonymousSource_derivesEnclosingClassPackage() {
Graph<VertexInfo, DefaultWeightedEdge> classGraph = newClassGraph();
Graph<VertexInfo, DefaultWeightedEdge> pkgGraph = newClassGraph();
GraphDependencyCollector collector = new GraphDependencyCollector(classGraph, pkgGraph);

DefaultWeightedEdge edge = collector.addPackageDependency(
"dev.DeveloperWASDControl.<anonymous>", "com.almasb.fxgl.app.GameApplication");

assertNotNull(edge, "the anonymous class's dependency must create the real package edge");
assertEquals("dev", pkgGraph.getEdgeSource(edge).getName());
assertEquals("com.almasb.fxgl.app", pkgGraph.getEdgeTarget(edge).getName());
Set<String> packageNames =
pkgGraph.vertexSet().stream().map(VertexInfo::getName).collect(java.util.stream.Collectors.toSet());
assertEquals(
Set.of("dev", "com.almasb.fxgl.app"),
packageNames,
"no pseudo-package named after the enclosing class may exist");
}

/** Same rule for the target side: an anonymous target derives from its enclosing class's package. */
@DisplayName("addPackageDependency derives the enclosing class's package for a prefixed Kotlin <anonymous> target")
@Test
void addPackageDependency_prefixedKotlinAnonymousTarget_derivesEnclosingClassPackage() {
Graph<VertexInfo, DefaultWeightedEdge> classGraph = newClassGraph();
Graph<VertexInfo, DefaultWeightedEdge> pkgGraph = newClassGraph();
GraphDependencyCollector collector = new GraphDependencyCollector(classGraph, pkgGraph);

DefaultWeightedEdge edge = collector.addPackageDependency(
"com.almasb.fxgl.app.GameApplication", "dev.DeveloperWASDControl.<anonymous>");

assertNotNull(edge);
assertEquals("com.almasb.fxgl.app", pkgGraph.getEdgeSource(edge).getName());
assertEquals("dev", pkgGraph.getEdgeTarget(edge).getName());
assertFalse(
pkgGraph.vertexSet().stream().anyMatch(v -> v.getName().equals("dev.DeveloperWASDControl")),
"no pseudo-package named after the enclosing class may exist");
}

/** An anonymous class whose enclosing class has no package is a no-op, like the bare literal. */
@DisplayName("addPackageDependency with a packageless enclosing class is a no-op")
@Test
void addPackageDependency_packagelessEnclosingAnonymous_isNoOp() {
Graph<VertexInfo, DefaultWeightedEdge> classGraph = newClassGraph();
Graph<VertexInfo, DefaultWeightedEdge> pkgGraph = newClassGraph();
GraphDependencyCollector collector = new GraphDependencyCollector(classGraph, pkgGraph);

DefaultWeightedEdge edge = collector.addPackageDependency("DeveloperWASDControl.<anonymous>", "com.bar.Other");

assertNull(edge, "an anonymous class in a packageless enclosing class has no package edge");
assertTrue(pkgGraph.vertexSet().isEmpty(), "no package vertex may be created");
}

/** Verifies that explicit registration retains anonymous and synthetic class vertices. */
@DisplayName("registerClassVertex registers Java anonymous and Kotlin <anonymous> FQNs")
@Test
Expand Down
Loading
Loading