_anonymous}, which is {@code <}/{@code >}-free and human
- * recognizable. When no source path is mapped (or {@code codebaseGraphDTO == null}) this
- * degrades to {@link #renderSafeNodeId(String)} (the {@code lt_anonymous_gt} encoding).
- *
- * For non-anonymous vertices this is identical to {@link #renderSafeNodeId(String)}.
- *
- * @param vertex the fully qualified (or literal) class vertex name
- * @param codebaseGraphDTO the DTO carrying the {@code vertex -> source file path} mapping
- * @return a deterministic DOT-legal node id for the vertex
- */
+ /** Source mappings affect labels only; class identity always comes from the full name. */
String renderSafeNodeId(String vertex, CodebaseGraphDTO codebaseGraphDTO) {
- if (isAnonymousFqn(vertex)) {
- String owner = enclosingSourceFileBaseName(vertex, codebaseGraphDTO);
- if (owner != null) {
- // Append hash of FQN to prevent collisions between anonymous classes
- // from same-named files in different packages
- String discriminator = String.valueOf(Math.abs(vertex.hashCode()));
- return owner.replace("$", "_") + "_anonymous_" + discriminator;
- }
- }
return renderSafeNodeId(vertex);
}
- /**
- * Graph-aware DOT node id. Uses simple class name when unique across the graph,
- * falls back to full FQN with dots→underscores when collision exists.
- * Special handling for anonymous/synthetic vertices (uses lt_anonymous_gt encoding),
- * inner classes (always uses FQN), and anonymous with enclosing prefix.
- *
- * @param vertex the fully qualified (or literal) class vertex name
- * @param classGraph the class graph for collision detection
- * @return a deterministic DOT-legal node id for the vertex
- */
+ /** Class ids remain stable across full-graph and cycle views. */
String renderSafeNodeId(String vertex, Graph classGraph) {
- return renderSafeNodeId(vertex, classGraph, null);
+ return renderSafeNodeId(vertex);
}
- /**
- * Graph-aware DOT node id with DTO support. Uses simple class name when unique across the graph,
- * falls back to full FQN with dots→underscores when collision exists.
- * Special handling for anonymous/synthetic vertices (uses lt_anonymous_gt encoding or
- * source-aware ID from DTO), inner classes (always uses FQN), and anonymous with enclosing prefix.
- *
- * @param vertex the fully qualified (or literal) class vertex name
- * @param classGraph the class graph for collision detection
- * @param codebaseGraphDTO optional DTO for source-aware anonymous vertex IDs
- * @return a deterministic DOT-legal node id for the vertex
- */
- String renderSafeNodeId(
+ /** Resolves the same class id regardless of graph membership or source-file mappings. */
+ static String renderSafeNodeId(
String vertex, Graph classGraph, CodebaseGraphDTO codebaseGraphDTO) {
- // Special handling for anonymous/synthetic vertices
- if (isAnonymousFqn(vertex)) {
- // If DTO provided, try to get source-aware ID
- if (codebaseGraphDTO != null) {
- String owner = enclosingSourceFileBaseName(vertex, codebaseGraphDTO);
- if (owner != null) {
- return owner.replace("$", "_") + "_anonymous";
- }
- }
- // No DTO or no source mapping: use lt_anonymous_gt encoding
- return vertex.replace(".", "_")
- .replace("$", "_")
- .replace("<", "lt_")
- .replace(">", "_gt");
- }
-
- // Check if this is an anonymous class with enclosing class prefix (e.g., dev.Class.)
- if (vertex.contains(".")) {
- return vertex.replace(".", "_")
- .replace("$", "_")
- .replace("<", "lt_")
- .replace(">", "_gt");
- }
-
- // Check if this is an inner class (contains $)
- if (vertex.contains("$")) {
- // Inner classes always use FQN-based ID
- return vertex.replace(".", "_").replace("$", "_");
- }
-
- // For regular classes, check if simple name is unique in the graph
- String simpleName = getClassName(vertex);
-
- // Count occurrences of this simple name in the graph
- long count = classGraph.vertexSet().stream()
- .map(graphVertex -> getClassName(graphVertex.getName()))
- .filter(simpleName::equals)
- .count();
-
- if (count == 1) {
- // Unique simple name - use it (with $/< > escaping for safety)
- return simpleName.replace("$", "_").replace("<", "lt_").replace(">", "_gt");
- } else {
- // Collision - use full FQN with dots→underscores
- return vertex.replace(".", "_").replace("$", "_");
- }
+ return renderSafeNodeId(vertex);
}
/**
@@ -782,7 +654,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 +685,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) {
@@ -828,7 +700,7 @@ private String anonymousOwnerLabel(String vertex, CodebaseGraphDTO codebaseGraph
* trailing simple-name segment, either standalone ({@code ""}) or prefixed by the
* enclosing class/package (e.g. {@code "dev.DeveloperWASDControl."}). Such a
* vertex cannot appear verbatim in a DOT node id (the {@code <}/{@code >} are illegal), so
- * this predicate decides when the source-aware id/label derivation must kick in.
+ * this predicate decides when the source-aware label derivation must kick in.
*/
static boolean isAnonymousFqn(String vertex) {
if (vertex == null) {
@@ -892,50 +764,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 +791,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. */
@@ -1027,29 +848,18 @@ public String renderPackageGraphVisuals(String repoUrl, CodebaseGraphDTO codebas
return stringBuilder.toString();
}
- /** Builds raw DOT source for the package relationship graph. */
+ /**
+ * 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) {
- 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, codebaseGraphDTO, packagesToRemove, packageRelationshipsToRemove);
+ return NestedDotBuilder.buildPackageDot(hierarchy, repoUrl);
}
/** Builds escaped package-graph DOT suitable for a JavaScript template literal. */
@@ -1058,59 +868,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..689ef245 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, codebaseGraphDTO, 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();
}
@@ -751,14 +747,19 @@ private static String graphIdentifier(String value) {
return "graph_" + sanitized + "_" + Integer.toUnsignedString(original.hashCode(), 36);
}
- /** Checks if a class belongs to a given package by checking the fully qualified class name. */
- private boolean isClassInPackage(String className, String packageName, CodebaseGraphDTO codebaseGraphDTO) {
+ /**
+ * Checks if a class belongs to a given package, deriving the class's package with the same
+ * rule the graphs use ({@link HierarchyBuilder#packageOf}, mirroring
+ * {@code GraphDependencyCollector.getPackageFromFqn}): a Kotlin anonymous class belongs to
+ * its enclosing class's package ({@code dev.Owner. -> dev}), and packageless
+ * classes match no package.
+ */
+ boolean isClassInPackage(String className, String packageName, CodebaseGraphDTO codebaseGraphDTO) {
if (className == null || packageName == null) {
return false;
}
- // Check if the class name starts with the package name followed by a dot
- int lastDot = className.lastIndexOf('.');
- return lastDot > 0 && className.substring(0, lastDot).equals(packageName);
+ String classPackage = HierarchyBuilder.packageOf(className);
+ return classPackage != null && classPackage.equals(packageName);
}
/**
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..6dddffcf
--- /dev/null
+++ b/report/src/main/java/org/hjug/refactorfirst/report/NestedDotBuilder.java
@@ -0,0 +1,206 @@
+package org.hjug.refactorfirst.report;
+
+import java.util.List;
+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} 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
+ * 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
+ * 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();
+ }
+
+ /** 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.
+ 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: label, ellipse shape, the red removal fill, then the hyperlink.
+ * The {@code URL}/{@code target} attributes come last — mirroring the package nodes — and
+ * are emitted only when the constructed URL passes the http(s) validation the package
+ * hyperlinks apply.
+ */
+ private static void renderClassNode(
+ ClassNodeDTO cls, String repoUrl, boolean includeHyperlinks, StringBuilder dot) {
+ dot.append(cls.getId()).append(" [");
+ 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");
+ }
+ if (includeHyperlinks && cls.getPath() != null && !cls.getPath().isBlank()) {
+ String url = (repoUrl == null ? "" : repoUrl) + cls.getPath();
+ if (isHttpUrl(url)) {
+ dot.append(" URL=\"").append(HtmlReport.escapeDotQuoted(url)).append("\" target=\"_blank\"");
+ }
+ }
+ 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 {
+ java.net.URI parsed = java.net.URI.create(url.replace(" ", "%20"));
+ String scheme = parsed.getScheme();
+ return parsed.getHost() != null && ("http".equalsIgnoreCase(scheme) || "https".equalsIgnoreCase(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());
+ 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..f9d80bf9
--- /dev/null
+++ b/report/src/main/java/org/hjug/refactorfirst/report/model/PackageNodeDTO.java
@@ -0,0 +1,54 @@
+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;
+
+ /**
+ * 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<>();
+
+ /** 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..6ccc812a 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. Drop subgraphs by their children, preserving class nodes such
+ // as cluster_Helper and the edges between the kept nodes.
+ const droppedClusters = new Set();
graphlibGraph.nodes().forEach(node => {
+ if (graphlibGraph.children(node).length > 0) {
+ 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,14 @@ Loading RefactorFirst Report...
var nodes = [];
var links = [];
+ // Drop subgraphs by their children, preserving class nodes such
+ // as cluster_Helper and the edges between the kept nodes.
+ var droppedClusters = new Set();
graphlibGraph.nodes().forEach(function (node) {
+ if (graphlibGraph.children(node).length > 0) {
+ droppedClusters.add(node);
+ return;
+ }
var nodeData = graphlibGraph.node(node);
nodes.push({
id: node,
@@ -167,6 +184,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,
@@ -332,6 +350,132 @@ Loading RefactorFirst Report...