From acc2449510653d804b12e21758d2fa873f31ac4b Mon Sep 17 00:00:00 2001 From: Jim Bethancourt Date: Tue, 20 Oct 2026 17:35:12 -0500 Subject: [PATCH 1/6] #184 Rendering Source Hierarchy - Rendering packages as subgraphs in the class graph - Rendering packages as subgraphs and rectangles in the package graph --- AGENTS.md | 93 ++- .../GraphDependencyCollector.java | 19 +- .../GraphDependencyCollectorTest.java | 60 ++ .../report/HierarchyBuilder.java | 401 ++++++++++++ .../hjug/refactorfirst/report/HtmlReport.java | 275 ++------- .../refactorfirst/report/JsonGenerator.java | 32 +- .../report/NestedDotBuilder.java | 229 +++++++ .../report/SimpleHtmlReport.java | 2 +- .../report/model/ClassNodeDTO.java | 34 ++ .../refactorfirst/report/model/EdgeDTO.java | 29 + .../report/model/GraphVisualDTO.java | 2 +- .../report/model/HierarchyDTO.java | 28 + .../report/model/LargestCycleDTO.java | 2 +- .../report/model/PackageNodeDTO.java | 47 ++ report/src/main/resources/viewer/index.html | 167 ++++- .../report/DotLanguageEscapingTest.java | 113 ---- .../report/HierarchyBuilderTest.java | 570 +++++++++++++++++ .../report/HierarchyJsonEscapingTest.java | 120 ++++ .../report/HtmlReportKotlinTest.java | 15 +- .../refactorfirst/report/HtmlReportTest.java | 72 +-- .../report/JsonGeneratorTest.java | 9 +- .../report/MustacheTemplateRenderingTest.java | 21 +- .../report/NestedDotBuilderTest.java | 574 ++++++++++++++++++ .../report/ViewerScriptTest.java | 125 ++++ 24 files changed, 2589 insertions(+), 450 deletions(-) create mode 100644 report/src/main/java/org/hjug/refactorfirst/report/HierarchyBuilder.java create mode 100644 report/src/main/java/org/hjug/refactorfirst/report/NestedDotBuilder.java create mode 100644 report/src/main/java/org/hjug/refactorfirst/report/model/ClassNodeDTO.java create mode 100644 report/src/main/java/org/hjug/refactorfirst/report/model/EdgeDTO.java create mode 100644 report/src/main/java/org/hjug/refactorfirst/report/model/HierarchyDTO.java create mode 100644 report/src/main/java/org/hjug/refactorfirst/report/model/PackageNodeDTO.java delete mode 100644 report/src/test/java/org/hjug/refactorfirst/report/DotLanguageEscapingTest.java create mode 100644 report/src/test/java/org/hjug/refactorfirst/report/HierarchyBuilderTest.java create mode 100644 report/src/test/java/org/hjug/refactorfirst/report/HierarchyJsonEscapingTest.java create mode 100644 report/src/test/java/org/hjug/refactorfirst/report/NestedDotBuilderTest.java create mode 100644 report/src/test/java/org/hjug/refactorfirst/report/ViewerScriptTest.java diff --git a/AGENTS.md b/AGENTS.md index ce9e6249..356b6c43 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -14,8 +14,92 @@ - Central DTO: `CodebaseGraphDTO` (JGraphT graphs + disharmony lists + metrics) - CLI entry point: `org.hjug.refactorfirst.Main` → `ReportCommand` - Fat jar location: `cli/target/cli-.jar` -- **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). +- **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 an anonymous source never creates an `""` package-graph vertex: the package of a Kotlin anonymous FQN derives from its **enclosing class** (`dev.Owner.` → `dev`, and a packageless `Owner`/bare `""` → 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 ""} 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 <}. `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` (edge-endpoint packages as `renderAsNode` rectangle + nodes under their ancestor clusters). Nested `$`/Kotlin `` + 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_` (`renderSafePackageNodeId`), cluster ids are + `cluster_` (`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)`): a package with child + packages renders **once** as a labelled cluster (no rectangle inside its + own box — the name never appears twice), a childless package renders once + as a filled rectangle. Edge endpoints are the `package_` node ids: + a leaf's id is its rectangle; a cluster-represented package gets an + invisible anchor (`package_x [shape=point style=invis]`, emitted only + when an edge references it) and the edge carries `ltail`/`lhead` plus + graph-level `compound=true`, so relationships attach cluster ↔ rectangle + in both directions with the edge clipped to the cluster border. + `buildRawClassGraphDot`/`buildRawClassCycleDot`/`buildRawPackageGraphDot` + in `HtmlReport` keep their signatures and delegate here, so the classic + inline HTML report renders the nested graphs too. +- **Two render engines**: the class and cycle maps render through vizdom + (`@vizdom/vizdom-ts-web@0.1.19`); the package map renders through + **`@hpcc-js/wasm-graphviz@1.7.0`** (`https://cdn.jsdelivr.net/npm/@hpcc-js/wasm-graphviz@1.7.0/dist/index.js`, + the pre-vizdom engine this repo used before #164; the wasm is embedded in + the single-file ESM module — no separate asset). The split is required: + vizdom silently ignores `compound`/`ltail`/`lhead`, which the redesigned + package graph depends on. The classic report's `printHead` prefetches both + modules and `generatePackageGraphImage` renders via + `Graphviz.load()` + `graphviz.dot(...)` (strip the engine's xml/doctype + preamble before injecting the SVG); the viewer and the report page mirror + this (page: `public/widgets/graphviz-bridge.js` exposes `window.Graphviz` + and reports the `graphviz` widget). +- **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 and wasm-graphviz bake label `fontcolor` + into fill-only glyph paths / text at parse time, so the dark palette + re-synthesizes with `fontcolor = "#9fb0c0"` on edges. Node and cluster + labels sit on the engines' white shapes and stay default-colored in both + palettes. graphlib-dot flattens `subgraph` statements, so the sigma/3D + popups keep nodes/edges but lose the cluster boxes — accepted. ## Java 25 analysis (JEP 238 multi-release jar) @@ -131,6 +215,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) diff --git a/codebase-graph-builder/src/main/java/org/hjug/graphbuilder/GraphDependencyCollector.java b/codebase-graph-builder/src/main/java/org/hjug/graphbuilder/GraphDependencyCollector.java index c312d8b5..828f0e3b 100644 --- a/codebase-graph-builder/src/main/java/org/hjug/graphbuilder/GraphDependencyCollector.java +++ b/codebase-graph-builder/src/main/java/org/hjug/graphbuilder/GraphDependencyCollector.java @@ -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 ""-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. -> "dev", and + // Owner. -> "" 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); } /** diff --git a/codebase-graph-builder/src/test/java/org/hjug/graphbuilder/GraphDependencyCollectorTest.java b/codebase-graph-builder/src/test/java/org/hjug/graphbuilder/GraphDependencyCollectorTest.java index 3fe10325..86a3fc53 100644 --- a/codebase-graph-builder/src/test/java/org/hjug/graphbuilder/GraphDependencyCollectorTest.java +++ b/codebase-graph-builder/src/test/java/org/hjug/graphbuilder/GraphDependencyCollectorTest.java @@ -157,6 +157,66 @@ void addPackageDependency_kotlinAnonymousSource_doesNotPollutePackageGraph() { assertTrue(pkgGraph.edgeSet().isEmpty()); } + /** + * A prefixed Kotlin anonymous FQN (e.g. {@code dev.DeveloperWASDControl.}) 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 source") + @Test + void addPackageDependency_prefixedKotlinAnonymousSource_derivesEnclosingClassPackage() { + Graph classGraph = newClassGraph(); + Graph pkgGraph = newClassGraph(); + GraphDependencyCollector collector = new GraphDependencyCollector(classGraph, pkgGraph); + + DefaultWeightedEdge edge = collector.addPackageDependency( + "dev.DeveloperWASDControl.", "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 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 target") + @Test + void addPackageDependency_prefixedKotlinAnonymousTarget_derivesEnclosingClassPackage() { + Graph classGraph = newClassGraph(); + Graph pkgGraph = newClassGraph(); + GraphDependencyCollector collector = new GraphDependencyCollector(classGraph, pkgGraph); + + DefaultWeightedEdge edge = collector.addPackageDependency( + "com.almasb.fxgl.app.GameApplication", "dev.DeveloperWASDControl."); + + 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 classGraph = newClassGraph(); + Graph pkgGraph = newClassGraph(); + GraphDependencyCollector collector = new GraphDependencyCollector(classGraph, pkgGraph); + + DefaultWeightedEdge edge = collector.addPackageDependency("DeveloperWASDControl.", "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 FQNs") @Test diff --git a/report/src/main/java/org/hjug/refactorfirst/report/HierarchyBuilder.java b/report/src/main/java/org/hjug/refactorfirst/report/HierarchyBuilder.java new file mode 100644 index 00000000..c9a66fa5 --- /dev/null +++ b/report/src/main/java/org/hjug/refactorfirst/report/HierarchyBuilder.java @@ -0,0 +1,401 @@ +package org.hjug.refactorfirst.report; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashMap; +import java.util.HexFormat; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import org.hjug.cbc.RankedCycle; +import org.hjug.graphbuilder.CodebaseGraphDTO; +import org.hjug.graphbuilder.VertexInfo; +import org.hjug.refactorfirst.report.model.ClassNodeDTO; +import org.hjug.refactorfirst.report.model.EdgeDTO; +import org.hjug.refactorfirst.report.model.HierarchyDTO; +import org.hjug.refactorfirst.report.model.PackageNodeDTO; +import org.jgrapht.Graph; +import org.jgrapht.graph.DefaultWeightedEdge; + +/** + * Builds the nested package/class hierarchy for every generated DOT graph from the VertexInfo + * class and package graphs. + * + *

