From 900802cc6febbb26ee372f4f0d6b2707ea0af785 Mon Sep 17 00:00:00 2001 From: Jim Bethancourt Date: Tue, 9 Jun 2026 12:11:16 -0500 Subject: [PATCH 1/5] #182 Add hyperlinks for classes Add hyperlinks for classes in report and DOT images --- .../main/java/org/hjug/git/GitLogReader.java | 23 ++++ .../mavenreport/RefactorFirstMavenReport.java | 3 + .../hjug/refactorfirst/report/HtmlReport.java | 54 ++++++--- .../report/SimpleHtmlReport.java | 113 +++++++++++++----- .../report/DisharmonyRenderingTest.java | 16 +-- .../refactorfirst/report/HtmlReportTest.java | 19 ++- report/src/test/resources/dotPlayground.html | 73 +++++++++++ 7 files changed, 244 insertions(+), 57 deletions(-) create mode 100644 report/src/test/resources/dotPlayground.html diff --git a/change-proneness-ranker/src/main/java/org/hjug/git/GitLogReader.java b/change-proneness-ranker/src/main/java/org/hjug/git/GitLogReader.java index ef197ab3..5b1c839d 100644 --- a/change-proneness-ranker/src/main/java/org/hjug/git/GitLogReader.java +++ b/change-proneness-ranker/src/main/java/org/hjug/git/GitLogReader.java @@ -56,6 +56,29 @@ public File getGitDir(File basedir) { return repositoryBuilder.getGitDir(); } + /** + * Returns the current commit hash at HEAD + * + * @return the commit hash as a String, or null if HEAD cannot be resolved + * @throws IOException if an I/O error occurs + */ + public String getCurrentCommitHash() throws IOException { + ObjectId headCommit = gitRepository.resolve("HEAD"); + if (headCommit == null) { + return null; + } + return headCommit.getName(); + } + + /** + * Returns the repository's origin URL + * + * @return the origin URL as a String, or null if not configured + */ + public String getOriginUrl() { + return gitRepository.getConfig().getString("remote", "origin", "url"); + } + // log --follow implementation may be worth adopting in the future // https://github.com/spearce/jgit/blob/master/org.eclipse.jgit.pgm/src/org/eclipse/jgit/pgm/RevWalkTextBuiltin.java diff --git a/refactor-first-maven-plugin/src/main/java/org/hjug/mavenreport/RefactorFirstMavenReport.java b/refactor-first-maven-plugin/src/main/java/org/hjug/mavenreport/RefactorFirstMavenReport.java index aaf3983a..8fb8dff5 100644 --- a/refactor-first-maven-plugin/src/main/java/org/hjug/mavenreport/RefactorFirstMavenReport.java +++ b/refactor-first-maven-plugin/src/main/java/org/hjug/mavenreport/RefactorFirstMavenReport.java @@ -1,6 +1,8 @@ package org.hjug.mavenreport; import java.util.*; + +import lombok.SneakyThrows; import lombok.extern.slf4j.Slf4j; import org.apache.maven.doxia.markup.HtmlMarkup; import org.apache.maven.doxia.sink.Sink; @@ -63,6 +65,7 @@ public String getDescription(Locale locale) { + " have the highest priority values."; } + @SneakyThrows @Override public void executeReport(Locale locale) { HtmlReport htmlReport = new HtmlReport(); 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 bd46d220..377b2dde 100644 --- a/report/src/main/java/org/hjug/refactorfirst/report/HtmlReport.java +++ b/report/src/main/java/org/hjug/refactorfirst/report/HtmlReport.java @@ -8,6 +8,7 @@ import org.hjug.cbc.RankedCycle; import org.hjug.cbc.RankedDisharmony; import org.hjug.gdg.GraphDataGenerator; +import org.hjug.graphbuilder.CodebaseGraphDTO; import org.jgrapht.Graph; import org.jgrapht.graph.DefaultWeightedEdge; @@ -465,8 +466,8 @@ String renderDisharmonyChart(String anchorId, String title, List\n" + "\n"; } - String buildClassGraphDot(Graph classGraph) { + String buildClassGraphDot( + Graph classGraph, String repoUrl, CodebaseGraphDTO codebaseGraphDTO) { StringBuilder dot = new StringBuilder(); dot.append("`strict digraph G {\n"); @@ -563,17 +565,26 @@ String buildClassGraphDot(Graph classGraph) { dot.append(className.replace("$", "_")); + dot.append(" ["); + dot.append(hyperlinkClassForDot(vertex, repoUrl, codebaseGraphDTO)); + if (vertexesToRemove.contains(vertex)) { - dot.append(" [color=red style=filled]\n"); + dot.append(" color=red style=filled"); } - dot.append(";\n"); + dot.append("];\n"); } dot.append("}`;"); return dot.toString(); } + String hyperlinkClassForDot(String fqClassName, String repoUrl, CodebaseGraphDTO codebaseGraphDTO) { + StringBuilder sb = new StringBuilder(); + String path = codebaseGraphDTO.getClassToSourceFilePathMapping().get(fqClassName); + return sb.append("URL=\"" + repoUrl + path + "\" target=\"_blank\"").toString(); + } + private void renderEdge( Graph classGraph, DefaultWeightedEdge edge, StringBuilder dot) { // render edge @@ -622,8 +633,8 @@ private void renderEdge( } @Override - public String renderCycleVisuals(RankedCycle cycle) { - String dot = buildCycleDot(classGraph, cycle); + public String renderCycleVisuals(RankedCycle cycle, String repoUrl, CodebaseGraphDTO codebaseGraphDTO) { + String dot = buildCycleDot(classGraph, cycle, repoUrl, codebaseGraphDTO); String cycleName = getClassName(cycle.getCycleName()).replace("$", "_"); @@ -643,7 +654,11 @@ public String renderCycleVisuals(RankedCycle cycle) { return stringBuilder.toString(); } - String buildCycleDot(Graph classGraph, RankedCycle cycle) { + String buildCycleDot( + Graph classGraph, + RankedCycle cycle, + String repoUrl, + CodebaseGraphDTO codebaseGraphDTO) { StringBuilder dot = new StringBuilder(); dot.append("`strict digraph G {\n"); @@ -653,18 +668,29 @@ String buildCycleDot(Graph classGraph, RankedCycle // render vertices for (String vertex : cycle.getVertexSet()) { - dot.append(getClassName(vertex).replace("$", "_")); + String className = getClassName(vertex); + + // if the vertex is a nested class and has no outgoing edges, skip it + if (className.contains("$") + && className.split("\\$")[className.split("\\$").length - 1].matches("\\d+") + && classGraph.outDegreeOf(vertex) == 0) { + continue; + } + + dot.append(className.replace("$", "_")); + + dot.append(" ["); + dot.append(hyperlinkClassForDot(vertex, repoUrl, codebaseGraphDTO)); if (vertexesToRemove.contains(vertex)) { - dot.append(" [color=red style=filled]\n"); + dot.append(" color=red style=filled"); } - dot.append(";\n"); + dot.append("];\n"); } dot.append("}`;"); - - return dot.toString().replace("$", "_"); + return dot.toString(); } String generate2DPopup(String cycleName) { 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 f8422cf1..cb058743 100644 --- a/report/src/main/java/org/hjug/refactorfirst/report/SimpleHtmlReport.java +++ b/report/src/main/java/org/hjug/refactorfirst/report/SimpleHtmlReport.java @@ -13,6 +13,7 @@ import java.util.*; import java.util.regex.Matcher; import java.util.regex.Pattern; +import lombok.SneakyThrows; import lombok.extern.slf4j.Slf4j; import org.hjug.cbc.*; import org.hjug.dsm.CircularReferenceChecker; @@ -23,6 +24,7 @@ import org.hjug.feedback.vertex.kernelized.EnhancedParameterComputer; import org.hjug.git.GitLogReader; import org.hjug.graphbuilder.CodebaseGraphDTO; +import org.hjug.graphbuilder.metrics.DisharmonyMetric; import org.hjug.graphbuilder.metrics.DisharmonyTypes; import org.hjug.metrics.DisharmonyInstance; import org.jgrapht.Graph; @@ -63,6 +65,7 @@ public class SimpleHtmlReport { .setMinifyCss(true) .build(); + @SneakyThrows public void execute( int edgeAnalysisCount, boolean analyzeCycles, @@ -116,7 +119,8 @@ public StringBuilder generateReport( String testSourceDirectory, String projectName, String projectVersion, - File baseDir) { + File baseDir) + throws Exception { if (testSourceDirectory == null || testSourceDirectory.isEmpty()) { testSourceDirectory = "src" + File.separator + "test"; @@ -285,6 +289,11 @@ public StringBuilder generateReport( boolean hasAnyDisharmony = !edgesToRemove.isEmpty() || !rankedCycles.isEmpty() || !rankedDisharmoniesByAnchor.isEmpty(); + String repoUrl; + try (GitLogReader glr = new GitLogReader(new File(projectBaseDir))) { + repoUrl = glr.getOriginUrl().replace(".git", "") + "/blob/" + glr.getCurrentCommitHash() + "/"; + } + if (!hasAnyDisharmony) { stringBuilder .append("
Congratulations! ") @@ -292,7 +301,7 @@ public StringBuilder generateReport( .append(" ") .append(projectVersion) .append(" has no Cycles or Disharmonies!
"); - stringBuilder.append(renderClassGraphVisuals()); + stringBuilder.append(renderClassGraphVisuals(repoUrl, codebaseGraphDTO)); stringBuilder.append(renderGithubButtons()); log.info("Done! No Disharmonies found!"); return stringBuilder; @@ -330,13 +339,13 @@ public StringBuilder generateReport( stringBuilder.append("\n" + "\n" + "\n"); log.info("Generating HTML Report"); - stringBuilder.append(renderClassGraphVisuals()); + stringBuilder.append(renderClassGraphVisuals(repoUrl, codebaseGraphDTO)); stringBuilder.append("
\n"); stringBuilder.append(renderGithubButtons()); stringBuilder.append("
\n"); if (!edgeDisharmonies.isEmpty()) { - stringBuilder.append(renderEdgeDisharmonies(edgeDisharmonies)); + stringBuilder.append(renderEdgeDisharmonies(edgeDisharmonies, repoUrl, codebaseGraphDTO)); stringBuilder.append("
\n" + "
\n" + "
\n" + "
\n" + "
\n" + "
\n" + "
\n"); } @@ -344,29 +353,33 @@ public StringBuilder generateReport( List rankedForType = rankedDisharmoniesByAnchor.get(spec.anchorId()); if (rankedForType != null && !rankedForType.isEmpty()) { stringBuilder.append(renderDisharmonyInfo( - spec.anchorId(), spec.title(), spec.methodLevel(), showDetails, rankedForType)); + repoUrl, spec.anchorId(), spec.title(), spec.methodLevel(), showDetails, rankedForType)); stringBuilder.append("
\n" + "
\n" + "
\n" + "
\n" + "
\n" + "
\n" + "
\n"); } } if (!rankedCycles.isEmpty()) { - stringBuilder.append(renderCycles(rankedCycles)); + stringBuilder.append(renderCycles(rankedCycles, repoUrl, codebaseGraphDTO)); } log.debug(stringBuilder.toString()); return stringBuilder; } - private String renderCycles(List rankedCycles) { + private String renderCycles(List rankedCycles, String repoUrl, CodebaseGraphDTO codebaseGraphDTO) { StringBuilder stringBuilder = new StringBuilder(); stringBuilder.append(renderClassCycleSummary(rankedCycles)); - rankedCycles.stream().limit(1).map(this::renderSingleCycle).forEach(stringBuilder::append); + rankedCycles.stream() + .limit(1) + .map((RankedCycle cycle) -> renderSingleCycle(cycle, repoUrl, codebaseGraphDTO)) + .forEach(stringBuilder::append); return stringBuilder.toString(); } - private String renderEdgeDisharmonies(List edgeDisharmonies) { + private String renderEdgeDisharmonies( + List edgeDisharmonies, String repoUrl, CodebaseGraphDTO codebaseGraphDTO) { StringBuilder stringBuilder = new StringBuilder(); stringBuilder.append( @@ -379,7 +392,9 @@ private String renderEdgeDisharmonies(List edgeDisharmonies) { .append("Number of Relationships to Remove: ") .append(edgesToRemove.size()) .append("
\n"); - stringBuilder.append("Classes in bold should be broken apart").append("
\n"); + stringBuilder + .append("Classes with * should be broken apart") + .append("
\n"); stringBuilder.append("\n"); // Content @@ -396,7 +411,7 @@ private String renderEdgeDisharmonies(List edgeDisharmonies) { for (RankedDisharmony edge : edgeDisharmonies) { stringBuilder.append("\n"); - for (String rowData : getEdgeDisharmony(edge)) { + for (String rowData : getEdgeDisharmony(edge, repoUrl, codebaseGraphDTO)) { stringBuilder.append(drawTableCell(rowData)); } @@ -421,9 +436,9 @@ private String[] getEdgeDisharmonyTableHeadings() { }; } - private String[] getEdgeDisharmony(RankedDisharmony edgeInfo) { + private String[] getEdgeDisharmony(RankedDisharmony edgeInfo, String repoUrl, CodebaseGraphDTO codebaseGraphDTO) { return new String[] { - renderEdge(edgeInfo.getEdge()), + renderEdge(edgeInfo.getEdge(), repoUrl, codebaseGraphDTO), String.valueOf(edgeInfo.getPriority()), String.valueOf(edgeInfo.getCycleCount()), String.valueOf(edgeInfo.getEffortRank()), @@ -508,6 +523,39 @@ private String renderEdge(DefaultWeightedEdge edge) { .toString(); } + private String renderEdge(DefaultWeightedEdge edge, String repoUrl, CodebaseGraphDTO codebaseGraphDTO) { + StringBuilder edgesToCut = new StringBuilder(); + String[] vertexes = extractVertexes(edge); + + String startVertex = vertexes[0].trim(); + String start; + if (vertexesToRemove.contains(startVertex)) { + start = hyperlinkClass(startVertex, repoUrl, codebaseGraphDTO) + "*"; + } else { + start = hyperlinkClass(startVertex, repoUrl, codebaseGraphDTO); + } + + String endVertex = vertexes[1].trim(); + String end; + if (vertexesToRemove.contains(endVertex)) { + end = hyperlinkClass(endVertex, repoUrl, codebaseGraphDTO) + "*"; + } else { + end = hyperlinkClass(endVertex, repoUrl, codebaseGraphDTO); + } + + // → is HTML "Right Arrow" code + return edgesToCut + .append(start + " → " + end + " : " + (int) classGraph.getEdgeWeight(edge)) + .toString(); + } + + String hyperlinkClass(String className, String repoUrl, CodebaseGraphDTO codebaseGraphDTO) { + StringBuilder sb = new StringBuilder(); + String path = codebaseGraphDTO.getClassToSourceFilePathMapping().get(className); + return sb.append("" + getClassName(className) + "") + .toString(); + } + private String[] getCycleSummaryTableHeadings() { return new String[] {"Cycle Name", "Priority", "Class Count", "Relationship Count" /*, "Minimum Cuts"*/}; } @@ -522,7 +570,7 @@ private String[] getRankedCycleSummaryData(RankedCycle rankedCycle, StringBuilde }; } - private String renderSingleCycle(RankedCycle cycle) { + private String renderSingleCycle(RankedCycle cycle, String repoUrl, CodebaseGraphDTO codebaseGraphDTO) { StringBuilder stringBuilder = new StringBuilder(); stringBuilder.append("
\n"); @@ -535,11 +583,12 @@ private String renderSingleCycle(RankedCycle cycle) { "

