diff --git a/refactor-first-maven-plugin/src/main/java/org/hjug/mavenreport/RefactorFirstMavenJsonGenerator.java b/refactor-first-maven-plugin/src/main/java/org/hjug/mavenreport/RefactorFirstMavenJsonGenerator.java new file mode 100644 index 00000000..089f674a --- /dev/null +++ b/refactor-first-maven-plugin/src/main/java/org/hjug/mavenreport/RefactorFirstMavenJsonGenerator.java @@ -0,0 +1,65 @@ +package org.hjug.mavenreport; + +import java.io.File; +import lombok.extern.slf4j.Slf4j; +import org.apache.maven.plugin.AbstractMojo; +import org.apache.maven.plugins.annotations.LifecyclePhase; +import org.apache.maven.plugins.annotations.Mojo; +import org.apache.maven.plugins.annotations.Parameter; +import org.apache.maven.plugins.annotations.ResolutionScope; +import org.apache.maven.project.MavenProject; +import org.hjug.refactorfirst.report.JsonGenerator; + +@Slf4j +@Mojo( + name = "jsonReport", + defaultPhase = LifecyclePhase.SITE, + requiresDependencyResolution = ResolutionScope.RUNTIME, + requiresProject = true, + threadSafe = true, + inheritByDefault = false) +public class RefactorFirstMavenJsonGenerator extends AbstractMojo { + + @Parameter(property = "showDetails") + private boolean showDetails; + + @Parameter(property = "backEdgeAnalysisCount") + protected int backEdgeAnalysisCount = 50; + + @Parameter(property = "analyzeCycles") + private boolean analyzeCycles = true; + + @Parameter(property = "excludeTests") + private boolean excludeTests = true; + + @Parameter(property = "testSourceDirectory") + private String testSourceDirectory; + + @Parameter(defaultValue = "${project.name}") + private String projectName; + + @Parameter(defaultValue = "${project.version}") + private String projectVersion; + + @Parameter(readonly = true, defaultValue = "${project}") + private MavenProject project; + + @Parameter(property = "project.build.directory") + protected File outputDirectory; + + /** Generates the RefactorFirst JSON report for the current Maven project. */ + @Override + public void execute() { + JsonGenerator generator = new JsonGenerator(); + generator.execute( + backEdgeAnalysisCount, + analyzeCycles, + showDetails, + excludeTests, + testSourceDirectory, + projectName, + projectVersion, + project.getBasedir(), + outputDirectory); + } +} diff --git a/report/pom.xml b/report/pom.xml index a3a763e0..aeddfcc7 100644 --- a/report/pom.xml +++ b/report/pom.xml @@ -26,6 +26,13 @@ in.wilsonl.minifyhtml minify-html + + + com.github.spullara.mustache.java + compiler + 0.9.10 + test + 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 e343d5dd..04100a72 100644 --- a/report/src/main/java/org/hjug/refactorfirst/report/HtmlReport.java +++ b/report/src/main/java/org/hjug/refactorfirst/report/HtmlReport.java @@ -561,7 +561,8 @@ private static String generateDotImage(String graphName) { + " }\n" + "\n"; } - String buildClassGraphDot( + /** Builds raw DOT source for the complete class relationship graph. */ + String buildRawClassGraphDot( Graph classGraph, String repoUrl, CodebaseGraphDTO codebaseGraphDTO) { StringBuilder dot = new StringBuilder(); dot.append("strict digraph G {\n"); @@ -583,7 +584,13 @@ String buildClassGraphDot( renderClassVertices(classGraph, repoUrl, codebaseGraphDTO, vertexesToRender, dot); dot.append("}"); - return toJavaScriptTemplateLiteral(dot.toString()); + return dot.toString(); + } + + /** Builds escaped class-graph DOT suitable for a JavaScript template literal. */ + String buildClassGraphDot( + Graph classGraph, String repoUrl, CodebaseGraphDTO codebaseGraphDTO) { + return toJavaScriptTemplateLiteral(buildRawClassGraphDot(classGraph, repoUrl, codebaseGraphDTO)); } private void renderClassVertices( @@ -942,7 +949,8 @@ public String renderClassCycleVisuals(RankedCycle cycle, String repoUrl, Codebas return stringBuilder.toString(); } - String buildClassCycleDot( + /** Builds raw DOT source for a ranked class cycle. */ + String buildRawClassCycleDot( Graph classGraph, RankedCycle cycle, String repoUrl, @@ -959,7 +967,16 @@ String buildClassCycleDot( renderClassVertices(classGraph, repoUrl, codebaseGraphDTO, vertexSet, dot); dot.append("}"); - return toJavaScriptTemplateLiteral(dot.toString()); + return dot.toString(); + } + + /** Builds escaped class-cycle DOT suitable for a JavaScript template literal. */ + String buildClassCycleDot( + Graph classGraph, + RankedCycle cycle, + String repoUrl, + CodebaseGraphDTO codebaseGraphDTO) { + return toJavaScriptTemplateLiteral(buildRawClassCycleDot(classGraph, cycle, repoUrl, codebaseGraphDTO)); } @Override @@ -996,7 +1013,8 @@ public String renderPackageGraphVisuals(String repoUrl, CodebaseGraphDTO codebas return stringBuilder.toString(); } - String buildPackageGraphDot( + /** Builds raw DOT source for the package relationship graph. */ + String buildRawPackageGraphDot( Graph packageGraph, String repoUrl, CodebaseGraphDTO codebaseGraphDTO) { StringBuilder dot = new StringBuilder(); dot.append("strict digraph G {\n"); @@ -1017,7 +1035,13 @@ String buildPackageGraphDot( renderPackageVertices(packageGraph, repoUrl, codebaseGraphDTO, vertexesToRender, dot); dot.append("}"); - return toJavaScriptTemplateLiteral(dot.toString()); + return dot.toString(); + } + + /** Builds escaped package-graph DOT suitable for a JavaScript template literal. */ + String buildPackageGraphDot( + Graph packageGraph, String repoUrl, CodebaseGraphDTO codebaseGraphDTO) { + return toJavaScriptTemplateLiteral(buildRawPackageGraphDot(packageGraph, repoUrl, codebaseGraphDTO)); } private void renderPackageGraphEdge( diff --git a/report/src/main/java/org/hjug/refactorfirst/report/JsonGenerator.java b/report/src/main/java/org/hjug/refactorfirst/report/JsonGenerator.java new file mode 100644 index 00000000..f271116c --- /dev/null +++ b/report/src/main/java/org/hjug/refactorfirst/report/JsonGenerator.java @@ -0,0 +1,670 @@ +package org.hjug.refactorfirst.report; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import java.io.File; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.time.Instant; +import java.util.*; +import lombok.SneakyThrows; +import lombok.extern.slf4j.Slf4j; +import org.hjug.cbc.CostBenefitCalculator; +import org.hjug.cbc.CycleRanker; +import org.hjug.cbc.RankedCycle; +import org.hjug.cbc.RankedDisharmony; +import org.hjug.feedback.CycleRemovalComputer; +import org.hjug.feedback.CycleRemovalResult; +import org.hjug.git.GitLogReader; +import org.hjug.graphbuilder.CodebaseGraphDTO; +import org.hjug.graphbuilder.metrics.DisharmonyMetric; +import org.hjug.metrics.DisharmonyInstance; +import org.hjug.refactorfirst.report.model.*; +import org.jgrapht.graph.DefaultWeightedEdge; + +@Slf4j +public class JsonGenerator extends HtmlReport { + + public static final String DIRECTORY_NAME = ".refactorfirst"; + public static final String FILE_NAME = "refactor-first.json"; + + private final ObjectMapper objectMapper = new ObjectMapper().enable(SerializationFeature.INDENT_OUTPUT); + + /** Generates report data and writes it with the bundled viewer resources. */ + @SneakyThrows + public void execute( + int edgeAnalysisCount, + boolean analyzeCycles, + boolean showDetails, + boolean excludeTests, + String testSourceDirectory, + String projectName, + String projectVersion, + File baseDir, + File outputDir) { + + Path projectPath = baseDir != null + ? baseDir.toPath().toAbsolutePath().normalize() + : Path.of("").toAbsolutePath().normalize(); + + Path reportRoot = + outputDir != null ? outputDir.toPath().toAbsolutePath().normalize() : projectPath; + Path dotRefactorFirstDir = reportRoot.resolve(DIRECTORY_NAME); + if (!Files.exists(dotRefactorFirstDir)) { + Files.createDirectories(dotRefactorFirstDir); + } + + RefactorFirstReportDTO reportDTO = generateReportData( + showDetails, + edgeAnalysisCount, + analyzeCycles, + excludeTests, + testSourceDirectory, + projectName, + projectVersion, + projectPath.toFile()); + + String json = objectMapper.writeValueAsString(reportDTO); + + Path targetFile = dotRefactorFirstDir.resolve(FILE_NAME); + Path tempFile = dotRefactorFirstDir.resolve(FILE_NAME + ".tmp"); + Files.writeString(tempFile, json, StandardCharsets.UTF_8); + Files.move(tempFile, targetFile, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE); + + log.info("RefactorFirst JSON successfully generated at {}", targetFile.toAbsolutePath()); + + // Copy Mustache template and viewer to .refactorfirst directory + copyViewerResources(dotRefactorFirstDir); + } + + /** Copies the Mustache template and browser viewer into the report directory. */ + private void copyViewerResources(Path targetDir) { + try { + // Copy Mustache template + try (InputStream templateStream = + getClass().getResourceAsStream("/templates/refactor-first-report.mustache")) { + if (templateStream != null) { + Path templateTarget = targetDir.resolve("refactor-first-report.mustache"); + Files.copy(templateStream, templateTarget, StandardCopyOption.REPLACE_EXISTING); + log.debug("Copied Mustache template to {}", templateTarget); + } else { + log.warn("Mustache template not found in resources"); + } + } + + // Copy index.html viewer + try (InputStream viewerStream = getClass().getResourceAsStream("/viewer/index.html")) { + if (viewerStream != null) { + Path viewerTarget = targetDir.resolve("index.html"); + Files.copy(viewerStream, viewerTarget, StandardCopyOption.REPLACE_EXISTING); + log.debug("Copied viewer index.html to {}", viewerTarget); + } else { + log.warn("Viewer index.html not found in resources"); + } + } + } catch (Exception e) { + log.warn("Failed to copy viewer resources: {}", e.getMessage()); + } + } + + /** Analyzes a project and converts its findings into serializable report data. */ + public RefactorFirstReportDTO generateReportData( + boolean showDetails, + int edgeAnalysisCount, + boolean analyzeCycles, + boolean excludeTests, + String testSourceDirectory, + String projectName, + String projectVersion, + File baseDir) + throws Exception { + + if (testSourceDirectory == null || testSourceDirectory.isEmpty()) { + testSourceDirectory = "src" + File.separator + "test"; + } + + String projectBaseDir; + Optional optionalGitDir; + if (baseDir != null) { + projectBaseDir = baseDir.getPath(); + optionalGitDir = Optional.ofNullable(GitLogReader.getGitDir(baseDir)); + } else { + projectBaseDir = Path.of("").toAbsolutePath().toString(); + optionalGitDir = Optional.ofNullable(GitLogReader.getGitDir(new File(projectBaseDir))); + } + + String scanTimestamp = formatter.format(Instant.now()); + + if (optionalGitDir.isEmpty()) { + log.info("Done! No Git repository found!"); + return RefactorFirstReportDTO.builder() + .project(ProjectMetadataDTO.builder() + .name(projectName) + .version(projectVersion) + .baseDir(projectBaseDir) + .repoUrl("") + .scanTimestamp(scanTimestamp) + .hasAnyDisharmony(false) + .analysisFailed(true) + .build()) + .build(); + } + + try { + File gitDir = optionalGitDir.get(); + String parentOfGitDir = gitDir.getParentFile().getPath(); + + CycleRanker cycleRanker = new CycleRanker(projectBaseDir, parentOfGitDir); + List rankedClassCycles = List.of(); + CodebaseGraphDTO codebaseGraphDTO; + if (analyzeCycles) { + cycleRanker.generateClassReferencesGraph(excludeTests, testSourceDirectory); + codebaseGraphDTO = cycleRanker.getCodebaseGraphDTO(); + rankedClassCycles = cycleRanker.rankCycles(codebaseGraphDTO.getClassReferencesGraph()); + } else { + codebaseGraphDTO = cycleRanker.generateClassReferencesGraph(excludeTests, testSourceDirectory); + } + + classGraph = codebaseGraphDTO.getClassReferencesGraph(); + CycleRemovalComputer cycleRemovalComputer = new CycleRemovalComputer(); + + CycleRemovalResult classCycleRemovalResult = + cycleRemovalComputer.computeCycleRemovalInformation(classGraph); + Map classEdgeCycleCounts = classCycleRemovalResult.getEdgeCycleCounts(); + classRelationshipsToRemove = classCycleRemovalResult.getEdgesToRemove(); + classesToRemove = classCycleRemovalResult.getVertexesToRemove(); + classCycles = classCycleRemovalResult.getCycles(); + + packageGraph = codebaseGraphDTO.getPackageReferencesGraph(); + CycleRemovalResult packageCycleRemovalResult = + cycleRemovalComputer.computeCycleRemovalInformation(packageGraph); + Map packageEdgeCycleCounts = packageCycleRemovalResult.getEdgeCycleCounts(); + packageRelationshipsToRemove = packageCycleRemovalResult.getEdgesToRemove(); + packagesToRemove = packageCycleRemovalResult.getVertexesToRemove(); + packageCycles = packageCycleRemovalResult.getCycles(); + + Map> rankedDisharmoniesByAnchor = new LinkedHashMap<>(); + List classRelationshipDisharmonies = List.of(); + List packageRelationshipDisharmonies = List.of(); + + try (CostBenefitCalculator costBenefitCalculator = + new CostBenefitCalculator(projectBaseDir, codebaseGraphDTO.getClassToSourceFilePathMapping())) { + packageRelationshipDisharmonies = costBenefitCalculator.calculateRelationshipCostBenefitValues( + packageGraph, + packageEdgeCycleCounts, + codebaseGraphDTO, + packagesToRemove, + packageCycles, + List.of()); + classRelationshipDisharmonies = costBenefitCalculator.calculateRelationshipCostBenefitValues( + classGraph, + classEdgeCycleCounts, + codebaseGraphDTO, + classesToRemove, + packageCycles, + packageRelationshipDisharmonies); + + for (DisharmonySpec spec : DISHARMONY_SPECS) { + List instances = spec.methodLevel() + ? costBenefitCalculator.getMethodDisharmonies(codebaseGraphDTO, spec.type()) + : costBenefitCalculator.getClassDisharmonies(codebaseGraphDTO, spec.type()); + if (!instances.isEmpty()) { + rankedDisharmoniesByAnchor.put( + spec.anchorId(), costBenefitCalculator.calculateDisharmonyCostBenefitValues(instances)); + } + } + } + + boolean hasAnyDisharmony = !classRelationshipsToRemove.isEmpty() + || !packageRelationshipsToRemove.isEmpty() + || !rankedClassCycles.isEmpty() + || !rankedDisharmoniesByAnchor.isEmpty(); + + String repoUrl = getRepoUrl(projectBaseDir); + + ProjectMetadataDTO projectMetadata = ProjectMetadataDTO.builder() + .name(projectName) + .version(projectVersion) + .repoUrl(repoUrl) + .baseDir(projectBaseDir) + .scanTimestamp(scanTimestamp) + .hasAnyDisharmony(hasAnyDisharmony) + .build(); + + // 1. Class Map + int classCount = classGraph.vertexSet().size(); + int classRelationshipCount = classGraph.edgeSet().size(); + String classGraphDot = buildRawClassGraphDot(classGraph, repoUrl, codebaseGraphDTO); + GraphVisualDTO classMapDTO = GraphVisualDTO.builder() + .graphId("classGraph") + .classCount(classCount) + .relationshipCount(classRelationshipCount) + .dotThreshold(dotGraphThreshold) + .dotThresholdExceeded(classCount + classRelationshipCount >= dotGraphThreshold) + .dot(classGraphDot) + .build(); + + // 2. Class Relationships To Remove + List classRelList = new ArrayList<>(); + for (RankedDisharmony edgeInfo : classRelationshipDisharmonies) { + String[] vertexes = extractVertexes(edgeInfo.getEdge()); + String startVertex = vertexes[0].trim(); + String endVertex = vertexes[1].trim(); + + classRelList.add(ClassRelationshipDTO.builder() + .sourceClass(startVertex) + .targetClass(endVertex) + .sourceMarked(classesToRemove.contains(startVertex)) + .targetMarked(classesToRemove.contains(endVertex)) + .weight((int) classGraph.getEdgeWeight(edgeInfo.getEdge())) + .renderedLabel(renderPlainClassEdge(edgeInfo.getEdge())) + .priority(edgeInfo.getPriority()) + .cycleCount(edgeInfo.getCycleCount()) + .effortRank(edgeInfo.getEffortRank()) + .alsoRemovesPackageRelationship(edgeInfo.isPackageRelationshipShouldBeRemoved()) + .packageCycleCount(edgeInfo.getPackageCycleCount()) + .build()); + } + + ClassRelationshipsToRemoveDTO classRelationshipsToRemoveDTO = ClassRelationshipsToRemoveDTO.builder() + .cycleCount(classCycles.size()) + .relationshipsToRemoveCount(classRelationshipsToRemove.size()) + .hasRelationships(!classRelationshipsToRemove.isEmpty()) + .relationships(classRelList) + .build(); + + // 3. Package Map + int packageCount = packageGraph.vertexSet().size(); + int packageRelationshipCount = packageGraph.edgeSet().size(); + boolean hasPackageEdges = !packageGraph.edgeSet().isEmpty(); + String packageGraphDot = + hasPackageEdges ? buildRawPackageGraphDot(packageGraph, repoUrl, codebaseGraphDTO) : ""; + GraphVisualDTO packageMapDTO = GraphVisualDTO.builder() + .hasEdges(hasPackageEdges) + .graphId("packageGraph") + .classCount(packageCount) + .relationshipCount(packageRelationshipCount) + .dotThreshold(dotGraphThreshold) + .dotThresholdExceeded(packageCount + packageRelationshipCount >= dotGraphThreshold) + .dot(packageGraphDot) + .build(); + + // 4. Package Relationships To Remove + List packageRelList = new ArrayList<>(); + for (RankedDisharmony edgeInfo : packageRelationshipDisharmonies) { + String[] cells = getPackageRelationshipDisharmony(edgeInfo, repoUrl, codebaseGraphDTO); + String[] vertexes = extractVertexes(edgeInfo.getEdge()); + String startVertex = vertexes[0].trim(); + String endVertex = vertexes[1].trim(); + + List breakClassRels = + cells.length > 4 && !cells[4].isBlank() ? List.of(cells[4].split("
")) : List.of(); + + packageRelList.add(PackageRelationshipDTO.builder() + .sourcePackage(startVertex) + .targetPackage(endVertex) + .sourceMarked(packagesToRemove.contains(startVertex)) + .targetMarked(packagesToRemove.contains(endVertex)) + .weight((int) packageGraph.getEdgeWeight(edgeInfo.getEdge())) + .renderedLabel(cells[0]) + .priority(edgeInfo.getPriority()) + .cycleCount(edgeInfo.getCycleCount()) + .effortRank(edgeInfo.getEffortRank()) + .classRelationshipsToBreakPackage(breakClassRels) + .build()); + } + + PackageRelationshipsToRemoveDTO packageRelationshipsToRemoveDTO = PackageRelationshipsToRemoveDTO.builder() + .cycleCount(packageCycles.size()) + .relationshipsToRemoveCount(packageRelationshipsToRemove.size()) + .hasRelationships(!packageRelationshipsToRemove.isEmpty()) + .relationships(packageRelList) + .build(); + + // 5. Disharmonies + List disharmonySections = new ArrayList<>(); + for (DisharmonySpec spec : DISHARMONY_SPECS) { + List ranked = rankedDisharmoniesByAnchor.get(spec.anchorId()); + if (ranked != null && !ranked.isEmpty()) { + disharmonySections.add(buildDisharmonySection(spec, showDetails, ranked, repoUrl)); + } + } + + // 6. Cycles + List cycleSummaries = new ArrayList<>(); + for (RankedCycle cycle : rankedClassCycles) { + String[] summaryData = getRankedCycleSummaryData(cycle); + cycleSummaries.add(CycleSummaryDTO.builder() + .cycleName(summaryData[0]) + .priority(Integer.parseInt(summaryData[1])) + .classCount(Integer.parseInt(summaryData[2])) + .relationshipCount(Integer.parseInt(summaryData[3])) + .build()); + } + + LargestCycleDTO largestCycleDTO = null; + if (!rankedClassCycles.isEmpty()) { + RankedCycle largestCycle = rankedClassCycles.get(0); + String cycleName = getClassName(largestCycle.getCycleName()); + String cycleIdentifier = graphIdentifier(cycleName); + int cCount = largestCycle.getCycleNodes().size(); + int rCount = largestCycle.getEdgeSet().size(); + String cycleDot = buildRawClassCycleDot(classGraph, largestCycle, repoUrl, codebaseGraphDTO); + + List breakdown = new ArrayList<>(); + for (String vertex : largestCycle.getVertexSet()) { + String className; + if (classesToRemove.contains(vertex)) { + className = hyperlinkClass(vertex, repoUrl, codebaseGraphDTO) + "*"; + } else { + className = hyperlinkClass(vertex, repoUrl, codebaseGraphDTO); + } + + StringBuilder edges = new StringBuilder(); + for (DefaultWeightedEdge edge : largestCycle.getEdgeSet()) { + if (edge.toString().startsWith("(" + vertex + " :")) { + if (classRelationshipsToRemove.contains(edge)) { + edges.append(""); + edges.append(renderClassEdge(edge) + "*"); + edges.append(""); + } else { + edges.append(renderClassEdge(edge)); + } + edges.append("
\n"); + } + } + breakdown.add(CycleBreakdownRowDTO.builder() + .className(className) + .edgesHtml(edges.toString()) + .build()); + } + + largestCycleDTO = LargestCycleDTO.builder() + .hasCycleMap(true) + .cycleName(cycleName) + .cycleIdentifier(cycleIdentifier) + .classCount(cCount) + .relationshipCount(rCount) + .dotThresholdExceeded(cCount + rCount >= dotGraphThreshold) + .dot(cycleDot) + .breakdown(breakdown) + .build(); + } + + ClassCyclesDTO classCyclesDTO = ClassCyclesDTO.builder() + .hasCycles(!rankedClassCycles.isEmpty()) + .summary(cycleSummaries) + .largestCycle(largestCycleDTO) + .build(); + + return RefactorFirstReportDTO.builder() + .project(projectMetadata) + .classMap(classMapDTO) + .classRelationshipsToRemove(classRelationshipsToRemoveDTO) + .packageMap(packageMapDTO) + .packageRelationshipsToRemove(packageRelationshipsToRemoveDTO) + .hasDisharmonies(!disharmonySections.isEmpty()) + .disharmonies(disharmonySections) + .classCycles(classCyclesDTO) + .build(); + } catch (Exception e) { + log.warn("Analysis failed or git history unavailable", e); + return RefactorFirstReportDTO.builder() + .project(ProjectMetadataDTO.builder() + .name(projectName) + .version(projectVersion) + .baseDir(projectBaseDir) + .repoUrl("") + .scanTimestamp(scanTimestamp) + .hasAnyDisharmony(false) + .analysisFailed(true) + .build()) + .build(); + } + } + + /** Renders a relationship label as plain text for Mustache's escaped interpolation. */ + private String renderPlainClassEdge(DefaultWeightedEdge edge) { + String[] vertexes = extractVertexes(edge); + String startVertex = vertexes[0].trim(); + String endVertex = vertexes[1].trim(); + String startMarker = classesToRemove.contains(startVertex) ? "*" : ""; + String endMarker = classesToRemove.contains(endVertex) ? "*" : ""; + return getClassName(startVertex) + startMarker + " → " + getClassName(endVertex) + endMarker + " : " + + (int) classGraph.getEdgeWeight(edge); + } + + /** Converts ranked instances of one disharmony type into chart and table data. */ + private DisharmonySectionDTO buildDisharmonySection( + DisharmonySpec spec, boolean showDetails, List ranked, String repoUrl) { + + int maxPriority = ranked.get(ranked.size() - 1).getPriority(); + + // Chart + List bubbles = new ArrayList<>(); + for (RankedDisharmony rd : ranked) { + String label = rd.getFileName() != null + ? rd.getFileName() + : rd.getRawPriority().toString(); + bubbles.add(createBubble( + rd.getFileName(), + label, + rd.getEffortRank(), + rd.getChangePronenessRank(), + rd.getPriority(), + maxPriority)); + } + + DisharmonyChartDTO chartDTO = DisharmonyChartDTO.builder() + .canvasId("chart_" + spec.anchorId()) + .xAxisLabel("Effort to refactor") + .yAxisLabel("Relative churn (impact)") + .bubbles(bubbles) + .build(); + + // Table + List headers = new ArrayList<>(); + headers.add("Class"); + if (spec.methodLevel()) { + headers.add("Method"); + } + headers.add("Priority"); + if (showDetails) { + headers.add("Raw Priority"); + headers.add("Description"); + } + headers.add("Change Proneness Rank"); + headers.add("Effort Rank"); + if (showDetails && !ranked.isEmpty()) { + for (DisharmonyMetric m : ranked.get(0).getRankedMetrics()) { + headers.add(m.getName()); + headers.add(m.getName() + " Rank"); + } + } + boolean showPartners = !ranked.isEmpty() && ranked.get(0).getDuplicationPartners() != null; + if (showPartners) { + headers.add("Duplicate Partners"); + } + headers.add("Most Recent Commit Date"); + headers.add("Commit Count"); + if (showDetails) { + headers.add("Date of First Commit"); + headers.add("Full Path"); + } + + List rows = new ArrayList<>(); + for (RankedDisharmony rd : ranked) { + List cells = new ArrayList<>(); + + // Class link + cells.add(DisharmonyTableCellDTO.builder() + .content("" + + escapeHtmlLabel(rd.getFileName()) + "") + .align("left") + .build()); + + // Method + if (spec.methodLevel()) { + String sig = rd.getMethodSignature(); + if (!showDetails && sig != null) { + sig = getSimpleMethodSignature(sig); + } + cells.add(DisharmonyTableCellDTO.builder() + .content(escapeHtmlLabel(sig)) + .align("left") + .build()); + } + + // Priority + cells.add(DisharmonyTableCellDTO.builder() + .content(rd.getPriority().toString()) + .align("right") + .build()); + + if (showDetails) { + cells.add(DisharmonyTableCellDTO.builder() + .content(rd.getRawPriority().toString()) + .align("right") + .build()); + cells.add(DisharmonyTableCellDTO.builder() + .content(escapeHtmlLabel(rd.getDescription())) + .align("left") + .build()); + } + + // Change Proneness & Effort + cells.add(DisharmonyTableCellDTO.builder() + .content(rd.getChangePronenessRank().toString()) + .align("right") + .build()); + cells.add(DisharmonyTableCellDTO.builder() + .content(rd.getEffortRank().toString()) + .align("right") + .build()); + + if (showDetails) { + for (DisharmonyMetric m : rd.getRankedMetrics()) { + double v = m.getValue(); + String formatted = v == Math.floor(v) ? String.valueOf((long) v) : String.valueOf(v); + cells.add(DisharmonyTableCellDTO.builder() + .content(formatted) + .align("right") + .build()); + cells.add(DisharmonyTableCellDTO.builder() + .content(m.getRank() != null ? m.getRank().toString() : "") + .align("right") + .build()); + } + } + + if (showPartners) { + String duplicationPartners = rd.getDuplicationPartners(); + if (!showDetails && duplicationPartners != null) { + duplicationPartners = simplifyDuplicatePartners(duplicationPartners); + } + cells.add(DisharmonyTableCellDTO.builder() + .content( + duplicationPartners != null + ? escapeHtmlLabel(duplicationPartners).replace(";", "
") + : "") + .align("left") + .build()); + } + + cells.add(DisharmonyTableCellDTO.builder() + .content(formatter.format(rd.getMostRecentCommitTime())) + .align("right") + .build()); + cells.add(DisharmonyTableCellDTO.builder() + .content(rd.getCommitCount().toString()) + .align("right") + .build()); + + if (showDetails) { + cells.add(DisharmonyTableCellDTO.builder() + .content(formatter.format(rd.getFirstCommitTime())) + .align("right") + .build()); + cells.add(DisharmonyTableCellDTO.builder() + .content(escapeHtmlLabel(rd.getPath())) + .align("left") + .build()); + } + + rows.add(DisharmonyTableRowDTO.builder().cells(cells).build()); + } + + DisharmonyTableDTO tableDTO = + DisharmonyTableDTO.builder().headers(headers).rows(rows).build(); + + return DisharmonySectionDTO.builder() + .type(spec.type()) + .anchorId(spec.anchorId()) + .title(spec.title()) + .methodLevel(spec.methodLevel()) + .problem(spec.problem()) + .solution(spec.solution()) + .maxPriority(maxPriority) + .chart(chartDTO) + .table(tableDTO) + .build(); + } + + /** Creates a chart bubble whose size and color reflect the finding priority. */ + public ChartJsBubbleDTO createBubble( + String id, String label, int effortRank, int changePronenessRank, int priority, int maxPriority) { + + int minRadius = 6; + int maxRadius = 24; + int radius; + if (maxPriority <= 1) { + radius = maxRadius; + } else { + double fraction = (double) (maxPriority - priority) / (maxPriority - 1); + radius = (int) Math.round(minRadius + fraction * (maxRadius - minRadius)); + } + + String color; + String borderColor; + if (maxPriority <= 1 || priority == 1) { + color = "rgba(235, 64, 52, 0.75)"; + borderColor = "rgb(235, 64, 52)"; + } else if (priority == maxPriority) { + color = "rgba(39, 174, 96, 0.75)"; + borderColor = "rgb(39, 174, 96)"; + } else { + double t = (double) (priority - 1) / (maxPriority - 1); + int red = (int) Math.round(235 * (1 - t) + 39 * t); + int green = (int) Math.round(64 * (1 - t) + 174 * t); + int blue = (int) Math.round(52 * (1 - t) + 96 * t); + color = String.format("rgba(%d, %d, %d, 0.75)", red, green, blue); + borderColor = String.format("rgb(%d, %d, %d)", red, green, blue); + } + + return ChartJsBubbleDTO.builder() + .id(id) + .label(label) + .x(effortRank) + .y(changePronenessRank) + .r(radius) + .priority(priority) + .effortRank(effortRank) + .changePronenessRank(changePronenessRank) + .color(color) + .borderColor(borderColor) + .build(); + } + + /** Creates a stable HTML-safe graph identifier from a display value. */ + private static String graphIdentifier(String value) { + String original = value == null ? "" : value; + String sanitized = original.replaceAll("[^A-Za-z0-9_]", "_"); + if (sanitized.isEmpty()) { + sanitized = "cycle"; + } + return "graph_" + sanitized + "_" + Integer.toUnsignedString(original.hashCode(), 36); + } +} 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 ae5ce6be..8593c2a9 100644 --- a/report/src/main/java/org/hjug/refactorfirst/report/SimpleHtmlReport.java +++ b/report/src/main/java/org/hjug/refactorfirst/report/SimpleHtmlReport.java @@ -48,6 +48,117 @@ public class SimpleHtmlReport { public final String[] classCycleTableHeadings = {"Classes", "Relationships"}; + public static final List DISHARMONY_SPECS = List.of( + new DisharmonySpec( + DisharmonyTypes.GOD_CLASS, + "GOD", + "God Classes", + false, + "God Classes take on too much responsibility,", + "Extract related islands of functionality into separate classes. Leave God classes that don't change often alone."), + new DisharmonySpec( + DisharmonyTypes.DATA_CLASS, + "DATA_CLASS", + "Data Classes", + false, + "Data Classes are dumb data holders that other classes rely on.", + "Move the data/variable(s) to the same class as the operation."), + new DisharmonySpec( + DisharmonyTypes.BRAIN_CLASS, + "BRAIN_CLASS", + "Brain Classes", + false, + "Brain Classes are complex, lack cohesion, and have at least one Brain Method.", + "Decompose Brain Methods into smaller methods."), + new DisharmonySpec( + DisharmonyTypes.REFUSED_PARENT_BEQUEST, + "RPB", + "Refused Parent Bequest", + false, + "Child class is large and often complex, but doesn't override or use many of the parent class's methods", + "Do one or more of the following:
" + + "- Extract the child class into a separate class. Move the methods that are used from the parent class into the child class.
" + + "- Make unused protected members private in the parent class.
" + + "- If a parent class has multiple children, move methods not used by all descendants to another class."), + new DisharmonySpec( + DisharmonyTypes.TRADITION_BREAKER, + "TB", + "Tradition Breakers", + false, + "Child class adds many new public methods, but doesn't override or use many of the parent class's methods", + "Do one or more of the following:
" + + "- Make public child methods unused outside of the class non-public.
" + + "- Pull duplicated methods in child classes into the parent class.
" + + "- Move methods in the child class that are unrelated to the parent class to another class.
" + + "- Remove the child class from the hierarchy."), + new DisharmonySpec( + DisharmonyTypes.SIGNIFICANT_DUPLICATION, + "SIG_DUP", + "Significant Duplication", + false, + "Nearly identical code is found in multiple classes, leading to increased maintenance costs.", + "- Move duplicated code in the same class into a new method.
" + + "- Move duplicated code into a separate or parent class.
" + + "- Move duplicated code in two child classes or in parent/child classes into the parent class."), + new DisharmonySpec( + DisharmonyTypes.BRAIN_METHOD, + "BRAIN_METHOD", + "Brain Methods", + true, + "Method is long, complicated, and uses many variables.", + "- Decompose the method into two or more smaller methods.
" + + "- If part of the method relies heavily on an outside class, extract that functionality out of the calling method and move it to the called class."), + new DisharmonySpec( + DisharmonyTypes.FEATURE_ENVY, + "FEATURE_ENVY", + "Feature Envy", + true, + "Method is more interested in data in other classes than its own class.", + "Move the method (or part of the method) to the class where it uses the most data."), + new DisharmonySpec( + DisharmonyTypes.INTENSIVE_COUPLING, + "INTENSIVE_COUPLING", + "Intensive Coupling", + true, + "Method calls too many methods from a few unrelated classes (often in a separate package).", + "Move the calling method to a class more closely related to the other classes that the original method can call."), + new DisharmonySpec( + DisharmonyTypes.DISPERSED_COUPLING, + "DISPERSED_COUPLING", + "Dispersed Coupling", + true, + "Method calls a few methods in many classes", + "Reduce the size of the calling method. Extract methods from the calling method into the target classes."), + new DisharmonySpec( + DisharmonyTypes.SHOTGUN_SURGERY, + "SHOTGUN_SURGERY", + "Shotgun Surgery", + true, + "Method is called by many methods in many classes", + "- Move the method closer to the calling classes (move the behavior closer to the data) if it is small.
" + + "- If it is a large method, treat it as a Brain Method and decompose it into two or more smaller methods."), + new DisharmonySpec( + DisharmonyTypes.EXCESSIVE_EXTENSIONS, + "EXCESSIVE_EXTENSIONS", + "Excessive Extensions", + false, + "Class declares many extension functions across many receiver types, indicating it's trying to extend too many unrelated types.", + "Consider moving extension functions closer to the types they extend. Group related extensions into separate files or classes."), + new DisharmonySpec( + DisharmonyTypes.LARGE_SEALED_HIERARCHY, + "LARGE_SEALED_HIERARCHY", + "Large Sealed Hierarchy", + false, + "Sealed class has many permitted subtypes, making the hierarchy hard to maintain and exhaustive when expressions become unwieldy.", + "Re-evaluate the domain model. Consider grouping subtypes into intermediate sealed classes or using a different pattern."), + new DisharmonySpec( + DisharmonyTypes.DATA_CLASS_WITH_LOGIC, + "DATA_CLASS_WITH_LOGIC", + "Data Class with Logic", + false, + "Data class contains non-accessor methods with business logic, violating the data carrier principle.", + "Move business logic to separate service classes. Keep data classes as pure data holders with only accessor methods.")); + Graph classGraph; Graph packageGraph; Map> classCycles; @@ -114,6 +225,7 @@ public void execute( log.info("Done! View the report at target/site/{}", filename); } + /** Analyzes a project and renders the findings as a complete HTML report. */ public StringBuilder generateReport( boolean showDetails, int edgeAnalysisCount, @@ -202,117 +314,7 @@ public StringBuilder generateReport( packagesToRemove = packageCycleRemovalResult.getVertexesToRemove(); packageCycles = packageCycleRemovalResult.getCycles(); - // Ordered (type, anchorId, displayTitle, isMethodLevel) for all disharmonies - final List disharmonySpecs = List.of( - new DisharmonySpec( - DisharmonyTypes.GOD_CLASS, - "GOD", - "God Classes", - false, - "God Classes take on too much responsibility,", - "Extract related islands of functionality into separate classes. Leave God classes that don't change often alone."), - new DisharmonySpec( - DisharmonyTypes.DATA_CLASS, - "DATA_CLASS", - "Data Classes", - false, - "Data Classes are dumb data holders that other classes rely on.", - "Move the data/variable(s) to the same class as the operation."), - new DisharmonySpec( - DisharmonyTypes.BRAIN_CLASS, - "BRAIN_CLASS", - "Brain Classes", - false, - "Brain Classes are complex, lack cohesion, and have at least one Brain Method.", - "Decompose Brain Methods into smaller methods."), - new DisharmonySpec( - DisharmonyTypes.REFUSED_PARENT_BEQUEST, - "RPB", - "Refused Parent Bequest", - false, - "Child class is large and often complex, but doesn't override or use many of the parent class's methods", - "Do one or more of the following:
" - + "- Extract the child class into a separate class. Move the methods that are used from the parent class into the child class.
" - + "- Make unused protected members private in the parent class.
" - + "- If a parent class has multiple children, move methods not used by all descendants to another class."), - new DisharmonySpec( - DisharmonyTypes.TRADITION_BREAKER, - "TB", - "Tradition Breakers", - false, - "Child class adds many new public methods, but doesn't override or use many of the parent class's methods", - "Do one or more of the following:
" - + "- Make public child methods unused outside of the class non-public.
" - + "- Pull duplicated methods in child classes into the parent class.
" - + "- Move methods in the child class that are unrelated to the parent class to another class.
" - + "- Remove the child class from the hierarchy."), - new DisharmonySpec( - DisharmonyTypes.SIGNIFICANT_DUPLICATION, - "SIG_DUP", - "Significant Duplication", - false, - "Nearly identical code is found in multiple classes, leading to increased maintenance costs.", - "- Move duplicated code in the same class into a new method.
" - + "- Move duplicated code into a separate or parent class.
" - + "- Move duplicated code in two child classes or in parent/child classes into the parent class."), - new DisharmonySpec( - DisharmonyTypes.BRAIN_METHOD, - "BRAIN_METHOD", - "Brain Methods", - true, - "Method is long, complicated, and uses many variables.", - "- Decompose the method into two or more smaller methods.
" - + "- If part of the method relies heavily on an outside class, extract that functionality out of the calling method and move it to the called class."), - new DisharmonySpec( - DisharmonyTypes.FEATURE_ENVY, - "FEATURE_ENVY", - "Feature Envy", - true, - "Method is more interested in data in other classes than its own class.", - "Move the method (or part of the method) to the class where it uses the most data."), - new DisharmonySpec( - DisharmonyTypes.INTENSIVE_COUPLING, - "INTENSIVE_COUPLING", - "Intensive Coupling", - true, - "Method calls too many methods from a few unrelated classes (often in a separate package).", - "Move the calling method to a class more closely related to the other classes that the original method can call."), - new DisharmonySpec( - DisharmonyTypes.DISPERSED_COUPLING, - "DISPERSED_COUPLING", - "Dispersed Coupling", - true, - "Method calls a few methods in many classes", - "Reduce the size of the calling method. Extract methods from the calling method into the target classes."), - new DisharmonySpec( - DisharmonyTypes.SHOTGUN_SURGERY, - "SHOTGUN_SURGERY", - "Shotgun Surgery", - true, - "Method is called by many methods in many classes", - "- Move the method closer to the calling classes (move the behavior closer to the data) if it is small.
" - + "- If it is a large method, treat it as a Brain Method and decompose it into two or more smaller methods."), - new DisharmonySpec( - DisharmonyTypes.EXCESSIVE_EXTENSIONS, - "EXCESSIVE_EXTENSIONS", - "Excessive Extensions", - false, - "Class declares many extension functions across many receiver types, indicating it's trying to extend too many unrelated types.", - "Consider moving extension functions closer to the types they extend. Group related extensions into separate files or classes."), - new DisharmonySpec( - DisharmonyTypes.LARGE_SEALED_HIERARCHY, - "LARGE_SEALED_HIERARCHY", - "Large Sealed Hierarchy", - false, - "Sealed class has many permitted subtypes, making the hierarchy hard to maintain and exhaustive when expressions become unwieldy.", - "Re-evaluate the domain model. Consider grouping subtypes into intermediate sealed classes or using a different pattern."), - new DisharmonySpec( - DisharmonyTypes.DATA_CLASS_WITH_LOGIC, - "DATA_CLASS_WITH_LOGIC", - "Data Class with Logic", - false, - "Data class contains non-accessor methods with business logic, violating the data carrier principle.", - "Move business logic to separate service classes. Keep data classes as pure data holders with only accessor methods.")); + final List disharmonySpecs = DISHARMONY_SPECS; Map> rankedDisharmoniesByAnchor = new LinkedHashMap<>(); @@ -615,7 +617,8 @@ private String[] getPackageRelationshipDisharmonyTableHeadings() { }; } - private String[] getClassRelationshipDisharmony( + /** Builds the table cells for a ranked class relationship. */ + String[] getClassRelationshipDisharmony( RankedDisharmony edgeInfo, String repoUrl, CodebaseGraphDTO codebaseGraphDTO) { boolean removePkgRel = edgeInfo.isPackageRelationshipShouldBeRemoved(); return new String[] { @@ -628,7 +631,8 @@ private String[] getClassRelationshipDisharmony( }; } - private String[] getPackageRelationshipDisharmony( + /** Builds the table cells for a ranked package relationship. */ + String[] getPackageRelationshipDisharmony( RankedDisharmony edgeInfo, String repoUrl, CodebaseGraphDTO codebaseGraphDTO) { Set classRelationshipsInPackageRelationship = @@ -682,7 +686,8 @@ private String renderClassCycleSummary(List rankedCycles) { return stringBuilder.toString(); } - private String renderClassEdge(DefaultWeightedEdge edge) { + /** Renders a class edge without repository links for serialized report data. */ + String renderClassEdge(DefaultWeightedEdge edge) { StringBuilder edgesToCut = new StringBuilder(); String[] vertexes = extractVertexes(edge); String startVertex = vertexes[0].trim(); @@ -729,17 +734,17 @@ private String renderPackageEdge(DefaultWeightedEdge edge, String repoUrl, Codeb String startVertex = vertexes[0].trim(); String start; if (packagesToRemove.contains(startVertex)) { - start = startVertex + "*"; + start = escapeHtmlLabel(startVertex) + "*"; } else { - start = startVertex; + start = escapeHtmlLabel(startVertex); } String endVertex = vertexes[1].trim(); String end; if (packagesToRemove.contains(endVertex)) { - end = endVertex + "*"; + end = escapeHtmlLabel(endVertex) + "*"; } else { - end = endVertex; + end = escapeHtmlLabel(endVertex); } // → is HTML "Right Arrow" code @@ -753,7 +758,8 @@ String hyperlinkClass(String className, String repoUrl, CodebaseGraphDTO codebas if (path == null || path.isBlank()) { return escapeHtmlLabel(getClassName(className)); } - return "" + escapeHtmlLabel(getClassName(className)) + ""; + return "" + + escapeHtmlLabel(getClassName(className)) + ""; } /** @@ -763,14 +769,23 @@ String hyperlinkClass(String className, String repoUrl, CodebaseGraphDTO codebas * surrounding anchor/table markup. */ static String escapeHtmlLabel(String label) { + if (label == null) { + return ""; + } return label.replace("&", "&").replace("<", "<").replace(">", ">"); } + /** Escapes repository-derived values for use in a quoted HTML attribute. */ + static String escapeHtmlAttribute(String value) { + return escapeHtmlLabel(value).replace("\"", """).replace("'", "'"); + } + private String[] getClassCycleSummaryTableHeadings() { return new String[] {"Cycle Name", "Priority", "Class Count", "Relationship Count"}; } - private String[] getRankedCycleSummaryData(RankedCycle rankedCycle) { + /** Builds the summary-table cells for a ranked class cycle. */ + String[] getRankedCycleSummaryData(RankedCycle rankedCycle) { return new String[] { // "Cycle Name", "Priority", "Class Count", "Relationship Count" getClassName(rankedCycle.getCycleName()), diff --git a/report/src/main/java/org/hjug/refactorfirst/report/model/ChartJsBubbleDTO.java b/report/src/main/java/org/hjug/refactorfirst/report/model/ChartJsBubbleDTO.java new file mode 100644 index 00000000..31b7f643 --- /dev/null +++ b/report/src/main/java/org/hjug/refactorfirst/report/model/ChartJsBubbleDTO.java @@ -0,0 +1,23 @@ +package org.hjug.refactorfirst.report.model; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ChartJsBubbleDTO { + private String id; + private String label; + private int x; + private int y; + private int r; + private int priority; + private int effortRank; + private int changePronenessRank; + private String color; + private String borderColor; +} diff --git a/report/src/main/java/org/hjug/refactorfirst/report/model/ClassCyclesDTO.java b/report/src/main/java/org/hjug/refactorfirst/report/model/ClassCyclesDTO.java new file mode 100644 index 00000000..bc081bf1 --- /dev/null +++ b/report/src/main/java/org/hjug/refactorfirst/report/model/ClassCyclesDTO.java @@ -0,0 +1,21 @@ +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; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ClassCyclesDTO { + private boolean hasCycles; + + @Builder.Default + private List summary = new ArrayList<>(); + + private LargestCycleDTO largestCycle; +} diff --git a/report/src/main/java/org/hjug/refactorfirst/report/model/ClassRelationshipDTO.java b/report/src/main/java/org/hjug/refactorfirst/report/model/ClassRelationshipDTO.java new file mode 100644 index 00000000..76f8ca3a --- /dev/null +++ b/report/src/main/java/org/hjug/refactorfirst/report/model/ClassRelationshipDTO.java @@ -0,0 +1,26 @@ +package org.hjug.refactorfirst.report.model; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ClassRelationshipDTO { + private String sourceClass; + private String targetClass; + private String sourceUrl; + private String targetUrl; + private boolean sourceMarked; + private boolean targetMarked; + private int weight; + private String renderedLabel; + private int priority; + private int cycleCount; + private int effortRank; + private boolean alsoRemovesPackageRelationship; + private int packageCycleCount; +} diff --git a/report/src/main/java/org/hjug/refactorfirst/report/model/ClassRelationshipsToRemoveDTO.java b/report/src/main/java/org/hjug/refactorfirst/report/model/ClassRelationshipsToRemoveDTO.java new file mode 100644 index 00000000..56272b2b --- /dev/null +++ b/report/src/main/java/org/hjug/refactorfirst/report/model/ClassRelationshipsToRemoveDTO.java @@ -0,0 +1,21 @@ +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; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ClassRelationshipsToRemoveDTO { + private int cycleCount; + private int relationshipsToRemoveCount; + private boolean hasRelationships; + + @Builder.Default + private List relationships = new ArrayList<>(); +} diff --git a/report/src/main/java/org/hjug/refactorfirst/report/model/CycleBreakdownRowDTO.java b/report/src/main/java/org/hjug/refactorfirst/report/model/CycleBreakdownRowDTO.java new file mode 100644 index 00000000..5f930aa3 --- /dev/null +++ b/report/src/main/java/org/hjug/refactorfirst/report/model/CycleBreakdownRowDTO.java @@ -0,0 +1,15 @@ +package org.hjug.refactorfirst.report.model; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class CycleBreakdownRowDTO { + private String className; + private String edgesHtml; +} diff --git a/report/src/main/java/org/hjug/refactorfirst/report/model/CycleSummaryDTO.java b/report/src/main/java/org/hjug/refactorfirst/report/model/CycleSummaryDTO.java new file mode 100644 index 00000000..c20d56a2 --- /dev/null +++ b/report/src/main/java/org/hjug/refactorfirst/report/model/CycleSummaryDTO.java @@ -0,0 +1,17 @@ +package org.hjug.refactorfirst.report.model; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class CycleSummaryDTO { + private String cycleName; + private int priority; + private int classCount; + private int relationshipCount; +} diff --git a/report/src/main/java/org/hjug/refactorfirst/report/model/DisharmonyChartDTO.java b/report/src/main/java/org/hjug/refactorfirst/report/model/DisharmonyChartDTO.java new file mode 100644 index 00000000..bcc8e884 --- /dev/null +++ b/report/src/main/java/org/hjug/refactorfirst/report/model/DisharmonyChartDTO.java @@ -0,0 +1,21 @@ +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; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class DisharmonyChartDTO { + private String canvasId; + private String xAxisLabel; + private String yAxisLabel; + + @Builder.Default + private List bubbles = new ArrayList<>(); +} diff --git a/report/src/main/java/org/hjug/refactorfirst/report/model/DisharmonySectionDTO.java b/report/src/main/java/org/hjug/refactorfirst/report/model/DisharmonySectionDTO.java new file mode 100644 index 00000000..317a7f80 --- /dev/null +++ b/report/src/main/java/org/hjug/refactorfirst/report/model/DisharmonySectionDTO.java @@ -0,0 +1,22 @@ +package org.hjug.refactorfirst.report.model; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class DisharmonySectionDTO { + private String type; + private String anchorId; + private String title; + private boolean methodLevel; + private String problem; + private String solution; + private int maxPriority; + private DisharmonyChartDTO chart; + private DisharmonyTableDTO table; +} diff --git a/report/src/main/java/org/hjug/refactorfirst/report/model/DisharmonyTableCellDTO.java b/report/src/main/java/org/hjug/refactorfirst/report/model/DisharmonyTableCellDTO.java new file mode 100644 index 00000000..ba93f900 --- /dev/null +++ b/report/src/main/java/org/hjug/refactorfirst/report/model/DisharmonyTableCellDTO.java @@ -0,0 +1,15 @@ +package org.hjug.refactorfirst.report.model; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class DisharmonyTableCellDTO { + private String content; + private String align; +} diff --git a/report/src/main/java/org/hjug/refactorfirst/report/model/DisharmonyTableDTO.java b/report/src/main/java/org/hjug/refactorfirst/report/model/DisharmonyTableDTO.java new file mode 100644 index 00000000..78c1f0b3 --- /dev/null +++ b/report/src/main/java/org/hjug/refactorfirst/report/model/DisharmonyTableDTO.java @@ -0,0 +1,20 @@ +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; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class DisharmonyTableDTO { + @Builder.Default + private List headers = new ArrayList<>(); + + @Builder.Default + private List rows = new ArrayList<>(); +} diff --git a/report/src/main/java/org/hjug/refactorfirst/report/model/DisharmonyTableRowDTO.java b/report/src/main/java/org/hjug/refactorfirst/report/model/DisharmonyTableRowDTO.java new file mode 100644 index 00000000..f7ad1159 --- /dev/null +++ b/report/src/main/java/org/hjug/refactorfirst/report/model/DisharmonyTableRowDTO.java @@ -0,0 +1,17 @@ +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; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class DisharmonyTableRowDTO { + @Builder.Default + private List cells = new ArrayList<>(); +} 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 new file mode 100644 index 00000000..577c5d1f --- /dev/null +++ b/report/src/main/java/org/hjug/refactorfirst/report/model/GraphVisualDTO.java @@ -0,0 +1,20 @@ +package org.hjug.refactorfirst.report.model; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class GraphVisualDTO { + private String graphId; + private int classCount; + private int relationshipCount; + private int dotThreshold; + private boolean dotThresholdExceeded; + private String dot; + private boolean hasEdges; +} 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 new file mode 100644 index 00000000..e6c55cee --- /dev/null +++ b/report/src/main/java/org/hjug/refactorfirst/report/model/LargestCycleDTO.java @@ -0,0 +1,25 @@ +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; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class LargestCycleDTO { + private boolean hasCycleMap; + private String cycleName; + private String cycleIdentifier; + private int classCount; + private int relationshipCount; + private String dot; + private boolean dotThresholdExceeded; + + @Builder.Default + private List breakdown = new ArrayList<>(); +} diff --git a/report/src/main/java/org/hjug/refactorfirst/report/model/PackageRelationshipDTO.java b/report/src/main/java/org/hjug/refactorfirst/report/model/PackageRelationshipDTO.java new file mode 100644 index 00000000..c38d4c10 --- /dev/null +++ b/report/src/main/java/org/hjug/refactorfirst/report/model/PackageRelationshipDTO.java @@ -0,0 +1,27 @@ +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; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class PackageRelationshipDTO { + private String sourcePackage; + private String targetPackage; + private boolean sourceMarked; + private boolean targetMarked; + private int weight; + private String renderedLabel; + private int priority; + private int cycleCount; + private int effortRank; + + @Builder.Default + private List classRelationshipsToBreakPackage = new ArrayList<>(); +} diff --git a/report/src/main/java/org/hjug/refactorfirst/report/model/PackageRelationshipsToRemoveDTO.java b/report/src/main/java/org/hjug/refactorfirst/report/model/PackageRelationshipsToRemoveDTO.java new file mode 100644 index 00000000..6abeba30 --- /dev/null +++ b/report/src/main/java/org/hjug/refactorfirst/report/model/PackageRelationshipsToRemoveDTO.java @@ -0,0 +1,21 @@ +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; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class PackageRelationshipsToRemoveDTO { + private int cycleCount; + private int relationshipsToRemoveCount; + private boolean hasRelationships; + + @Builder.Default + private List relationships = new ArrayList<>(); +} diff --git a/report/src/main/java/org/hjug/refactorfirst/report/model/ProjectMetadataDTO.java b/report/src/main/java/org/hjug/refactorfirst/report/model/ProjectMetadataDTO.java new file mode 100644 index 00000000..cb62b85f --- /dev/null +++ b/report/src/main/java/org/hjug/refactorfirst/report/model/ProjectMetadataDTO.java @@ -0,0 +1,20 @@ +package org.hjug.refactorfirst.report.model; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ProjectMetadataDTO { + private String name; + private String version; + private String repoUrl; + private String baseDir; + private String scanTimestamp; + private boolean hasAnyDisharmony; + private boolean analysisFailed; +} diff --git a/report/src/main/java/org/hjug/refactorfirst/report/model/RefactorFirstReportDTO.java b/report/src/main/java/org/hjug/refactorfirst/report/model/RefactorFirstReportDTO.java new file mode 100644 index 00000000..eef43bb7 --- /dev/null +++ b/report/src/main/java/org/hjug/refactorfirst/report/model/RefactorFirstReportDTO.java @@ -0,0 +1,26 @@ +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; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class RefactorFirstReportDTO { + private ProjectMetadataDTO project; + private GraphVisualDTO classMap; + private ClassRelationshipsToRemoveDTO classRelationshipsToRemove; + private GraphVisualDTO packageMap; + private PackageRelationshipsToRemoveDTO packageRelationshipsToRemove; + private boolean hasDisharmonies; + + @Builder.Default + private List disharmonies = new ArrayList<>(); + + private ClassCyclesDTO classCycles; +} diff --git a/report/src/main/resources/templates/refactor-first-report.mustache b/report/src/main/resources/templates/refactor-first-report.mustache new file mode 100644 index 00000000..428beaae --- /dev/null +++ b/report/src/main/resources/templates/refactor-first-report.mustache @@ -0,0 +1,444 @@ + + + + + Refactor First Report for {{project.name}} {{project.version}} + + + + + + + + + + + + + +
+ + +
+

+ RefactorFirst Report for + {{project.name}} {{project.version}} +

+ + +
+

Show RefactorFirst some ❤️

+ Star + Fork + Watch + Issue + Sponsor +
+ + +
+ +{{#project.analysisFailed}} +
+ Analysis incomplete: RefactorFirst could not finish scanning this project. + The absence of findings below does not mean the project is clean. +
+{{/project.analysisFailed}} + + +

Class Map

+ + + + + +
+ Red lines represent relationships to remove.
+ Red nodes represent classes to remove.
+ Zoom in / out with your mouse wheel and click/move to drag the image.
+ Number of classes: {{classMap.classCount}} Number of relationships: {{classMap.relationshipCount}}
+
+ +{{#classMap.dotThresholdExceeded}} +
SVG is too big to render quickly
+{{/classMap.dotThresholdExceeded}} +{{^classMap.dotThresholdExceeded}} +
+{{/classMap.dotThresholdExceeded}} + +
+
+
+
+ + +{{#classRelationshipsToRemove.hasRelationships}} + +

Refactor Starting with Priority 1

+
+ Current Class Cycle Count: {{classRelationshipsToRemove.cycleCount}}
+ Number of Class Relationships to Remove: {{classRelationshipsToRemove.relationshipsToRemoveCount}}
+ Classes with * should be broken apart
+ Removing class relationships below will eliminate class cycles +
+
+ + + + + + + + + + + + + {{#classRelationshipsToRemove.relationships}} + + + + + + + + + {{/classRelationshipsToRemove.relationships}} + +
Class RelationshipPriorityIn Class
Cycles
Relationship
Strength
Also Removes Pkg
Cycle Relationship
In Package
Cycles
{{renderedLabel}}{{priority}}{{cycleCount}}{{effortRank}}{{#alsoRemovesPackageRelationship}} + true{{/alsoRemovesPackageRelationship}}{{^alsoRemovesPackageRelationship}} + false{{/alsoRemovesPackageRelationship}}{{packageCycleCount}}
+
+{{/classRelationshipsToRemove.hasRelationships}} + +
+
+
+
+ + +{{#packageMap.hasEdges}} +

Package Map

+ + + + + +
+ Red lines represent relationships to remove.
+ Red nodes represent packages to remove.
+ Zoom in / out with your mouse wheel and click/move to drag the image.
+ Number of packages: {{packageMap.classCount}} Number of relationships: {{packageMap.relationshipCount}}
+
+ + {{#packageMap.dotThresholdExceeded}} +
SVG is too big to render quickly
+ {{/packageMap.dotThresholdExceeded}} + {{^packageMap.dotThresholdExceeded}} +
+ {{/packageMap.dotThresholdExceeded}} +{{/packageMap.hasEdges}} + +
+
+
+
+ + +{{#packageRelationshipsToRemove.hasRelationships}} + +

Refactor Starting with Priority 1

+
+ Current Package Cycle Count: {{packageRelationshipsToRemove.cycleCount}}
+ Number of Package Relationships to Remove: {{packageRelationshipsToRemove.relationshipsToRemoveCount}}
+ Packages and classes with * should be broken apart
+ Removing package relationships below will eliminate package cycles +
+
+ + + + + + + + + + + + {{#packageRelationshipsToRemove.relationships}} + + + + + + + + {{/packageRelationshipsToRemove.relationships}} + +
Package RelationshipPriorityIn Pkg
Cycles
Relationship
Strength
Class Relationships to Remove
To Break Package Relationship
{{{renderedLabel}}}{{priority}}{{cycleCount}}{{effortRank}}{{#classRelationshipsToBreakPackage}} + {{{.}}}
{{/classRelationshipsToBreakPackage}}
+
+{{/packageRelationshipsToRemove.hasRelationships}} + + +{{#disharmonies}} +
+
+
+
+ +
+ + + + + + + + + +
Problem:{{problem}}
Solution:{{{solution}}}
+
+ + +
+
+ +
+
+ +
+ +

{{title}} by the numbers: (Refactor Starting with Priority 1)

+
+ + + + {{#table.headers}} + + {{/table.headers}} + + + + {{#table.rows}} + + {{#cells}} + + {{/cells}} + + {{/table.rows}} + +
{{.}}
{{{content}}}
+
+{{/disharmonies}} + +
+
+
+
+ + +{{#classCycles.hasCycles}} + +

Class Cycles by the numbers:

+
+ + + + + + + + + + + {{#classCycles.summary}} + + + + + + + {{/classCycles.summary}} + +
Cycle NamePriorityClass CountRelationship Count
{{cycleName}}{{priority}}{{classCount}}{{relationshipCount}}
+
+ + {{#classCycles.largestCycle.hasCycleMap}} +

Largest Class Cycle : {{classCycles.largestCycle.cycleName}}

+

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

+ + + + + + +
+ + +
+ + + + + + + + + {{#classCycles.largestCycle.breakdown}} + + + + + {{/classCycles.largestCycle.breakdown}} + +
ClassesRelationships
{{{className}}}{{{edgesHtml}}}
+
+ {{/classCycles.largestCycle.hasCycleMap}} +{{/classCycles.hasCycles}} + +
+
+
+
+ Last Published: {{project.scanTimestamp}} +
+ + diff --git a/report/src/main/resources/viewer/index.html b/report/src/main/resources/viewer/index.html new file mode 100644 index 00000000..ef607130 --- /dev/null +++ b/report/src/main/resources/viewer/index.html @@ -0,0 +1,526 @@ + + + + + RefactorFirst Interactive Report Viewer + + + + + + + + + + + + +
+

Loading RefactorFirst Report...

+ +
+ + +
+ + + + + + + + + + + diff --git a/report/src/test/java/org/hjug/refactorfirst/report/DotLanguageEscapingTest.java b/report/src/test/java/org/hjug/refactorfirst/report/DotLanguageEscapingTest.java new file mode 100644 index 00000000..5cde0162 --- /dev/null +++ b/report/src/test/java/org/hjug/refactorfirst/report/DotLanguageEscapingTest.java @@ -0,0 +1,113 @@ +package org.hjug.refactorfirst.report; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.hjug.refactorfirst.report.model.GraphVisualDTO; +import org.hjug.refactorfirst.report.model.RefactorFirstReportDTO; +import org.junit.jupiter.api.Test; + +class DotLanguageEscapingTest { + + private final ObjectMapper objectMapper = new ObjectMapper(); + + /** Verifies that JSON round-trips DOT quotes and line breaks. */ + @Test + void testDotDigraphWithQuotesAndNewlines() throws Exception { + String rawDot = "strict digraph G {\n" + + " ActiveTestSuite -> TestSuite [ label = \"6\" weight = \"6\" ];\n" + + " ActiveTestSuite -> TestCase [ label = \"2\" weight = \"2\" ];\n" + + "}"; + + GraphVisualDTO classMap = GraphVisualDTO.builder() + .graphId("classGraph") + .classCount(3) + .relationshipCount(2) + .dot(rawDot) + .build(); + + RefactorFirstReportDTO report = + RefactorFirstReportDTO.builder().classMap(classMap).build(); + + String json = objectMapper.writeValueAsString(report); + + // Verify JSON escapes newlines and quotes properly + assertTrue(json.contains("\\\"6\\\"")); + assertTrue(json.contains("\\n")); + + // Verify deserialization restores the exact raw DOT string + RefactorFirstReportDTO deserialized = objectMapper.readValue(json, RefactorFirstReportDTO.class); + assertEquals(rawDot, deserialized.getClassMap().getDot()); + } + + /** Verifies that JSON preserves Java inner-class dollar signs in DOT. */ + @Test + void testDotDigraphWithJavaInnerClassDollarSign() throws Exception { + String rawDot = "strict digraph G {\n" + + " Outer_Inner [ label=\"Outer\\$Inner\" ];\n" + + " Outer_1 [ label=\"Outer\\$1\" color=red style=filled ];\n" + + " Outer -> Outer_Inner [ label = \"1\" weight = \"1\" ];\n" + + "}"; + + GraphVisualDTO classMap = + GraphVisualDTO.builder().graphId("classGraph").dot(rawDot).build(); + + RefactorFirstReportDTO report = + RefactorFirstReportDTO.builder().classMap(classMap).build(); + + String json = objectMapper.writeValueAsString(report); + // Backslash in raw string is escaped as \\ in JSON + assertTrue(json.contains("Outer\\\\$Inner")); + assertTrue(json.contains("Outer\\\\$1")); + + RefactorFirstReportDTO deserialized = objectMapper.readValue(json, RefactorFirstReportDTO.class); + assertEquals(rawDot, deserialized.getClassMap().getDot()); + } + + /** Verifies that JSON preserves Kotlin anonymous-class labels in DOT. */ + @Test + void testDotDigraphWithKotlinAnonymousLiteral() throws Exception { + String rawDot = "strict digraph G {\n" + + " DeveloperWASDControl_anonymous [ label=\"DeveloperWASDControl\\$anonymous\" ];\n" + + " lt_anonymous_gt [ label=\"\" ];\n" + + " DeveloperWASDControl -> DeveloperWASDControl_anonymous [ label = \"1\" weight = \"1\" ];\n" + + "}"; + + GraphVisualDTO classMap = + GraphVisualDTO.builder().graphId("classGraph").dot(rawDot).build(); + + RefactorFirstReportDTO report = + RefactorFirstReportDTO.builder().classMap(classMap).build(); + + String json = objectMapper.writeValueAsString(report); + assertTrue(json.contains("DeveloperWASDControl\\\\$anonymous")); + assertTrue(json.contains("")); + + RefactorFirstReportDTO deserialized = objectMapper.readValue(json, RefactorFirstReportDTO.class); + assertEquals(rawDot, deserialized.getClassMap().getDot()); + } + + /** Verifies that JSON round-trips DOT hyperlink attributes. */ + @Test + void testDotDigraphWithHyperlinkAttributes() throws Exception { + String rawDot = "strict digraph G {\n" + + " A [URL=\"https://github.com/refactorfirst/RefactorFirst/blob/src/A.java\" target=\"_blank\"];\n" + + " B [URL=\"https://github.com/refactorfirst/RefactorFirst/blob/src/B.java\" target=\"_blank\"];\n" + + " A -> B [ label = \"2\" weight = \"2\" color = \"red\" ];\n" + + "}"; + + GraphVisualDTO classMap = + GraphVisualDTO.builder().graphId("classGraph").dot(rawDot).build(); + + RefactorFirstReportDTO report = + RefactorFirstReportDTO.builder().classMap(classMap).build(); + + String json = objectMapper.writeValueAsString(report); + assertTrue(json.contains("URL=\\\"https://github.com/refactorfirst/RefactorFirst/blob/src/A.java\\\"")); + assertTrue(json.contains("target=\\\"_blank\\\"")); + + RefactorFirstReportDTO deserialized = objectMapper.readValue(json, RefactorFirstReportDTO.class); + assertEquals(rawDot, deserialized.getClassMap().getDot()); + } +} diff --git a/report/src/test/java/org/hjug/refactorfirst/report/JsonGeneratorTest.java b/report/src/test/java/org/hjug/refactorfirst/report/JsonGeneratorTest.java new file mode 100644 index 00000000..40976f2a --- /dev/null +++ b/report/src/test/java/org/hjug/refactorfirst/report/JsonGeneratorTest.java @@ -0,0 +1,197 @@ +package org.hjug.refactorfirst.report; + +import static org.junit.jupiter.api.Assertions.*; + +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.File; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Comparator; +import org.hjug.refactorfirst.report.model.ChartJsBubbleDTO; +import org.hjug.refactorfirst.report.model.RefactorFirstReportDTO; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class JsonGeneratorTest { + + private Path tempDir; + private final ObjectMapper objectMapper = new ObjectMapper(); + + /** Creates an isolated project directory for each test. */ + @BeforeEach + void setUp() throws Exception { + tempDir = Files.createTempDirectory("jsonGeneratorTest"); + } + + /** Removes the isolated project directory after each test. */ + @AfterEach + void tearDown() throws Exception { + if (tempDir != null && Files.exists(tempDir)) { + Files.walk(tempDir) + .sorted(Comparator.reverseOrder()) + .map(Path::toFile) + .forEach(File::delete); + } + } + + /** Verifies that generation creates the report directory and JSON file. */ + @Test + void testDirectoryAndFileCreatedIfNotExist() throws Exception { + JsonGenerator generator = new JsonGenerator(); + File baseDir = tempDir.toFile(); + + generator.execute(50, true, false, true, "src/test", "TestProject", "1.0.0", baseDir, null); + + Path dotRefactorFirstDir = tempDir.resolve(".refactorfirst"); + Path jsonFile = dotRefactorFirstDir.resolve("refactor-first.json"); + + assertTrue(Files.exists(dotRefactorFirstDir), ".refactorfirst directory should be created"); + assertTrue(Files.exists(jsonFile), "refactor-first.json file should be created"); + + RefactorFirstReportDTO report = objectMapper.readValue(jsonFile.toFile(), RefactorFirstReportDTO.class); + assertNotNull(report); + assertEquals("TestProject", report.getProject().getName()); + assertEquals("1.0.0", report.getProject().getVersion()); + assertTrue(report.getProject().isAnalysisFailed()); + } + + /** Verifies that a configured output directory controls where report artifacts are written. */ + @Test + void testConfiguredOutputDirectoryIsHonored() throws Exception { + Path outputDir = tempDir.resolve("custom-output"); + + new JsonGenerator() + .execute( + 50, + true, + false, + true, + "src/test", + "TestProject", + "1.0.0", + tempDir.toFile(), + outputDir.toFile()); + + assertTrue(Files.exists(outputDir.resolve(".refactorfirst/refactor-first.json"))); + assertFalse(Files.exists(tempDir.resolve(".refactorfirst/refactor-first.json"))); + + String viewer = Files.readString(outputDir.resolve(".refactorfirst/index.html")); + assertTrue(viewer.contains("accept=\".json,.mustache\" multiple")); + assertFalse(viewer.contains("getFallbackTemplate")); + } + + /** Verifies HTML encoding used for repository-derived text and attribute values. */ + @Test + void testRepositoryTextEncoding() { + assertEquals("<script>&", SimpleHtmlReport.escapeHtmlLabel(" → B") + .priority(1) + .cycleCount(3) + .effortRank(2) + .alsoRemovesPackageRelationship(true) + .packageCycleCount(1) + .build(); + + ClassRelationshipsToRemoveDTO classRels = ClassRelationshipsToRemoveDTO.builder() + .cycleCount(5) + .relationshipsToRemoveCount(3) + .hasRelationships(true) + .relationships(List.of(rel)) + .build(); + + RefactorFirstReportDTO report = RefactorFirstReportDTO.builder() + .project(project) + .classMap(classMap) + .classRelationshipsToRemove(classRels) + .packageMap(GraphVisualDTO.builder().hasEdges(false).build()) + .packageRelationshipsToRemove(PackageRelationshipsToRemoveDTO.builder() + .cycleCount(0) + .relationshipsToRemoveCount(0) + .relationships(List.of()) + .build()) + .hasDisharmonies(false) + .disharmonies(List.of()) + .classCycles(ClassCyclesDTO.builder().hasCycles(false).build()) + .build(); + + String rendered = renderTemplate(template, report); + + // Verify table headers + assertTrue(rendered.contains("Class Relationship")); + assertTrue(rendered.contains("Priority")); + assertTrue(rendered.contains("In Class
Cycles")); + assertTrue(rendered.contains("Relationship
Strength")); + assertTrue(rendered.contains("Also Removes Pkg
Cycle Relationship")); + assertTrue(rendered.contains("In Package
Cycles")); + + // Verify table data - alsoRemovesPackageRelationship renders true + assertTrue(rendered.contains("true")); + assertTrue(rendered.contains("A <script>alert(1)</script> → B")); + assertFalse(rendered.contains("")); + } + + /** Verifies that the template renders disharmony charts and tables. */ + @Test + void testTemplateRendersDisharmonyCanvases() throws Exception { + String template = loadTemplate(); + + ProjectMetadataDTO project = ProjectMetadataDTO.builder() + .name("TestProject") + .version("1.0.0") + .repoUrl("https://github.com/test/test") + .baseDir("/test") + .scanTimestamp("9/8/26, 7:34 PM") + .hasAnyDisharmony(true) + .build(); + + GraphVisualDTO classMap = GraphVisualDTO.builder() + .graphId("classGraph") + .classCount(10) + .relationshipCount(20) + .dot("digraph G {}") + .dotThresholdExceeded(false) + .build(); + + ChartJsBubbleDTO bubble = ChartJsBubbleDTO.builder() + .id("TestClass") + .label("TestClass.java") + .x(5) + .y(10) + .r(18) + .priority(1) + .effortRank(5) + .changePronenessRank(10) + .color("rgba(235, 64, 52, 0.75)") + .borderColor("rgb(235, 64, 52)") + .build(); + + DisharmonyChartDTO chart = DisharmonyChartDTO.builder() + .canvasId("chart_GOD") + .xAxisLabel("Effort to refactor") + .yAxisLabel("Relative churn") + .bubbles(List.of(bubble)) + .build(); + + DisharmonyTableDTO table = DisharmonyTableDTO.builder() + .headers(List.of("Class", "Priority")) + .rows(List.of(DisharmonyTableRowDTO.builder() + .cells(List.of( + DisharmonyTableCellDTO.builder() + .content("TestClass.java") + .align("left") + .build(), + DisharmonyTableCellDTO.builder() + .content("1") + .align("right") + .build())) + .build())) + .build(); + + DisharmonySectionDTO section = DisharmonySectionDTO.builder() + .type("God Class") + .anchorId("GOD") + .title("God Classes") + .methodLevel(false) + .problem("God Classes take on too much responsibility") + .solution("Extract related functionality") + .maxPriority(5) + .chart(chart) + .table(table) + .build(); + + RefactorFirstReportDTO report = RefactorFirstReportDTO.builder() + .project(project) + .classMap(classMap) + .classRelationshipsToRemove(ClassRelationshipsToRemoveDTO.builder() + .cycleCount(0) + .relationshipsToRemoveCount(0) + .relationships(List.of()) + .build()) + .packageMap(GraphVisualDTO.builder().hasEdges(false).build()) + .packageRelationshipsToRemove(PackageRelationshipsToRemoveDTO.builder() + .cycleCount(0) + .relationshipsToRemoveCount(0) + .relationships(List.of()) + .build()) + .hasDisharmonies(true) + .disharmonies(List.of(section)) + .classCycles(ClassCyclesDTO.builder().hasCycles(false).build()) + .build(); + + String rendered = renderTemplate(template, report); + + // Verify disharmony section + assertTrue(rendered.contains("

God Classes

")); + assertTrue(rendered.contains("Problem:")); + assertTrue(rendered.contains("God Classes take on too much responsibility")); + assertTrue(rendered.contains("Solution:")); + assertTrue(rendered.contains("Extract related functionality")); + + // Verify Chart.js canvas + assertTrue(rendered.contains("")); + + // Verify table + assertTrue(rendered.contains("Class")); + assertTrue(rendered.contains("Priority")); + } + + /** Verifies that the template renders cycle maps and breakdown data. */ + @Test + void testTemplateRendersCycleMapAndBreakdown() throws Exception { + String template = loadTemplate(); + + ProjectMetadataDTO project = ProjectMetadataDTO.builder() + .name("TestProject") + .version("1.0.0") + .repoUrl("https://github.com/test/test") + .baseDir("/test") + .scanTimestamp("9/8/26, 7:34 PM") + .hasAnyDisharmony(true) + .build(); + + GraphVisualDTO classMap = GraphVisualDTO.builder() + .graphId("classGraph") + .classCount(10) + .relationshipCount(20) + .dot("digraph G {}") + .dotThresholdExceeded(false) + .build(); + + CycleSummaryDTO cycleSummary = CycleSummaryDTO.builder() + .cycleName("A -> B -> C -> A") + .priority(1) + .classCount(3) + .relationshipCount(3) + .build(); + + CycleBreakdownRowDTO breakdownRow = CycleBreakdownRowDTO.builder() + .className("A*") + .edgesHtml("A → B*
") + .build(); + + LargestCycleDTO largestCycle = LargestCycleDTO.builder() + .hasCycleMap(true) + .cycleName("A -> B -> C -> A") + .cycleIdentifier("graph_A_B_C_A_abc123") + .classCount(3) + .relationshipCount(3) + .dotThresholdExceeded(false) + .dot("digraph G { A -> B; B -> C; C -> A; }") + .breakdown(List.of(breakdownRow)) + .build(); + + ClassCyclesDTO classCycles = ClassCyclesDTO.builder() + .hasCycles(true) + .summary(List.of(cycleSummary)) + .largestCycle(largestCycle) + .build(); + + RefactorFirstReportDTO report = RefactorFirstReportDTO.builder() + .project(project) + .classMap(classMap) + .classRelationshipsToRemove(ClassRelationshipsToRemoveDTO.builder() + .cycleCount(0) + .relationshipsToRemoveCount(0) + .relationships(List.of()) + .build()) + .packageMap(GraphVisualDTO.builder().hasEdges(false).build()) + .packageRelationshipsToRemove(PackageRelationshipsToRemoveDTO.builder() + .cycleCount(0) + .relationshipsToRemoveCount(0) + .relationships(List.of()) + .build()) + .hasDisharmonies(false) + .disharmonies(List.of()) + .classCycles(classCycles) + .build(); + + String rendered = renderTemplate(template, report); + + // Verify cycles summary table + assertTrue(rendered.contains("

Class Cycles

")); + assertTrue(rendered.contains("Cycle Name")); + assertTrue(rendered.contains("Priority")); + assertTrue(rendered.contains("Class Count")); + assertTrue(rendered.contains("Relationship Count")); + // Mustache escapes HTML by default: > becomes > + assertTrue(rendered.contains(CYCLE_NAME_ESCAPED)); + + // Verify cycle map section + assertTrue(rendered.contains("Largest Class Cycle")); + assertTrue(rendered.contains("Limiting number of cycles displayed to 1")); + assertTrue(rendered.contains("Show " + CYCLE_NAME_ESCAPED + " 3D Popup")); + assertTrue(rendered.contains("Show " + CYCLE_NAME_ESCAPED + " 2D Popup")); + + // Verify cycle breakdown table + assertTrue(rendered.contains("Classes")); + assertTrue(rendered.contains("Relationships")); + assertTrue(rendered.contains("*")); + } + + /** Renders a template with the supplied report data. */ + private String renderTemplate(String template, RefactorFirstReportDTO data) throws Exception { + MustacheFactory mf = new DefaultMustacheFactory(); + Mustache mustache = mf.compile(new StringReader(template), "test"); + StringWriter writer = new StringWriter(); + mustache.execute(writer, data).flush(); + return writer.toString(); + } +} diff --git a/report/src/test/java/org/hjug/refactorfirst/report/ReportDataSerializationTest.java b/report/src/test/java/org/hjug/refactorfirst/report/ReportDataSerializationTest.java new file mode 100644 index 00000000..f6ded5f4 --- /dev/null +++ b/report/src/test/java/org/hjug/refactorfirst/report/ReportDataSerializationTest.java @@ -0,0 +1,167 @@ +package org.hjug.refactorfirst.report; + +import static org.junit.jupiter.api.Assertions.*; + +import com.fasterxml.jackson.databind.ObjectMapper; +import java.util.List; +import org.hjug.refactorfirst.report.model.*; +import org.junit.jupiter.api.Test; + +class ReportDataSerializationTest { + + private final ObjectMapper objectMapper = new ObjectMapper(); + + /** Verifies project metadata JSON serialization and deserialization. */ + @Test + void testProjectMetadataSerialization() throws Exception { + ProjectMetadataDTO project = ProjectMetadataDTO.builder() + .name("JUnit") + .version("4.13.3-SNAPSHOT") + .repoUrl("https://github.com/junit-team/junit4/blob/main/") + .baseDir("/repo") + .scanTimestamp("9/8/26, 7:34 PM") + .hasAnyDisharmony(true) + .analysisFailed(true) + .build(); + + RefactorFirstReportDTO report = + RefactorFirstReportDTO.builder().project(project).build(); + + String json = objectMapper.writeValueAsString(report); + assertTrue(json.contains("\"name\":\"JUnit\"")); + assertTrue(json.contains("\"version\":\"4.13.3-SNAPSHOT\"")); + assertTrue(json.contains("\"hasAnyDisharmony\":true")); + assertTrue(json.contains("\"analysisFailed\":true")); + + RefactorFirstReportDTO deserialized = objectMapper.readValue(json, RefactorFirstReportDTO.class); + assertEquals("JUnit", deserialized.getProject().getName()); + assertEquals("4.13.3-SNAPSHOT", deserialized.getProject().getVersion()); + assertTrue(deserialized.getProject().isHasAnyDisharmony()); + assertTrue(deserialized.getProject().isAnalysisFailed()); + } + + /** Verifies disharmony chart JSON serialization and deserialization. */ + @Test + void testDisharmonyBubbleChartSerialization() throws Exception { + ChartJsBubbleDTO bubble = ChartJsBubbleDTO.builder() + .id("ComparisonCompactor") + .label("ComparisonCompactor.java") + .x(2) + .y(14) + .r(18) + .priority(1) + .effortRank(2) + .changePronenessRank(14) + .color("rgba(235, 64, 52, 0.75)") + .borderColor("rgb(235, 64, 52)") + .build(); + + DisharmonyChartDTO chart = DisharmonyChartDTO.builder() + .canvasId("chart_god") + .xAxisLabel("Effort to refactor") + .yAxisLabel("Relative churn") + .bubbles(List.of(bubble)) + .build(); + + DisharmonySectionDTO section = DisharmonySectionDTO.builder() + .type("God Class") + .anchorId("GOD") + .title("God Classes") + .methodLevel(false) + .problem("God Classes take on too much responsibility") + .solution("Extract related functionality") + .maxPriority(5) + .chart(chart) + .build(); + + RefactorFirstReportDTO report = + RefactorFirstReportDTO.builder().disharmonies(List.of(section)).build(); + + String json = objectMapper.writeValueAsString(report); + assertTrue(json.contains("\"anchorId\":\"GOD\"")); + assertTrue(json.contains("\"bubbles\":[")); + assertTrue(json.contains("\"priority\":1")); + assertTrue(json.contains("\"r\":18")); + + RefactorFirstReportDTO deserialized = objectMapper.readValue(json, RefactorFirstReportDTO.class); + assertEquals(1, deserialized.getDisharmonies().size()); + assertEquals( + 18, + deserialized + .getDisharmonies() + .get(0) + .getChart() + .getBubbles() + .get(0) + .getR()); + } + + /** Verifies disharmony table JSON serialization and deserialization. */ + @Test + void testTableRowsSerialization() throws Exception { + DisharmonyTableCellDTO cell1 = DisharmonyTableCellDTO.builder() + .content("Bar.java") + .align("left") + .build(); + DisharmonyTableCellDTO cell2 = + DisharmonyTableCellDTO.builder().content("1").align("right").build(); + + DisharmonyTableRowDTO row = + DisharmonyTableRowDTO.builder().cells(List.of(cell1, cell2)).build(); + + DisharmonyTableDTO table = DisharmonyTableDTO.builder() + .headers(List.of("Class", "Priority")) + .rows(List.of(row)) + .build(); + + DisharmonySectionDTO section = DisharmonySectionDTO.builder() + .type("Data Class") + .anchorId("DATA_CLASS") + .title("Data Classes") + .table(table) + .build(); + + RefactorFirstReportDTO report = + RefactorFirstReportDTO.builder().disharmonies(List.of(section)).build(); + + String json = objectMapper.writeValueAsString(report); + assertTrue(json.contains("Bar.java")); + + RefactorFirstReportDTO deserialized = objectMapper.readValue(json, RefactorFirstReportDTO.class); + assertEquals( + "Bar.java", + deserialized + .getDisharmonies() + .get(0) + .getTable() + .getRows() + .get(0) + .getCells() + .get(0) + .getContent()); + } + + /** Verifies JSON serialization for a report with no findings. */ + @Test + void testEmptyReportSerialization() throws Exception { + ProjectMetadataDTO project = ProjectMetadataDTO.builder() + .name("CleanProject") + .version("1.0.0") + .repoUrl("https://github.com/clean/clean") + .baseDir("/clean") + .scanTimestamp("9/8/26, 7:34 PM") + .hasAnyDisharmony(false) + .build(); + + RefactorFirstReportDTO report = RefactorFirstReportDTO.builder() + .project(project) + .disharmonies(List.of()) + .build(); + + String json = objectMapper.writeValueAsString(report); + assertTrue(json.contains("\"hasAnyDisharmony\":false")); + RefactorFirstReportDTO deserialized = objectMapper.readValue(json, RefactorFirstReportDTO.class); + assertFalse(deserialized.getProject().isHasAnyDisharmony()); + assertTrue(deserialized.getDisharmonies().isEmpty()); + } +}