The hierarchy is the single source of truth for graph structure: packages nest as ancestor + * clusters with single-child chains collapsed into their deepest package, classes resolve to + * their owning package (including {@code $}-nested classes and Kotlin {@code } + * classes via the enclosing class's package), and packageless classes land in the synthetic + * {@link #DEFAULT_PACKAGE} bucket. The package graph additionally marks the packages that + * participate in relationships as rectangle nodes nested inside their ancestor clusters. + * + *

Node ids and labels reuse the exact rules of the previous DOT emitters — including + * source-aware ids for anonymous vertices and FQN fallback on simple-name collisions — so the + * DOT builders (Java and JavaScript) consume them verbatim. Packages flagged + * {@link PackageNodeDTO#isNeedsBreakUp()} render with a pink fill; classes and edges flagged + * {@link ClassNodeDTO#isMarkedForRemoval()} / {@link EdgeDTO#isMarkedForRemoval()} keep the red + * fill and highlighting used today. + */ +public final class HierarchyBuilder { + + /** Synthetic bucket for classes without a package. */ + public static final String DEFAULT_PACKAGE = "(default package)"; + + private HierarchyBuilder() {} + + /** + * Builds the class-map hierarchy: package clusters containing the classes that participate + * in at least one class relationship, matching the vertices the class map renders today. + * + * @param classGraph the class reference graph + * @param codebaseGraphDTO the DTO carrying the {@code vertex -> source file path} mapping + * @param classesToRemove classes selected for removal to break cycles + * @param packagesToRemove packages selected for removal to break cycles + * @param classRelationshipsToRemove class relationships selected for removal + * @return the nested package + class hierarchy with resolved ids, labels, and flags + */ + public static HierarchyDTO buildClassHierarchy( + Graph classGraph, + CodebaseGraphDTO codebaseGraphDTO, + Set classesToRemove, + Set packagesToRemove, + Set classRelationshipsToRemove) { + // the class map renders the endpoints of every class edge + Set vertexNames = new LinkedHashSet<>(); + for (DefaultWeightedEdge edge : classGraph.edgeSet()) { + vertexNames.add(classGraph.getEdgeSource(edge).getName()); + vertexNames.add(classGraph.getEdgeTarget(edge).getName()); + } + return buildClassLevelHierarchy( + classGraph, + codebaseGraphDTO, + classesToRemove, + packagesToRemove, + classRelationshipsToRemove, + vertexNames, + classGraph.edgeSet(), + true); + } + + /** + * Builds the cycle-map hierarchy restricted to the given cycle's classes and edges while + * consulting the full class graph for node id collision rules, so ids match the class map. + * + * @param cycle the ranked cycle to restrict the hierarchy to + * @return the nested package + class hierarchy for the cycle's members + */ + public static HierarchyDTO buildCycleHierarchy( + Graph classGraph, + CodebaseGraphDTO codebaseGraphDTO, + Set classesToRemove, + Set packagesToRemove, + Set classRelationshipsToRemove, + RankedCycle cycle) { + return buildClassLevelHierarchy( + classGraph, + codebaseGraphDTO, + classesToRemove, + packagesToRemove, + classRelationshipsToRemove, + new LinkedHashSet<>(cycle.getVertexSet()), + cycle.getEdgeSet(), + false); + } + + /** + * Builds the package-map hierarchy: packages participating in at least one package + * relationship become rectangle nodes nested inside their (collapsed) ancestor clusters. + * + * @param packageGraph the package reference graph + * @param packagesToRemove packages selected for removal to break cycles + * @param packageRelationshipsToRemove package relationships selected for removal + * @return the nested package hierarchy with rectangle node packages and their edges + */ + public static HierarchyDTO buildPackageHierarchy( + Graph packageGraph, + Set packagesToRemove, + Set packageRelationshipsToRemove) { + // the package map renders the endpoints of every package edge + Set nodePackages = new LinkedHashSet<>(); + for (DefaultWeightedEdge edge : packageGraph.edgeSet()) { + nodePackages.add(packageGraph.getEdgeSource(edge).getName()); + nodePackages.add(packageGraph.getEdgeTarget(edge).getName()); + } + // Every break-up package must appear in the package map's flagged list, so seed the + // node set with the full removal list — belt and braces for members that take part in + // no rendered edge, keeping the Class and Package graphs' break-up lists identical. + for (VertexInfo packageToRemove : packagesToRemove) { + nodePackages.add(packageToRemove.getName()); + } + + Map packages = new HashMap<>(); + for (String name : nodePackages) { + Pkg pkg = packageNode(packages, name); + pkg.renderAsNode = true; + pkg.nodeId = HtmlReport.renderSafePackageNodeId(name); + } + + List edges = new ArrayList<>(); + for (DefaultWeightedEdge edge : packageGraph.edgeSet()) { + String source = packageGraph.getEdgeSource(edge).getName(); + String target = packageGraph.getEdgeTarget(edge).getName(); + edges.add(EdgeDTO.builder() + .source(HtmlReport.renderSafePackageNodeId(source)) + .target(HtmlReport.renderSafePackageNodeId(target)) + .weight((int) packageGraph.getEdgeWeight(edge)) + .markedForRemoval(packageRelationshipsToRemove.contains(edge)) + .build()); + } + + HierarchyDTO hierarchy = assemble(packages, edges, packagesToRemove); + // Packages with children render as clusters — their relationships attach to an + // invisible anchor inside them via compound ltail/lhead edges — and only + // childless packages render as visible rectangle nodes, so every package name + // appears exactly once in the package graph. + markLeafPackagesAsNodes(hierarchy.getPackages()); + return hierarchy; + } + + /** + * Flips renderAsNode to leaf-ness on the assembled package tree: packages with kept + * children become cluster-only (their nodeId remains as the invisible edge anchor), + * childless packages become the visible rectangle nodes. + */ + private static void markLeafPackagesAsNodes(List packages) { + for (PackageNodeDTO pkg : packages) { + List children = pkg.getChildren() == null ? List.of() : pkg.getChildren(); + pkg.setRenderAsNode(children.isEmpty()); + markLeafPackagesAsNodes(children); + } + } + + /** + * Shared class/cycle assembly: resolves ids, labels, paths, and removal flags for the given + * vertex names, converts the given edges to id-referencing DTOs (dropping edges that touch + * suppressed sink vertices), and nests the classes into the package tree. When + * {@code includeAllBreakUpPackages} is set, every break-up package joins the tree even when + * none of its classes render, so the class map's flagged list always matches the package + * map's. + */ + private static HierarchyDTO buildClassLevelHierarchy( + Graph classGraph, + CodebaseGraphDTO codebaseGraphDTO, + Set classesToRemove, + Set packagesToRemove, + Set classRelationshipsToRemove, + Set vertexNames, + Set edges, + boolean includeAllBreakUpPackages) { + Map classesByName = new LinkedHashMap<>(); + for (String vertex : vertexNames) { + // same render-time noise filter as the class/cycle DOT emitters + if (HtmlReport.isSinkAnonymousOrSyntheticVertex(classGraph, vertex)) { + continue; + } + classesByName.put( + vertex, + ClassNodeDTO.builder() + .fqn(vertex) + .id(HtmlReport.renderSafeNodeId(vertex, classGraph, codebaseGraphDTO)) + .label( + HtmlReport.isAnonymousFqn(vertex) + ? HtmlReport.anonymousOwnerLabel(vertex, codebaseGraphDTO) + : SimpleHtmlReport.getClassName(vertex)) + .path(classSourcePath(vertex, codebaseGraphDTO)) + .markedForRemoval(classesToRemove.contains(VertexInfo.forName(vertex))) + .build()); + } + + List edgeDtos = new ArrayList<>(); + for (DefaultWeightedEdge edge : edges) { + String source = classGraph.getEdgeSource(edge).getName(); + String target = classGraph.getEdgeTarget(edge).getName(); + if (!classesByName.containsKey(source) || !classesByName.containsKey(target)) { + continue; // edge touches a suppressed sink-only anonymous/synthetic vertex + } + edgeDtos.add(EdgeDTO.builder() + .source(classesByName.get(source).getId()) + .target(classesByName.get(target).getId()) + .weight((int) classGraph.getEdgeWeight(edge)) + .markedForRemoval(classRelationshipsToRemove.contains(edge)) + .build()); + } + + Map packages = new HashMap<>(); + for (ClassNodeDTO cls : classesByName.values()) { + packageNode(packages, packageOf(cls.getFqn())).classes.add(cls); + } + if (includeAllBreakUpPackages) { + // Guarantee the class map flags the full break-up list even when a package's + // classes do not render: seed its cluster so pruning and collapse keep it. + for (VertexInfo packageToRemove : packagesToRemove) { + String name = packageToRemove.getName(); + if (!DEFAULT_PACKAGE.equals(name)) { + packageNode(packages, name); + } + } + } + + return assemble(packages, edgeDtos, packagesToRemove); + } + + /** + * Derives the owning package of a class vertex name. Nested ({@code $}) classes belong to + * the package of their name's last dot segment; Kotlin anonymous classes belong to the + * package of their enclosing class; packageless names have no package ({@code null}). + */ + static String packageOf(String fqn) { + int lastDot = fqn.lastIndexOf('.'); + String simple = lastDot >= 0 ? fqn.substring(lastDot + 1) : fqn; + if (simple.startsWith("<")) { + // Kotlin anonymous: the enclosing construct is a class, e.g. pkg.Outer + if (lastDot <= 0) { + return null; + } + String enclosing = fqn.substring(0, lastDot); + int enclosingDot = enclosing.lastIndexOf('.'); + return enclosingDot > 0 ? enclosing.substring(0, enclosingDot) : null; + } + return lastDot > 0 ? fqn.substring(0, lastDot) : null; + } + + /** Returns the source file path relative to the project root, or an empty string. */ + private static String classSourcePath(String vertex, CodebaseGraphDTO codebaseGraphDTO) { + if (codebaseGraphDTO == null || codebaseGraphDTO.getClassToSourceFilePathMapping() == null) { + return ""; + } + String path = codebaseGraphDTO.getClassToSourceFilePathMapping().get(vertex); + return path == null ? "" : path; + } + + /** Creates (or returns) the package node for the name, creating and linking all ancestors. */ + private static Pkg packageNode(Map packages, String packageName) { + String name = packageName == null || packageName.isEmpty() ? DEFAULT_PACKAGE : packageName; + Pkg existing = packages.get(name); + if (existing != null) { + return existing; + } + Pkg pkg = new Pkg(name); + packages.put(name, pkg); + String parent = parentPackage(name); + if (parent != null) { + packageNode(packages, parent).children.add(pkg); + } + return pkg; + } + + /** Returns the parent package name, or {@code null} for top-level packages and the default bucket. */ + private static String parentPackage(String name) { + if (DEFAULT_PACKAGE.equals(name)) { + return null; + } + int dot = name.lastIndexOf('.'); + return dot > 0 ? name.substring(0, dot) : null; + } + + /** + * Flags breakup packages, prunes empty branches, collapses single-child chains, sorts every + * collection deterministically, and converts the internal tree to the DTO tree. + */ + private static HierarchyDTO assemble( + Map packages, List edges, Set packagesToRemove) { + for (Pkg pkg : packages.values()) { + if (!DEFAULT_PACKAGE.equals(pkg.name)) { + pkg.needsBreakUp = packagesToRemove.contains(VertexInfo.forName(pkg.name)); + } + } + + List roots = new ArrayList<>(); + for (Pkg pkg : packages.values()) { + if (parentPackage(pkg.name) == null) { + Pkg pruned = pruneAndCollapse(pkg); + if (pruned != null) { + roots.add(toDto(pruned)); + } + } + } + roots.sort(Comparator.comparing(PackageNodeDTO::getName)); + + edges.sort(Comparator.comparing(EdgeDTO::getSource).thenComparing(EdgeDTO::getTarget)); + return HierarchyDTO.builder().packages(roots).edges(edges).build(); + } + + /** + * Prunes branches without rendered content and collapses single-child chains: a package + * with no content of its own and exactly one kept child is replaced by that child so chains + * like {@code com -> com.foo -> com.foo.bar} render as a single cluster labelled with the + * deepest package name. + */ + private static Pkg pruneAndCollapse(Pkg pkg) { + List kept = new ArrayList<>(); + for (Pkg child : pkg.children) { + Pkg pruned = pruneAndCollapse(child); + if (pruned != null) { + kept.add(pruned); + } + } + // A flagged package is content of its own: it must never be pruned away for being + // empty, nor merged into its single child — the class map must keep listing every + // break-up package so its list matches the package map's. + boolean hasContent = pkg.renderAsNode || !pkg.classes.isEmpty() || pkg.needsBreakUp; + if (!hasContent && kept.isEmpty()) { + return null; // nothing rendered at or below this package + } + if (!hasContent && kept.size() == 1) { + return kept.get(0); // pure ancestor of a single child: merge into the child + } + pkg.children.clear(); + pkg.children.addAll(kept); + return pkg; + } + + /** Converts a pruned package subtree to the DTO tree with sorted children and classes. */ + private static PackageNodeDTO toDto(Pkg pkg) { + List children = new ArrayList<>(); + for (Pkg child : pkg.children) { + children.add(toDto(child)); + } + children.sort(Comparator.comparing(PackageNodeDTO::getName)); + + List classes = new ArrayList<>(pkg.classes); + classes.sort(Comparator.comparing(ClassNodeDTO::getFqn)); + + return PackageNodeDTO.builder() + .name(pkg.name) + .id(clusterId(pkg.name)) + .nodeId(pkg.nodeId) + .renderAsNode(pkg.renderAsNode) + .needsBreakUp(pkg.needsBreakUp) + .children(children) + .classes(classes) + .build(); + } + + /** Returns the deterministic DOT-safe cluster id {@code cluster_} for a package name. */ + static String clusterId(String packageName) { + try { + byte[] digest = MessageDigest.getInstance("SHA-256").digest(packageName.getBytes(StandardCharsets.UTF_8)); + return "cluster_" + HexFormat.of().formatHex(digest, 0, 12); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException("SHA-256 is required by the Java runtime", e); + } + } + + /** Mutable internal package tree node used while assembling the hierarchy. */ + private static final class Pkg { + final String name; + final List children = new ArrayList<>(); + final List classes = new ArrayList<>(); + String nodeId; + boolean renderAsNode; + boolean needsBreakUp; + + Pkg(String name) { + this.name = name; + } + } +} diff --git a/report/src/main/java/org/hjug/refactorfirst/report/HtmlReport.java b/report/src/main/java/org/hjug/refactorfirst/report/HtmlReport.java index 6f6c3b34..c9472101 100644 --- a/report/src/main/java/org/hjug/refactorfirst/report/HtmlReport.java +++ b/report/src/main/java/org/hjug/refactorfirst/report/HtmlReport.java @@ -10,6 +10,7 @@ import org.hjug.gdg.GraphDataGenerator; import org.hjug.graphbuilder.CodebaseGraphDTO; import org.hjug.graphbuilder.VertexInfo; +import org.hjug.refactorfirst.report.model.HierarchyDTO; import org.jgrapht.Graph; import org.jgrapht.graph.DefaultWeightedEdge; @@ -406,6 +407,8 @@ public String printHead() { + "\n" + "\n" + "\n" + // package graph renderer - the only engine honoring compound cluster edges + + "\n" // Make the output look decent. Don't use in RefactorFirstMavenReport. + "\n"; } @@ -563,30 +566,41 @@ private static String generateDotImage(String graphName) { + " }\n" + "\n"; } - /** Builds raw DOT source for the complete class relationship graph. */ + /** + * Renders the package graph's compound DOT (parents as clusters, childless packages as + * rectangles, ltail/lhead cluster edges) through the pinned @hpcc-js/wasm-graphviz + * module — vizdom ignores compound edge attributes, so the package graph needs the + * real Graphviz engine. + */ + private static String generatePackageGraphImage(String graphName) { + return "

\n" + + "\n"; + } + + /** + * Builds raw DOT source for the complete class relationship graph as nested package + * clusters containing the classes that participate in at least one relationship. + */ String buildRawClassGraphDot( Graph classGraph, String repoUrl, CodebaseGraphDTO codebaseGraphDTO) { - StringBuilder dot = new StringBuilder(); - dot.append("strict digraph G {\n"); - - for (DefaultWeightedEdge edge : classGraph.edgeSet()) { - renderClassGraphEdge(classGraph, edge, codebaseGraphDTO, dot); - } - - // capture only classes that have a relationship with one or more other classes - Set vertexesToRender = new HashSet<>(); - for (DefaultWeightedEdge edge : classGraph.edgeSet()) { - String[] vertexes = extractVertexes(edge); - String originVertex = vertexes[0].trim(); - vertexesToRender.add(originVertex); - vertexesToRender.add(vertexes[1].trim()); - } - - // render vertices - renderClassVertices(classGraph, repoUrl, codebaseGraphDTO, vertexesToRender, dot); - - dot.append("}"); - return dot.toString(); + HierarchyDTO hierarchy = HierarchyBuilder.buildClassHierarchy( + classGraph, codebaseGraphDTO, classesToRemove, packagesToRemove, classRelationshipsToRemove); + return NestedDotBuilder.buildNestedDot(hierarchy, repoUrl, true); } /** Builds escaped class-graph DOT suitable for a JavaScript template literal. */ @@ -595,64 +609,6 @@ String buildClassGraphDot( return toJavaScriptTemplateLiteral(buildRawClassGraphDot(classGraph, repoUrl, codebaseGraphDTO)); } - /** - * Appends class DOT nodes, omitting sink-only anonymous or synthetic vertices and labels that - * match their node IDs. Includes source-link attributes supplied by the report and highlights - * classes selected for removal. - */ - private void renderClassVertices( - Graph classGraph, - String repoUrl, - CodebaseGraphDTO codebaseGraphDTO, - Set vertexesToRender, - StringBuilder dot) { - for (String vertex : vertexesToRender) { - String className = getClassName(vertex); - - // Suppress sink-only anonymous/synthetic vertices (no outgoing edges) so the DOT graph - // stays readable; active anonymous/synthetic classes still render. - if (isSinkAnonymousOrSyntheticVertex(classGraph, vertex)) { - continue; - } - - String safeNodeId = renderSafeNodeId(vertex, classGraph, codebaseGraphDTO); - dot.append(safeNodeId); - - dot.append(" ["); - dot.append(hyperlinkClassForDot(vertex, repoUrl, codebaseGraphDTO)); - - String label; - if (isAnonymousFqn(vertex)) { - // Kotlin "" renders under the enclosing source file's base name as the - // owner with $ as the enclosing-class separator (escaped for DOT). - label = escapeDotQuoted(anonymousOwnerLabel(vertex, codebaseGraphDTO)); - } else { - label = escapeDotQuoted(className); - } - - if (!label.equals(safeNodeId)) { - dot.append(" label=\"").append(label).append("\""); - } - - if (classesToRemove.contains(VertexInfo.forName(vertex))) { - dot.append(" color=red style=filled"); - } - - dot.append("];\n"); - } - } - - String hyperlinkClassForDot(String fqClassName, String repoUrl, CodebaseGraphDTO codebaseGraphDTO) { - if (codebaseGraphDTO == null || codebaseGraphDTO.getClassToSourceFilePathMapping() == null) { - return ""; - } - String path = codebaseGraphDTO.getClassToSourceFilePathMapping().get(fqClassName); - if (path == null || path.isBlank()) { - return ""; - } - return "URL=\"" + escapeDotQuoted(repoUrl + path) + "\" target=\"_blank\""; - } - /** * Returns the DOT-safe node id for a class vertex. This is the fully qualified class name * with {@code .} replaced by {@code _} and {@code $} replaced by {@code _} (for inner classes), @@ -666,7 +622,7 @@ String hyperlinkClassForDot(String fqClassName, String repoUrl, CodebaseGraphDTO * @param vertex the fully qualified (or literal) class vertex name * @return a deterministic DOT-legal node id derived from the fully qualified class name */ - String renderSafeNodeId(String vertex) { + static String renderSafeNodeId(String vertex) { // Full FQN with dots→underscores for uniqueness across packages (Option A) // $ -> _ (Java inner/anonymous convention) // < -> lt_ and > -> _gt (Kotlin literal) @@ -726,7 +682,7 @@ String renderSafeNodeId(String vertex, Graph cl * @param codebaseGraphDTO optional DTO for source-aware anonymous vertex IDs * @return a deterministic DOT-legal node id for the vertex */ - String renderSafeNodeId( + static String renderSafeNodeId( String vertex, Graph classGraph, CodebaseGraphDTO codebaseGraphDTO) { // Special handling for anonymous/synthetic vertices if (isAnonymousFqn(vertex)) { @@ -782,7 +738,7 @@ String renderSafeNodeId( * {@code DeveloperWASDControl}. Returns {@code null} when the vertex has no mapped source * path or {@code codebaseGraphDTO == null}. */ - private String enclosingSourceFileBaseName(String vertex, CodebaseGraphDTO codebaseGraphDTO) { + private static String enclosingSourceFileBaseName(String vertex, CodebaseGraphDTO codebaseGraphDTO) { if (codebaseGraphDTO == null) { return null; } @@ -813,7 +769,7 @@ private String enclosingSourceFileBaseName(String vertex, CodebaseGraphDTO codeb * e.g. {@code DeveloperWASDControl$anonymous} -> {@code DeveloperWASDControl\$anonymous}. * Falls back to the raw class name (e.g. {@code }) when no owner is recoverable. */ - private String anonymousOwnerLabel(String vertex, CodebaseGraphDTO codebaseGraphDTO) { + static String anonymousOwnerLabel(String vertex, CodebaseGraphDTO codebaseGraphDTO) { String owner = enclosingSourceFileBaseName(vertex, codebaseGraphDTO); String simple = getClassName(vertex); if (owner != null) { @@ -892,50 +848,6 @@ private static String simpleNameAfterLastDollar(String vertex) { return dollar >= 0 ? afterDot.substring(dollar + 1) : afterDot; } - /** Appends a weighted class edge to DOT, highlighting removals and suppressing edges to hidden sink vertices. */ - private void renderClassGraphEdge( - Graph classGraph, - DefaultWeightedEdge edge, - CodebaseGraphDTO codebaseGraphDTO, - StringBuilder dot) { - // render edge - String[] vertexes = extractVertexes(edge); - - String startVertex = vertexes[0].trim(); - String start = renderSafeNodeId(startVertex, classGraph, codebaseGraphDTO); - String endVertex = vertexes[1].trim(); - String end = renderSafeNodeId(endVertex, classGraph, codebaseGraphDTO); - - // Suppress edges that touch a sink-only anonymous/synthetic vertex; the vertex itself is - // skipped in renderClassVertices, so an edge pointing at (or from) it would dangle. - if (isSinkAnonymousOrSyntheticVertex(classGraph, startVertex) - || isSinkAnonymousOrSyntheticVertex(classGraph, endVertex)) { - log.debug("Skipping edge touching a sink anonymous/synthetic vertex: {} -> {}", startVertex, endVertex); - return; - } - - log.debug("Rendering edge: {} -> {}", startVertex, endVertex); - dot.append(start); - dot.append(" -> "); - dot.append(end); - - // render edge attributes - int edgeWeight = (int) classGraph.getEdgeWeight(edge); - dot.append(" [ "); - dot.append("label = \""); - dot.append(edgeWeight); - dot.append("\" "); - dot.append("weight = \""); - dot.append(edgeWeight); - dot.append("\""); - - if (classRelationshipsToRemove.contains(edge)) { - dot.append(" color = \"red\""); - } - - dot.append(" ];\n"); - } - @Override public String renderClassCycleVisuals(RankedCycle cycle, String repoUrl, CodebaseGraphDTO codebaseGraphDTO) { String dot = buildClassCycleDot(classGraph, cycle, repoUrl, codebaseGraphDTO); @@ -963,25 +875,18 @@ public String renderClassCycleVisuals(RankedCycle cycle, String repoUrl, Codebas return stringBuilder.toString(); } - /** Builds raw DOT source for a ranked class cycle. */ + /** + * Builds raw DOT source for a ranked class cycle as nested package clusters, consulting the + * full class graph for the cycle members' node ids. + */ String buildRawClassCycleDot( Graph classGraph, RankedCycle cycle, String repoUrl, CodebaseGraphDTO codebaseGraphDTO) { - StringBuilder dot = new StringBuilder(); - dot.append("strict digraph G {\n"); - - for (DefaultWeightedEdge edge : cycle.getEdgeSet()) { - renderClassGraphEdge(classGraph, edge, codebaseGraphDTO, dot); - } - - // render vertices - Set vertexSet = cycle.getVertexSet(); - renderClassVertices(classGraph, repoUrl, codebaseGraphDTO, vertexSet, dot); - - dot.append("}"); - return dot.toString(); + HierarchyDTO hierarchy = HierarchyBuilder.buildCycleHierarchy( + classGraph, codebaseGraphDTO, classesToRemove, packagesToRemove, classRelationshipsToRemove, cycle); + return NestedDotBuilder.buildNestedDot(hierarchy, repoUrl, true); } /** Builds escaped class-cycle DOT suitable for a JavaScript template literal. */ @@ -1018,7 +923,7 @@ public String renderPackageGraphVisuals(String repoUrl, CodebaseGraphDTO codebas .append(relationshipCount) .append("
"); if (packageCount + relationshipCount < dotGraphThreshold) { - stringBuilder.append(generateDotImage(packageGraphName)); + stringBuilder.append(generatePackageGraphImage(packageGraphName)); } else { // revisit and add DOT SVG popup button stringBuilder.append("
\nSVG is too big to render quickly
\n"); @@ -1027,29 +932,18 @@ public String renderPackageGraphVisuals(String repoUrl, CodebaseGraphDTO codebas return stringBuilder.toString(); } - /** Builds raw DOT source for the package relationship graph. */ + /** + * Builds raw compound DOT source for the package relationship graph — parents as + * labelled clusters, childless packages as rectangles, edges clipped to cluster + * borders — for rendering by @hpcc-js/wasm-graphviz. The {@code repoUrl} and + * {@code codebaseGraphDTO} parameters are retained for signature compatibility; + * package shapes carry no hyperlinks. + */ String buildRawPackageGraphDot( Graph packageGraph, String repoUrl, CodebaseGraphDTO codebaseGraphDTO) { - StringBuilder dot = new StringBuilder(); - dot.append("strict digraph G {\n"); - - for (DefaultWeightedEdge edge : packageGraph.edgeSet()) { - renderPackageGraphEdge(packageGraph, edge, dot); - } - - // capture only classes that have a relationship with one or more other classes - Set vertexesToRender = new HashSet<>(); - for (DefaultWeightedEdge edge : packageGraph.edgeSet()) { - String[] vertexes = extractVertexes(edge); - vertexesToRender.add(vertexes[0].trim()); - vertexesToRender.add(vertexes[1].trim()); - } - - // render vertices - renderPackageVertices(packageGraph, repoUrl, codebaseGraphDTO, vertexesToRender, dot); - - dot.append("}"); - return dot.toString(); + HierarchyDTO hierarchy = + HierarchyBuilder.buildPackageHierarchy(packageGraph, packagesToRemove, packageRelationshipsToRemove); + return NestedDotBuilder.buildPackageDot(hierarchy); } /** Builds escaped package-graph DOT suitable for a JavaScript template literal. */ @@ -1058,59 +952,6 @@ String buildPackageGraphDot( return toJavaScriptTemplateLiteral(buildRawPackageGraphDot(packageGraph, repoUrl, codebaseGraphDTO)); } - /** Appends a weighted package edge to DOT, using safe node IDs and highlighting selected removals. */ - private void renderPackageGraphEdge( - Graph packageGraph, DefaultWeightedEdge edge, StringBuilder dot) { - // render edge - String[] vertexes = extractVertexes(edge); - - String start = renderSafePackageNodeId(vertexes[0].trim()); - String end = renderSafePackageNodeId(vertexes[1].trim()); - - log.debug("Rendering edge: {} -> {}", start, end); - dot.append(start); - dot.append(" -> "); - dot.append(end); - - // render edge attributes - int edgeWeight = (int) packageGraph.getEdgeWeight(edge); - dot.append(" [ "); - dot.append("label = \""); - dot.append(edgeWeight); - dot.append("\" "); - dot.append("weight = \""); - dot.append(edgeWeight); - dot.append("\""); - - if (packageRelationshipsToRemove.contains(edge)) { - dot.append(" color = \"red\""); - } - - dot.append(" ];\n"); - } - - /** Appends escaped package nodes to DOT and highlights packages selected for removal. */ - private void renderPackageVertices( - Graph classGraph, - String repoUrl, - CodebaseGraphDTO codebaseGraphDTO, - Set vertexesToRender, - StringBuilder dot) { - for (String packageName : vertexesToRender) { - dot.append(renderSafePackageNodeId(packageName)); - - dot.append(" [label=\""); - dot.append(escapeDotQuoted(packageName)); - dot.append("\""); - - if (packagesToRemove.contains(VertexInfo.forName(packageName))) { - dot.append(" color=red style=filled"); - } - - dot.append("];\n"); - } - } - static String renderSafePackageNodeId(String packageName) { try { byte[] digest = MessageDigest.getInstance("SHA-256").digest(packageName.getBytes(StandardCharsets.UTF_8)); diff --git a/report/src/main/java/org/hjug/refactorfirst/report/JsonGenerator.java b/report/src/main/java/org/hjug/refactorfirst/report/JsonGenerator.java index 4fc81f7f..f9f997fa 100644 --- a/report/src/main/java/org/hjug/refactorfirst/report/JsonGenerator.java +++ b/report/src/main/java/org/hjug/refactorfirst/report/JsonGenerator.java @@ -82,17 +82,6 @@ public void execute( // copyViewerResources(dotRefactorFirstDir); } - /** - * Intentionally excluding hyperlinks in DOT to reduce JSON file size - * - * @param fqClassName The fully qualified class name - * @param repoUrl The repo URL - * @param codebaseGraphDTO The DTO containing the codebase graph - */ - String hyperlinkClassForDot(String fqClassName, String repoUrl, CodebaseGraphDTO codebaseGraphDTO) { - return ""; - } - /** Copies the Mustache template and browser viewer into the report directory. */ private void copyViewerResources(Path targetDir) { try { @@ -250,14 +239,15 @@ public RefactorFirstReportDTO generateReportData( // 1. Class Map int classCount = classGraph.vertexSet().size(); int classRelationshipCount = classGraph.edgeSet().size(); - String classGraphDot = buildRawClassGraphDot(classGraph, repoUrl, codebaseGraphDTO); + HierarchyDTO classGraphHierarchy = HierarchyBuilder.buildClassHierarchy( + classGraph, codebaseGraphDTO, classesToRemove, packagesToRemove, classRelationshipsToRemove); GraphVisualDTO classMapDTO = GraphVisualDTO.builder() .graphId("classGraph") .classCount(classCount) .relationshipCount(classRelationshipCount) .dotThreshold(dotGraphThreshold) .dotThresholdExceeded(classCount + classRelationshipCount >= dotGraphThreshold) - .dot(classGraphDot) + .hierarchy(classGraphHierarchy) .build(); // 2. Class Relationships To Remove @@ -296,8 +286,8 @@ public RefactorFirstReportDTO generateReportData( int packageCount = packageGraph.vertexSet().size(); int packageRelationshipCount = packageGraph.edgeSet().size(); boolean hasPackageEdges = !packageGraph.edgeSet().isEmpty(); - String packageGraphDot = - hasPackageEdges ? buildRawPackageGraphDot(packageGraph, repoUrl, codebaseGraphDTO) : ""; + HierarchyDTO packageGraphHierarchy = HierarchyBuilder.buildPackageHierarchy( + packageGraph, packagesToRemove, packageRelationshipsToRemove); GraphVisualDTO packageMapDTO = GraphVisualDTO.builder() .hasEdges(hasPackageEdges) .graphId("packageGraph") @@ -305,7 +295,7 @@ public RefactorFirstReportDTO generateReportData( .relationshipCount(packageRelationshipCount) .dotThreshold(dotGraphThreshold) .dotThresholdExceeded(packageCount + packageRelationshipCount >= dotGraphThreshold) - .dot(packageGraphDot) + .hierarchy(packageGraphHierarchy) .build(); // 4. Package Relationships To Remove @@ -429,7 +419,13 @@ public RefactorFirstReportDTO generateReportData( String cycleIdentifier = graphIdentifier(cycleName); int cCount = largestCycle.getCycleNodes().size(); int rCount = largestCycle.getEdgeSet().size(); - String cycleDot = buildRawClassCycleDot(classGraph, largestCycle, repoUrl, codebaseGraphDTO); + HierarchyDTO cycleHierarchy = HierarchyBuilder.buildCycleHierarchy( + classGraph, + codebaseGraphDTO, + classesToRemove, + packagesToRemove, + classRelationshipsToRemove, + largestCycle); List breakdown = new ArrayList<>(); for (String vertex : largestCycle.getVertexSet()) { @@ -466,7 +462,7 @@ public RefactorFirstReportDTO generateReportData( .classCount(cCount) .relationshipCount(rCount) .dotThresholdExceeded(cCount + rCount >= dotGraphThreshold) - .dot(cycleDot) + .hierarchy(cycleHierarchy) .breakdown(breakdown) .build(); } diff --git a/report/src/main/java/org/hjug/refactorfirst/report/NestedDotBuilder.java b/report/src/main/java/org/hjug/refactorfirst/report/NestedDotBuilder.java new file mode 100644 index 00000000..0c8a0504 --- /dev/null +++ b/report/src/main/java/org/hjug/refactorfirst/report/NestedDotBuilder.java @@ -0,0 +1,229 @@ +package org.hjug.refactorfirst.report; + +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import org.hjug.refactorfirst.report.model.ClassNodeDTO; +import org.hjug.refactorfirst.report.model.EdgeDTO; +import org.hjug.refactorfirst.report.model.HierarchyDTO; +import org.hjug.refactorfirst.report.model.PackageNodeDTO; + +/** + * Renders a {@link HierarchyDTO} as nested DOT: packages become labelled clusters (rectangles), + * package-map participants become rectangle nodes inside their ancestor clusters, and classes + * become ellipse nodes inside their owning package cluster. + * + *

Visual contract: classes marked for removal keep the {@code color=red style=filled} fill; + * packages that need to be broken up render pink — clusters via {@code style=filled + * fillcolor=pink}, package nodes via {@code color=pink style=filled} — replacing the red + * previously used for packages. + * + *

Clusters are emitted before the edges so every node keeps its cluster scope: in DOT a node + * belongs to the cluster where it is first defined, and edges declared afterwards may freely + * connect nodes across clusters. Edges keep the exact attribute format of the previous emitters + * and reference the ids pre-resolved in the hierarchy. + */ +public final class NestedDotBuilder { + + private NestedDotBuilder() {} + + /** + * Renders the hierarchy as nested DOT. + * + * @param hierarchy the nested package/class hierarchy + * @param repoUrl repository URL prefix for class hyperlinks + * @param includeHyperlinks whether class nodes carry {@code URL} attributes + * @return DOT source for a strict digraph whose packages nest as clusters + */ + public static String buildNestedDot(HierarchyDTO hierarchy, String repoUrl, boolean includeHyperlinks) { + StringBuilder dot = new StringBuilder(); + dot.append("strict digraph G {\n"); + if (hierarchy != null) { + if (hierarchy.getPackages() != null) { + for (PackageNodeDTO pkg : hierarchy.getPackages()) { + renderPackage(pkg, repoUrl, includeHyperlinks, dot); + } + } + if (hierarchy.getEdges() != null) { + for (EdgeDTO edge : hierarchy.getEdges()) { + renderEdge(edge, dot); + } + } + } + dot.append("}"); + return dot.toString(); + } + + /** + * Renders the package-map hierarchy as DOT for @hpcc-js/wasm-graphviz (the only renderer + * that honors compound cluster edges): packages with children render once as labelled + * clusters, childless packages render once as filled rectangles, and edges attach to + * cluster-represented packages through invisible anchor nodes clipped to the cluster + * borders via {@code ltail}/{@code lhead} under {@code compound=true}. + * + * @param hierarchy the package-map hierarchy + * @return DOT source for a strict compound digraph + */ + public static String buildPackageDot(HierarchyDTO hierarchy) { + StringBuilder dot = new StringBuilder(); + dot.append("strict digraph G {\n"); + dot.append("compound=true;\n"); + if (hierarchy != null) { + List packages = hierarchy.getPackages() == null ? List.of() : hierarchy.getPackages(); + List edges = hierarchy.getEdges() == null ? List.of() : hierarchy.getEdges(); + + Set referencedIds = new HashSet<>(); + Map packagesByNodeId = new HashMap<>(); + for (EdgeDTO edge : edges) { + referencedIds.add(edge.getSource()); + referencedIds.add(edge.getTarget()); + } + indexPackagesByNodeId(packages, packagesByNodeId); + + for (PackageNodeDTO pkg : packages) { + renderPackageGraphPackage(pkg, referencedIds, dot); + } + for (EdgeDTO edge : edges) { + renderPackageGraphEdge(edge, packagesByNodeId, dot); + } + } + dot.append("}"); + return dot.toString(); + } + + /** Indexes the tree's packages by their node ids so edges can resolve cluster endpoints. */ + private static void indexPackagesByNodeId(List packages, Map byNodeId) { + for (PackageNodeDTO pkg : packages) { + if (pkg.getNodeId() != null) { + byNodeId.put(pkg.getNodeId(), pkg); + } + indexPackagesByNodeId(pkg.getChildren() == null ? List.of() : pkg.getChildren(), byNodeId); + } + } + /** + * Appends one package-map shape: childless packages as filled rectangles, packages with + * children as labelled clusters whose invisible anchor is emitted only when an edge + * references it. + */ + private static void renderPackageGraphPackage(PackageNodeDTO pkg, Set referencedIds, StringBuilder dot) { + if (pkg.isRenderAsNode()) { + dot.append(pkg.getNodeId()) + .append(" [label=\"") + .append(HtmlReport.escapeDotQuoted(pkg.getName())) + .append("\" shape=rectangle style=filled fillcolor=") + .append(pkg.isNeedsBreakUp() ? "pink" : "white") + .append("];\n"); + return; + } + dot.append("subgraph ").append(pkg.getId()).append(" {\n"); + dot.append("label=\"").append(HtmlReport.escapeDotQuoted(pkg.getName())).append("\";\n"); + dot.append("style=filled fillcolor=") + .append(pkg.isNeedsBreakUp() ? "pink" : "white") + .append(";\n"); + if (pkg.getNodeId() != null && referencedIds.contains(pkg.getNodeId())) { + dot.append(pkg.getNodeId()).append(" [shape=point style=invis];\n"); + } + List children = pkg.getChildren() == null ? List.of() : pkg.getChildren(); + for (PackageNodeDTO child : children) { + renderPackageGraphPackage(child, referencedIds, dot); + } + dot.append("}\n"); + } + + /** + * Appends one package relationship, clipping at the cluster border of any endpoint + * whose package is cluster-represented. + */ + private static void renderPackageGraphEdge( + EdgeDTO edge, Map packagesByNodeId, StringBuilder dot) { + dot.append(edge.getSource()).append(" -> ").append(edge.getTarget()); + dot.append(" [ "); + dot.append("label = \"").append(edge.getWeight()).append("\" "); + dot.append("weight = \"").append(edge.getWeight()).append("\""); + if (edge.isMarkedForRemoval()) { + dot.append(" color = \"red\""); + } + PackageNodeDTO source = packagesByNodeId.get(edge.getSource()); + if (source != null && !source.isRenderAsNode()) { + dot.append(" ltail = \"").append(source.getId()).append("\""); + } + PackageNodeDTO target = packagesByNodeId.get(edge.getTarget()); + if (target != null && !target.isRenderAsNode()) { + dot.append(" lhead = \"").append(target.getId()).append("\""); + } + dot.append(" ];\n"); + } + + /** Appends one package cluster: label, explicit fill, its node, its classes, and children. */ + private static void renderPackage( + PackageNodeDTO pkg, String repoUrl, boolean includeHyperlinks, StringBuilder dot) { + dot.append("subgraph ").append(pkg.getId()).append(" {\n"); + dot.append("label=\"").append(HtmlReport.escapeDotQuoted(pkg.getName())).append("\";\n"); + // Every cluster declares an explicit fill: vizdom propagates a styled cluster's fill + // to its nested cluster rects, so an unflagged child of a pink break-up cluster must + // say white explicitly or it would inherit the pink. White is vizdom's default + // cluster fill, so unflagged clusters look unchanged. In the package map the pink + // signal belongs to the rectangle node, not its own cluster box. + if (pkg.isNeedsBreakUp() && !pkg.isRenderAsNode()) { + dot.append("style=filled fillcolor=pink;\n"); + } else { + dot.append("style=filled fillcolor=white;\n"); + } + if (pkg.isRenderAsNode()) { + dot.append(pkg.getNodeId()) + .append(" [label=\"") + .append(HtmlReport.escapeDotQuoted(pkg.getName())) + .append("\" shape=rectangle"); + if (pkg.isNeedsBreakUp()) { + dot.append(" color=pink style=filled"); + } + dot.append("];\n"); + } + List classes = pkg.getClasses() == null ? List.of() : pkg.getClasses(); + for (ClassNodeDTO cls : classes) { + renderClassNode(cls, repoUrl, includeHyperlinks, dot); + } + List children = pkg.getChildren() == null ? List.of() : pkg.getChildren(); + for (PackageNodeDTO child : children) { + renderPackage(child, repoUrl, includeHyperlinks, dot); + } + dot.append("}\n"); + } + + /** Appends one class node: hyperlink, label, ellipse shape, and the red removal fill. */ + private static void renderClassNode( + ClassNodeDTO cls, String repoUrl, boolean includeHyperlinks, StringBuilder dot) { + dot.append(cls.getId()).append(" ["); + if (includeHyperlinks && cls.getPath() != null && !cls.getPath().isBlank()) { + String url = (repoUrl == null ? "" : repoUrl) + cls.getPath(); + dot.append("URL=\"") + .append(HtmlReport.escapeDotQuoted(url)) + .append("\"") + .append(" target=\"_blank\""); + } + if (!cls.getLabel().equals(cls.getId())) { + dot.append(" label=\"") + .append(HtmlReport.escapeDotQuoted(cls.getLabel())) + .append("\""); + } + dot.append(" shape=ellipse"); + if (cls.isMarkedForRemoval()) { + dot.append(" color=red style=filled"); + } + dot.append("];\n"); + } + + /** Appends one weighted edge, red when the relationship is marked for removal. */ + private static void renderEdge(EdgeDTO edge, StringBuilder dot) { + dot.append(edge.getSource()).append(" -> ").append(edge.getTarget()); + dot.append(" [ "); + dot.append("label = \"").append(edge.getWeight()).append("\" "); + dot.append("weight = \"").append(edge.getWeight()).append("\""); + if (edge.isMarkedForRemoval()) { + dot.append(" color = \"red\""); + } + dot.append(" ];\n"); + } +} 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 603c5fee..0688d1fc 100644 --- a/report/src/main/java/org/hjug/refactorfirst/report/SimpleHtmlReport.java +++ b/report/src/main/java/org/hjug/refactorfirst/report/SimpleHtmlReport.java @@ -1234,7 +1234,7 @@ String renderDisharmonyChart(String anchorId, String title, ListCarries the pre-resolved DOT node id and display label so renderers reuse the exact id + * logic of the previous DOT emitters, including source-aware ids for Kotlin anonymous classes. + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ClassNodeDTO { + + /** Fully qualified class name (or literal anonymous vertex name). */ + private String fqn; + + /** Pre-resolved DOT-safe node id from the existing safe-id rules. */ + private String id; + + /** Display label: the simple class name, or the anonymous owner label. */ + private String label; + + /** Source file path relative to the project root, or an empty string when unknown. */ + private String path; + + /** Whether the class was selected for removal to break cycles (red fill). */ + private boolean markedForRemoval; +} diff --git a/report/src/main/java/org/hjug/refactorfirst/report/model/EdgeDTO.java b/report/src/main/java/org/hjug/refactorfirst/report/model/EdgeDTO.java new file mode 100644 index 00000000..23a426ee --- /dev/null +++ b/report/src/main/java/org/hjug/refactorfirst/report/model/EdgeDTO.java @@ -0,0 +1,29 @@ +package org.hjug.refactorfirst.report.model; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * One graph edge in the nested hierarchy, referencing the pre-resolved DOT node ids of its + * endpoints (class node ids in the class/cycle graphs, package node ids in the package graph). + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class EdgeDTO { + + /** DOT node id of the edge source. */ + private String source; + + /** DOT node id of the edge target. */ + private String target; + + /** Edge weight (relationship count), rendered as the edge label. */ + private int weight; + + /** Whether the relationship was selected for removal to break cycles (red edge). */ + private boolean markedForRemoval; +} diff --git a/report/src/main/java/org/hjug/refactorfirst/report/model/GraphVisualDTO.java b/report/src/main/java/org/hjug/refactorfirst/report/model/GraphVisualDTO.java index 577c5d1f..66b20e31 100644 --- a/report/src/main/java/org/hjug/refactorfirst/report/model/GraphVisualDTO.java +++ b/report/src/main/java/org/hjug/refactorfirst/report/model/GraphVisualDTO.java @@ -15,6 +15,6 @@ public class GraphVisualDTO { private int relationshipCount; private int dotThreshold; private boolean dotThresholdExceeded; - private String dot; + private HierarchyDTO hierarchy; private boolean hasEdges; } diff --git a/report/src/main/java/org/hjug/refactorfirst/report/model/HierarchyDTO.java b/report/src/main/java/org/hjug/refactorfirst/report/model/HierarchyDTO.java new file mode 100644 index 00000000..4a071006 --- /dev/null +++ b/report/src/main/java/org/hjug/refactorfirst/report/model/HierarchyDTO.java @@ -0,0 +1,28 @@ +package org.hjug.refactorfirst.report.model; + +import java.util.List; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * Nested package/class hierarchy that replaces raw DOT in refactor-first.json and drives every + * nested package + class DOT graph. + * + *

Packages form a tree of clusters ({@link PackageNodeDTO}); classes ({@link ClassNodeDTO}) + * hang off their owning package; edges ({@link EdgeDTO}) reference the pre-resolved DOT node ids + * so renderers never re-derive id or label logic. + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class HierarchyDTO { + + /** Root package clusters in sorted order, with single-child chains collapsed. */ + private List packages; + + /** Edges referencing the resolved DOT node ids, sorted by source then target. */ + private List edges; +} diff --git a/report/src/main/java/org/hjug/refactorfirst/report/model/LargestCycleDTO.java b/report/src/main/java/org/hjug/refactorfirst/report/model/LargestCycleDTO.java index e6c55cee..2e782018 100644 --- a/report/src/main/java/org/hjug/refactorfirst/report/model/LargestCycleDTO.java +++ b/report/src/main/java/org/hjug/refactorfirst/report/model/LargestCycleDTO.java @@ -17,7 +17,7 @@ public class LargestCycleDTO { private String cycleIdentifier; private int classCount; private int relationshipCount; - private String dot; + private HierarchyDTO hierarchy; private boolean dotThresholdExceeded; @Builder.Default diff --git a/report/src/main/java/org/hjug/refactorfirst/report/model/PackageNodeDTO.java b/report/src/main/java/org/hjug/refactorfirst/report/model/PackageNodeDTO.java new file mode 100644 index 00000000..79ccb39d --- /dev/null +++ b/report/src/main/java/org/hjug/refactorfirst/report/model/PackageNodeDTO.java @@ -0,0 +1,47 @@ +package org.hjug.refactorfirst.report.model; + +import java.util.ArrayList; +import java.util.List; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * One package in the nested hierarchy. + * + *

Every emitted package is a rectangle: a labelled cluster in the class/cycle graphs, or a + * rectangle node ({@code renderAsNode}) nested inside its ancestor clusters in the package + * graph. Packages flagged with {@code needsBreakUp} are rendered with a pink fill rather than + * the red previously used, distinguishing package-level breakup candidates from classes marked + * for removal. + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class PackageNodeDTO { + + /** Package name (FQN), or the synthetic {@code (default package)} bucket. */ + private String name; + + /** Pre-resolved DOT-safe cluster id ({@code cluster_}), stable and collision-free. */ + private String id; + + /** Pre-resolved DOT node id when this package renders as a node; {@code null} otherwise. */ + private String nodeId; + + /** Whether this package is a rectangle node in the package graph. */ + private boolean renderAsNode; + + /** Whether the package was selected for removal to break cycles (pink fill). */ + private boolean needsBreakUp; + + /** Child package clusters, sorted by name, with single-child chains collapsed. */ + @Builder.Default + private List children = new ArrayList<>(); + + /** Classes owned by this package, sorted by FQN; empty in the package graph. */ + @Builder.Default + private List classes = new ArrayList<>(); +} diff --git a/report/src/main/resources/viewer/index.html b/report/src/main/resources/viewer/index.html index ef607130..dfe37d92 100644 --- a/report/src/main/resources/viewer/index.html +++ b/report/src/main/resources/viewer/index.html @@ -331,6 +331,126 @@

Loading RefactorFirst Report...

\n" + "\n" + "\n" - // package graph renderer - the only engine honoring compound cluster edges - + "\n" // Make the output look decent. Don't use in RefactorFirstMavenReport. + "\n"; } @@ -553,6 +572,8 @@ private static String generateDotImage(String graphName) { + graphName + "_dot);\n" + " const directedGraph = dotGraph.to_directed();\n" + " const positioned = directedGraph.layout();\n" + " let svg = positioned.to_svg().to_string();\n" + + " // vizdom drops the target attribute on hyperlinked nodes; restore new-tab behavior\n" + + " svg = svg.replace('\n"; } - /** - * Renders the package graph's compound DOT (parents as clusters, childless packages as - * rectangles, ltail/lhead cluster edges) through the pinned @hpcc-js/wasm-graphviz - * module — vizdom ignores compound edge attributes, so the package graph needs the - * real Graphviz engine. - */ - private static String generatePackageGraphImage(String graphName) { - return "
\n" - + "\n"; - } - /** * Builds raw DOT source for the complete class relationship graph as nested package * clusters containing the classes that participate in at least one relationship. @@ -923,7 +918,7 @@ public String renderPackageGraphVisuals(String repoUrl, CodebaseGraphDTO codebas .append(relationshipCount) .append("
"); if (packageCount + relationshipCount < dotGraphThreshold) { - stringBuilder.append(generatePackageGraphImage(packageGraphName)); + stringBuilder.append(generateDotImage(packageGraphName)); } else { // revisit and add DOT SVG popup button stringBuilder.append("
\nSVG is too big to render quickly
\n"); @@ -933,17 +928,17 @@ public String renderPackageGraphVisuals(String repoUrl, CodebaseGraphDTO codebas } /** - * Builds raw compound DOT source for the package relationship graph — parents as - * labelled clusters, childless packages as rectangles, edges clipped to cluster - * borders — for rendering by @hpcc-js/wasm-graphviz. The {@code repoUrl} and - * {@code codebaseGraphDTO} parameters are retained for signature compatibility; - * package shapes carry no hyperlinks. + * Builds raw DOT source for the package relationship graph: one ellipse node per + * participating package and the relationship edges between them — the main-branch + * package map, rendered by vizdom like the class and cycle graphs. The + * {@code repoUrl} and {@code codebaseGraphDTO} parameters are retained for + * signature compatibility; the full hierarchy stays in refactor-first.json. */ String buildRawPackageGraphDot( Graph packageGraph, String repoUrl, CodebaseGraphDTO codebaseGraphDTO) { - HierarchyDTO hierarchy = - HierarchyBuilder.buildPackageHierarchy(packageGraph, packagesToRemove, packageRelationshipsToRemove); - return NestedDotBuilder.buildPackageDot(hierarchy); + HierarchyDTO hierarchy = HierarchyBuilder.buildPackageHierarchy( + packageGraph, codebaseGraphDTO, packagesToRemove, packageRelationshipsToRemove); + return NestedDotBuilder.buildPackageDot(hierarchy, repoUrl); } /** Builds escaped package-graph DOT suitable for a JavaScript template literal. */ diff --git a/report/src/main/java/org/hjug/refactorfirst/report/JsonGenerator.java b/report/src/main/java/org/hjug/refactorfirst/report/JsonGenerator.java index f9f997fa..c33aa0d2 100644 --- a/report/src/main/java/org/hjug/refactorfirst/report/JsonGenerator.java +++ b/report/src/main/java/org/hjug/refactorfirst/report/JsonGenerator.java @@ -287,7 +287,7 @@ public RefactorFirstReportDTO generateReportData( int packageRelationshipCount = packageGraph.edgeSet().size(); boolean hasPackageEdges = !packageGraph.edgeSet().isEmpty(); HierarchyDTO packageGraphHierarchy = HierarchyBuilder.buildPackageHierarchy( - packageGraph, packagesToRemove, packageRelationshipsToRemove); + packageGraph, codebaseGraphDTO, packagesToRemove, packageRelationshipsToRemove); GraphVisualDTO packageMapDTO = GraphVisualDTO.builder() .hasEdges(hasPackageEdges) .graphId("packageGraph") diff --git a/report/src/main/java/org/hjug/refactorfirst/report/NestedDotBuilder.java b/report/src/main/java/org/hjug/refactorfirst/report/NestedDotBuilder.java index 0c8a0504..eae30882 100644 --- a/report/src/main/java/org/hjug/refactorfirst/report/NestedDotBuilder.java +++ b/report/src/main/java/org/hjug/refactorfirst/report/NestedDotBuilder.java @@ -1,24 +1,20 @@ package org.hjug.refactorfirst.report; -import java.util.HashMap; -import java.util.HashSet; import java.util.List; -import java.util.Map; -import java.util.Set; import org.hjug.refactorfirst.report.model.ClassNodeDTO; import org.hjug.refactorfirst.report.model.EdgeDTO; import org.hjug.refactorfirst.report.model.HierarchyDTO; import org.hjug.refactorfirst.report.model.PackageNodeDTO; /** - * Renders a {@link HierarchyDTO} as nested DOT: packages become labelled clusters (rectangles), - * package-map participants become rectangle nodes inside their ancestor clusters, and classes - * become ellipse nodes inside their owning package cluster. + * Renders a {@link HierarchyDTO} in two modes: the class/cycle maps as nested DOT (packages + * become labelled clusters, classes become ellipse nodes inside their owning package cluster), + * and the package map as the flat relationship graph — one ellipse per participating package + * with only the relationship edges, matching the main-branch package map. * - *

Visual contract: classes marked for removal keep the {@code color=red style=filled} fill; - * packages that need to be broken up render pink — clusters via {@code style=filled - * fillcolor=pink}, package nodes via {@code color=pink style=filled} — replacing the red - * previously used for packages. + *

Visual contract: classes marked for removal keep the {@code color=red style=filled} fill + * in the class/cycle maps; packages flagged for breakup fill red in the flat package map, + * and relationships flagged for removal render red everywhere. * *

Clusters are emitted before the edges so every node keeps its cluster scope: in DOT a node * belongs to the cluster where it is first defined, and edges declared afterwards may freely @@ -56,106 +52,6 @@ public static String buildNestedDot(HierarchyDTO hierarchy, String repoUrl, bool return dot.toString(); } - /** - * Renders the package-map hierarchy as DOT for @hpcc-js/wasm-graphviz (the only renderer - * that honors compound cluster edges): packages with children render once as labelled - * clusters, childless packages render once as filled rectangles, and edges attach to - * cluster-represented packages through invisible anchor nodes clipped to the cluster - * borders via {@code ltail}/{@code lhead} under {@code compound=true}. - * - * @param hierarchy the package-map hierarchy - * @return DOT source for a strict compound digraph - */ - public static String buildPackageDot(HierarchyDTO hierarchy) { - StringBuilder dot = new StringBuilder(); - dot.append("strict digraph G {\n"); - dot.append("compound=true;\n"); - if (hierarchy != null) { - List packages = hierarchy.getPackages() == null ? List.of() : hierarchy.getPackages(); - List edges = hierarchy.getEdges() == null ? List.of() : hierarchy.getEdges(); - - Set referencedIds = new HashSet<>(); - Map packagesByNodeId = new HashMap<>(); - for (EdgeDTO edge : edges) { - referencedIds.add(edge.getSource()); - referencedIds.add(edge.getTarget()); - } - indexPackagesByNodeId(packages, packagesByNodeId); - - for (PackageNodeDTO pkg : packages) { - renderPackageGraphPackage(pkg, referencedIds, dot); - } - for (EdgeDTO edge : edges) { - renderPackageGraphEdge(edge, packagesByNodeId, dot); - } - } - dot.append("}"); - return dot.toString(); - } - - /** Indexes the tree's packages by their node ids so edges can resolve cluster endpoints. */ - private static void indexPackagesByNodeId(List packages, Map byNodeId) { - for (PackageNodeDTO pkg : packages) { - if (pkg.getNodeId() != null) { - byNodeId.put(pkg.getNodeId(), pkg); - } - indexPackagesByNodeId(pkg.getChildren() == null ? List.of() : pkg.getChildren(), byNodeId); - } - } - /** - * Appends one package-map shape: childless packages as filled rectangles, packages with - * children as labelled clusters whose invisible anchor is emitted only when an edge - * references it. - */ - private static void renderPackageGraphPackage(PackageNodeDTO pkg, Set referencedIds, StringBuilder dot) { - if (pkg.isRenderAsNode()) { - dot.append(pkg.getNodeId()) - .append(" [label=\"") - .append(HtmlReport.escapeDotQuoted(pkg.getName())) - .append("\" shape=rectangle style=filled fillcolor=") - .append(pkg.isNeedsBreakUp() ? "pink" : "white") - .append("];\n"); - return; - } - dot.append("subgraph ").append(pkg.getId()).append(" {\n"); - dot.append("label=\"").append(HtmlReport.escapeDotQuoted(pkg.getName())).append("\";\n"); - dot.append("style=filled fillcolor=") - .append(pkg.isNeedsBreakUp() ? "pink" : "white") - .append(";\n"); - if (pkg.getNodeId() != null && referencedIds.contains(pkg.getNodeId())) { - dot.append(pkg.getNodeId()).append(" [shape=point style=invis];\n"); - } - List children = pkg.getChildren() == null ? List.of() : pkg.getChildren(); - for (PackageNodeDTO child : children) { - renderPackageGraphPackage(child, referencedIds, dot); - } - dot.append("}\n"); - } - - /** - * Appends one package relationship, clipping at the cluster border of any endpoint - * whose package is cluster-represented. - */ - private static void renderPackageGraphEdge( - EdgeDTO edge, Map packagesByNodeId, StringBuilder dot) { - dot.append(edge.getSource()).append(" -> ").append(edge.getTarget()); - dot.append(" [ "); - dot.append("label = \"").append(edge.getWeight()).append("\" "); - dot.append("weight = \"").append(edge.getWeight()).append("\""); - if (edge.isMarkedForRemoval()) { - dot.append(" color = \"red\""); - } - PackageNodeDTO source = packagesByNodeId.get(edge.getSource()); - if (source != null && !source.isRenderAsNode()) { - dot.append(" ltail = \"").append(source.getId()).append("\""); - } - PackageNodeDTO target = packagesByNodeId.get(edge.getTarget()); - if (target != null && !target.isRenderAsNode()) { - dot.append(" lhead = \"").append(target.getId()).append("\""); - } - dot.append(" ];\n"); - } - /** Appends one package cluster: label, explicit fill, its node, its classes, and children. */ private static void renderPackage( PackageNodeDTO pkg, String repoUrl, boolean includeHyperlinks, StringBuilder dot) { @@ -164,8 +60,7 @@ private static void renderPackage( // Every cluster declares an explicit fill: vizdom propagates a styled cluster's fill // to its nested cluster rects, so an unflagged child of a pink break-up cluster must // say white explicitly or it would inherit the pink. White is vizdom's default - // cluster fill, so unflagged clusters look unchanged. In the package map the pink - // signal belongs to the rectangle node, not its own cluster box. + // cluster fill, so unflagged clusters look unchanged. if (pkg.isNeedsBreakUp() && !pkg.isRenderAsNode()) { dot.append("style=filled fillcolor=pink;\n"); } else { @@ -215,6 +110,83 @@ private static void renderClassNode( dot.append("];\n"); } + /** + * Renders the package-map hierarchy as the flat relationship graph: every + * package that participates in at least one relationship (or was selected for + * removal to break cycles) renders once as an ellipse node carrying its name, + * and the edges carry only the relationship weights — matching the + * main-branch package map, rendered by vizdom like the class and cycle maps. + * Packages flagged {@code needsBreakUp} fill red; relationships flagged for + * removal render red. Packages with a source path also carry a hyperlink — + * {@code URL} + {@code target="_blank"} — built from the repository URL + * prefix; only http(s) URLs are ever emitted. The full hierarchy stays in + * refactor-first.json for LLM consumption; only the relationship information + * renders. + * + * @param hierarchy the package-map hierarchy + * @param repoUrl repository URL prefix for package hyperlinks + * @return DOT source for a strict digraph of ellipse package nodes and edges + */ + public static String buildPackageDot(HierarchyDTO hierarchy, String repoUrl) { + StringBuilder dot = new StringBuilder(); + dot.append("strict digraph G {\n"); + if (hierarchy != null) { + List packages = hierarchy.getPackages() == null ? List.of() : hierarchy.getPackages(); + renderFlatPackageNodes(packages, repoUrl, dot); + List edges = hierarchy.getEdges() == null ? List.of() : hierarchy.getEdges(); + for (EdgeDTO edge : edges) { + renderEdge(edge, dot); + } + } + dot.append("}"); + return dot.toString(); + } + + /** Appends one ellipse node for every package in the tree that carries a node id. */ + private static void renderFlatPackageNodes(List packages, String repoUrl, StringBuilder dot) { + for (PackageNodeDTO pkg : packages) { + if (pkg.getNodeId() != null) { + dot.append(pkg.getNodeId()) + .append(" [label=\"") + .append(HtmlReport.escapeDotQuoted(pkg.getName())) + .append("\" shape=ellipse"); + if (pkg.isNeedsBreakUp()) { + dot.append(" color=red style=filled"); + } + String url = packageHyperlinkUrl(pkg, repoUrl); + if (url != null) { + dot.append(" URL=\"") + .append(HtmlReport.escapeDotQuoted(url)) + .append("\" target=\"_blank\""); + } + dot.append("];\n"); + } + renderFlatPackageNodes(pkg.getChildren() == null ? List.of() : pkg.getChildren(), repoUrl, dot); + } + } + + /** + * Returns the package node's hyperlink target when the repository URL and the + * package's source directory resolve to an http(s) URL; {@code null} otherwise. + */ + private static String packageHyperlinkUrl(PackageNodeDTO pkg, String repoUrl) { + if (pkg.getPath() == null || pkg.getPath().isEmpty()) { + return null; + } + String url = (repoUrl == null ? "" : repoUrl) + pkg.getPath(); + return isHttpUrl(url) ? url : null; + } + + /** Only http(s) URLs may become links. */ + private static boolean isHttpUrl(String url) { + try { + String scheme = java.net.URI.create(url).getScheme(); + return "http".equals(scheme) || "https".equals(scheme); + } catch (IllegalArgumentException e) { + return false; + } + } + /** Appends one weighted edge, red when the relationship is marked for removal. */ private static void renderEdge(EdgeDTO edge, StringBuilder dot) { dot.append(edge.getSource()).append(" -> ").append(edge.getTarget()); diff --git a/report/src/main/java/org/hjug/refactorfirst/report/model/PackageNodeDTO.java b/report/src/main/java/org/hjug/refactorfirst/report/model/PackageNodeDTO.java index 79ccb39d..f9d80bf9 100644 --- a/report/src/main/java/org/hjug/refactorfirst/report/model/PackageNodeDTO.java +++ b/report/src/main/java/org/hjug/refactorfirst/report/model/PackageNodeDTO.java @@ -37,6 +37,13 @@ public class PackageNodeDTO { /** Whether the package was selected for removal to break cycles (pink fill). */ private boolean needsBreakUp; + /** + * Repo-relative source directory of the package (with trailing slash), derived from + * the mapped class source paths; empty when no mapped class is known. Feeds the + * package graph's hyperlinks. + */ + private String path; + /** Child package clusters, sorted by name, with single-child chains collapsed. */ @Builder.Default private List children = new ArrayList<>(); diff --git a/report/src/main/resources/viewer/index.html b/report/src/main/resources/viewer/index.html index dfe37d92..4dc1cd6d 100644 --- a/report/src/main/resources/viewer/index.html +++ b/report/src/main/resources/viewer/index.html @@ -111,7 +111,16 @@

Loading RefactorFirst Report...

// Convert graphlib graph to graphology graph const graphologyGraph = new graphology.Graph(); + // graphlib-dot creates a node for every named subgraph: in the class + // map that is one cluster node per package, labelled with the package + // name. Packages are noise in the popups, so drop the cluster nodes + // while keeping every node the clusters contained and the edges between them. + const droppedClusters = new Set(); graphlibGraph.nodes().forEach(node => { + if (node.startsWith('cluster_')) { + droppedClusters.add(node); + return; + } const attrs = graphlibGraph.node(node); graphologyGraph.addNode(node, { label: attrs.label || node, @@ -121,6 +130,7 @@

Loading RefactorFirst Report...

}); graphlibGraph.edges().forEach(edge => { + if (droppedClusters.has(edge.v) || droppedClusters.has(edge.w)) return; const attrs = graphlibGraph.edge(edge); graphologyGraph.addEdge(edge.v, edge.w, { color: attrs.color, @@ -158,7 +168,15 @@

Loading RefactorFirst Report...

var nodes = []; var links = []; + // Packages are noise in the popups: drop the cluster nodes + // graphlib-dot creates per named subgraph, keeping every node they + // contained and the edges between the kept nodes. + var droppedClusters = new Set(); graphlibGraph.nodes().forEach(function (node) { + if (node.startsWith('cluster_')) { + droppedClusters.add(node); + return; + } var nodeData = graphlibGraph.node(node); nodes.push({ id: node, @@ -167,6 +185,7 @@

Loading RefactorFirst Report...

}); graphlibGraph.edges().forEach(function (edge) { + if (droppedClusters.has(edge.v) || droppedClusters.has(edge.w)) return; links.push({ source: edge.v, target: edge.w, @@ -331,7 +350,6 @@

Loading RefactorFirst Report...