Largest Class Cycle : " + getClassName(cycle.getCycleName()) + "

\n"); stringBuilder.append( "

Limiting number of cycles displayed to 1 to keep page load time fast

\n"); - stringBuilder.append(renderCycleVisuals(cycle)); + stringBuilder.append(renderCycleVisuals(cycle, repoUrl, codebaseGraphDTO)); stringBuilder.append("
"); stringBuilder.append(""); - stringBuilder.append("Bold text indicates class or relationship to remove to decompose cycle"); + stringBuilder.append( + "* indicates class to remove, bold text indicates relationships to remove to decompose cycle"); stringBuilder.append(""); int classCount = cycle.getCycleNodes().size(); int relationshipCount = cycle.getEdgeSet().size(); @@ -561,9 +610,11 @@ private String renderSingleCycle(RankedCycle cycle) { for (String vertex : cycle.getVertexSet()) { stringBuilder.append(""); - String className = getClassName(vertex); + String className; if (vertexesToRemove.contains(vertex)) { - className = "" + className + ""; + className = hyperlinkClass(vertex, repoUrl, codebaseGraphDTO) + "*"; + } else { + className = hyperlinkClass(vertex, repoUrl, codebaseGraphDTO); } stringBuilder.append(drawTableCell(className)); @@ -574,9 +625,6 @@ private String renderSingleCycle(RankedCycle cycle) { if (edgesToRemove.contains(edge)) { edges.append(""); edges.append(renderEdge(edge)); - if (cycle.getMinCutEdges().contains(edge)) { - edges.append("*"); - } edges.append(""); } else { edges.append(renderEdge(edge)); @@ -596,11 +644,11 @@ private String renderSingleCycle(RankedCycle cycle) { return stringBuilder.toString(); } - public String renderClassGraphVisuals() { + public String renderClassGraphVisuals(String repoUrl, CodebaseGraphDTO codebaseGraphDTO) { return ""; // empty on purpose } - public String renderCycleVisuals(RankedCycle cycle) { + public String renderCycleVisuals(RankedCycle cycle, String repoUrl, CodebaseGraphDTO codebaseGraphDTO) { return ""; // empty on purpose } @@ -682,11 +730,16 @@ String getOutputName() { } /** - * Renders a table section for any non-God-Class disharmony type. + * Renders a table section for any disharmony type. * Column headers are derived from the ranked metrics carried on each RankedDisharmony. */ public String renderDisharmonyInfo( - String anchorId, String title, boolean methodLevel, boolean showDetails, List ranked) { + String repoUrl, + String anchorId, + String title, + boolean methodLevel, + boolean showDetails, + List ranked) { if (ranked.isEmpty()) { return ""; } @@ -709,8 +762,7 @@ public String renderDisharmonyInfo( sb.append("\n"); // Build headers from the first item's ranked metrics - List sampleMetrics = - ranked.get(0).getRankedMetrics(); + List sampleMetrics = ranked.get(0).getRankedMetrics(); boolean showPartners = ranked.get(0).getDuplicationPartners() != null; @@ -727,7 +779,7 @@ public String renderDisharmonyInfo( sb.append("\n"); sb.append("\n"); if (showDetails) { - for (org.hjug.graphbuilder.metrics.DisharmonyMetric m : sampleMetrics) { + for (DisharmonyMetric m : sampleMetrics) { sb.append("\n"); sb.append("\n"); } @@ -746,7 +798,8 @@ public String renderDisharmonyInfo( sb.append("\n"); for (RankedDisharmony rd : ranked) { sb.append("\n"); - sb.append(drawTableCell(rd.getFileName())); + sb.append(drawTableCell( + "" + rd.getFileName() + "")); if (methodLevel) { String sig = rd.getMethodSignature(); if (!showDetails && sig != null) { @@ -763,7 +816,7 @@ public String renderDisharmonyInfo( sb.append(drawTableCell(rd.getChangePronenessRank().toString())); sb.append(drawTableCell(rd.getEffortRank().toString())); if (showDetails) { - for (org.hjug.graphbuilder.metrics.DisharmonyMetric m : rd.getRankedMetrics()) { + for (DisharmonyMetric m : rd.getRankedMetrics()) { double v = m.getValue(); String formatted = (v == Math.floor(v)) ? String.valueOf((long) v) : String.valueOf(v); sb.append(drawTableCell(formatted)); diff --git a/report/src/test/java/org/hjug/refactorfirst/report/DisharmonyRenderingTest.java b/report/src/test/java/org/hjug/refactorfirst/report/DisharmonyRenderingTest.java index 67892887..b3e64bc0 100644 --- a/report/src/test/java/org/hjug/refactorfirst/report/DisharmonyRenderingTest.java +++ b/report/src/test/java/org/hjug/refactorfirst/report/DisharmonyRenderingTest.java @@ -21,7 +21,7 @@ class DisharmonyRenderingTest { void renderDisharmonyInfoContainsTitle() { List ranked = List.of(makeRankedDisharmony("BrainClass.java", null, 1, 57.0, 3.0, 0.3)); - String html = simpleReport.renderDisharmonyInfo("BRAIN", "Brain Classes", false, false, ranked); + String html = simpleReport.renderDisharmonyInfo("", "BRAIN", "Brain Classes", false, false, ranked); assertTrue(html.contains("Brain Classes"), "HTML must contain the section title"); assertTrue(html.contains("id=\"BRAIN\""), "HTML must contain the anchor id"); @@ -32,7 +32,7 @@ void simpleModeShowsDescriptionColumnNotMetricColumns() { List ranked = List.of(makeRankedDisharmony("BrainClass.java", null, 1, 57.0, 3.0, 0.3)); ranked.get(0).setDescription("Brain Class detected: Brain Methods=1, LOC=200, WMC=3, TCC=0.3"); - String html = simpleReport.renderDisharmonyInfo("BRAIN", "Brain Classes", false, false, ranked); + String html = simpleReport.renderDisharmonyInfo("", "BRAIN", "Brain Classes", false, false, ranked); assertTrue(html.contains(" ranked = List.of(makeRankedDisharmony("BrainClass.java", null, 1, 57.0, 3.0, 0.3)); - String simple = simpleReport.renderDisharmonyInfo("BRAIN", "Brain Classes", false, false, ranked); - String detailed = simpleReport.renderDisharmonyInfo("BRAIN", "Brain Classes", false, true, ranked); + String simple = simpleReport.renderDisharmonyInfo("", "BRAIN", "Brain Classes", false, false, ranked); + String detailed = simpleReport.renderDisharmonyInfo("", "BRAIN", "Brain Classes", false, true, ranked); assertFalse(simple.contains("BrainMethods Rank"), "Simple mode should not show rank columns"); assertFalse(simple.contains(""), "Simple mode should not show metric value columns"); @@ -60,7 +60,7 @@ void renderDisharmonyInfoForMethodLevelShowsMethodColumn() { List ranked = List.of(makeRankedDisharmony("BrainClass.java", "heavyMethod()", 1, 70.0, 5.0, 5.0)); - String html = simpleReport.renderDisharmonyInfo("BRAIN_METHOD", "Brain Methods", true, false, ranked); + String html = simpleReport.renderDisharmonyInfo("", "BRAIN_METHOD", "Brain Methods", true, false, ranked); assertTrue(html.contains("Method"), "Method-level rendering must include a Method column header"); assertTrue(html.contains("heavyMethod()"), "Method-level rendering must include the method signature"); @@ -70,7 +70,7 @@ void renderDisharmonyInfoForMethodLevelShowsMethodColumn() { void renderDisharmonyInfoForClassLevelDoesNotShowMethodColumn() { List ranked = List.of(makeRankedDisharmony("BrainClass.java", null, 1, 57.0, 3.0, 0.3)); - String html = simpleReport.renderDisharmonyInfo("BRAIN", "Brain Classes", false, false, ranked); + String html = simpleReport.renderDisharmonyInfo("", "BRAIN", "Brain Classes", false, false, ranked); // Class-level should not have an empty method cell (null signature) assertFalse(html.contains("null"), "Class-level rendering must not have null method signature cells"); @@ -120,7 +120,7 @@ void significantDuplicationTableShowsDuplicatePartnersColumn() { rd.setDuplicationPartners("computeResult(int) ↔ CrossClassB.computeResult(int)"); String html = - simpleReport.renderDisharmonyInfo("SIG_DUP", "Significant Duplication", false, false, List.of(rd)); + simpleReport.renderDisharmonyInfo("", "SIG_DUP", "Significant Duplication", false, false, List.of(rd)); assertTrue(html.contains("Duplicate Partners"), "Table must show 'Duplicate Partners' column header"); assertTrue(html.contains("CrossClassB"), "Table must show partner class name in the Duplicate Partners cell"); @@ -130,7 +130,7 @@ void significantDuplicationTableShowsDuplicatePartnersColumn() { void otherDisharmonyTableOmitsDuplicatePartnersColumn() { RankedDisharmony rd = makeRankedDisharmony("BrainClass.java", null, 1, 57.0, 3.0, 0.3); - String html = simpleReport.renderDisharmonyInfo("BRAIN", "Brain Classes", false, false, List.of(rd)); + String html = simpleReport.renderDisharmonyInfo("", "BRAIN", "Brain Classes", false, false, List.of(rd)); assertFalse(html.contains("Duplicate Partners"), "Non-duplication table must not show 'Duplicate Partners'"); } diff --git a/report/src/test/java/org/hjug/refactorfirst/report/HtmlReportTest.java b/report/src/test/java/org/hjug/refactorfirst/report/HtmlReportTest.java index 514e791b..585ff578 100644 --- a/report/src/test/java/org/hjug/refactorfirst/report/HtmlReportTest.java +++ b/report/src/test/java/org/hjug/refactorfirst/report/HtmlReportTest.java @@ -1,10 +1,13 @@ package org.hjug.refactorfirst.report; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; import java.util.*; import org.hjug.cbc.CycleNode; import org.hjug.cbc.RankedCycle; +import org.hjug.graphbuilder.CodebaseGraphDTO; import org.jgrapht.Graph; import org.jgrapht.graph.DefaultDirectedWeightedGraph; import org.jgrapht.graph.DefaultWeightedEdge; @@ -52,15 +55,21 @@ void buildCycleDot() { new RankedCycle(cycleName, 0, classGraph.vertexSet(), classGraph.edgeSet(), 0, null, cycleNodes); HtmlReport htmlReport = new HtmlReport(); - String dot = htmlReport.buildCycleDot(classGraph, rankedCycle); - + CodebaseGraphDTO dto = mock(CodebaseGraphDTO.class); + HashMap map = new HashMap<>(); + map.put("A", "/src/main/java/org/hjug/refactorfirst/A.java"); + map.put("B", "/src/main/java/org/hjug/refactorfirst/B.java"); + map.put("C", "/src/main/java/org/hjug/refactorfirst/C.java"); + when(dto.getClassToSourceFilePathMapping()).thenReturn(map); + String repoUrl = "https://github.com/refactorfirst/RefactorFirst/blob"; + String dot = htmlReport.buildCycleDot(classGraph, rankedCycle, repoUrl, dto); String expectedDot = "`strict digraph G {\n" + "A -> B [ label = \"2\" weight = \"2\" ];\n" + "B -> C [ label = \"1\" weight = \"1\" ];\n" + "C -> A [ label = \"1\" weight = \"1\" ];\n" - + "A;\n" - + "B;\n" - + "C;\n" + + "A [URL=\"https://github.com/refactorfirst/RefactorFirst/blob/src/main/java/org/hjug/refactorfirst/A.java\" target=\"_blank\"];\n" + + "B [URL=\"https://github.com/refactorfirst/RefactorFirst/blob/src/main/java/org/hjug/refactorfirst/B.java\" target=\"_blank\"];\n" + + "C [URL=\"https://github.com/refactorfirst/RefactorFirst/blob/src/main/java/org/hjug/refactorfirst/C.java\" target=\"_blank\"];\n" + "}`;"; assertEquals(expectedDot, dot); diff --git a/report/src/test/resources/dotPlayground.html b/report/src/test/resources/dotPlayground.html new file mode 100644 index 00000000..2d57d335 --- /dev/null +++ b/report/src/test/resources/dotPlayground.html @@ -0,0 +1,73 @@ + + + + + Graphviz DOT in HTML + + + +

Graphviz DOT Embedded in HTML

+

Enter DOT language code below and click "Render Graph".

+ + + +
+ + + +
+ + + + + + + + From 9e767488d829882d32b6c93f7ca4156d0ccdc26c Mon Sep 17 00:00:00 2001 From: Jim Bethancourt Date: Tue, 9 Jun 2026 13:03:00 -0500 Subject: [PATCH 2/5] Use try-with-resources for gitLogReader to ensure proper resource management --- .../refactorfirst/report/SimpleHtmlReport.java | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) 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 cb058743..90fbfb05 100644 --- a/report/src/main/java/org/hjug/refactorfirst/report/SimpleHtmlReport.java +++ b/report/src/main/java/org/hjug/refactorfirst/report/SimpleHtmlReport.java @@ -132,16 +132,16 @@ public StringBuilder generateReport( stringBuilder.append(printBreadcrumbs()); stringBuilder.append(printProjectHeader(projectName, projectVersion)); - GitLogReader gitLogReader = new GitLogReader(); String projectBaseDir; Optional optionalGitDir; - - if (baseDir != null) { - projectBaseDir = baseDir.getPath(); - optionalGitDir = Optional.ofNullable(gitLogReader.getGitDir(baseDir)); - } else { - projectBaseDir = Paths.get("").toAbsolutePath().toString(); - optionalGitDir = Optional.ofNullable(gitLogReader.getGitDir(new File(projectBaseDir))); + try (GitLogReader gitLogReader = new GitLogReader()) { + if (baseDir != null) { + projectBaseDir = baseDir.getPath(); + optionalGitDir = Optional.ofNullable(gitLogReader.getGitDir(baseDir)); + } else { + projectBaseDir = Paths.get("").toAbsolutePath().toString(); + optionalGitDir = Optional.ofNullable(gitLogReader.getGitDir(new File(projectBaseDir))); + } } File gitDir; From e5ff55abdbcff2438524e57d566d17109fde98eb Mon Sep 17 00:00:00 2001 From: Jim Bethancourt Date: Tue, 9 Jun 2026 13:37:07 -0500 Subject: [PATCH 3/5] Revert "Use try-with-resources for gitLogReader to ensure proper resource management" This reverts commit 9e767488d829882d32b6c93f7ca4156d0ccdc26c. --- .../refactorfirst/report/SimpleHtmlReport.java | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) 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 90fbfb05..cb058743 100644 --- a/report/src/main/java/org/hjug/refactorfirst/report/SimpleHtmlReport.java +++ b/report/src/main/java/org/hjug/refactorfirst/report/SimpleHtmlReport.java @@ -132,16 +132,16 @@ public StringBuilder generateReport( stringBuilder.append(printBreadcrumbs()); stringBuilder.append(printProjectHeader(projectName, projectVersion)); + GitLogReader gitLogReader = new GitLogReader(); String projectBaseDir; Optional optionalGitDir; - try (GitLogReader gitLogReader = new GitLogReader()) { - if (baseDir != null) { - projectBaseDir = baseDir.getPath(); - optionalGitDir = Optional.ofNullable(gitLogReader.getGitDir(baseDir)); - } else { - projectBaseDir = Paths.get("").toAbsolutePath().toString(); - optionalGitDir = Optional.ofNullable(gitLogReader.getGitDir(new File(projectBaseDir))); - } + + if (baseDir != null) { + projectBaseDir = baseDir.getPath(); + optionalGitDir = Optional.ofNullable(gitLogReader.getGitDir(baseDir)); + } else { + projectBaseDir = Paths.get("").toAbsolutePath().toString(); + optionalGitDir = Optional.ofNullable(gitLogReader.getGitDir(new File(projectBaseDir))); } File gitDir; From 6bb8da97feedecc2cfa656cbc04c02ae87bec3e4 Mon Sep 17 00:00:00 2001 From: Jim Bethancourt Date: Tue, 9 Jun 2026 14:04:04 -0500 Subject: [PATCH 4/5] Improve menu --- .../hjug/refactorfirst/report/HtmlReport.java | 18 +++-- .../report/SimpleHtmlReport.java | 68 +++++++++++-------- 2 files changed, 51 insertions(+), 35 deletions(-) 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 377b2dde..6de751ef 100644 --- a/report/src/main/java/org/hjug/refactorfirst/report/HtmlReport.java +++ b/report/src/main/java/org/hjug/refactorfirst/report/HtmlReport.java @@ -1,9 +1,6 @@ package org.hjug.refactorfirst.report; -import java.util.HashSet; -import java.util.List; -import java.util.Locale; -import java.util.Set; +import java.util.*; import lombok.extern.slf4j.Slf4j; import org.hjug.cbc.RankedCycle; import org.hjug.cbc.RankedDisharmony; @@ -418,6 +415,17 @@ public String printTitle(String projectName, String projectVersion) { return "Refactor First Report for " + projectName + " " + projectVersion + " \n"; } + @Override + StringBuilder createMenu( + List disharmonySpecs, + Map> rankedDisharmoniesByAnchor, + List rankedCycles) { + StringBuilder stringBuilder = new StringBuilder(); + stringBuilder.append("
  • Class Map
  • \n"); + stringBuilder.append(super.createMenu(disharmonySpecs, rankedDisharmoniesByAnchor, rankedCycles)); + return stringBuilder; + } + @Override String renderGithubButtons() { return "
    \n" + "Show RefactorFirst some ❤️\n" @@ -492,7 +500,7 @@ public String renderClassGraphVisuals(String repoUrl, CodebaseGraphDTO codebaseG private StringBuilder generateGraphButtons(String graphName, String dot) { StringBuilder stringBuilder = new StringBuilder(); - stringBuilder.append("

    Class Map

    "); + stringBuilder.append("

    Class Map

    "); stringBuilder.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 cb058743..b17a8739 100644 --- a/report/src/main/java/org/hjug/refactorfirst/report/SimpleHtmlReport.java +++ b/report/src/main/java/org/hjug/refactorfirst/report/SimpleHtmlReport.java @@ -308,35 +308,9 @@ public StringBuilder generateReport( } stringBuilder.append("
    \n" + "\n" + "
    \n"); + log.info("Generating HTML Report"); stringBuilder.append(renderClassGraphVisuals(repoUrl, codebaseGraphDTO)); @@ -366,6 +340,40 @@ public StringBuilder generateReport( return stringBuilder; } + StringBuilder createMenu( + List disharmonySpecs, + Map> rankedDisharmoniesByAnchor, + List rankedCycles) { + StringBuilder menu = new StringBuilder(); + if (!edgesToRemove.isEmpty()) { + menu.append("
  • Edges To Remove
  • \n"); + } + + if (!disharmonySpecs.isEmpty()) { + menu.append("
  • Disharmonies\n" + "
      "); + } + + for (DisharmonySpec spec : disharmonySpecs) { + if (rankedDisharmoniesByAnchor.containsKey(spec.anchorId())) { + menu.append("
    • ") + .append(spec.title()) + .append("
    • \n"); + } + } + + if (!disharmonySpecs.isEmpty()) { + menu.append("
    \n" + "
  • "); + } + + if (!rankedCycles.isEmpty()) { + menu.append("
  • Class Cycles
  • \n"); + menu.append("
  • Cycle Map
  • \n"); + } + return menu; + } + private String renderCycles(List rankedCycles, String repoUrl, CodebaseGraphDTO codebaseGraphDTO) { StringBuilder stringBuilder = new StringBuilder(); stringBuilder.append(renderClassCycleSummary(rankedCycles)); @@ -579,8 +587,8 @@ private String renderSingleCycle(RankedCycle cycle, String repoUrl, CodebaseGrap stringBuilder.append("
    \n"); stringBuilder.append("
    \n"); - stringBuilder.append( - "

    Largest Class Cycle : " + getClassName(cycle.getCycleName()) + "

    \n"); + stringBuilder.append("

    Largest Class Cycle : " + + getClassName(cycle.getCycleName()) + "

    \n"); stringBuilder.append( "

    Limiting number of cycles displayed to 1 to keep page load time fast

    \n"); stringBuilder.append(renderCycleVisuals(cycle, repoUrl, codebaseGraphDTO)); From 6a85c5369fae2580f39e7f5cb589482a173d037c Mon Sep 17 00:00:00 2001 From: Jim Bethancourt Date: Tue, 9 Jun 2026 14:39:25 -0500 Subject: [PATCH 5/5] #182 Add explanatory text --- .../main/java/org/hjug/refactorfirst/report/HtmlReport.java | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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 6de751ef..c21c1b4f 100644 --- a/report/src/main/java/org/hjug/refactorfirst/report/HtmlReport.java +++ b/report/src/main/java/org/hjug/refactorfirst/report/HtmlReport.java @@ -510,7 +510,9 @@ private StringBuilder generateGraphButtons(String graphName, String dot) { stringBuilder.append("
    \nRed lines represent relationships to remove.
    \n"); stringBuilder.append("Red nodes represent classes to remove.
    \n"); - stringBuilder.append("Zoom in / out with your mouse wheel and click/move to drag the image.\n"); + stringBuilder.append("Zoom in / out with your mouse wheel and click/move to drag the image.
    \n"); + stringBuilder.append( + "Clicking on a node in the DOT graph (if present below) will open its source file in the repo. It will not open a new browser window.\n"); stringBuilder.append("
    \n"); return stringBuilder; }
    Change Proneness RankEffort Rank").append(m.getName()).append("").append(m.getName()).append(" Rank
    BrainMethods