From 9d08aa1f9eeff9c908d5cc7708e0e75b6fcf5dc6 Mon Sep 17 00:00:00 2001 From: slop Date: Fri, 10 Apr 2026 21:37:46 -0700 Subject: [PATCH 01/13] Add forbidden name detection, DI violations, and severity icons MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add FORBIDDEN severity level with ๐Ÿšซ icon to AntiPattern.Severity and FindingSeverity - Detect forbidden package names (utils, helpers, managers, common, misc, base, shared) - Detect dependency inversion violations: flag concrete class usage when interface exists - Update all renderers (Dashboard, ProjectReport, IncludedBuild) to use emoji severity icons - Smell classes in forbidden-named packages elevated to FORBIDDEN severity - Tests for all new detection rules --- .../srcx/analysis/AntiPatternDetector.kt | 124 ++++++++++++++++- .../gradle/srcx/analysis/ProjectAnalysis.kt | 1 + .../gradle/srcx/model/AnalysisSummary.kt | 8 +- .../gradle/srcx/report/DashboardRenderer.kt | 32 +++-- .../srcx/report/IncludedBuildRenderer.kt | 23 ++-- .../srcx/report/ProjectReportRenderer.kt | 11 +- .../srcx/analysis/AntiPatternDetectorTest.kt | 129 +++++++++++++++++- .../srcx/report/IncludedBuildRendererTest.kt | 2 +- .../srcx/report/ProjectReportAnalysisTest.kt | 4 +- 9 files changed, 299 insertions(+), 35 deletions(-) diff --git a/src/main/kotlin/zone/clanker/gradle/srcx/analysis/AntiPatternDetector.kt b/src/main/kotlin/zone/clanker/gradle/srcx/analysis/AntiPatternDetector.kt index f3b9a49..b1350c0 100644 --- a/src/main/kotlin/zone/clanker/gradle/srcx/analysis/AntiPatternDetector.kt +++ b/src/main/kotlin/zone/clanker/gradle/srcx/analysis/AntiPatternDetector.kt @@ -1,4 +1,4 @@ -@file:Suppress("ktlint:standard:filename") +@file:Suppress("ktlint:standard:filename", "TooManyFunctions") package zone.clanker.gradle.srcx.analysis @@ -30,11 +30,15 @@ data class AntiPattern( enum class Severity( val icon: String, ) { - WARNING("WARNING"), - INFO("INFO"), + FORBIDDEN("\uD83D\uDEAB"), + WARNING("โš \uFE0F"), + INFO("โ„น\uFE0F"), } } +private val FORBIDDEN_PACKAGE_NAMES = + setOf("utils", "helpers", "managers", "common", "misc", "base", "shared") + /** Detect anti-patterns across the classified components and dependency edges. */ fun detectAntiPatterns( components: List, @@ -45,10 +49,12 @@ fun detectAntiPatterns( val patterns = mutableListOf() patterns.addAll(detectSmellClasses(components, rootDir)) + patterns.addAll(detectForbiddenNames(components, rootDir)) patterns.addAll(detectSingleImplInterfaces(components, resolver, rootDir)) patterns.addAll(detectGodClasses(components, rootDir)) patterns.addAll(detectDeepInheritance(components, resolver, rootDir)) patterns.addAll(detectCircularDeps(edges)) + patterns.addAll(detectDependencyInversionViolations(components, resolver, rootDir)) patterns.addAll(detectMissingTests(components, rootDir)) return patterns.sortedWith(compareBy({ it.severity }, { it.file.path })) @@ -85,8 +91,15 @@ private fun detectSmellClasses( .filter { it.role in setOf(ComponentRole.MANAGER, ComponentRole.HELPER, ComponentRole.UTIL) } .map { c -> val roleLabel = c.role.name.lowercase() + val lastSegment = c.source.packageName.substringAfterLast(".") + val severity = + if (lastSegment in FORBIDDEN_PACKAGE_NAMES) { + AntiPattern.Severity.FORBIDDEN + } else { + AntiPattern.Severity.WARNING + } AntiPattern( - severity = AntiPattern.Severity.WARNING, + severity = severity, message = "`${c.source.simpleName}` is a $roleLabel class", file = c.source.file.relativeTo(rootDir), suggestion = @@ -95,6 +108,109 @@ private fun detectSmellClasses( ) } +@Suppress("UnusedParameter") +private fun detectForbiddenNames( + components: List, + rootDir: File, +): List { + val patterns = mutableListOf() + + // Detect classes in forbidden-named packages + val inForbiddenPackages = + components.filter { c -> + val lastSegment = c.source.packageName.substringAfterLast(".") + lastSegment in FORBIDDEN_PACKAGE_NAMES + } + val packageGroups = inForbiddenPackages.groupBy { it.source.packageName } + for ((pkg, _) in packageGroups) { + val lastSegment = pkg.substringAfterLast(".") + patterns.add( + AntiPattern( + severity = AntiPattern.Severity.FORBIDDEN, + message = "Package `$pkg` uses forbidden name `$lastSegment`", + file = File("."), + suggestion = + "Rename the package to describe what it actually does " + + "instead of using a generic catch-all name.", + ), + ) + } + + return patterns +} + +private fun detectDependencyInversionViolations( + components: List, + resolver: SupertypeResolver, + rootDir: File, +): List { + val nonTestComponents = + components.filter { c -> + !c.source.file.path + .contains("/test/") && + !c.source.file.path + .contains("\\test\\") && + !c.source.isInterface + } + + val patterns = mutableListOf() + + nonTestComponents.forEach { c -> + patterns.addAll(checkImportsForDiViolations(c, resolver, rootDir)) + } + + return patterns.distinctBy { it.message } +} + +private fun checkImportsForDiViolations( + c: ClassifiedComponent, + resolver: SupertypeResolver, + rootDir: File, +): List = + c.source.imports.mapNotNull { importedFqn -> + val importedSimpleName = importedFqn.substringAfterLast(".") + val resolved = resolver.resolve(c, importedSimpleName) ?: return@mapNotNull null + val isAbstraction = resolved.source.isInterface || resolved.source.isAbstract || resolved.source.isDataClass + if (isAbstraction) return@mapNotNull null + buildDiViolationPattern(c, resolved, resolver, rootDir) + } + +private fun buildDiViolationPattern( + c: ClassifiedComponent, + resolved: ClassifiedComponent, + resolver: SupertypeResolver, + rootDir: File, +): AntiPattern { + val implementedInterfaces = + resolved.source.supertypes + .mapNotNull { supertype -> + resolver.resolve(resolved, supertype) + }.filter { it.source.isInterface } + + return if (implementedInterfaces.isNotEmpty()) { + val ifaceName = implementedInterfaces.first().source.simpleName + AntiPattern( + severity = AntiPattern.Severity.WARNING, + message = + "Constructor takes concrete `${resolved.source.simpleName}` " + + "instead of interface `$ifaceName`", + file = c.source.file.relativeTo(rootDir), + suggestion = + "Depend on the interface `$ifaceName` instead of the concrete class " + + "to improve testability and flexibility.", + ) + } else { + AntiPattern( + severity = AntiPattern.Severity.INFO, + message = + "Dependency on concrete class `${resolved.source.simpleName}` " + + "in `${c.source.simpleName}`", + file = c.source.file.relativeTo(rootDir), + suggestion = "Consider extracting an interface for `${resolved.source.simpleName}`.", + ) + } +} + private fun detectSingleImplInterfaces( components: List, resolver: SupertypeResolver, diff --git a/src/main/kotlin/zone/clanker/gradle/srcx/analysis/ProjectAnalysis.kt b/src/main/kotlin/zone/clanker/gradle/srcx/analysis/ProjectAnalysis.kt index 0507c63..6411b0f 100644 --- a/src/main/kotlin/zone/clanker/gradle/srcx/analysis/ProjectAnalysis.kt +++ b/src/main/kotlin/zone/clanker/gradle/srcx/analysis/ProjectAnalysis.kt @@ -23,6 +23,7 @@ data class ProjectAnalysis( zone.clanker.gradle.srcx.model.Finding( severity = when (ap.severity) { + AntiPattern.Severity.FORBIDDEN -> zone.clanker.gradle.srcx.model.FindingSeverity.FORBIDDEN AntiPattern.Severity.WARNING -> zone.clanker.gradle.srcx.model.FindingSeverity.WARNING AntiPattern.Severity.INFO -> zone.clanker.gradle.srcx.model.FindingSeverity.INFO }, diff --git a/src/main/kotlin/zone/clanker/gradle/srcx/model/AnalysisSummary.kt b/src/main/kotlin/zone/clanker/gradle/srcx/model/AnalysisSummary.kt index 06a2a52..f0e5332 100644 --- a/src/main/kotlin/zone/clanker/gradle/srcx/model/AnalysisSummary.kt +++ b/src/main/kotlin/zone/clanker/gradle/srcx/model/AnalysisSummary.kt @@ -3,7 +3,13 @@ package zone.clanker.gradle.srcx.model /** * Severity level of an analysis finding. */ -enum class FindingSeverity { WARNING, INFO } +enum class FindingSeverity( + val icon: String, +) { + FORBIDDEN("\uD83D\uDEAB"), + WARNING("โš \uFE0F"), + INFO("โ„น\uFE0F"), +} /** * A single finding from code analysis. diff --git a/src/main/kotlin/zone/clanker/gradle/srcx/report/DashboardRenderer.kt b/src/main/kotlin/zone/clanker/gradle/srcx/report/DashboardRenderer.kt index 11a087f..37e58a3 100644 --- a/src/main/kotlin/zone/clanker/gradle/srcx/report/DashboardRenderer.kt +++ b/src/main/kotlin/zone/clanker/gradle/srcx/report/DashboardRenderer.kt @@ -51,7 +51,9 @@ internal class DashboardRenderer( includedBuildSummaries.values.sumOf { projects -> projects.sumOf { it.symbols.size } } val totalWarnings = summaries.sumOf { s -> - s.analysis?.findings?.count { it.severity == FindingSeverity.WARNING } ?: 0 + s.analysis?.findings?.count { + it.severity == FindingSeverity.WARNING || it.severity == FindingSeverity.FORBIDDEN + } ?: 0 } val subprojects = summaries.flatMap { it.subprojects }.distinct() val packages = summaries.flatMap { s -> s.symbols.map { it.packageName.value } }.distinct().sorted() @@ -87,7 +89,10 @@ internal class DashboardRenderer( for (s in summaries) { if (s.symbols.isEmpty() && s.dependencies.isEmpty() && s.sourceSets.isEmpty()) continue val sets = s.sourceSets.joinToString(", ") { it.name.value }.ifEmpty { "-" } - val warnings = s.analysis?.findings?.count { it.severity == FindingSeverity.WARNING } ?: 0 + val warnings = + s.analysis?.findings?.count { + it.severity == FindingSeverity.WARNING || it.severity == FindingSeverity.FORBIDDEN + } ?: 0 appendLine("| ${s.projectPath} | ${s.symbols.size} | $sets | ${s.dependencies.size} | $warnings |") } appendLine() @@ -123,7 +128,9 @@ internal class DashboardRenderer( val symbolCount = buildSummaries?.sumOf { it.symbols.size } ?: 0 val warningCount = buildSummaries?.sumOf { s -> - s.analysis?.findings?.count { it.severity == FindingSeverity.WARNING } ?: 0 + s.analysis?.findings?.count { + it.severity == FindingSeverity.WARNING || it.severity == FindingSeverity.FORBIDDEN + } ?: 0 } ?: 0 val link = "${ref.relativePath}/.srcx/context.md" appendLine("| ${ref.name} | $projectCount | $symbolCount | $warningCount | [view]($link) |") @@ -218,26 +225,35 @@ internal class DashboardRenderer( } val combined = (allFindings + buildFindings).distinctBy { it.third.message } + val forbidden = combined.filter { it.second == FindingSeverity.FORBIDDEN } val warnings = combined.filter { it.second == FindingSeverity.WARNING } val notes = combined.filter { it.second == FindingSeverity.INFO } - if (warnings.isEmpty() && notes.isEmpty()) return + if (forbidden.isEmpty() && warnings.isEmpty() && notes.isEmpty()) return appendLine("## Problems") appendLine() + if (forbidden.isNotEmpty()) { + appendLine("### Forbidden") + appendLine() + for ((source, severity, finding) in forbidden) { + appendLine("- ${severity.icon} **$source** โ€” ${finding.message}") + } + appendLine() + } if (warnings.isNotEmpty()) { appendLine("### Warnings") appendLine() - for ((source, _, finding) in warnings) { - appendLine("- **$source** โ€” ${finding.message}") + for ((source, severity, finding) in warnings) { + appendLine("- ${severity.icon} **$source** โ€” ${finding.message}") } appendLine() } if (notes.isNotEmpty()) { appendLine("### Notes") appendLine() - for ((source, _, finding) in notes) { - appendLine("- **$source** โ€” ${finding.message}") + for ((source, severity, finding) in notes) { + appendLine("- ${severity.icon} **$source** โ€” ${finding.message}") } appendLine() } diff --git a/src/main/kotlin/zone/clanker/gradle/srcx/report/IncludedBuildRenderer.kt b/src/main/kotlin/zone/clanker/gradle/srcx/report/IncludedBuildRenderer.kt index a821654..d33179a 100644 --- a/src/main/kotlin/zone/clanker/gradle/srcx/report/IncludedBuildRenderer.kt +++ b/src/main/kotlin/zone/clanker/gradle/srcx/report/IncludedBuildRenderer.kt @@ -34,7 +34,9 @@ internal class IncludedBuildRenderer( val totalSymbols = summaries.sumOf { it.symbols.size } val totalWarnings = summaries.sumOf { s -> - s.analysis?.findings?.count { it.severity == FindingSeverity.WARNING } ?: 0 + s.analysis?.findings?.count { + it.severity == FindingSeverity.WARNING || it.severity == FindingSeverity.FORBIDDEN + } ?: 0 } val packages = summaries @@ -155,31 +157,22 @@ internal class IncludedBuildRenderer( appendLine() } + @Suppress("CyclomaticComplexMethod") private fun StringBuilder.appendProblems() { val allFindings = summaries .flatMap { s -> s.analysis?.findings ?: emptyList() } .distinctBy { it.message } - val warnings = allFindings.filter { it.severity == FindingSeverity.WARNING } - val notes = allFindings.filter { it.severity == FindingSeverity.INFO } val allCycles = summaries.flatMap { s -> s.analysis?.cycles ?: emptyList() } - if (warnings.isEmpty() && notes.isEmpty() && allCycles.isEmpty()) return + if (allFindings.isEmpty() && allCycles.isEmpty()) return appendLine("## Problems") appendLine() - if (warnings.isNotEmpty()) { - for (f in warnings) { - appendLine("- **WARNING** ${f.message}") - appendLine(" - ${f.suggestion}") - } - } - if (notes.isNotEmpty()) { - for (f in notes) { - appendLine("- **INFO** ${f.message}") - appendLine(" - ${f.suggestion}") - } + for (f in allFindings.sortedBy { it.severity }) { + appendLine("- **${f.severity.icon}** ${f.message}") + appendLine(" - ${f.suggestion}") } if (allCycles.isNotEmpty()) { appendLine() diff --git a/src/main/kotlin/zone/clanker/gradle/srcx/report/ProjectReportRenderer.kt b/src/main/kotlin/zone/clanker/gradle/srcx/report/ProjectReportRenderer.kt index 7cdabd3..4923064 100644 --- a/src/main/kotlin/zone/clanker/gradle/srcx/report/ProjectReportRenderer.kt +++ b/src/main/kotlin/zone/clanker/gradle/srcx/report/ProjectReportRenderer.kt @@ -86,18 +86,23 @@ internal class ProjectReportRenderer( } } + val forbidden = analysis.findings.filter { it.severity == FindingSeverity.FORBIDDEN } val warnings = analysis.findings.filter { it.severity == FindingSeverity.WARNING } val infos = analysis.findings.filter { it.severity == FindingSeverity.INFO } - if (warnings.isNotEmpty() || infos.isNotEmpty()) { + if (forbidden.isNotEmpty() || warnings.isNotEmpty() || infos.isNotEmpty()) { appendLine("## Findings") appendLine() + for (f in forbidden) { + appendLine("- **${f.severity.icon}** ${f.message}") + appendLine(" - ${f.suggestion}") + } for (f in warnings) { - appendLine("- **WARNING** ${f.message}") + appendLine("- **${f.severity.icon}** ${f.message}") appendLine(" - ${f.suggestion}") } for (f in infos) { - appendLine("- **INFO** ${f.message}") + appendLine("- **${f.severity.icon}** ${f.message}") appendLine(" - ${f.suggestion}") } appendLine() diff --git a/src/test/kotlin/zone/clanker/gradle/srcx/analysis/AntiPatternDetectorTest.kt b/src/test/kotlin/zone/clanker/gradle/srcx/analysis/AntiPatternDetectorTest.kt index db517f8..e92a339 100644 --- a/src/test/kotlin/zone/clanker/gradle/srcx/analysis/AntiPatternDetectorTest.kt +++ b/src/test/kotlin/zone/clanker/gradle/srcx/analysis/AntiPatternDetectorTest.kt @@ -2,6 +2,7 @@ package zone.clanker.gradle.srcx.analysis import io.kotest.core.spec.style.BehaviorSpec import io.kotest.matchers.collections.shouldBeEmpty +import io.kotest.matchers.collections.shouldHaveSize import io.kotest.matchers.shouldBe import java.io.File @@ -18,6 +19,7 @@ class AntiPatternDetectorTest : val methods: List = emptyList(), val lineCount: Int = 50, val isInterface: Boolean = false, + val isAbstract: Boolean = false, val isDataClass: Boolean = false, val supertypes: List = emptyList(), ) @@ -33,7 +35,7 @@ class AntiPatternDetectorTest : annotations = config.annotations, supertypes = config.supertypes, isInterface = config.isInterface, - isAbstract = false, + isAbstract = config.isAbstract, isObject = false, isDataClass = config.isDataClass, language = SourceFileMetadata.Language.KOTLIN, @@ -103,5 +105,130 @@ class AntiPatternDetectorTest : patterns.any { it.message.contains("only one implementation") } shouldBe true } } + + `when`("class is in a forbidden-named package") { + val helper = + component( + ComponentConfig( + simpleName = "StringHelper", + packageName = "com.example.helpers", + ), + ) + val patterns = detectAntiPatterns(listOf(helper), emptyList(), rootDir) + + then("it detects forbidden package name") { + val forbidden = + patterns.filter { it.severity == AntiPattern.Severity.FORBIDDEN } + forbidden.any { it.message.contains("forbidden name") } shouldBe true + forbidden.any { it.message.contains("helpers") } shouldBe true + } + + then("smell class in forbidden package gets FORBIDDEN severity") { + val smellPatterns = + patterns.filter { it.message.contains("helper class") } + smellPatterns shouldHaveSize 1 + smellPatterns[0].severity shouldBe AntiPattern.Severity.FORBIDDEN + } + } + + `when`("class is in a normal package with smell name") { + val helper = + component( + ComponentConfig( + simpleName = "StringHelper", + packageName = "com.example.text", + ), + ) + val patterns = detectAntiPatterns(listOf(helper), emptyList(), rootDir) + + then("smell class gets WARNING severity") { + val smellPatterns = + patterns.filter { it.message.contains("helper class") } + smellPatterns shouldHaveSize 1 + smellPatterns[0].severity shouldBe AntiPattern.Severity.WARNING + } + + then("no forbidden package name detected") { + patterns.none { it.message.contains("forbidden name") } shouldBe true + } + } + + `when`("dependency inversion is violated") { + val iface = + component( + ComponentConfig("Dispatcher", isInterface = true), + ) + val concrete = + component( + ComponentConfig( + simpleName = "AgentDispatcher", + supertypes = listOf("Dispatcher"), + ), + ) + val consumer = + component( + ComponentConfig( + simpleName = "WorkflowEngine", + imports = listOf("com.example.AgentDispatcher"), + ), + ) + val components = listOf(iface, concrete, consumer) + val edges = buildDependencyGraph(components) + val patterns = detectAntiPatterns(components, edges, rootDir) + + then("it detects the dependency inversion violation") { + val dipViolation = + patterns.filter { + it.message.contains("Constructor takes concrete") + } + dipViolation shouldHaveSize 1 + dipViolation[0].severity shouldBe AntiPattern.Severity.WARNING + dipViolation[0].message shouldBe + "Constructor takes concrete `AgentDispatcher` instead of interface `Dispatcher`" + } + } + + `when`("dependency is on concrete class without interface") { + val concrete = + component( + ComponentConfig(simpleName = "EmailSender"), + ) + val consumer = + component( + ComponentConfig( + simpleName = "NotificationService", + annotations = listOf("Service"), + imports = listOf("com.example.EmailSender"), + ), + ) + val components = listOf(concrete, consumer) + val edges = buildDependencyGraph(components) + val patterns = detectAntiPatterns(components, edges, rootDir) + + then("it suggests extracting an interface") { + val suggestions = + patterns.filter { + it.message.contains("Dependency on concrete class") + } + suggestions shouldHaveSize 1 + suggestions[0].severity shouldBe AntiPattern.Severity.INFO + suggestions[0].suggestion shouldBe + "Consider extracting an interface for `EmailSender`." + } + } + + `when`("severity FORBIDDEN has the correct icon") { + then("FORBIDDEN icon is the no-entry emoji") { + AntiPattern.Severity.FORBIDDEN.icon shouldBe "\uD83D\uDEAB" + } + + then("WARNING icon is the warning emoji") { + AntiPattern.Severity.WARNING.icon shouldBe "โš \uFE0F" + } + + then("INFO icon is the info emoji") { + AntiPattern.Severity.INFO.icon shouldBe "โ„น\uFE0F" + } + } } }) diff --git a/src/test/kotlin/zone/clanker/gradle/srcx/report/IncludedBuildRendererTest.kt b/src/test/kotlin/zone/clanker/gradle/srcx/report/IncludedBuildRendererTest.kt index c4fbcb1..7dda5af 100644 --- a/src/test/kotlin/zone/clanker/gradle/srcx/report/IncludedBuildRendererTest.kt +++ b/src/test/kotlin/zone/clanker/gradle/srcx/report/IncludedBuildRendererTest.kt @@ -124,7 +124,7 @@ class IncludedBuildRendererTest : then("it contains problems") { output shouldContain "## Problems" - output shouldContain "**WARNING** `Codec` may be doing too much" + output shouldContain "**โš \uFE0F** `Codec` may be doing too much" } then("it does not show projects table for single project") { diff --git a/src/test/kotlin/zone/clanker/gradle/srcx/report/ProjectReportAnalysisTest.kt b/src/test/kotlin/zone/clanker/gradle/srcx/report/ProjectReportAnalysisTest.kt index 13d3d13..3fe2b28 100644 --- a/src/test/kotlin/zone/clanker/gradle/srcx/report/ProjectReportAnalysisTest.kt +++ b/src/test/kotlin/zone/clanker/gradle/srcx/report/ProjectReportAnalysisTest.kt @@ -87,9 +87,9 @@ class ProjectReportAnalysisTest : then("it contains findings section") { output shouldContain "## Findings" - output shouldContain "**WARNING** `FooHelper` is a helper class" + output shouldContain "**โš \uFE0F** `FooHelper` is a helper class" output shouldContain "Move methods closer" - output shouldContain "**INFO** `Bar` has no test" + output shouldContain "**โ„น\uFE0F** `Bar` has no test" } then("it contains cycles section") { From f387428937b4a77eb3eca230ddecf48ceb2b96f7 Mon Sep 17 00:00:00 2001 From: slop Date: Fri, 10 Apr 2026 21:55:45 -0700 Subject: [PATCH 02/13] Add cross-build hub class detection ContextTask now collects source dirs from root project AND all included builds, runs the full analysis pipeline on the merged set. Hub classes referenced across build boundaries now appear in a Hot Classes (cross-build) section with a table and per-hub dependent lists. Uses model-layer AnalysisSummary to respect package boundary rules (report does not import from analysis). --- .../srcx/analysis/AntiPatternDetector.kt | 2 +- .../gradle/srcx/report/DashboardRenderer.kt | 30 +++++++++++ .../clanker/gradle/srcx/task/ContextTask.kt | 54 +++++++++++++------ 3 files changed, 69 insertions(+), 17 deletions(-) diff --git a/src/main/kotlin/zone/clanker/gradle/srcx/analysis/AntiPatternDetector.kt b/src/main/kotlin/zone/clanker/gradle/srcx/analysis/AntiPatternDetector.kt index b1350c0..968f19e 100644 --- a/src/main/kotlin/zone/clanker/gradle/srcx/analysis/AntiPatternDetector.kt +++ b/src/main/kotlin/zone/clanker/gradle/srcx/analysis/AntiPatternDetector.kt @@ -37,7 +37,7 @@ data class AntiPattern( } private val FORBIDDEN_PACKAGE_NAMES = - setOf("utils", "helpers", "managers", "common", "misc", "base", "shared") + setOf("util", "utils", "helper", "helpers", "manager", "managers", "misc", "base") /** Detect anti-patterns across the classified components and dependency edges. */ fun detectAntiPatterns( diff --git a/src/main/kotlin/zone/clanker/gradle/srcx/report/DashboardRenderer.kt b/src/main/kotlin/zone/clanker/gradle/srcx/report/DashboardRenderer.kt index 37e58a3..c7f0d5a 100644 --- a/src/main/kotlin/zone/clanker/gradle/srcx/report/DashboardRenderer.kt +++ b/src/main/kotlin/zone/clanker/gradle/srcx/report/DashboardRenderer.kt @@ -1,5 +1,6 @@ package zone.clanker.gradle.srcx.report +import zone.clanker.gradle.srcx.model.AnalysisSummary import zone.clanker.gradle.srcx.model.FindingSeverity import zone.clanker.gradle.srcx.model.HubClass import zone.clanker.gradle.srcx.model.ProjectSummary @@ -14,6 +15,7 @@ import zone.clanker.gradle.srcx.model.SymbolKind * dependencies, build dependency graph (Mermaid), per-project symbols with roles, * included build summaries with links, and a problems section. */ +@Suppress("LongParameterList") internal class DashboardRenderer( private val rootName: String, private val summaries: List, @@ -21,6 +23,7 @@ internal class DashboardRenderer( private val includedBuildSummaries: Map> = emptyMap(), private val buildEdges: List = emptyList(), private val classDiagram: String = "", + private val crossBuildAnalysis: AnalysisSummary? = null, ) { data class IncludedBuildRef( val name: String, @@ -41,6 +44,7 @@ internal class DashboardRenderer( appendBuildGraph() appendIncludedBuilds() appendSymbols() + appendCrossBuildHubs() appendClassGraph() appendProblems() } @@ -202,6 +206,30 @@ internal class DashboardRenderer( } } + private fun StringBuilder.appendCrossBuildHubs() { + val hubs = crossBuildAnalysis?.hubs ?: return + if (hubs.isEmpty()) return + appendLine("## Hot Classes (cross-build)") + appendLine() + appendLine("| Class | File | Dependents | Role |") + appendLine("|-------|------|------------|------|") + for (hub in hubs) { + appendLine( + "| `${hub.name}` | ${hub.filePath}:${hub.line} " + + "| ${hub.dependentCount} | ${hub.role} |", + ) + } + appendLine() + for (hub in hubs.filter { it.dependentCount >= HUB_DETAIL_THRESHOLD }) { + appendLine("### ${hub.name}") + appendLine() + for (dep in hub.dependents) { + appendLine("- ${dep.name} โ€” ${dep.filePath}:${dep.line}") + } + appendLine() + } + } + private fun StringBuilder.appendClassGraph() { if (classDiagram.isBlank()) return appendLine("## Class Dependencies") @@ -260,6 +288,8 @@ internal class DashboardRenderer( } companion object { + private const val HUB_DETAIL_THRESHOLD = 3 + fun projectReportPath(projectPath: String): String { val sanitized = projectPath.replace(":", "/").trimStart('/') return if (sanitized.isEmpty()) "root/context.md" else "$sanitized/context.md" diff --git a/src/main/kotlin/zone/clanker/gradle/srcx/task/ContextTask.kt b/src/main/kotlin/zone/clanker/gradle/srcx/task/ContextTask.kt index b8147c4..7791c06 100644 --- a/src/main/kotlin/zone/clanker/gradle/srcx/task/ContextTask.kt +++ b/src/main/kotlin/zone/clanker/gradle/srcx/task/ContextTask.kt @@ -15,6 +15,8 @@ import org.gradle.api.tasks.PathSensitive import org.gradle.api.tasks.PathSensitivity import org.gradle.api.tasks.TaskAction import zone.clanker.gradle.srcx.Srcx +import zone.clanker.gradle.srcx.analysis.ProjectAnalysis +import zone.clanker.gradle.srcx.analysis.analyzeProject import zone.clanker.gradle.srcx.analysis.buildDependencyGraph import zone.clanker.gradle.srcx.analysis.classifyAll import zone.clanker.gradle.srcx.analysis.generateDependencyDiagram @@ -128,7 +130,7 @@ abstract class ContextTask : DefaultTask() { val includedBuildSummaries = collectIncludedBuildSummaries(builds) val buildPairs = builds.map { it.name to it.dir } val buildEdges = ReportWriter.computeBuildEdges(buildPairs, includedBuildSummaries) - val classDiagram = generateClassDiagram(projects) + val crossBuild = analyzeCrossBuild(projects, builds, root) val renderer = DashboardRenderer( rootName = rootName.get(), @@ -136,7 +138,8 @@ abstract class ContextTask : DefaultTask() { includedBuilds = includedBuildRefs, includedBuildSummaries = includedBuildSummaries, buildEdges = buildEdges, - classDiagram = classDiagram, + classDiagram = crossBuild.first, + crossBuildAnalysis = crossBuild.second?.toSummary(), ) val dir = File(root, outDir) dir.mkdirs() @@ -155,25 +158,44 @@ abstract class ContextTask : DefaultTask() { } } - private fun generateClassDiagram(projects: Map): String { - val allDirs = - projects.values - .flatMap { projectDir -> - ProjectScanner - .discoverSourceSets(projectDir) - .flatMap { ss -> - ProjectScanner.sourceSetDirs(projectDir, ss.value) - } - }.filter { it.exists() } - if (allDirs.isEmpty()) return "" + private fun analyzeCrossBuild( + projects: Map, + builds: List, + rootDir: File, + ): Pair { + val allDirs = collectAllSourceDirs(projects, builds) + if (allDirs.isEmpty()) return "" to null return runCatching { + val analysis = analyzeProject(allDirs, rootDir) val sources = scanSources(allDirs) val components = classifyAll(sources) val depEdges = buildDependencyGraph(components) - generateDependencyDiagram(components, depEdges) + val diagram = generateDependencyDiagram(components, depEdges) + diagram to analysis }.onFailure { e -> - logger.warn("srcx: class diagram generation failed: ${e.message}") - }.getOrDefault("") + logger.warn("srcx: cross-build analysis failed: ${e.message}") + }.getOrDefault("" to null) + } + + private fun collectAllSourceDirs( + projects: Map, + builds: List, + ): List { + val rootDirs = + projects.values.flatMap { projectDir -> + ProjectScanner + .discoverSourceSets(projectDir) + .flatMap { ss -> ProjectScanner.sourceSetDirs(projectDir, ss.value) } + } + val includedDirs = + builds.flatMap { build -> + build.projects.flatMap { (_, dir) -> + ProjectScanner + .discoverSourceSets(dir) + .flatMap { ss -> ProjectScanner.sourceSetDirs(dir, ss.value) } + } + } + return (rootDirs + includedDirs).filter { it.exists() } } } From caa4c490627382b27b061b4c688fa525c17753cc Mon Sep 17 00:00:00 2001 From: slop Date: Fri, 10 Apr 2026 22:03:16 -0700 Subject: [PATCH 03/13] Make forbidden patterns configurable via DSL Add forbiddenPackages and forbiddenClassSuffixes to the srcx extension DSL. Both are additive on top of defaults. Also adds detectForbiddenClassNames for suffix-based class name detection. Usage: srcx { forbiddenPackages.add("legacy") forbiddenClassSuffixes.add("BaseActivity") } --- .../kotlin/zone/clanker/gradle/srcx/Srcx.kt | 20 +++++++++- .../srcx/analysis/AntiPatternDetector.kt | 37 +++++++++++++++---- .../gradle/srcx/analysis/ProjectAnalysis.kt | 9 ++++- .../clanker/gradle/srcx/task/ContextTask.kt | 16 +++++++- 4 files changed, 70 insertions(+), 12 deletions(-) diff --git a/src/main/kotlin/zone/clanker/gradle/srcx/Srcx.kt b/src/main/kotlin/zone/clanker/gradle/srcx/Srcx.kt index 9491d61..2bdb3ba 100644 --- a/src/main/kotlin/zone/clanker/gradle/srcx/Srcx.kt +++ b/src/main/kotlin/zone/clanker/gradle/srcx/Srcx.kt @@ -71,15 +71,23 @@ data object Srcx { } } + val DEFAULT_FORBIDDEN_PACKAGES: Set = + setOf("util", "utils", "helper", "helpers", "manager", "managers", "misc", "base") + + val DEFAULT_FORBIDDEN_CLASS_SUFFIXES: Set = + setOf("Helper", "Manager", "Utils", "Util") + /** * DSL extension registered as `srcx { }` on the Settings object. * - * Controls the output directory and auto-generation behavior. + * Controls the output directory, auto-generation, and forbidden name patterns. * * ```kotlin * srcx { * outputDir.set(".srcx") * autoGenerate.set(true) + * forbiddenPackages.add("legacy") + * forbiddenClassSuffixes.add("BaseActivity") * } * ``` * @@ -97,6 +105,12 @@ data object Srcx { /** Dependency scopes to exclude from scanning. All others are discovered automatically. */ abstract val excludeDepScopes: SetProperty + + /** Package names to flag as forbidden. Additive on top of defaults. */ + abstract val forbiddenPackages: SetProperty + + /** Class name suffixes to flag as forbidden. Additive on top of defaults. */ + abstract val forbiddenClassSuffixes: SetProperty } /** @@ -120,6 +134,8 @@ data object Srcx { extension.outputDir.convention(OUTPUT_DIR) extension.autoGenerate.convention(false) extension.excludeDepScopes.convention(DEFAULT_EXCLUDED_DEP_SCOPES) + extension.forbiddenPackages.convention(DEFAULT_FORBIDDEN_PACKAGES) + extension.forbiddenClassSuffixes.convention(DEFAULT_FORBIDDEN_CLASS_SUFFIXES) settings.gradle.rootProject( Action { rootProject -> @@ -163,6 +179,8 @@ data object Srcx { task.includedBuildInfos.set( rootProject.provider { collectIncludedBuildInfos(rootProject) }, ) + task.forbiddenPackages.convention(extension.forbiddenPackages) + task.forbiddenClassSuffixes.convention(extension.forbiddenClassSuffixes) } } val cleanTask = diff --git a/src/main/kotlin/zone/clanker/gradle/srcx/analysis/AntiPatternDetector.kt b/src/main/kotlin/zone/clanker/gradle/srcx/analysis/AntiPatternDetector.kt index 968f19e..bd562aa 100644 --- a/src/main/kotlin/zone/clanker/gradle/srcx/analysis/AntiPatternDetector.kt +++ b/src/main/kotlin/zone/clanker/gradle/srcx/analysis/AntiPatternDetector.kt @@ -36,20 +36,20 @@ data class AntiPattern( } } -private val FORBIDDEN_PACKAGE_NAMES = - setOf("util", "utils", "helper", "helpers", "manager", "managers", "misc", "base") - /** Detect anti-patterns across the classified components and dependency edges. */ fun detectAntiPatterns( components: List, edges: List, rootDir: File, + forbiddenPackages: Set = zone.clanker.gradle.srcx.Srcx.DEFAULT_FORBIDDEN_PACKAGES, + forbiddenClassSuffixes: Set = zone.clanker.gradle.srcx.Srcx.DEFAULT_FORBIDDEN_CLASS_SUFFIXES, ): List { val resolver = SupertypeResolver(components) val patterns = mutableListOf() - patterns.addAll(detectSmellClasses(components, rootDir)) - patterns.addAll(detectForbiddenNames(components, rootDir)) + patterns.addAll(detectSmellClasses(components, rootDir, forbiddenPackages)) + patterns.addAll(detectForbiddenNames(components, rootDir, forbiddenPackages)) + patterns.addAll(detectForbiddenClassNames(components, rootDir, forbiddenClassSuffixes)) patterns.addAll(detectSingleImplInterfaces(components, resolver, rootDir)) patterns.addAll(detectGodClasses(components, rootDir)) patterns.addAll(detectDeepInheritance(components, resolver, rootDir)) @@ -86,6 +86,7 @@ private class SupertypeResolver( private fun detectSmellClasses( components: List, rootDir: File, + forbiddenPackages: Set, ): List = components .filter { it.role in setOf(ComponentRole.MANAGER, ComponentRole.HELPER, ComponentRole.UTIL) } @@ -93,7 +94,7 @@ private fun detectSmellClasses( val roleLabel = c.role.name.lowercase() val lastSegment = c.source.packageName.substringAfterLast(".") val severity = - if (lastSegment in FORBIDDEN_PACKAGE_NAMES) { + if (lastSegment in forbiddenPackages) { AntiPattern.Severity.FORBIDDEN } else { AntiPattern.Severity.WARNING @@ -112,14 +113,14 @@ private fun detectSmellClasses( private fun detectForbiddenNames( components: List, rootDir: File, + forbiddenPackages: Set, ): List { val patterns = mutableListOf() - // Detect classes in forbidden-named packages val inForbiddenPackages = components.filter { c -> val lastSegment = c.source.packageName.substringAfterLast(".") - lastSegment in FORBIDDEN_PACKAGE_NAMES + lastSegment in forbiddenPackages } val packageGroups = inForbiddenPackages.groupBy { it.source.packageName } for ((pkg, _) in packageGroups) { @@ -139,6 +140,26 @@ private fun detectForbiddenNames( return patterns } +private fun detectForbiddenClassNames( + components: List, + rootDir: File, + forbiddenSuffixes: Set, +): List = + components + .filter { c -> forbiddenSuffixes.any { suffix -> c.source.simpleName.endsWith(suffix) } } + .filter { + !it.source.file.path + .contains("/test/") + }.map { c -> + val matchedSuffix = forbiddenSuffixes.first { c.source.simpleName.endsWith(it) } + AntiPattern( + severity = AntiPattern.Severity.WARNING, + message = "`${c.source.simpleName}` uses forbidden suffix `$matchedSuffix`", + file = c.source.file.relativeTo(rootDir), + suggestion = "Rename to describe what the class does instead of using a generic suffix.", + ) + } + private fun detectDependencyInversionViolations( components: List, resolver: SupertypeResolver, diff --git a/src/main/kotlin/zone/clanker/gradle/srcx/analysis/ProjectAnalysis.kt b/src/main/kotlin/zone/clanker/gradle/srcx/analysis/ProjectAnalysis.kt index 6411b0f..48c7009 100644 --- a/src/main/kotlin/zone/clanker/gradle/srcx/analysis/ProjectAnalysis.kt +++ b/src/main/kotlin/zone/clanker/gradle/srcx/analysis/ProjectAnalysis.kt @@ -61,14 +61,19 @@ data class ProjectAnalysis( * Parses source files, classifies components, builds the dependency graph, * detects anti-patterns, finds hub classes, and identifies cycles. */ -fun analyzeProject(sourceDirs: List, rootDir: File): ProjectAnalysis { +fun analyzeProject( + sourceDirs: List, + rootDir: File, + forbiddenPackages: Set = zone.clanker.gradle.srcx.Srcx.DEFAULT_FORBIDDEN_PACKAGES, + forbiddenClassSuffixes: Set = zone.clanker.gradle.srcx.Srcx.DEFAULT_FORBIDDEN_CLASS_SUFFIXES, +): ProjectAnalysis { val sources = scanSources(sourceDirs) if (sources.isEmpty()) return ProjectAnalysis(emptyList(), emptyList(), emptyMap(), emptyList()) val components = classifyAll(sources) val edges = buildDependencyGraph(components) - val antiPatterns = detectAntiPatterns(components, edges, rootDir) + val antiPatterns = detectAntiPatterns(components, edges, rootDir, forbiddenPackages, forbiddenClassSuffixes) val hubs = findHubClasses(components, edges) val roles = components.associate { it.source.simpleName to it.role } val cycles = findCycles(edges) diff --git a/src/main/kotlin/zone/clanker/gradle/srcx/task/ContextTask.kt b/src/main/kotlin/zone/clanker/gradle/srcx/task/ContextTask.kt index 7791c06..775bd92 100644 --- a/src/main/kotlin/zone/clanker/gradle/srcx/task/ContextTask.kt +++ b/src/main/kotlin/zone/clanker/gradle/srcx/task/ContextTask.kt @@ -88,6 +88,14 @@ abstract class ContextTask : DefaultTask() { @get:Internal abstract val includedBuildInfos: ListProperty + /** Package names to flag as forbidden in anti-pattern detection. */ + @get:Input + abstract val forbiddenPackages: SetProperty + + /** Class name suffixes to flag as forbidden in anti-pattern detection. */ + @get:Input + abstract val forbiddenClassSuffixes: SetProperty + init { group = Srcx.GROUP description = "Generate comprehensive context report" @@ -166,7 +174,13 @@ abstract class ContextTask : DefaultTask() { val allDirs = collectAllSourceDirs(projects, builds) if (allDirs.isEmpty()) return "" to null return runCatching { - val analysis = analyzeProject(allDirs, rootDir) + val analysis = + analyzeProject( + allDirs, + rootDir, + forbiddenPackages.get(), + forbiddenClassSuffixes.get(), + ) val sources = scanSources(allDirs) val components = classifyAll(sources) val depEdges = buildDependencyGraph(components) From fc7930640b8f2b7f579d32e98b0ae69892a7855f Mon Sep 17 00:00:00 2001 From: slop Date: Fri, 10 Apr 2026 22:07:53 -0700 Subject: [PATCH 04/13] Use varargs DSL and contains matching for forbidden patterns - forbiddenPackages("legacy", "internal") vararg syntax - forbiddenClassPatterns("Base", "Impl") vararg syntax - Class name matching uses contains instead of endsWith so BaseActivity, DataHelperImpl, AbstractManager all match - Rename DEFAULT_FORBIDDEN_CLASS_SUFFIXES to DEFAULT_FORBIDDEN_CLASS_PATTERNS - Remove class doc comments from constants --- .../kotlin/zone/clanker/gradle/srcx/Srcx.kt | 38 ++++++++++--------- .../srcx/analysis/AntiPatternDetector.kt | 14 +++---- .../gradle/srcx/analysis/ProjectAnalysis.kt | 4 +- 3 files changed, 29 insertions(+), 27 deletions(-) diff --git a/src/main/kotlin/zone/clanker/gradle/srcx/Srcx.kt b/src/main/kotlin/zone/clanker/gradle/srcx/Srcx.kt index 2bdb3ba..bc433f3 100644 --- a/src/main/kotlin/zone/clanker/gradle/srcx/Srcx.kt +++ b/src/main/kotlin/zone/clanker/gradle/srcx/Srcx.kt @@ -7,6 +7,7 @@ import org.gradle.api.initialization.Settings import org.gradle.api.logging.Logging import org.gradle.api.provider.Property import org.gradle.api.provider.SetProperty +import org.gradle.api.tasks.Internal import zone.clanker.gradle.srcx.scan.ProjectScanner import zone.clanker.gradle.srcx.scan.SymbolExtractor import zone.clanker.gradle.srcx.task.CleanTask @@ -74,20 +75,18 @@ data object Srcx { val DEFAULT_FORBIDDEN_PACKAGES: Set = setOf("util", "utils", "helper", "helpers", "manager", "managers", "misc", "base") - val DEFAULT_FORBIDDEN_CLASS_SUFFIXES: Set = + val DEFAULT_FORBIDDEN_CLASS_PATTERNS: Set = setOf("Helper", "Manager", "Utils", "Util") /** * DSL extension registered as `srcx { }` on the Settings object. * - * Controls the output directory, auto-generation, and forbidden name patterns. - * * ```kotlin * srcx { * outputDir.set(".srcx") * autoGenerate.set(true) - * forbiddenPackages.add("legacy") - * forbiddenClassSuffixes.add("BaseActivity") + * forbiddenPackages("legacy", "internal", "compat") + * forbiddenClassPatterns("Base", "Impl", "Abstract") * } * ``` * @@ -97,20 +96,23 @@ data object Srcx { abstract class SettingsExtension @Inject constructor() { - /** Output directory relative to the root project. */ abstract val outputDir: Property - - /** When true, compileKotlin/compileJava tasks will finalize with srcx-context. */ abstract val autoGenerate: Property - - /** Dependency scopes to exclude from scanning. All others are discovered automatically. */ abstract val excludeDepScopes: SetProperty - /** Package names to flag as forbidden. Additive on top of defaults. */ - abstract val forbiddenPackages: SetProperty + @get:Internal + abstract val forbiddenPackageNames: SetProperty + + @get:Internal + abstract val forbiddenClassNamePatterns: SetProperty - /** Class name suffixes to flag as forbidden. Additive on top of defaults. */ - abstract val forbiddenClassSuffixes: SetProperty + fun forbiddenPackages(vararg names: String) { + forbiddenPackageNames.addAll(names.toList()) + } + + fun forbiddenClassPatterns(vararg patterns: String) { + forbiddenClassNamePatterns.addAll(patterns.toList()) + } } /** @@ -134,8 +136,8 @@ data object Srcx { extension.outputDir.convention(OUTPUT_DIR) extension.autoGenerate.convention(false) extension.excludeDepScopes.convention(DEFAULT_EXCLUDED_DEP_SCOPES) - extension.forbiddenPackages.convention(DEFAULT_FORBIDDEN_PACKAGES) - extension.forbiddenClassSuffixes.convention(DEFAULT_FORBIDDEN_CLASS_SUFFIXES) + extension.forbiddenPackageNames.convention(DEFAULT_FORBIDDEN_PACKAGES) + extension.forbiddenClassNamePatterns.convention(DEFAULT_FORBIDDEN_CLASS_PATTERNS) settings.gradle.rootProject( Action { rootProject -> @@ -179,8 +181,8 @@ data object Srcx { task.includedBuildInfos.set( rootProject.provider { collectIncludedBuildInfos(rootProject) }, ) - task.forbiddenPackages.convention(extension.forbiddenPackages) - task.forbiddenClassSuffixes.convention(extension.forbiddenClassSuffixes) + task.forbiddenPackages.convention(extension.forbiddenPackageNames) + task.forbiddenClassSuffixes.convention(extension.forbiddenClassNamePatterns) } } val cleanTask = diff --git a/src/main/kotlin/zone/clanker/gradle/srcx/analysis/AntiPatternDetector.kt b/src/main/kotlin/zone/clanker/gradle/srcx/analysis/AntiPatternDetector.kt index bd562aa..b9237cf 100644 --- a/src/main/kotlin/zone/clanker/gradle/srcx/analysis/AntiPatternDetector.kt +++ b/src/main/kotlin/zone/clanker/gradle/srcx/analysis/AntiPatternDetector.kt @@ -42,14 +42,14 @@ fun detectAntiPatterns( edges: List, rootDir: File, forbiddenPackages: Set = zone.clanker.gradle.srcx.Srcx.DEFAULT_FORBIDDEN_PACKAGES, - forbiddenClassSuffixes: Set = zone.clanker.gradle.srcx.Srcx.DEFAULT_FORBIDDEN_CLASS_SUFFIXES, + forbiddenClassPatterns: Set = zone.clanker.gradle.srcx.Srcx.DEFAULT_FORBIDDEN_CLASS_PATTERNS, ): List { val resolver = SupertypeResolver(components) val patterns = mutableListOf() patterns.addAll(detectSmellClasses(components, rootDir, forbiddenPackages)) patterns.addAll(detectForbiddenNames(components, rootDir, forbiddenPackages)) - patterns.addAll(detectForbiddenClassNames(components, rootDir, forbiddenClassSuffixes)) + patterns.addAll(detectForbiddenClassNames(components, rootDir, forbiddenClassPatterns)) patterns.addAll(detectSingleImplInterfaces(components, resolver, rootDir)) patterns.addAll(detectGodClasses(components, rootDir)) patterns.addAll(detectDeepInheritance(components, resolver, rootDir)) @@ -143,20 +143,20 @@ private fun detectForbiddenNames( private fun detectForbiddenClassNames( components: List, rootDir: File, - forbiddenSuffixes: Set, + forbiddenPatterns: Set, ): List = components - .filter { c -> forbiddenSuffixes.any { suffix -> c.source.simpleName.endsWith(suffix) } } + .filter { c -> forbiddenPatterns.any { pattern -> c.source.simpleName.contains(pattern) } } .filter { !it.source.file.path .contains("/test/") }.map { c -> - val matchedSuffix = forbiddenSuffixes.first { c.source.simpleName.endsWith(it) } + val matched = forbiddenPatterns.first { c.source.simpleName.contains(it) } AntiPattern( severity = AntiPattern.Severity.WARNING, - message = "`${c.source.simpleName}` uses forbidden suffix `$matchedSuffix`", + message = "`${c.source.simpleName}` contains forbidden pattern `$matched`", file = c.source.file.relativeTo(rootDir), - suggestion = "Rename to describe what the class does instead of using a generic suffix.", + suggestion = "Rename to describe what the class does instead of using a generic name.", ) } diff --git a/src/main/kotlin/zone/clanker/gradle/srcx/analysis/ProjectAnalysis.kt b/src/main/kotlin/zone/clanker/gradle/srcx/analysis/ProjectAnalysis.kt index 48c7009..4092040 100644 --- a/src/main/kotlin/zone/clanker/gradle/srcx/analysis/ProjectAnalysis.kt +++ b/src/main/kotlin/zone/clanker/gradle/srcx/analysis/ProjectAnalysis.kt @@ -65,7 +65,7 @@ fun analyzeProject( sourceDirs: List, rootDir: File, forbiddenPackages: Set = zone.clanker.gradle.srcx.Srcx.DEFAULT_FORBIDDEN_PACKAGES, - forbiddenClassSuffixes: Set = zone.clanker.gradle.srcx.Srcx.DEFAULT_FORBIDDEN_CLASS_SUFFIXES, + forbiddenClassPatterns: Set = zone.clanker.gradle.srcx.Srcx.DEFAULT_FORBIDDEN_CLASS_PATTERNS, ): ProjectAnalysis { val sources = scanSources(sourceDirs) if (sources.isEmpty()) return ProjectAnalysis(emptyList(), emptyList(), emptyMap(), emptyList()) @@ -73,7 +73,7 @@ fun analyzeProject( val components = classifyAll(sources) val edges = buildDependencyGraph(components) - val antiPatterns = detectAntiPatterns(components, edges, rootDir, forbiddenPackages, forbiddenClassSuffixes) + val antiPatterns = detectAntiPatterns(components, edges, rootDir, forbiddenPackages, forbiddenClassPatterns) val hubs = findHubClasses(components, edges) val roles = components.associate { it.source.simpleName to it.role } val cycles = findCycles(edges) From 1fc9be49b4b023346c1208de441dba0e41e57991 Mon Sep 17 00:00:00 2001 From: slop Date: Fri, 10 Apr 2026 22:20:40 -0700 Subject: [PATCH 05/13] Replace string concatenation with templates, fix line length Extract long string literals into vals. Use triple-quoted templates where needed. Remove @Suppress("MaxLineLength") annotations. All messages stay as single strings, no + concatenation. --- .../srcx/analysis/AntiPatternDetector.kt | 58 +++++++++---------- .../gradle/srcx/report/DashboardRenderer.kt | 5 +- 2 files changed, 29 insertions(+), 34 deletions(-) diff --git a/src/main/kotlin/zone/clanker/gradle/srcx/analysis/AntiPatternDetector.kt b/src/main/kotlin/zone/clanker/gradle/srcx/analysis/AntiPatternDetector.kt index b9237cf..baf4f96 100644 --- a/src/main/kotlin/zone/clanker/gradle/srcx/analysis/AntiPatternDetector.kt +++ b/src/main/kotlin/zone/clanker/gradle/srcx/analysis/AntiPatternDetector.kt @@ -99,13 +99,13 @@ private fun detectSmellClasses( } else { AntiPattern.Severity.WARNING } + val suggestion = + "Behavior in $roleLabel classes belongs closer to where it's used." AntiPattern( severity = severity, message = "`${c.source.simpleName}` is a $roleLabel class", file = c.source.file.relativeTo(rootDir), - suggestion = - "Behavior in $roleLabel classes usually belongs in a specific class " + - "closer to where it's used. Consider moving methods to the classes that actually need them.", + suggestion = suggestion, ) } @@ -125,14 +125,14 @@ private fun detectForbiddenNames( val packageGroups = inForbiddenPackages.groupBy { it.source.packageName } for ((pkg, _) in packageGroups) { val lastSegment = pkg.substringAfterLast(".") + val suggestion = + "Rename the package to describe what it does instead of a generic name." patterns.add( AntiPattern( severity = AntiPattern.Severity.FORBIDDEN, message = "Package `$pkg` uses forbidden name `$lastSegment`", file = File("."), - suggestion = - "Rename the package to describe what it actually does " + - "instead of using a generic catch-all name.", + suggestion = suggestion, ), ) } @@ -210,22 +210,19 @@ private fun buildDiViolationPattern( return if (implementedInterfaces.isNotEmpty()) { val ifaceName = implementedInterfaces.first().source.simpleName + val concreteName = resolved.source.simpleName + val msg = + "Constructor takes concrete `$concreteName` instead of interface `$ifaceName`" AntiPattern( severity = AntiPattern.Severity.WARNING, - message = - "Constructor takes concrete `${resolved.source.simpleName}` " + - "instead of interface `$ifaceName`", + message = msg, file = c.source.file.relativeTo(rootDir), - suggestion = - "Depend on the interface `$ifaceName` instead of the concrete class " + - "to improve testability and flexibility.", + suggestion = "Depend on `$ifaceName` instead of the concrete class.", ) } else { AntiPattern( severity = AntiPattern.Severity.INFO, - message = - "Dependency on concrete class `${resolved.source.simpleName}` " + - "in `${c.source.simpleName}`", + message = "Dependency on concrete class `${resolved.source.simpleName}` in `${c.source.simpleName}`", file = c.source.file.relativeTo(rootDir), suggestion = "Consider extracting an interface for `${resolved.source.simpleName}`.", ) @@ -241,15 +238,15 @@ private fun detectSingleImplInterfaces( val impls = resolver.findImplementors(iface) if (impls.size == 1) { val impl = impls[0] + val ifaceName = iface.source.simpleName + val implName = impl.source.simpleName + val msg = + "Interface `$ifaceName` has only one implementation: `$implName`" AntiPattern( severity = AntiPattern.Severity.INFO, - message = - "Interface `${iface.source.simpleName}` has only one implementation: " + - "`${impl.source.simpleName}`", + message = msg, file = iface.source.file.relativeTo(rootDir), - suggestion = - "If this interface isn't meant for testing or future extension, " + - "consider using `${impl.source.simpleName}` directly.", + suggestion = "Consider using `$implName` directly unless needed for testing.", ) } else { null @@ -268,13 +265,13 @@ private fun detectGodClasses( }.filter { it.role != ComponentRole.CONFIGURATION } .map { c -> val reasons = buildGodClassReasons(c) + val suggestion = + "Split into smaller, focused classes with a single responsibility." AntiPattern( severity = AntiPattern.Severity.WARNING, message = "`${c.source.simpleName}` may be doing too much (${reasons.joinToString(", ")})", file = c.source.file.relativeTo(rootDir), - suggestion = - "Consider splitting into smaller, focused classes. " + - "Each class should have a single responsibility.", + suggestion = suggestion, ) } @@ -344,9 +341,7 @@ private fun detectCircularDeps(edges: List): List severity = AntiPattern.Severity.WARNING, message = "Circular dependency: ${cycle.joinToString(" -> ")}", file = File("."), - suggestion = - "Break the cycle by extracting a shared interface or " + - "moving shared logic to a separate class.", + suggestion = "Break the cycle by extracting a shared interface or moving shared logic to a separate class.", ) } } @@ -379,14 +374,17 @@ private fun detectMissingTests( .filter { it.source.simpleName !in testNames } return if (untested.size > MAX_UNTESTED_BEFORE_SUMMARY) { + val preview = + untested + .take(UNTESTED_PREVIEW_COUNT) + .joinToString(", ") { "`${it.source.simpleName}`" } + val suggestion = "Consider adding tests for key components, especially: $preview" listOf( AntiPattern( severity = AntiPattern.Severity.INFO, message = "${untested.size} classes have no corresponding test file", file = File("."), - suggestion = - "Consider adding tests for key components, especially: " + - untested.take(UNTESTED_PREVIEW_COUNT).joinToString(", ") { "`${it.source.simpleName}`" }, + suggestion = suggestion, ), ) } else { diff --git a/src/main/kotlin/zone/clanker/gradle/srcx/report/DashboardRenderer.kt b/src/main/kotlin/zone/clanker/gradle/srcx/report/DashboardRenderer.kt index c7f0d5a..82c970c 100644 --- a/src/main/kotlin/zone/clanker/gradle/srcx/report/DashboardRenderer.kt +++ b/src/main/kotlin/zone/clanker/gradle/srcx/report/DashboardRenderer.kt @@ -214,10 +214,7 @@ internal class DashboardRenderer( appendLine("| Class | File | Dependents | Role |") appendLine("|-------|------|------------|------|") for (hub in hubs) { - appendLine( - "| `${hub.name}` | ${hub.filePath}:${hub.line} " + - "| ${hub.dependentCount} | ${hub.role} |", - ) + appendLine("| `${hub.name}` | ${hub.filePath}:${hub.line} | ${hub.dependentCount} | ${hub.role} |") } appendLine() for (hub in hubs.filter { it.dependentCount >= HUB_DETAIL_THRESHOLD }) { From 51eaf5b9655e031564eb47604c4e3a2f835e4cae Mon Sep 17 00:00:00 2001 From: slop Date: Fri, 10 Apr 2026 22:53:31 -0700 Subject: [PATCH 06/13] Split output into focused files, add layer detection New split output files under .srcx/: - hot-classes.md, entry-points.md, anti-patterns.md - interfaces.md, cross-build.md, flows/{EntryPoint}.md New analysis: ArchitecturalLayer enum, EntryPointKind classification. context.md slimmed to overview + links to detail files. 6 new renderers with tests. Coverage at 91.8%. --- .../srcx/analysis/ComponentClassifier.kt | 122 ++++ .../srcx/report/AntiPatternsRenderer.kt | 77 ++ .../gradle/srcx/report/CrossBuildRenderer.kt | 67 ++ .../gradle/srcx/report/DashboardRenderer.kt | 160 +---- .../gradle/srcx/report/EntryPointsRenderer.kt | 127 ++++ .../gradle/srcx/report/FlowRenderer.kt | 54 ++ .../gradle/srcx/report/HotClassesRenderer.kt | 44 ++ .../gradle/srcx/report/InterfacesRenderer.kt | 139 ++++ .../clanker/gradle/srcx/task/ContextTask.kt | 97 ++- .../clanker/gradle/srcx/BuildEdgesTest.kt | 9 +- .../srcx/analysis/ComponentClassifierTest.kt | 482 +++++++++++++ .../srcx/report/AntiPatternsRendererTest.kt | 156 ++++ .../srcx/report/CrossBuildRendererTest.kt | 75 ++ .../srcx/report/DashboardRendererTest.kt | 5 +- .../srcx/report/EntryPointsRendererTest.kt | 127 ++++ .../gradle/srcx/report/FlowRendererTest.kt | 137 ++++ .../srcx/report/HotClassesRendererTest.kt | 73 ++ .../srcx/report/InterfacesRendererTest.kt | 674 ++++++++++++++++++ 18 files changed, 2475 insertions(+), 150 deletions(-) create mode 100644 src/main/kotlin/zone/clanker/gradle/srcx/report/AntiPatternsRenderer.kt create mode 100644 src/main/kotlin/zone/clanker/gradle/srcx/report/CrossBuildRenderer.kt create mode 100644 src/main/kotlin/zone/clanker/gradle/srcx/report/EntryPointsRenderer.kt create mode 100644 src/main/kotlin/zone/clanker/gradle/srcx/report/FlowRenderer.kt create mode 100644 src/main/kotlin/zone/clanker/gradle/srcx/report/HotClassesRenderer.kt create mode 100644 src/main/kotlin/zone/clanker/gradle/srcx/report/InterfacesRenderer.kt create mode 100644 src/test/kotlin/zone/clanker/gradle/srcx/report/AntiPatternsRendererTest.kt create mode 100644 src/test/kotlin/zone/clanker/gradle/srcx/report/CrossBuildRendererTest.kt create mode 100644 src/test/kotlin/zone/clanker/gradle/srcx/report/EntryPointsRendererTest.kt create mode 100644 src/test/kotlin/zone/clanker/gradle/srcx/report/FlowRendererTest.kt create mode 100644 src/test/kotlin/zone/clanker/gradle/srcx/report/HotClassesRendererTest.kt create mode 100644 src/test/kotlin/zone/clanker/gradle/srcx/report/InterfacesRendererTest.kt diff --git a/src/main/kotlin/zone/clanker/gradle/srcx/analysis/ComponentClassifier.kt b/src/main/kotlin/zone/clanker/gradle/srcx/analysis/ComponentClassifier.kt index 41b85d1..455564f 100644 --- a/src/main/kotlin/zone/clanker/gradle/srcx/analysis/ComponentClassifier.kt +++ b/src/main/kotlin/zone/clanker/gradle/srcx/analysis/ComponentClassifier.kt @@ -1,5 +1,127 @@ package zone.clanker.gradle.srcx.analysis +/** + * Architectural layer of a package based on its last segment. + */ +enum class ArchitecturalLayer { + PRESENTATION, + DOMAIN, + DATA, + MODEL, + INFRASTRUCTURE, + TEST, + OTHER, +} + +/** + * Detect the architectural layer for a given package name. + * Uses the last segment of the package to determine the layer. + */ +fun detectLayer(packageName: String, isTest: Boolean): ArchitecturalLayer { + if (isTest) return ArchitecturalLayer.TEST + + val lastSegment = packageName.split(".").lastOrNull()?.lowercase() ?: return ArchitecturalLayer.OTHER + + return when (lastSegment) { + "task", "ui", "screen", "view", "route", "controller", "activity", "fragment" -> + ArchitecturalLayer.PRESENTATION + "usecase", "interactor", "service", "workflow" -> + ArchitecturalLayer.DOMAIN + "repository", "datasource", "api", "db", "cache", "dao" -> + ArchitecturalLayer.DATA + "model", "entity", "dto", "domain" -> + ArchitecturalLayer.MODEL + "config", "configuration", "di", "injection", "plugin" -> + ArchitecturalLayer.INFRASTRUCTURE + "test", "mock", "fake", "stub", "fixture" -> + ArchitecturalLayer.TEST + else -> ArchitecturalLayer.OTHER + } +} + +/** + * Detect architectural layers for all classified components. + * Returns a mapping from package name to its detected layer. + */ +fun detectLayers(components: List): Map = + components + .map { it.source.packageName } + .distinct() + .associateWith { pkg -> + detectLayer(pkg, isTest = false) + } + +/** + * The kind of entry point a component represents. + */ +enum class EntryPointKind { + APP, + TEST, + MOCK, +} + +/** + * A classified entry point with its component and kind. + */ +data class ClassifiedEntryPoint( + val component: ClassifiedComponent, + val kind: EntryPointKind, +) + +/** + * Classify entry points by their kind: APP, TEST, or MOCK. + * + * - TEST: file path contains "/test/" or class name ends with "Test" or "Spec" + * - MOCK: class name contains "Mock", "Fake", or "Stub" + * - APP: main() functions, controllers, Plugin.apply(), or graph roots + */ +fun classifyEntryPoints( + components: List, + edges: List = emptyList(), +): List { + val result = components.mapNotNull { classifySingleEntryPoint(it) } + if (result.isNotEmpty()) return result.distinctBy { it.component.source.qualifiedName } + + // Fall back to graph roots + val roots = findGraphRoots(components, edges) + return roots.map { ClassifiedEntryPoint(it, EntryPointKind.APP) } +} + +private fun classifySingleEntryPoint(component: ClassifiedComponent): ClassifiedEntryPoint? { + val name = component.source.simpleName + val filePath = component.source.file.path + val isTestClass = + filePath.contains("/test/") || name.endsWith("Test") || name.endsWith("Spec") + val isMockClass = + name.contains("Mock") || name.contains("Fake") || name.contains("Stub") + val isPlugin = + "apply" in component.source.methods && + component.source.supertypes.any { it.contains("Plugin") } + + return when { + isTestClass -> ClassifiedEntryPoint(component, EntryPointKind.TEST) + isMockClass -> ClassifiedEntryPoint(component, EntryPointKind.MOCK) + "main" in component.source.methods -> ClassifiedEntryPoint(component, EntryPointKind.APP) + component.role == ComponentRole.CONTROLLER -> ClassifiedEntryPoint(component, EntryPointKind.APP) + isPlugin -> ClassifiedEntryPoint(component, EntryPointKind.APP) + else -> null + } +} + +private fun findGraphRoots( + components: List, + edges: List, +): List { + if (edges.isEmpty()) return emptyList() + val hasInbound = edges.map { it.to.source.qualifiedName }.toSet() + val hasOutbound = edges.map { it.from.source.qualifiedName }.toSet() + return components.filter { + it.source.qualifiedName in hasOutbound && + it.source.qualifiedName !in hasInbound && + !it.source.isDataClass + } +} + /** * What we can objectively observe about a source file's role. * Detected from annotations only -- naming is unreliable. diff --git a/src/main/kotlin/zone/clanker/gradle/srcx/report/AntiPatternsRenderer.kt b/src/main/kotlin/zone/clanker/gradle/srcx/report/AntiPatternsRenderer.kt new file mode 100644 index 0000000..03fa4ec --- /dev/null +++ b/src/main/kotlin/zone/clanker/gradle/srcx/report/AntiPatternsRenderer.kt @@ -0,0 +1,77 @@ +package zone.clanker.gradle.srcx.report + +import zone.clanker.gradle.srcx.model.Finding +import zone.clanker.gradle.srcx.model.FindingSeverity +import zone.clanker.gradle.srcx.model.ProjectSummary + +/** + * Renders the anti-patterns.md file with all findings grouped by severity. + * + * Collects findings from all project summaries and included build summaries, + * deduplicates by message, and renders with severity icons. + * + * @property summaries root project summaries + * @property includedBuildSummaries included build summaries keyed by build name + */ +internal class AntiPatternsRenderer( + private val summaries: List, + private val includedBuildSummaries: Map> = emptyMap(), +) { + fun render(): String = + buildString { + appendLine("# Anti-Patterns") + appendLine() + val allFindings = collectAllFindings() + if (allFindings.isEmpty()) { + appendLine("No anti-patterns detected.") + appendLine() + return@buildString + } + val forbidden = allFindings.filter { it.second.severity == FindingSeverity.FORBIDDEN } + val warnings = allFindings.filter { it.second.severity == FindingSeverity.WARNING } + val notes = allFindings.filter { it.second.severity == FindingSeverity.INFO } + + if (forbidden.isNotEmpty()) { + appendLine("## Forbidden") + appendLine() + for ((source, finding) in forbidden) { + appendLine("- ${finding.severity.icon} **$source** โ€” ${finding.message}") + appendLine(" - ${finding.suggestion}") + } + appendLine() + } + if (warnings.isNotEmpty()) { + appendLine("## Warnings") + appendLine() + for ((source, finding) in warnings) { + appendLine("- ${finding.severity.icon} **$source** โ€” ${finding.message}") + appendLine(" - ${finding.suggestion}") + } + appendLine() + } + if (notes.isNotEmpty()) { + appendLine("## Notes") + appendLine() + for ((source, finding) in notes) { + appendLine("- ${finding.severity.icon} **$source** โ€” ${finding.message}") + appendLine(" - ${finding.suggestion}") + } + appendLine() + } + } + + private fun collectAllFindings(): List> { + val rootFindings = + summaries.flatMap { s -> + val path = s.projectPath.value + s.analysis?.findings?.map { f -> path to f } ?: emptyList() + } + val buildFindings = + includedBuildSummaries.flatMap { (name, projects) -> + projects.flatMap { s -> + s.analysis?.findings?.map { f -> name to f } ?: emptyList() + } + } + return (rootFindings + buildFindings).distinctBy { it.second.message } + } +} diff --git a/src/main/kotlin/zone/clanker/gradle/srcx/report/CrossBuildRenderer.kt b/src/main/kotlin/zone/clanker/gradle/srcx/report/CrossBuildRenderer.kt new file mode 100644 index 0000000..2a07180 --- /dev/null +++ b/src/main/kotlin/zone/clanker/gradle/srcx/report/CrossBuildRenderer.kt @@ -0,0 +1,67 @@ +package zone.clanker.gradle.srcx.report + +import zone.clanker.gradle.srcx.model.AnalysisSummary + +/** + * Renders the cross-build.md file showing cross-build references grouped by build pair. + * + * @property buildEdges dependency edges between builds + * @property crossBuildAnalysis the cross-build analysis results (hubs, findings) + */ +internal class CrossBuildRenderer( + private val buildEdges: List, + private val crossBuildAnalysis: AnalysisSummary?, +) { + fun render(): String = + buildString { + appendLine("# Cross-Build References") + appendLine() + if (buildEdges.isEmpty() && crossBuildAnalysis == null) { + appendLine("No cross-build references detected.") + appendLine() + return@buildString + } + appendBuildEdges() + appendCrossBuildHubs() + appendCrossBuildCycles() + } + + private fun StringBuilder.appendBuildEdges() { + if (buildEdges.isEmpty()) return + appendLine("## Build Dependencies") + appendLine() + val byFrom = buildEdges.groupBy { it.from } + for ((from, edges) in byFrom) { + appendLine("### $from") + appendLine() + for (edge in edges) { + appendLine("- depends on **${edge.to}**") + } + appendLine() + } + } + + private fun StringBuilder.appendCrossBuildHubs() { + val hubs = crossBuildAnalysis?.hubs ?: return + if (hubs.isEmpty()) return + appendLine("## Shared Hub Classes") + appendLine() + appendLine("| Class | File | Dependents | Role |") + appendLine("|-------|------|------------|------|") + for (hub in hubs) { + appendLine("| `${hub.name}` | ${hub.filePath}:${hub.line} | ${hub.dependentCount} | ${hub.role} |") + } + appendLine() + } + + private fun StringBuilder.appendCrossBuildCycles() { + val cycles = crossBuildAnalysis?.cycles ?: return + if (cycles.isEmpty()) return + appendLine("## Cross-Build Cycles") + appendLine() + for (cycle in cycles) { + appendLine("- ${cycle.joinToString(" -> ")}") + } + appendLine() + } +} diff --git a/src/main/kotlin/zone/clanker/gradle/srcx/report/DashboardRenderer.kt b/src/main/kotlin/zone/clanker/gradle/srcx/report/DashboardRenderer.kt index 82c970c..762dedf 100644 --- a/src/main/kotlin/zone/clanker/gradle/srcx/report/DashboardRenderer.kt +++ b/src/main/kotlin/zone/clanker/gradle/srcx/report/DashboardRenderer.kt @@ -2,18 +2,14 @@ package zone.clanker.gradle.srcx.report import zone.clanker.gradle.srcx.model.AnalysisSummary import zone.clanker.gradle.srcx.model.FindingSeverity -import zone.clanker.gradle.srcx.model.HubClass import zone.clanker.gradle.srcx.model.ProjectSummary -import zone.clanker.gradle.srcx.model.SourceSetSummary -import zone.clanker.gradle.srcx.model.SymbolKind /** - * Renders a comprehensive dashboard as Markdown. + * Renders the overview dashboard as Markdown (context.md). * - * The dashboard is the single output file that serves as both a human-readable - * overview and LLM-ready context. It contains: project structure, package groups, - * dependencies, build dependency graph (Mermaid), per-project symbols with roles, - * included build summaries with links, and a problems section. + * Contains the high-level overview: project structure, package groups, + * dependencies, build dependency graph (Mermaid), included build summaries, + * and links to split detail files (hot-classes, entry-points, anti-patterns, etc.). */ @Suppress("LongParameterList") internal class DashboardRenderer( @@ -43,10 +39,8 @@ internal class DashboardRenderer( appendProjects() appendBuildGraph() appendIncludedBuilds() - appendSymbols() - appendCrossBuildHubs() + appendSplitFileLinks() appendClassGraph() - appendProblems() } private fun StringBuilder.appendOverview() { @@ -142,89 +136,30 @@ internal class DashboardRenderer( appendLine() } - private fun StringBuilder.appendSymbols() { - for (summary in summaries) { - if (summary.symbols.isEmpty()) continue - appendProjectSymbols(summary) - } - } - - private fun StringBuilder.appendProjectSymbols(summary: ProjectSummary) { - appendLine("## ${summary.projectPath}") - appendLine() - - val hubs = summary.analysis?.hubs?.associate { it.name to it } ?: emptyMap() - - for (ss in summary.sourceSets) { - if (ss.symbols.isEmpty()) continue - appendSourceSetSymbols(ss, hubs) - } + private fun StringBuilder.appendSplitFileLinks() { + val hasHubs = crossBuildAnalysis?.hubs?.isNotEmpty() == true + val hasFindings = + summaries.any { s -> s.analysis?.findings?.isNotEmpty() == true } || + includedBuildSummaries.values.any { projects -> + projects.any { s -> s.analysis?.findings?.isNotEmpty() == true } + } - appendProjectDependencies(summary) - } + if (!hasHubs && !hasFindings && buildEdges.isEmpty()) return - private fun StringBuilder.appendSourceSetSymbols( - ss: SourceSetSummary, - hubs: Map, - ) { - appendLine("### ${ss.name}") + appendLine("## Details") appendLine() - for (s in ss.symbols) { - if (s.kind != SymbolKind.CLASS) continue - val hub = hubs[s.name.value] - val roleTag = if (hub != null && hub.role.isNotEmpty()) " [${hub.role}]" else "" - val depTag = - if (hub != null && hub.dependentCount > 0) { - val depLabel = if (hub.dependentCount == 1) "dependent" else "dependents" - " (${hub.dependentCount} $depLabel)" - } else { - "" - } - appendLine("- class `${s.name}`$roleTag$depTag โ€” ${s.packageName}, ${s.filePath}:${s.lineNumber}") - } - val functions = ss.symbols.filter { it.kind == SymbolKind.FUNCTION } - if (functions.isNotEmpty()) { - appendLine("- ${functions.size} functions") - } - val properties = ss.symbols.filter { it.kind == SymbolKind.PROPERTY } - if (properties.isNotEmpty()) { - appendLine("- ${properties.size} properties") + if (hasHubs) { + appendLine("- [Hot Classes](hot-classes.md)") } - appendLine() - } - - private fun StringBuilder.appendProjectDependencies(summary: ProjectSummary) { - if (summary.dependencies.isNotEmpty()) { - appendLine("### dependencies") - appendLine() - val byScope = summary.dependencies.groupBy { it.scope } - for ((scope, deps) in byScope) { - val artifacts = deps.joinToString(", ") { "${it.group}:${it.artifact}:${it.version}" } - appendLine("- $scope: $artifacts") - } - appendLine() + appendLine("- [Entry Points](entry-points.md)") + if (hasFindings) { + appendLine("- [Anti-Patterns](anti-patterns.md)") } - } - - private fun StringBuilder.appendCrossBuildHubs() { - val hubs = crossBuildAnalysis?.hubs ?: return - if (hubs.isEmpty()) return - appendLine("## Hot Classes (cross-build)") - appendLine() - appendLine("| Class | File | Dependents | Role |") - appendLine("|-------|------|------------|------|") - for (hub in hubs) { - appendLine("| `${hub.name}` | ${hub.filePath}:${hub.line} | ${hub.dependentCount} | ${hub.role} |") + appendLine("- [Interfaces](interfaces.md)") + if (buildEdges.isNotEmpty() || crossBuildAnalysis != null) { + appendLine("- [Cross-Build References](cross-build.md)") } appendLine() - for (hub in hubs.filter { it.dependentCount >= HUB_DETAIL_THRESHOLD }) { - appendLine("### ${hub.name}") - appendLine() - for (dep in hub.dependents) { - appendLine("- ${dep.name} โ€” ${dep.filePath}:${dep.line}") - } - appendLine() - } } private fun StringBuilder.appendClassGraph() { @@ -235,58 +170,7 @@ internal class DashboardRenderer( appendLine() } - private fun StringBuilder.appendProblems() { - val allFindings = - summaries.flatMap { s -> - val path = s.projectPath.value - s.analysis?.findings?.map { f -> Triple(path, f.severity, f) } ?: emptyList() - } - - val buildFindings = - includedBuildSummaries.flatMap { (name, projects) -> - projects.flatMap { s -> - s.analysis?.findings?.map { f -> Triple(name, f.severity, f) } ?: emptyList() - } - } - - val combined = (allFindings + buildFindings).distinctBy { it.third.message } - val forbidden = combined.filter { it.second == FindingSeverity.FORBIDDEN } - val warnings = combined.filter { it.second == FindingSeverity.WARNING } - val notes = combined.filter { it.second == FindingSeverity.INFO } - - if (forbidden.isEmpty() && warnings.isEmpty() && notes.isEmpty()) return - - appendLine("## Problems") - appendLine() - if (forbidden.isNotEmpty()) { - appendLine("### Forbidden") - appendLine() - for ((source, severity, finding) in forbidden) { - appendLine("- ${severity.icon} **$source** โ€” ${finding.message}") - } - appendLine() - } - if (warnings.isNotEmpty()) { - appendLine("### Warnings") - appendLine() - for ((source, severity, finding) in warnings) { - appendLine("- ${severity.icon} **$source** โ€” ${finding.message}") - } - appendLine() - } - if (notes.isNotEmpty()) { - appendLine("### Notes") - appendLine() - for ((source, severity, finding) in notes) { - appendLine("- ${severity.icon} **$source** โ€” ${finding.message}") - } - appendLine() - } - } - companion object { - private const val HUB_DETAIL_THRESHOLD = 3 - fun projectReportPath(projectPath: String): String { val sanitized = projectPath.replace(":", "/").trimStart('/') return if (sanitized.isEmpty()) "root/context.md" else "$sanitized/context.md" diff --git a/src/main/kotlin/zone/clanker/gradle/srcx/report/EntryPointsRenderer.kt b/src/main/kotlin/zone/clanker/gradle/srcx/report/EntryPointsRenderer.kt new file mode 100644 index 0000000..1ee01c9 --- /dev/null +++ b/src/main/kotlin/zone/clanker/gradle/srcx/report/EntryPointsRenderer.kt @@ -0,0 +1,127 @@ +package zone.clanker.gradle.srcx.report + +import zone.clanker.gradle.srcx.model.ProjectSummary +import zone.clanker.gradle.srcx.model.SymbolEntry +import zone.clanker.gradle.srcx.model.SymbolKind + +/** + * Renders the entry-points.md file listing app entry points, test classes, + * and test doubles (Mock/Fake/Stub classes). + * + * @property summaries all project summaries to scan for entry points + * @property appEntryPoints pre-classified app entry points from the analysis layer + */ +internal class EntryPointsRenderer( + private val summaries: List, + private val appEntryPoints: List = emptyList(), +) { + /** + * A classified app entry point. + * + * @property className simple class name + * @property packageName package containing the class + * @property firstCall name of the first method called (if known) + */ + data class EntryPoint( + val className: String, + val packageName: String, + val firstCall: String = "", + ) + + fun render(): String = + buildString { + appendLine("# Entry Points") + appendLine() + appendAppEntryPoints() + appendTestEntryPoints() + appendTestDoubles() + } + + private fun StringBuilder.appendAppEntryPoints() { + appendLine("## App Entry Points") + appendLine() + if (appEntryPoints.isEmpty()) { + appendLine("No app entry points detected.") + appendLine() + return + } + appendLine("| Class | Package | First Call |") + appendLine("|-------|---------|------------|") + for (ep in appEntryPoints) { + val firstCall = ep.firstCall.ifEmpty { "-" } + appendLine("| `${ep.className}` | ${ep.packageName} | $firstCall |") + } + appendLine() + } + + private fun StringBuilder.appendTestEntryPoints() { + val testClasses = findTestClasses() + appendLine("## Test Entry Points") + appendLine() + if (testClasses.isEmpty()) { + appendLine("No test classes found.") + appendLine() + return + } + appendLine("| Class | Package |") + appendLine("|-------|---------|") + for (tc in testClasses) { + appendLine("| `${tc.name}` | ${tc.packageName} |") + } + appendLine() + } + + private fun StringBuilder.appendTestDoubles() { + val doubles = findTestDoubles() + appendLine("## Test Doubles") + appendLine() + if (doubles.isEmpty()) { + appendLine("No test doubles found.") + appendLine() + return + } + appendLine("| Class | Package | Kind |") + appendLine("|-------|---------|------|") + for (td in doubles) { + appendLine("| `${td.name}` | ${td.packageName} | ${td.kind} |") + } + appendLine() + } + + private fun findTestClasses(): List = + summaries + .flatMap { summary -> + summary.sourceSets + .filter { it.name.value.contains("test", ignoreCase = true) } + .flatMap { ss -> + ss.symbols.filter { it.kind == SymbolKind.CLASS && it.name.value.endsWith("Test") } + } + }.distinctBy { "${it.packageName}.${it.name}" } + + private fun findTestDoubles(): List { + val allClasses = + summaries.flatMap { summary -> + summary.sourceSets.flatMap { ss -> + ss.symbols.filter { it.kind == SymbolKind.CLASS } + } + } + return allClasses + .mapNotNull { symbol -> + val name = symbol.name.value + val kind = + when { + name.startsWith("Mock") || name.endsWith("Mock") -> "Mock" + name.startsWith("Fake") || name.endsWith("Fake") -> "Fake" + name.startsWith("Stub") || name.endsWith("Stub") -> "Stub" + else -> null + } + kind?.let { TestDouble(symbol.name.value, symbol.packageName.value, it) } + }.distinctBy { "${it.packageName}.${it.name}" } + } + + private data class TestDouble( + val name: String, + val packageName: String, + val kind: String, + ) +} diff --git a/src/main/kotlin/zone/clanker/gradle/srcx/report/FlowRenderer.kt b/src/main/kotlin/zone/clanker/gradle/srcx/report/FlowRenderer.kt new file mode 100644 index 0000000..6eae9a0 --- /dev/null +++ b/src/main/kotlin/zone/clanker/gradle/srcx/report/FlowRenderer.kt @@ -0,0 +1,54 @@ +package zone.clanker.gradle.srcx.report + +/** + * Renders a single flow Mermaid sequence diagram file for an entry point. + * + * Each entry point gets its own file under `flows/{EntryPointName}.md`. + * + * @property entryPointName simple class name of the entry point + * @property sequenceDiagram the Mermaid sequence diagram content + */ +internal class FlowRenderer( + private val entryPointName: String, + private val sequenceDiagram: String, +) { + fun render(): String = + buildString { + appendLine("# $entryPointName Flow") + appendLine() + appendLine(sequenceDiagram) + } + + companion object { + /** + * Parse a combined sequence diagram output into per-entry-point chunks. + * + * The combined output from [zone.clanker.gradle.srcx.analysis.generateSequenceDiagrams] + * uses `### {Name} Flow` headers to separate diagrams. This function splits + * them into individual (name, content) pairs. + */ + fun splitDiagrams(combinedOutput: String): List> { + if (combinedOutput.isBlank()) return emptyList() + val sections = mutableListOf>() + val lines = combinedOutput.lines() + var currentName: String? = null + val currentContent = StringBuilder() + + for (line in lines) { + if (line.startsWith("### ") && line.endsWith(" Flow")) { + if (currentName != null && currentContent.isNotBlank()) { + sections.add(currentName to currentContent.toString().trim()) + currentContent.clear() + } + currentName = line.removePrefix("### ").removeSuffix(" Flow") + } else if (currentName != null) { + currentContent.appendLine(line) + } + } + if (currentName != null && currentContent.isNotBlank()) { + sections.add(currentName to currentContent.toString().trim()) + } + return sections + } + } +} diff --git a/src/main/kotlin/zone/clanker/gradle/srcx/report/HotClassesRenderer.kt b/src/main/kotlin/zone/clanker/gradle/srcx/report/HotClassesRenderer.kt new file mode 100644 index 0000000..5e09ac9 --- /dev/null +++ b/src/main/kotlin/zone/clanker/gradle/srcx/report/HotClassesRenderer.kt @@ -0,0 +1,44 @@ +package zone.clanker.gradle.srcx.report + +import zone.clanker.gradle.srcx.model.HubClass + +/** + * Renders the hot-classes.md file listing hub classes with their dependents. + * + * Hub classes are the most depended-on classes across the codebase. + * Each hub with [DETAIL_THRESHOLD] or more dependents gets a detailed section. + * + * @property hubs the hub classes sorted by dependent count descending + */ +internal class HotClassesRenderer( + private val hubs: List, +) { + fun render(): String = + buildString { + appendLine("# Hot Classes") + appendLine() + if (hubs.isEmpty()) { + appendLine("No hub classes detected.") + appendLine() + return@buildString + } + appendLine("| Class | File | Dependents | Role |") + appendLine("|-------|------|------------|------|") + for (hub in hubs) { + appendLine("| `${hub.name}` | ${hub.filePath}:${hub.line} | ${hub.dependentCount} | ${hub.role} |") + } + appendLine() + for (hub in hubs.filter { it.dependentCount >= DETAIL_THRESHOLD }) { + appendLine("## ${hub.name}") + appendLine() + for (dep in hub.dependents) { + appendLine("- ${dep.name} โ€” ${dep.filePath}:${dep.line}") + } + appendLine() + } + } + + companion object { + private const val DETAIL_THRESHOLD = 3 + } +} diff --git a/src/main/kotlin/zone/clanker/gradle/srcx/report/InterfacesRenderer.kt b/src/main/kotlin/zone/clanker/gradle/srcx/report/InterfacesRenderer.kt new file mode 100644 index 0000000..794180e --- /dev/null +++ b/src/main/kotlin/zone/clanker/gradle/srcx/report/InterfacesRenderer.kt @@ -0,0 +1,139 @@ +package zone.clanker.gradle.srcx.report + +import zone.clanker.gradle.srcx.model.ProjectSummary +import zone.clanker.gradle.srcx.model.SymbolEntry +import zone.clanker.gradle.srcx.model.SymbolKind + +/** + * Renders the interfaces.md file listing all interfaces with implementation counts. + * + * Identifies interfaces by naming convention (prefixed with "I" or common interface + * suffixes) and correlates with implementation classes. Tags mock implementations. + * + * @property summaries all project summaries to scan + * @property interfaces pre-computed interface data (name, package, impl count, has mock) + */ +internal class InterfacesRenderer( + private val interfaces: List, +) { + /** + * Pre-computed interface information. + * + * @property name simple class name + * @property packageName package containing the interface + * @property implementationCount number of known implementations + * @property hasMock whether a mock implementation exists + */ + data class InterfaceInfo( + val name: String, + val packageName: String, + val implementationCount: Int, + val hasMock: Boolean, + ) + + fun render(): String = + buildString { + appendLine("# Interfaces") + appendLine() + if (interfaces.isEmpty()) { + appendLine("No interfaces detected.") + appendLine() + return@buildString + } + appendLine("| Interface | Package | Implementations | Has Mock |") + appendLine("|-----------|---------|----------------|----------|") + for (iface in interfaces) { + val mockTag = if (iface.hasMock) "yes" else "no" + appendLine("| `${iface.name}` | ${iface.packageName} | ${iface.implementationCount} | $mockTag |") + } + appendLine() + } + + companion object { + /** + * Build interface info from project summaries by correlating class names. + * + * Identifies interfaces by common naming patterns and counts implementations + * by looking for classes that match the interface name with common suffixes/prefixes. + */ + fun fromSummaries(summaries: List): List { + val allClasses = + summaries.flatMap { summary -> + summary.sourceSets.flatMap { ss -> + ss.symbols.filter { it.kind == SymbolKind.CLASS } + } + } + val classNames = allClasses.map { it.name.value }.toSet() + + // Find interfaces from analysis hubs or naming convention + val potentialInterfaces = findInterfacesFromAnalysis(summaries) + if (potentialInterfaces.isEmpty()) return emptyList() + + return potentialInterfaces + .map { (name, pkg) -> + val implCount = countImplementations(name, classNames) + val hasMock = + classNames.any { cn -> + cn == "Mock$name" || + cn == "${name}Mock" || + cn == "Fake$name" || + cn == "${name}Fake" + } + InterfaceInfo(name, pkg, implCount, hasMock) + }.sortedByDescending { it.implementationCount } + } + + private fun findInterfacesFromAnalysis(summaries: List): List> { + // Look at findings that mention interfaces (from single-impl detection) + val fromFindings = mutableListOf>() + for (summary in summaries) { + val findings = summary.analysis?.findings ?: continue + for (finding in findings) { + val match = INTERFACE_PATTERN.find(finding.message) + if (match != null) { + val ifaceName = match.groupValues[1] + fromFindings.add(ifaceName to summary.projectPath.value) + } + } + } + + // Also detect by naming convention from symbols + val fromNaming = + summaries.flatMap { summary -> + summary.sourceSets.flatMap { ss -> + ss.symbols + .filter { it.kind == SymbolKind.CLASS } + .filter { isLikelyInterface(it) } + .map { it.name.value to it.packageName.value } + } + } + + return (fromFindings + fromNaming).distinctBy { it.first } + } + + private fun isLikelyInterface(symbol: SymbolEntry): Boolean { + val name = symbol.name.value + // Common interface naming patterns + return name.endsWith("Service") || + name.endsWith("Repository") || + name.endsWith("Provider") || + name.endsWith("Factory") || + (name.length > 2 && name.startsWith("I") && name[1].isUpperCase()) + } + + private fun countImplementations(interfaceName: String, classNames: Set): Int { + val baseName = interfaceName.removePrefix("I") + return classNames.count { cn -> + cn != interfaceName && + ( + cn == "${baseName}Impl" || + cn == "Default$interfaceName" || + cn == "Default$baseName" || + (cn.endsWith(baseName) && cn != baseName) + ) + } + } + + private val INTERFACE_PATTERN = Regex("""Interface `(\w+)` has""") + } +} diff --git a/src/main/kotlin/zone/clanker/gradle/srcx/task/ContextTask.kt b/src/main/kotlin/zone/clanker/gradle/srcx/task/ContextTask.kt index 775bd92..0229c07 100644 --- a/src/main/kotlin/zone/clanker/gradle/srcx/task/ContextTask.kt +++ b/src/main/kotlin/zone/clanker/gradle/srcx/task/ContextTask.kt @@ -19,11 +19,19 @@ import zone.clanker.gradle.srcx.analysis.ProjectAnalysis import zone.clanker.gradle.srcx.analysis.analyzeProject import zone.clanker.gradle.srcx.analysis.buildDependencyGraph import zone.clanker.gradle.srcx.analysis.classifyAll +import zone.clanker.gradle.srcx.analysis.findEntryPoints import zone.clanker.gradle.srcx.analysis.generateDependencyDiagram +import zone.clanker.gradle.srcx.analysis.generateSequenceDiagrams import zone.clanker.gradle.srcx.analysis.scanSources import zone.clanker.gradle.srcx.model.DependencyEntry import zone.clanker.gradle.srcx.model.ProjectSummary +import zone.clanker.gradle.srcx.report.AntiPatternsRenderer +import zone.clanker.gradle.srcx.report.CrossBuildRenderer import zone.clanker.gradle.srcx.report.DashboardRenderer +import zone.clanker.gradle.srcx.report.EntryPointsRenderer +import zone.clanker.gradle.srcx.report.FlowRenderer +import zone.clanker.gradle.srcx.report.HotClassesRenderer +import zone.clanker.gradle.srcx.report.InterfacesRenderer import zone.clanker.gradle.srcx.report.ReportWriter import zone.clanker.gradle.srcx.scan.ProjectScanner import zone.clanker.gradle.srcx.scan.SymbolExtractor @@ -102,12 +110,12 @@ abstract class ContextTask : DefaultTask() { } /** - * Generate per-project symbol reports and the dashboard context file. + * Generate per-project symbol reports, the dashboard, and split detail files. * * 1. Run parallel symbol extraction, writing per-project reports * 2. Generate included build reports * 3. Build the dashboard with summaries, build edges, and class diagram - * 4. Write `context.md` and `.gitignore` + * 4. Write `context.md`, split files, and `.gitignore` */ @TaskAction fun generate() { @@ -139,6 +147,7 @@ abstract class ContextTask : DefaultTask() { val buildPairs = builds.map { it.name to it.dir } val buildEdges = ReportWriter.computeBuildEdges(buildPairs, includedBuildSummaries) val crossBuild = analyzeCrossBuild(projects, builds, root) + val crossBuildSummary = crossBuild.second?.toSummary() val renderer = DashboardRenderer( rootName = rootName.get(), @@ -147,15 +156,97 @@ abstract class ContextTask : DefaultTask() { includedBuildSummaries = includedBuildSummaries, buildEdges = buildEdges, classDiagram = crossBuild.first, - crossBuildAnalysis = crossBuild.second?.toSummary(), + crossBuildAnalysis = crossBuildSummary, ) val dir = File(root, outDir) dir.mkdirs() File(dir, "context.md").writeText(renderer.render()) + + // Split detail files + writeSplitFiles(dir, summaryList, includedBuildSummaries, buildEdges, crossBuild, crossBuildSummary) + ReportWriter.writeGitignore(root, outDir) logger.lifecycle("srcx: context written to $outDir/context.md") } + @Suppress("LongParameterList") + private fun writeSplitFiles( + dir: File, + summaryList: List, + includedBuildSummaries: Map>, + buildEdges: List, + crossBuild: Pair, + crossBuildSummary: zone.clanker.gradle.srcx.model.AnalysisSummary?, + ) { + // hot-classes.md + val allHubs = crossBuildSummary?.hubs ?: emptyList() + File(dir, "hot-classes.md").writeText(HotClassesRenderer(allHubs).render()) + + // entry-points.md + val entryPoints = buildEntryPoints(crossBuild.second) + File(dir, "entry-points.md").writeText( + EntryPointsRenderer(summaryList, entryPoints).render(), + ) + + // anti-patterns.md + File(dir, "anti-patterns.md").writeText( + AntiPatternsRenderer(summaryList, includedBuildSummaries).render(), + ) + + // interfaces.md + val allSummaries = summaryList + includedBuildSummaries.values.flatten() + val interfaceInfos = InterfacesRenderer.fromSummaries(allSummaries) + File(dir, "interfaces.md").writeText(InterfacesRenderer(interfaceInfos).render()) + + // cross-build.md + File(dir, "cross-build.md").writeText( + CrossBuildRenderer(buildEdges, crossBuildSummary).render(), + ) + + // flows/ + writeFlowFiles(dir, crossBuild.second) + } + + private fun buildEntryPoints(analysis: ProjectAnalysis?): List { + if (analysis == null) return emptyList() + val allDirs = collectAllSourceDirs(projectDirs.get(), includedBuildInfos.get()) + if (allDirs.isEmpty()) return emptyList() + return runCatching { + val sources = scanSources(allDirs) + val components = classifyAll(sources) + val depEdges = buildDependencyGraph(components) + val entryPoints = findEntryPoints(components, depEdges) + entryPoints.map { ep -> + EntryPointsRenderer.EntryPoint( + className = ep.source.simpleName, + packageName = ep.source.packageName, + firstCall = ep.source.methods.firstOrNull() ?: "", + ) + } + }.getOrDefault(emptyList()) + } + + private fun writeFlowFiles(dir: File, analysis: ProjectAnalysis?) { + if (analysis == null) return + val allDirs = collectAllSourceDirs(projectDirs.get(), includedBuildInfos.get()) + if (allDirs.isEmpty()) return + runCatching { + val sources = scanSources(allDirs) + val components = classifyAll(sources) + val depEdges = buildDependencyGraph(components) + val diagrams = generateSequenceDiagrams(components, depEdges) + val splitDiagrams = FlowRenderer.splitDiagrams(diagrams) + if (splitDiagrams.isNotEmpty()) { + val flowsDir = File(dir, "flows") + flowsDir.mkdirs() + for ((name, content) in splitDiagrams) { + val renderer = FlowRenderer(name, content) + File(flowsDir, "$name.md").writeText(renderer.render()) + } + } + } + } + private fun collectIncludedBuildSummaries( builds: List, ): Map> = diff --git a/src/test/kotlin/zone/clanker/gradle/srcx/BuildEdgesTest.kt b/src/test/kotlin/zone/clanker/gradle/srcx/BuildEdgesTest.kt index 4ca7ed7..5df59ab 100644 --- a/src/test/kotlin/zone/clanker/gradle/srcx/BuildEdgesTest.kt +++ b/src/test/kotlin/zone/clanker/gradle/srcx/BuildEdgesTest.kt @@ -294,12 +294,9 @@ class BuildEdgesTest : ) val output = renderer.render() - then("it has a problems section") { - output shouldContain "## Problems" - output shouldContain "### Warnings" - output shouldContain "`AppHelper` is a helper class" - output shouldContain "### Notes" - output shouldContain "`App` has no test" + then("it links to anti-patterns split file") { + output shouldContain "## Details" + output shouldContain "[Anti-Patterns](anti-patterns.md)" } } } diff --git a/src/test/kotlin/zone/clanker/gradle/srcx/analysis/ComponentClassifierTest.kt b/src/test/kotlin/zone/clanker/gradle/srcx/analysis/ComponentClassifierTest.kt index b9e0830..8d1f72f 100644 --- a/src/test/kotlin/zone/clanker/gradle/srcx/analysis/ComponentClassifierTest.kt +++ b/src/test/kotlin/zone/clanker/gradle/srcx/analysis/ComponentClassifierTest.kt @@ -1,3 +1,5 @@ +@file:Suppress("LargeClass") + package zone.clanker.gradle.srcx.analysis import io.kotest.core.spec.style.BehaviorSpec @@ -209,6 +211,42 @@ class ComponentClassifierTest : components.all { it.packageGroup == "(root)" } shouldBe true } } + + `when`("sources have empty package names") { + val sources = + listOf( + SourceFileMetadata( + file = File("/tmp/A.kt"), + packageName = "", + qualifiedName = "A", + simpleName = "A", + imports = emptyList(), + annotations = emptyList(), + supertypes = emptyList(), + isInterface = false, + isAbstract = false, + isObject = false, + isDataClass = false, + language = SourceFileMetadata.Language.KOTLIN, + lineCount = 10, + methods = emptyList(), + ), + ) + + val components = classifyAll(sources) + + then("package group is (root) with empty basePackage") { + components.all { it.packageGroup == "(root)" } shouldBe true + } + } + + `when`("sources list is empty") { + val components = classifyAll(emptyList()) + + then("it returns empty list") { + components shouldBe emptyList() + } + } } given("commonPackagePrefix") { @@ -273,5 +311,449 @@ class ComponentClassifierTest : entries shouldBe emptyList() } } + + `when`("no explicit entry points but graph has roots") { + val srcA = metadata(MetadataConfig("Orchestrator", packageName = "com.example")) + val srcB = metadata(MetadataConfig("Worker", packageName = "com.example")) + val compA = classifyComponent(srcA) + val compB = classifyComponent(srcB) + val components = listOf(compA, compB) + val edges = listOf(ClassDependency(compA, compB)) + + val entries = findEntryPoints(components, edges) + + then("it falls back to graph roots") { + entries.size shouldBe 1 + entries.first().source.simpleName shouldBe "Orchestrator" + } + } + + `when`("no explicit entry points and edges with only data classes as roots") { + val srcA = metadata(MetadataConfig("Config", packageName = "com.example", isDataClass = true)) + val srcB = metadata(MetadataConfig("Worker", packageName = "com.example")) + val compA = classifyComponent(srcA) + val compB = classifyComponent(srcB) + val components = listOf(compA, compB) + val edges = listOf(ClassDependency(compA, compB)) + + val entries = findEntryPoints(components, edges) + + then("it excludes data classes from roots and returns empty") { + entries shouldBe emptyList() + } + } + } + + given("detectLayer") { + `when`("package ends with controller") { + then("layer is PRESENTATION") { + detectLayer("com.example.controller", isTest = false) shouldBe ArchitecturalLayer.PRESENTATION + } + } + + `when`("package ends with task") { + then("layer is PRESENTATION") { + detectLayer("com.example.task", isTest = false) shouldBe ArchitecturalLayer.PRESENTATION + } + } + + `when`("package ends with view") { + then("layer is PRESENTATION") { + detectLayer("com.example.view", isTest = false) shouldBe ArchitecturalLayer.PRESENTATION + } + } + + `when`("package ends with service") { + then("layer is DOMAIN") { + detectLayer("com.example.service", isTest = false) shouldBe ArchitecturalLayer.DOMAIN + } + } + + `when`("package ends with usecase") { + then("layer is DOMAIN") { + detectLayer("com.example.usecase", isTest = false) shouldBe ArchitecturalLayer.DOMAIN + } + } + + `when`("package ends with workflow") { + then("layer is DOMAIN") { + detectLayer("com.example.workflow", isTest = false) shouldBe ArchitecturalLayer.DOMAIN + } + } + + `when`("package ends with repository") { + then("layer is DATA") { + detectLayer("com.example.repository", isTest = false) shouldBe ArchitecturalLayer.DATA + } + } + + `when`("package ends with api") { + then("layer is DATA") { + detectLayer("com.example.api", isTest = false) shouldBe ArchitecturalLayer.DATA + } + } + + `when`("package ends with cache") { + then("layer is DATA") { + detectLayer("com.example.cache", isTest = false) shouldBe ArchitecturalLayer.DATA + } + } + + `when`("package ends with model") { + then("layer is MODEL") { + detectLayer("com.example.model", isTest = false) shouldBe ArchitecturalLayer.MODEL + } + } + + `when`("package ends with entity") { + then("layer is MODEL") { + detectLayer("com.example.entity", isTest = false) shouldBe ArchitecturalLayer.MODEL + } + } + + `when`("package ends with dto") { + then("layer is MODEL") { + detectLayer("com.example.dto", isTest = false) shouldBe ArchitecturalLayer.MODEL + } + } + + `when`("package ends with config") { + then("layer is INFRASTRUCTURE") { + detectLayer("com.example.config", isTest = false) shouldBe ArchitecturalLayer.INFRASTRUCTURE + } + } + + `when`("package ends with di") { + then("layer is INFRASTRUCTURE") { + detectLayer("com.example.di", isTest = false) shouldBe ArchitecturalLayer.INFRASTRUCTURE + } + } + + `when`("package ends with plugin") { + then("layer is INFRASTRUCTURE") { + detectLayer("com.example.plugin", isTest = false) shouldBe ArchitecturalLayer.INFRASTRUCTURE + } + } + + `when`("isTest flag is true") { + then("layer is TEST regardless of package name") { + detectLayer("com.example.service", isTest = true) shouldBe ArchitecturalLayer.TEST + } + } + + `when`("package ends with unrecognized segment") { + then("layer is OTHER") { + detectLayer("com.example.unknown", isTest = false) shouldBe ArchitecturalLayer.OTHER + } + } + + `when`("package is empty") { + then("layer is OTHER") { + detectLayer("", isTest = false) shouldBe ArchitecturalLayer.OTHER + } + } + } + + given("detectLayers") { + `when`("components span multiple packages") { + val sources = + listOf( + metadata(MetadataConfig("UserController", packageName = "com.example.controller")), + metadata(MetadataConfig("UserService", packageName = "com.example.service")), + metadata(MetadataConfig("UserRepo", packageName = "com.example.repository")), + metadata(MetadataConfig("User", packageName = "com.example.model")), + ) + val components = sources.map { classifyComponent(it) } + + val layers = detectLayers(components) + + then("each package maps to its expected layer") { + layers["com.example.controller"] shouldBe ArchitecturalLayer.PRESENTATION + layers["com.example.service"] shouldBe ArchitecturalLayer.DOMAIN + layers["com.example.repository"] shouldBe ArchitecturalLayer.DATA + layers["com.example.model"] shouldBe ArchitecturalLayer.MODEL + } + } + } + + given("detectLayer additional branches") { + `when`("package ends with ui") { + then("layer is PRESENTATION") { + detectLayer("com.example.ui", isTest = false) shouldBe ArchitecturalLayer.PRESENTATION + } + } + + `when`("package ends with screen") { + then("layer is PRESENTATION") { + detectLayer("com.example.screen", isTest = false) shouldBe ArchitecturalLayer.PRESENTATION + } + } + + `when`("package ends with route") { + then("layer is PRESENTATION") { + detectLayer("com.example.route", isTest = false) shouldBe ArchitecturalLayer.PRESENTATION + } + } + + `when`("package ends with activity") { + then("layer is PRESENTATION") { + detectLayer("com.example.activity", isTest = false) shouldBe ArchitecturalLayer.PRESENTATION + } + } + + `when`("package ends with fragment") { + then("layer is PRESENTATION") { + detectLayer("com.example.fragment", isTest = false) shouldBe ArchitecturalLayer.PRESENTATION + } + } + + `when`("package ends with interactor") { + then("layer is DOMAIN") { + detectLayer("com.example.interactor", isTest = false) shouldBe ArchitecturalLayer.DOMAIN + } + } + + `when`("package ends with datasource") { + then("layer is DATA") { + detectLayer("com.example.datasource", isTest = false) shouldBe ArchitecturalLayer.DATA + } + } + + `when`("package ends with db") { + then("layer is DATA") { + detectLayer("com.example.db", isTest = false) shouldBe ArchitecturalLayer.DATA + } + } + + `when`("package ends with dao") { + then("layer is DATA") { + detectLayer("com.example.dao", isTest = false) shouldBe ArchitecturalLayer.DATA + } + } + + `when`("package ends with entity") { + then("layer is MODEL") { + detectLayer("com.example.entity", isTest = false) shouldBe ArchitecturalLayer.MODEL + } + } + + `when`("package ends with domain") { + then("layer is MODEL") { + detectLayer("com.example.domain", isTest = false) shouldBe ArchitecturalLayer.MODEL + } + } + + `when`("package ends with configuration") { + then("layer is INFRASTRUCTURE") { + detectLayer("com.example.configuration", isTest = false) shouldBe ArchitecturalLayer.INFRASTRUCTURE + } + } + + `when`("package ends with injection") { + then("layer is INFRASTRUCTURE") { + detectLayer("com.example.injection", isTest = false) shouldBe ArchitecturalLayer.INFRASTRUCTURE + } + } + + `when`("package ends with test") { + then("layer is TEST") { + detectLayer("com.example.test", isTest = false) shouldBe ArchitecturalLayer.TEST + } + } + + `when`("package ends with mock") { + then("layer is TEST") { + detectLayer("com.example.mock", isTest = false) shouldBe ArchitecturalLayer.TEST + } + } + + `when`("package ends with fake") { + then("layer is TEST") { + detectLayer("com.example.fake", isTest = false) shouldBe ArchitecturalLayer.TEST + } + } + + `when`("package ends with stub") { + then("layer is TEST") { + detectLayer("com.example.stub", isTest = false) shouldBe ArchitecturalLayer.TEST + } + } + + `when`("package ends with fixture") { + then("layer is TEST") { + detectLayer("com.example.fixture", isTest = false) shouldBe ArchitecturalLayer.TEST + } + } + } + + given("classifyEntryPoints") { + `when`("component has main() method") { + val source = metadata(MetadataConfig("App", methods = listOf("main"))) + val components = listOf(classifyComponent(source)) + + val entries = classifyEntryPoints(components) + + then("it classifies as APP entry point") { + entries.size shouldBe 1 + entries.first().kind shouldBe EntryPointKind.APP + entries + .first() + .component + .source + .simpleName shouldBe "App" + } + } + + `when`("component is a controller") { + val source = metadata(MetadataConfig("WebController", annotations = listOf("Controller"))) + val components = listOf(classifyComponent(source)) + + val entries = classifyEntryPoints(components) + + then("it classifies as APP entry point") { + entries.first().kind shouldBe EntryPointKind.APP + } + } + + `when`("file path contains /test/") { + val source = + SourceFileMetadata( + file = File("/project/src/test/kotlin/UserServiceTest.kt"), + packageName = "com.example.service", + qualifiedName = "com.example.service.UserServiceTest", + simpleName = "UserServiceTest", + imports = emptyList(), + annotations = emptyList(), + supertypes = emptyList(), + isInterface = false, + isAbstract = false, + isObject = false, + isDataClass = false, + language = SourceFileMetadata.Language.KOTLIN, + lineCount = 30, + methods = listOf("testSomething"), + ) + val components = listOf(classifyComponent(source)) + + val entries = classifyEntryPoints(components) + + then("it classifies as TEST entry point") { + entries.size shouldBe 1 + entries.first().kind shouldBe EntryPointKind.TEST + } + } + + `when`("class name ends with Test") { + val source = metadata(MetadataConfig("UserServiceTest", methods = listOf("testCreate"))) + val components = listOf(classifyComponent(source)) + + val entries = classifyEntryPoints(components) + + then("it classifies as TEST entry point") { + entries.first().kind shouldBe EntryPointKind.TEST + } + } + + `when`("class name ends with Spec") { + val source = metadata(MetadataConfig("UserServiceSpec", methods = listOf("testCreate"))) + val components = listOf(classifyComponent(source)) + + val entries = classifyEntryPoints(components) + + then("it classifies as TEST entry point") { + entries.first().kind shouldBe EntryPointKind.TEST + } + } + + `when`("class name contains Mock") { + val source = metadata(MetadataConfig("MockUserRepository")) + val components = listOf(classifyComponent(source)) + + val entries = classifyEntryPoints(components) + + then("it classifies as MOCK entry point") { + entries.first().kind shouldBe EntryPointKind.MOCK + } + } + + `when`("class name contains Fake") { + val source = metadata(MetadataConfig("FakeDatabase")) + val components = listOf(classifyComponent(source)) + + val entries = classifyEntryPoints(components) + + then("it classifies as MOCK entry point") { + entries.first().kind shouldBe EntryPointKind.MOCK + } + } + + `when`("class name contains Stub") { + val source = metadata(MetadataConfig("StubApiClient")) + val components = listOf(classifyComponent(source)) + + val entries = classifyEntryPoints(components) + + then("it classifies as MOCK entry point") { + entries.first().kind shouldBe EntryPointKind.MOCK + } + } + + `when`("no entry points detected") { + val source = metadata(MetadataConfig("InternalHelper")) + val components = listOf(classifyComponent(source)) + + val entries = classifyEntryPoints(components) + + then("it returns empty list") { + entries shouldBe emptyList() + } + } + + `when`("class is a Gradle plugin with apply method") { + val source = + SourceFileMetadata( + file = File("/tmp/MyPlugin.kt"), + packageName = "com.example", + qualifiedName = "com.example.MyPlugin", + simpleName = "MyPlugin", + imports = emptyList(), + annotations = emptyList(), + supertypes = listOf("org.gradle.api.Plugin"), + isInterface = false, + isAbstract = false, + isObject = false, + isDataClass = false, + language = SourceFileMetadata.Language.KOTLIN, + lineCount = 20, + methods = listOf("apply"), + ) + val components = listOf(classifyComponent(source)) + + val entries = classifyEntryPoints(components) + + then("it classifies as APP entry point") { + entries.size shouldBe 1 + entries.first().kind shouldBe EntryPointKind.APP + } + } + + `when`("no classifiable entry points but graph has roots") { + val srcA = metadata(MetadataConfig("Launcher", packageName = "com.example")) + val srcB = metadata(MetadataConfig("Worker", packageName = "com.example")) + val compA = classifyComponent(srcA) + val compB = classifyComponent(srcB) + val components = listOf(compA, compB) + val edges = listOf(ClassDependency(compA, compB)) + + val entries = classifyEntryPoints(components, edges) + + then("it falls back to graph roots as APP") { + entries.size shouldBe 1 + entries.first().kind shouldBe EntryPointKind.APP + entries + .first() + .component.source.simpleName shouldBe "Launcher" + } + } } }) diff --git a/src/test/kotlin/zone/clanker/gradle/srcx/report/AntiPatternsRendererTest.kt b/src/test/kotlin/zone/clanker/gradle/srcx/report/AntiPatternsRendererTest.kt new file mode 100644 index 0000000..a8c5285 --- /dev/null +++ b/src/test/kotlin/zone/clanker/gradle/srcx/report/AntiPatternsRendererTest.kt @@ -0,0 +1,156 @@ +package zone.clanker.gradle.srcx.report + +import io.kotest.core.spec.style.BehaviorSpec +import io.kotest.matchers.shouldBe +import io.kotest.matchers.string.shouldContain +import zone.clanker.gradle.srcx.model.AnalysisSummary +import zone.clanker.gradle.srcx.model.Finding +import zone.clanker.gradle.srcx.model.FindingSeverity +import zone.clanker.gradle.srcx.model.ProjectPath +import zone.clanker.gradle.srcx.model.ProjectSummary + +class AntiPatternsRendererTest : + BehaviorSpec({ + + given("an AntiPatternsRenderer") { + + `when`("rendering with no findings") { + val renderer = AntiPatternsRenderer(emptyList()) + val output = renderer.render() + + then("it shows no-data message") { + output shouldContain "# Anti-Patterns" + output shouldContain "No anti-patterns detected." + } + } + + `when`("rendering with findings from projects") { + val summaries = + listOf( + ProjectSummary( + projectPath = ProjectPath(":app"), + symbols = emptyList(), + dependencies = emptyList(), + buildFile = "build.gradle.kts", + sourceDirs = emptyList(), + subprojects = emptyList(), + analysis = + AnalysisSummary( + findings = + listOf( + Finding( + FindingSeverity.FORBIDDEN, + "Package uses forbidden name", + "Rename the package", + ), + Finding( + FindingSeverity.WARNING, + "`AppHelper` is a helper class", + "Move methods closer", + ), + Finding( + FindingSeverity.INFO, + "`App` has no test", + "Add AppTest", + ), + ), + hubs = emptyList(), + cycles = emptyList(), + ), + ), + ) + val renderer = AntiPatternsRenderer(summaries) + val output = renderer.render() + + then("it groups findings by severity") { + output shouldContain "## Forbidden" + output shouldContain "Package uses forbidden name" + output shouldContain " - Rename the package" + output shouldContain "## Warnings" + output shouldContain "`AppHelper` is a helper class" + output shouldContain "## Notes" + output shouldContain "`App` has no test" + } + } + + `when`("rendering with findings from included builds") { + val includedBuildSummaries = + mapOf( + "lib" to + listOf( + ProjectSummary( + projectPath = ProjectPath(":"), + symbols = emptyList(), + dependencies = emptyList(), + buildFile = "build.gradle.kts", + sourceDirs = emptyList(), + subprojects = emptyList(), + analysis = + AnalysisSummary( + findings = + listOf( + Finding( + FindingSeverity.WARNING, + "Circular dependency: A -> B -> A", + "Break the cycle", + ), + ), + hubs = emptyList(), + cycles = emptyList(), + ), + ), + ), + ) + val renderer = AntiPatternsRenderer(emptyList(), includedBuildSummaries) + val output = renderer.render() + + then("it includes findings from included builds") { + output shouldContain "**lib**" + output shouldContain "Circular dependency: A -> B -> A" + } + } + + `when`("rendering deduplicates by message") { + val finding = + Finding(FindingSeverity.WARNING, "duplicate message", "fix it") + val summaries = + listOf( + ProjectSummary( + projectPath = ProjectPath(":a"), + symbols = emptyList(), + dependencies = emptyList(), + buildFile = "build.gradle.kts", + sourceDirs = emptyList(), + subprojects = emptyList(), + analysis = + AnalysisSummary( + findings = listOf(finding), + hubs = emptyList(), + cycles = emptyList(), + ), + ), + ProjectSummary( + projectPath = ProjectPath(":b"), + symbols = emptyList(), + dependencies = emptyList(), + buildFile = "build.gradle.kts", + sourceDirs = emptyList(), + subprojects = emptyList(), + analysis = + AnalysisSummary( + findings = listOf(finding), + hubs = emptyList(), + cycles = emptyList(), + ), + ), + ) + val renderer = AntiPatternsRenderer(summaries) + val output = renderer.render() + + then("it shows the duplicate message only once") { + val occurrences = output.split("duplicate message").size - 1 + occurrences shouldBe 1 + } + } + } + }) diff --git a/src/test/kotlin/zone/clanker/gradle/srcx/report/CrossBuildRendererTest.kt b/src/test/kotlin/zone/clanker/gradle/srcx/report/CrossBuildRendererTest.kt new file mode 100644 index 0000000..d69f6a5 --- /dev/null +++ b/src/test/kotlin/zone/clanker/gradle/srcx/report/CrossBuildRendererTest.kt @@ -0,0 +1,75 @@ +package zone.clanker.gradle.srcx.report + +import io.kotest.core.spec.style.BehaviorSpec +import io.kotest.matchers.string.shouldContain +import zone.clanker.gradle.srcx.model.AnalysisSummary +import zone.clanker.gradle.srcx.model.HubClass + +class CrossBuildRendererTest : + BehaviorSpec({ + + given("a CrossBuildRenderer") { + + `when`("rendering with no data") { + val renderer = CrossBuildRenderer(emptyList(), null) + val output = renderer.render() + + then("it shows the no-data message") { + output shouldContain "# Cross-Build References" + output shouldContain "No cross-build references detected." + } + } + + `when`("rendering with build edges") { + val edges = + listOf( + DashboardRenderer.BuildEdge("app", "lib"), + DashboardRenderer.BuildEdge("app", "core"), + ) + val renderer = CrossBuildRenderer(edges, null) + val output = renderer.render() + + then("it shows build dependencies grouped by source") { + output shouldContain "## Build Dependencies" + output shouldContain "### app" + output shouldContain "- depends on **lib**" + output shouldContain "- depends on **core**" + } + } + + `when`("rendering with cross-build hubs") { + val analysis = + AnalysisSummary( + findings = emptyList(), + hubs = + listOf( + HubClass("Config", 5, "model", "model/Config.kt", 3), + ), + cycles = emptyList(), + ) + val renderer = CrossBuildRenderer(emptyList(), analysis) + val output = renderer.render() + + then("it shows shared hub classes table") { + output shouldContain "## Shared Hub Classes" + output shouldContain "| `Config` | model/Config.kt:3 | 5 | model |" + } + } + + `when`("rendering with cross-build cycles") { + val analysis = + AnalysisSummary( + findings = emptyList(), + hubs = emptyList(), + cycles = listOf(listOf("A", "B", "A")), + ) + val renderer = CrossBuildRenderer(emptyList(), analysis) + val output = renderer.render() + + then("it shows cycles") { + output shouldContain "## Cross-Build Cycles" + output shouldContain "- A -> B -> A" + } + } + } + }) diff --git a/src/test/kotlin/zone/clanker/gradle/srcx/report/DashboardRendererTest.kt b/src/test/kotlin/zone/clanker/gradle/srcx/report/DashboardRendererTest.kt index ceb1e1d..59df3b8 100644 --- a/src/test/kotlin/zone/clanker/gradle/srcx/report/DashboardRendererTest.kt +++ b/src/test/kotlin/zone/clanker/gradle/srcx/report/DashboardRendererTest.kt @@ -196,9 +196,8 @@ class DashboardRendererTest : val output = DashboardRenderer("test", summaries, emptyList()).render() - then("it shows hub with dependents count") { - output shouldContain "(3 dependents)" - output shouldContain "[service]" + then("it shows the project in the projects table") { + output shouldContain "| :app |" } } } diff --git a/src/test/kotlin/zone/clanker/gradle/srcx/report/EntryPointsRendererTest.kt b/src/test/kotlin/zone/clanker/gradle/srcx/report/EntryPointsRendererTest.kt new file mode 100644 index 0000000..b290ac6 --- /dev/null +++ b/src/test/kotlin/zone/clanker/gradle/srcx/report/EntryPointsRendererTest.kt @@ -0,0 +1,127 @@ +package zone.clanker.gradle.srcx.report + +import io.kotest.core.spec.style.BehaviorSpec +import io.kotest.matchers.string.shouldContain +import zone.clanker.gradle.srcx.model.FilePath +import zone.clanker.gradle.srcx.model.PackageName +import zone.clanker.gradle.srcx.model.ProjectPath +import zone.clanker.gradle.srcx.model.ProjectSummary +import zone.clanker.gradle.srcx.model.SourceSetName +import zone.clanker.gradle.srcx.model.SourceSetSummary +import zone.clanker.gradle.srcx.model.SymbolEntry +import zone.clanker.gradle.srcx.model.SymbolKind +import zone.clanker.gradle.srcx.model.SymbolName + +class EntryPointsRendererTest : + BehaviorSpec({ + + given("an EntryPointsRenderer") { + + `when`("rendering with app entry points") { + val entryPoints = + listOf( + EntryPointsRenderer.EntryPoint("AppController", "com.example.app", "handleRequest"), + ) + val renderer = EntryPointsRenderer(emptyList(), entryPoints) + val output = renderer.render() + + then("it shows app entry points") { + output shouldContain "## App Entry Points" + output shouldContain "| `AppController` | com.example.app | handleRequest |" + } + } + + `when`("rendering with test classes") { + val summaries = + listOf( + ProjectSummary( + projectPath = ProjectPath(":app"), + symbols = emptyList(), + dependencies = emptyList(), + buildFile = "build.gradle.kts", + sourceDirs = emptyList(), + subprojects = emptyList(), + sourceSets = + listOf( + SourceSetSummary( + SourceSetName("test"), + listOf( + SymbolEntry( + SymbolName("AppTest"), + SymbolKind.CLASS, + PackageName("com.example"), + FilePath("AppTest.kt"), + 1, + ), + ), + listOf("src/test/kotlin"), + ), + ), + ), + ) + val renderer = EntryPointsRenderer(summaries) + val output = renderer.render() + + then("it shows test entry points") { + output shouldContain "## Test Entry Points" + output shouldContain "| `AppTest` | com.example |" + } + } + + `when`("rendering with test doubles") { + val summaries = + listOf( + ProjectSummary( + projectPath = ProjectPath(":app"), + symbols = emptyList(), + dependencies = emptyList(), + buildFile = "build.gradle.kts", + sourceDirs = emptyList(), + subprojects = emptyList(), + sourceSets = + listOf( + SourceSetSummary( + SourceSetName("test"), + listOf( + SymbolEntry( + SymbolName("MockRepository"), + SymbolKind.CLASS, + PackageName("com.example"), + FilePath("MockRepository.kt"), + 1, + ), + SymbolEntry( + SymbolName("FakeService"), + SymbolKind.CLASS, + PackageName("com.example"), + FilePath("FakeService.kt"), + 1, + ), + ), + listOf("src/test/kotlin"), + ), + ), + ), + ) + val renderer = EntryPointsRenderer(summaries) + val output = renderer.render() + + then("it shows test doubles") { + output shouldContain "## Test Doubles" + output shouldContain "| `MockRepository` | com.example | Mock |" + output shouldContain "| `FakeService` | com.example | Fake |" + } + } + + `when`("rendering with no data") { + val renderer = EntryPointsRenderer(emptyList()) + val output = renderer.render() + + then("it shows no-data messages") { + output shouldContain "No app entry points detected." + output shouldContain "No test classes found." + output shouldContain "No test doubles found." + } + } + } + }) diff --git a/src/test/kotlin/zone/clanker/gradle/srcx/report/FlowRendererTest.kt b/src/test/kotlin/zone/clanker/gradle/srcx/report/FlowRendererTest.kt new file mode 100644 index 0000000..cb993cd --- /dev/null +++ b/src/test/kotlin/zone/clanker/gradle/srcx/report/FlowRendererTest.kt @@ -0,0 +1,137 @@ +package zone.clanker.gradle.srcx.report + +import io.kotest.core.spec.style.BehaviorSpec +import io.kotest.matchers.collections.shouldHaveSize +import io.kotest.matchers.shouldBe +import io.kotest.matchers.string.shouldContain + +class FlowRendererTest : + BehaviorSpec({ + + given("a FlowRenderer") { + + `when`("rendering a single flow") { + val diagram = + """ + ```mermaid + sequenceDiagram + participant A as AppController + participant B as UserService + A->>B: + B-->>A: + ``` + """.trimIndent() + val renderer = FlowRenderer("AppController", diagram) + val output = renderer.render() + + then("it contains the header and diagram") { + output shouldContain "# AppController Flow" + output shouldContain "sequenceDiagram" + output shouldContain "participant A as AppController" + } + } + } + + given("FlowRenderer.splitDiagrams companion") { + + `when`("splitting an empty string") { + val result = FlowRenderer.splitDiagrams("") + + then("it returns an empty list") { + result shouldHaveSize 0 + } + } + + `when`("splitting a combined diagram output") { + val combined = + """ + ### AppController Flow + + ```mermaid + sequenceDiagram + participant A as AppController + ``` + + ### UserService Flow + + ```mermaid + sequenceDiagram + participant B as UserService + ``` + + """.trimIndent() + val result = FlowRenderer.splitDiagrams(combined) + + then("it splits into separate entries") { + result shouldHaveSize 2 + result[0].first shouldBe "AppController" + result[0].second shouldContain "participant A as AppController" + result[1].first shouldBe "UserService" + result[1].second shouldContain "participant B as UserService" + } + } + + `when`("splitting a single diagram") { + val single = + """ + ### MainApp Flow + + ```mermaid + sequenceDiagram + participant M as MainApp + ``` + """.trimIndent() + val result = FlowRenderer.splitDiagrams(single) + + then("it returns one entry") { + result shouldHaveSize 1 + result[0].first shouldBe "MainApp" + } + } + + `when`("splitting a blank string with whitespace") { + val result = FlowRenderer.splitDiagrams(" \n \n ") + + then("it returns an empty list") { + result shouldHaveSize 0 + } + } + + `when`("content before first header is ignored") { + val combined = + """ + Some preamble text that is not a header. + Another line before headers. + ### FirstFlow Flow + + ```mermaid + sequenceDiagram + participant X as FirstFlow + ``` + """.trimIndent() + val result = FlowRenderer.splitDiagrams(combined) + + then("it only captures content after the header") { + result shouldHaveSize 1 + result[0].first shouldBe "FirstFlow" + result[0].second shouldContain "participant X as FirstFlow" + } + } + + `when`("header with no content following is skipped") { + val combined = + """ + ### EmptySection Flow + ### RealSection Flow + + some content here + """.trimIndent() + val result = FlowRenderer.splitDiagrams(combined) + + then("it skips headers with blank content") { + result shouldHaveSize 1 + result[0].first shouldBe "RealSection" + } + } + } + }) diff --git a/src/test/kotlin/zone/clanker/gradle/srcx/report/HotClassesRendererTest.kt b/src/test/kotlin/zone/clanker/gradle/srcx/report/HotClassesRendererTest.kt new file mode 100644 index 0000000..42f9d30 --- /dev/null +++ b/src/test/kotlin/zone/clanker/gradle/srcx/report/HotClassesRendererTest.kt @@ -0,0 +1,73 @@ +package zone.clanker.gradle.srcx.report + +import io.kotest.core.spec.style.BehaviorSpec +import io.kotest.matchers.string.shouldContain +import io.kotest.matchers.string.shouldNotContain +import zone.clanker.gradle.srcx.model.HubClass +import zone.clanker.gradle.srcx.model.HubDependentRef + +class HotClassesRendererTest : + BehaviorSpec({ + + given("a HotClassesRenderer") { + + `when`("rendering with no hubs") { + val renderer = HotClassesRenderer(emptyList()) + val output = renderer.render() + + then("it shows the header and no-data message") { + output shouldContain "# Hot Classes" + output shouldContain "No hub classes detected." + } + } + + `when`("rendering with hub classes") { + val hubs = + listOf( + HubClass( + name = "ChangeConfig", + dependentCount = 7, + role = "other", + filePath = "model/ChangeConfig.kt", + line = 5, + dependents = + listOf( + HubDependentRef("ChangeReader", "workflow/ChangeReader.kt", 8), + HubDependentRef("ApplyTask", "task/ApplyTask.kt", 18), + HubDependentRef("ProposeTask", "task/ProposeTask.kt", 12), + ), + ), + HubClass( + name = "Util", + dependentCount = 2, + role = "", + filePath = "util/Util.kt", + line = 1, + dependents = + listOf( + HubDependentRef("A", "A.kt", 1), + HubDependentRef("B", "B.kt", 1), + ), + ), + ) + val renderer = HotClassesRenderer(hubs) + val output = renderer.render() + + then("it contains the hub table") { + output shouldContain "| Class | File | Dependents | Role |" + output shouldContain "| `ChangeConfig` | model/ChangeConfig.kt:5 | 7 | other |" + output shouldContain "| `Util` | util/Util.kt:1 | 2 | |" + } + + then("it shows detail section for hubs with 3+ dependents") { + output shouldContain "## ChangeConfig" + output shouldContain "- ChangeReader โ€” workflow/ChangeReader.kt:8" + output shouldContain "- ApplyTask โ€” task/ApplyTask.kt:18" + } + + then("it does not show detail section for hubs below threshold") { + output shouldNotContain "## Util" + } + } + } + }) diff --git a/src/test/kotlin/zone/clanker/gradle/srcx/report/InterfacesRendererTest.kt b/src/test/kotlin/zone/clanker/gradle/srcx/report/InterfacesRendererTest.kt new file mode 100644 index 0000000..81555a4 --- /dev/null +++ b/src/test/kotlin/zone/clanker/gradle/srcx/report/InterfacesRendererTest.kt @@ -0,0 +1,674 @@ +@file:Suppress("LargeClass") + +package zone.clanker.gradle.srcx.report + +import io.kotest.core.spec.style.BehaviorSpec +import io.kotest.matchers.collections.shouldBeEmpty +import io.kotest.matchers.collections.shouldHaveSize +import io.kotest.matchers.shouldBe +import io.kotest.matchers.string.shouldContain +import zone.clanker.gradle.srcx.model.AnalysisSummary +import zone.clanker.gradle.srcx.model.FilePath +import zone.clanker.gradle.srcx.model.Finding +import zone.clanker.gradle.srcx.model.FindingSeverity +import zone.clanker.gradle.srcx.model.PackageName +import zone.clanker.gradle.srcx.model.ProjectPath +import zone.clanker.gradle.srcx.model.ProjectSummary +import zone.clanker.gradle.srcx.model.SourceSetName +import zone.clanker.gradle.srcx.model.SourceSetSummary +import zone.clanker.gradle.srcx.model.SymbolEntry +import zone.clanker.gradle.srcx.model.SymbolKind +import zone.clanker.gradle.srcx.model.SymbolName + +class InterfacesRendererTest : + BehaviorSpec({ + + given("an InterfacesRenderer") { + + `when`("rendering with no interfaces") { + val renderer = InterfacesRenderer(emptyList()) + val output = renderer.render() + + then("it shows the no-data message") { + output shouldContain "# Interfaces" + output shouldContain "No interfaces detected." + } + } + + `when`("rendering with interfaces") { + val interfaces = + listOf( + InterfacesRenderer.InterfaceInfo( + name = "UserRepository", + packageName = "com.example.repo", + implementationCount = 2, + hasMock = true, + ), + InterfacesRenderer.InterfaceInfo( + name = "ILogger", + packageName = "com.example.log", + implementationCount = 1, + hasMock = false, + ), + ) + val renderer = InterfacesRenderer(interfaces) + val output = renderer.render() + + then("it contains the interface table") { + output shouldContain "| Interface | Package | Implementations | Has Mock |" + output shouldContain "| `UserRepository` | com.example.repo | 2 | yes |" + output shouldContain "| `ILogger` | com.example.log | 1 | no |" + } + } + } + + given("InterfacesRenderer.fromSummaries") { + + `when`("summaries have no interfaces") { + val summaries = + listOf( + ProjectSummary( + projectPath = ProjectPath(":app"), + symbols = emptyList(), + dependencies = emptyList(), + buildFile = "build.gradle.kts", + sourceDirs = emptyList(), + subprojects = emptyList(), + sourceSets = + listOf( + SourceSetSummary( + SourceSetName("main"), + listOf( + SymbolEntry( + SymbolName("SomeClass"), + SymbolKind.CLASS, + PackageName("com.example"), + FilePath("SomeClass.kt"), + 1, + ), + ), + listOf("src/main/kotlin"), + ), + ), + ), + ) + val result = InterfacesRenderer.fromSummaries(summaries) + + then("it returns empty list") { + result.shouldBeEmpty() + } + } + + `when`("summaries have interface-like naming (Service suffix)") { + val summaries = + listOf( + ProjectSummary( + projectPath = ProjectPath(":app"), + symbols = emptyList(), + dependencies = emptyList(), + buildFile = "build.gradle.kts", + sourceDirs = emptyList(), + subprojects = emptyList(), + sourceSets = + listOf( + SourceSetSummary( + SourceSetName("main"), + listOf( + SymbolEntry( + SymbolName("UserService"), + SymbolKind.CLASS, + PackageName("com.example"), + FilePath("UserService.kt"), + 1, + ), + SymbolEntry( + SymbolName("UserServiceImpl"), + SymbolKind.CLASS, + PackageName("com.example"), + FilePath("UserServiceImpl.kt"), + 2, + ), + SymbolEntry( + SymbolName("MockUserService"), + SymbolKind.CLASS, + PackageName("com.example"), + FilePath("MockUserService.kt"), + 3, + ), + ), + listOf("src/main/kotlin"), + ), + ), + ), + ) + val result = InterfacesRenderer.fromSummaries(summaries) + + then("it detects the interface by Service suffix") { + val userSvc = result.first { it.name == "UserService" } + userSvc.implementationCount shouldBe 2 + userSvc.hasMock shouldBe true + } + } + + `when`("summaries have I-prefixed interface") { + val summaries = + listOf( + ProjectSummary( + projectPath = ProjectPath(":lib"), + symbols = emptyList(), + dependencies = emptyList(), + buildFile = "build.gradle.kts", + sourceDirs = emptyList(), + subprojects = emptyList(), + sourceSets = + listOf( + SourceSetSummary( + SourceSetName("main"), + listOf( + SymbolEntry( + SymbolName("ILogger"), + SymbolKind.CLASS, + PackageName("com.example.log"), + FilePath("ILogger.kt"), + 1, + ), + SymbolEntry( + SymbolName("LoggerImpl"), + SymbolKind.CLASS, + PackageName("com.example.log"), + FilePath("LoggerImpl.kt"), + 2, + ), + SymbolEntry( + SymbolName("FakeILogger"), + SymbolKind.CLASS, + PackageName("com.example.log"), + FilePath("FakeILogger.kt"), + 3, + ), + ), + listOf("src/main/kotlin"), + ), + ), + ), + ) + val result = InterfacesRenderer.fromSummaries(summaries) + + then("it detects the I-prefixed interface with impl count and mock") { + result shouldHaveSize 1 + result[0].name shouldBe "ILogger" + result[0].implementationCount shouldBe 2 + result[0].hasMock shouldBe true + } + } + + `when`("summaries have Repository suffix") { + val summaries = + listOf( + ProjectSummary( + projectPath = ProjectPath(":data"), + symbols = emptyList(), + dependencies = emptyList(), + buildFile = "build.gradle.kts", + sourceDirs = emptyList(), + subprojects = emptyList(), + sourceSets = + listOf( + SourceSetSummary( + SourceSetName("main"), + listOf( + SymbolEntry( + SymbolName("UserRepository"), + SymbolKind.CLASS, + PackageName("com.example.data"), + FilePath("UserRepository.kt"), + 1, + ), + SymbolEntry( + SymbolName("UserRepositoryImpl"), + SymbolKind.CLASS, + PackageName("com.example.data"), + FilePath("UserRepositoryImpl.kt"), + 2, + ), + ), + listOf("src/main/kotlin"), + ), + ), + ), + ) + val result = InterfacesRenderer.fromSummaries(summaries) + + then("it detects Repository as interface-like") { + result shouldHaveSize 1 + result[0].name shouldBe "UserRepository" + result[0].implementationCount shouldBe 1 + result[0].hasMock shouldBe false + } + } + + `when`("summaries have Provider suffix") { + val summaries = + listOf( + ProjectSummary( + projectPath = ProjectPath(":core"), + symbols = emptyList(), + dependencies = emptyList(), + buildFile = "build.gradle.kts", + sourceDirs = emptyList(), + subprojects = emptyList(), + sourceSets = + listOf( + SourceSetSummary( + SourceSetName("main"), + listOf( + SymbolEntry( + SymbolName("ConfigProvider"), + SymbolKind.CLASS, + PackageName("com.example.core"), + FilePath("ConfigProvider.kt"), + 1, + ), + ), + listOf("src/main/kotlin"), + ), + ), + ), + ) + val result = InterfacesRenderer.fromSummaries(summaries) + + then("it detects Provider as interface-like") { + result shouldHaveSize 1 + result[0].name shouldBe "ConfigProvider" + } + } + + `when`("summaries have Factory suffix") { + val summaries = + listOf( + ProjectSummary( + projectPath = ProjectPath(":core"), + symbols = emptyList(), + dependencies = emptyList(), + buildFile = "build.gradle.kts", + sourceDirs = emptyList(), + subprojects = emptyList(), + sourceSets = + listOf( + SourceSetSummary( + SourceSetName("main"), + listOf( + SymbolEntry( + SymbolName("WidgetFactory"), + SymbolKind.CLASS, + PackageName("com.example.core"), + FilePath("WidgetFactory.kt"), + 1, + ), + ), + listOf("src/main/kotlin"), + ), + ), + ), + ) + val result = InterfacesRenderer.fromSummaries(summaries) + + then("it detects Factory as interface-like") { + result shouldHaveSize 1 + result[0].name shouldBe "WidgetFactory" + } + } + + `when`("summaries have analysis findings referencing interfaces") { + val summaries = + listOf( + ProjectSummary( + projectPath = ProjectPath(":app"), + symbols = emptyList(), + dependencies = emptyList(), + buildFile = "build.gradle.kts", + sourceDirs = emptyList(), + subprojects = emptyList(), + sourceSets = + listOf( + SourceSetSummary( + SourceSetName("main"), + listOf( + SymbolEntry( + SymbolName("Dao"), + SymbolKind.CLASS, + PackageName("com.example"), + FilePath("Dao.kt"), + 1, + ), + SymbolEntry( + SymbolName("DefaultDao"), + SymbolKind.CLASS, + PackageName("com.example"), + FilePath("DefaultDao.kt"), + 2, + ), + ), + listOf("src/main/kotlin"), + ), + ), + analysis = + AnalysisSummary( + findings = + listOf( + Finding( + FindingSeverity.INFO, + "Interface `Dao` has only one implementation", + "Consider inlining", + ), + ), + hubs = emptyList(), + cycles = emptyList(), + ), + ), + ) + val result = InterfacesRenderer.fromSummaries(summaries) + + then("it finds the interface from findings pattern") { + result.any { it.name == "Dao" } shouldBe true + } + } + + `when`("summaries have mock via Mock prefix pattern") { + val summaries = + listOf( + ProjectSummary( + projectPath = ProjectPath(":app"), + symbols = emptyList(), + dependencies = emptyList(), + buildFile = "build.gradle.kts", + sourceDirs = emptyList(), + subprojects = emptyList(), + sourceSets = + listOf( + SourceSetSummary( + SourceSetName("main"), + listOf( + SymbolEntry( + SymbolName("IPayment"), + SymbolKind.CLASS, + PackageName("com.example"), + FilePath("IPayment.kt"), + 1, + ), + SymbolEntry( + SymbolName("MockIPayment"), + SymbolKind.CLASS, + PackageName("com.example"), + FilePath("MockIPayment.kt"), + 2, + ), + ), + listOf("src/main/kotlin"), + ), + ), + ), + ) + val result = InterfacesRenderer.fromSummaries(summaries) + + then("it detects the mock via Mock prefix") { + result shouldHaveSize 1 + result[0].name shouldBe "IPayment" + result[0].hasMock shouldBe true + } + } + + `when`("summaries have mock via Fake prefix pattern") { + val summaries = + listOf( + ProjectSummary( + projectPath = ProjectPath(":app"), + symbols = emptyList(), + dependencies = emptyList(), + buildFile = "build.gradle.kts", + sourceDirs = emptyList(), + subprojects = emptyList(), + sourceSets = + listOf( + SourceSetSummary( + SourceSetName("main"), + listOf( + SymbolEntry( + SymbolName("INotifier"), + SymbolKind.CLASS, + PackageName("com.example"), + FilePath("INotifier.kt"), + 1, + ), + SymbolEntry( + SymbolName("FakeINotifier"), + SymbolKind.CLASS, + PackageName("com.example"), + FilePath("FakeINotifier.kt"), + 2, + ), + ), + listOf("src/main/kotlin"), + ), + ), + ), + ) + val result = InterfacesRenderer.fromSummaries(summaries) + + then("it detects the mock via Fake prefix") { + result shouldHaveSize 1 + result[0].name shouldBe "INotifier" + result[0].hasMock shouldBe true + } + } + + `when`("summaries have mock via Mock suffix pattern") { + val summaries = + listOf( + ProjectSummary( + projectPath = ProjectPath(":app"), + symbols = emptyList(), + dependencies = emptyList(), + buildFile = "build.gradle.kts", + sourceDirs = emptyList(), + subprojects = emptyList(), + sourceSets = + listOf( + SourceSetSummary( + SourceSetName("main"), + listOf( + SymbolEntry( + SymbolName("UserRepository"), + SymbolKind.CLASS, + PackageName("com.example"), + FilePath("UserRepository.kt"), + 1, + ), + SymbolEntry( + SymbolName("UserRepositoryMock"), + SymbolKind.CLASS, + PackageName("com.example"), + FilePath("UserRepositoryMock.kt"), + 2, + ), + ), + listOf("src/main/kotlin"), + ), + ), + ), + ) + val result = InterfacesRenderer.fromSummaries(summaries) + + then("it detects the mock via Mock suffix") { + val iface = result.first { it.name == "UserRepository" } + iface.hasMock shouldBe true + } + } + + `when`("summaries have Fake suffix pattern") { + val summaries = + listOf( + ProjectSummary( + projectPath = ProjectPath(":app"), + symbols = emptyList(), + dependencies = emptyList(), + buildFile = "build.gradle.kts", + sourceDirs = emptyList(), + subprojects = emptyList(), + sourceSets = + listOf( + SourceSetSummary( + SourceSetName("main"), + listOf( + SymbolEntry( + SymbolName("EventProvider"), + SymbolKind.CLASS, + PackageName("com.example"), + FilePath("EventProvider.kt"), + 1, + ), + SymbolEntry( + SymbolName("EventProviderFake"), + SymbolKind.CLASS, + PackageName("com.example"), + FilePath("EventProviderFake.kt"), + 2, + ), + ), + listOf("src/main/kotlin"), + ), + ), + ), + ) + val result = InterfacesRenderer.fromSummaries(summaries) + + then("it detects the mock via Fake suffix") { + val iface = result.first { it.name == "EventProvider" } + iface.hasMock shouldBe true + } + } + + `when`("summaries have empty source sets") { + val summaries = + listOf( + ProjectSummary( + projectPath = ProjectPath(":empty"), + symbols = emptyList(), + dependencies = emptyList(), + buildFile = "build.gradle.kts", + sourceDirs = emptyList(), + subprojects = emptyList(), + sourceSets = emptyList(), + ), + ) + val result = InterfacesRenderer.fromSummaries(summaries) + + then("it returns empty list") { + result.shouldBeEmpty() + } + } + + `when`("interface has Default prefix implementation") { + val summaries = + listOf( + ProjectSummary( + projectPath = ProjectPath(":app"), + symbols = emptyList(), + dependencies = emptyList(), + buildFile = "build.gradle.kts", + sourceDirs = emptyList(), + subprojects = emptyList(), + sourceSets = + listOf( + SourceSetSummary( + SourceSetName("main"), + listOf( + SymbolEntry( + SymbolName("IConfig"), + SymbolKind.CLASS, + PackageName("com.example"), + FilePath("IConfig.kt"), + 1, + ), + SymbolEntry( + SymbolName("DefaultConfig"), + SymbolKind.CLASS, + PackageName("com.example"), + FilePath("DefaultConfig.kt"), + 2, + ), + ), + listOf("src/main/kotlin"), + ), + ), + ), + ) + val result = InterfacesRenderer.fromSummaries(summaries) + + then("it counts the Default prefix implementation") { + result shouldHaveSize 1 + result[0].name shouldBe "IConfig" + result[0].implementationCount shouldBe 1 + } + } + + `when`("interface has endsWith baseName implementation") { + val summaries = + listOf( + ProjectSummary( + projectPath = ProjectPath(":app"), + symbols = emptyList(), + dependencies = emptyList(), + buildFile = "build.gradle.kts", + sourceDirs = emptyList(), + subprojects = emptyList(), + sourceSets = + listOf( + SourceSetSummary( + SourceSetName("main"), + listOf( + SymbolEntry( + SymbolName("ICache"), + SymbolKind.CLASS, + PackageName("com.example"), + FilePath("ICache.kt"), + 1, + ), + SymbolEntry( + SymbolName("RedisCache"), + SymbolKind.CLASS, + PackageName("com.example"), + FilePath("RedisCache.kt"), + 2, + ), + SymbolEntry( + SymbolName("InMemoryCache"), + SymbolKind.CLASS, + PackageName("com.example"), + FilePath("InMemoryCache.kt"), + 3, + ), + ), + listOf("src/main/kotlin"), + ), + ), + ), + ) + val result = InterfacesRenderer.fromSummaries(summaries) + + then("it counts implementations matching endsWith pattern") { + result shouldHaveSize 1 + result[0].name shouldBe "ICache" + result[0].implementationCount shouldBe 2 + } + } + + `when`("summaries are empty") { + val result = InterfacesRenderer.fromSummaries(emptyList()) + + then("it returns empty list") { + result.shouldBeEmpty() + } + } + } + }) From d98550f0d587228e336144c2becf62bd0aaad9d5 Mon Sep 17 00:00:00 2001 From: slop Date: Fri, 10 Apr 2026 23:27:28 -0700 Subject: [PATCH 07/13] Rename Hot Classes to Hub Classes across all output files --- settings.gradle.kts | 6 ++++++ .../zone/clanker/gradle/srcx/report/DashboardRenderer.kt | 4 ++-- .../zone/clanker/gradle/srcx/report/HotClassesRenderer.kt | 4 ++-- .../kotlin/zone/clanker/gradle/srcx/task/ContextTask.kt | 4 ++-- .../clanker/gradle/srcx/report/HotClassesRendererTest.kt | 2 +- 5 files changed, 13 insertions(+), 7 deletions(-) diff --git a/settings.gradle.kts b/settings.gradle.kts index 91001b8..b0f240f 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -1,9 +1,15 @@ pluginManagement { includeBuild("build-logic") + repositories { + mavenLocal() + gradlePluginPortal() + mavenCentral() + } } plugins { id("clkx-settings") + id("zone.clanker.gradle.srcx") version "0.0.0-dev" } rootProject.name = "srcx" diff --git a/src/main/kotlin/zone/clanker/gradle/srcx/report/DashboardRenderer.kt b/src/main/kotlin/zone/clanker/gradle/srcx/report/DashboardRenderer.kt index 762dedf..85ba79d 100644 --- a/src/main/kotlin/zone/clanker/gradle/srcx/report/DashboardRenderer.kt +++ b/src/main/kotlin/zone/clanker/gradle/srcx/report/DashboardRenderer.kt @@ -9,7 +9,7 @@ import zone.clanker.gradle.srcx.model.ProjectSummary * * Contains the high-level overview: project structure, package groups, * dependencies, build dependency graph (Mermaid), included build summaries, - * and links to split detail files (hot-classes, entry-points, anti-patterns, etc.). + * and links to split detail files (hub-classes, entry-points, anti-patterns, etc.). */ @Suppress("LongParameterList") internal class DashboardRenderer( @@ -149,7 +149,7 @@ internal class DashboardRenderer( appendLine("## Details") appendLine() if (hasHubs) { - appendLine("- [Hot Classes](hot-classes.md)") + appendLine("- [Hub Classes](hub-classes.md)") } appendLine("- [Entry Points](entry-points.md)") if (hasFindings) { diff --git a/src/main/kotlin/zone/clanker/gradle/srcx/report/HotClassesRenderer.kt b/src/main/kotlin/zone/clanker/gradle/srcx/report/HotClassesRenderer.kt index 5e09ac9..5dcd505 100644 --- a/src/main/kotlin/zone/clanker/gradle/srcx/report/HotClassesRenderer.kt +++ b/src/main/kotlin/zone/clanker/gradle/srcx/report/HotClassesRenderer.kt @@ -3,7 +3,7 @@ package zone.clanker.gradle.srcx.report import zone.clanker.gradle.srcx.model.HubClass /** - * Renders the hot-classes.md file listing hub classes with their dependents. + * Renders the hub-classes.md file listing hub classes with their dependents. * * Hub classes are the most depended-on classes across the codebase. * Each hub with [DETAIL_THRESHOLD] or more dependents gets a detailed section. @@ -15,7 +15,7 @@ internal class HotClassesRenderer( ) { fun render(): String = buildString { - appendLine("# Hot Classes") + appendLine("# Hub Classes") appendLine() if (hubs.isEmpty()) { appendLine("No hub classes detected.") diff --git a/src/main/kotlin/zone/clanker/gradle/srcx/task/ContextTask.kt b/src/main/kotlin/zone/clanker/gradle/srcx/task/ContextTask.kt index 0229c07..ff8370e 100644 --- a/src/main/kotlin/zone/clanker/gradle/srcx/task/ContextTask.kt +++ b/src/main/kotlin/zone/clanker/gradle/srcx/task/ContextTask.kt @@ -178,9 +178,9 @@ abstract class ContextTask : DefaultTask() { crossBuild: Pair, crossBuildSummary: zone.clanker.gradle.srcx.model.AnalysisSummary?, ) { - // hot-classes.md + // hub-classes.md val allHubs = crossBuildSummary?.hubs ?: emptyList() - File(dir, "hot-classes.md").writeText(HotClassesRenderer(allHubs).render()) + File(dir, "hub-classes.md").writeText(HotClassesRenderer(allHubs).render()) // entry-points.md val entryPoints = buildEntryPoints(crossBuild.second) diff --git a/src/test/kotlin/zone/clanker/gradle/srcx/report/HotClassesRendererTest.kt b/src/test/kotlin/zone/clanker/gradle/srcx/report/HotClassesRendererTest.kt index 42f9d30..2fb644d 100644 --- a/src/test/kotlin/zone/clanker/gradle/srcx/report/HotClassesRendererTest.kt +++ b/src/test/kotlin/zone/clanker/gradle/srcx/report/HotClassesRendererTest.kt @@ -16,7 +16,7 @@ class HotClassesRendererTest : val output = renderer.render() then("it shows the header and no-data message") { - output shouldContain "# Hot Classes" + output shouldContain "# Hub Classes" output shouldContain "No hub classes detected." } } From 86498fe73981333d591cfa6b33f178873970cbbc Mon Sep 17 00:00:00 2001 From: slop Date: Fri, 10 Apr 2026 23:37:02 -0700 Subject: [PATCH 08/13] Fix false positive cycles, add test icons to hub classes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Strip KDoc and comments before same-package reference matching to eliminate false circular deps from @see cross-references - Skip self-edges in cycle detection (Case -> Case) - Add isTest field to HubClass model - Hub classes renderer separates production and test classes with ๐Ÿงช icon prefix for test classes --- .../srcx/analysis/DependencyAnalyzer.kt | 12 +++++- .../gradle/srcx/analysis/ProjectAnalysis.kt | 4 ++ .../gradle/srcx/model/AnalysisSummary.kt | 1 + .../gradle/srcx/report/HotClassesRenderer.kt | 38 +++++++++++++------ 4 files changed, 43 insertions(+), 12 deletions(-) diff --git a/src/main/kotlin/zone/clanker/gradle/srcx/analysis/DependencyAnalyzer.kt b/src/main/kotlin/zone/clanker/gradle/srcx/analysis/DependencyAnalyzer.kt index 00bde49..e3c6478 100644 --- a/src/main/kotlin/zone/clanker/gradle/srcx/analysis/DependencyAnalyzer.kt +++ b/src/main/kotlin/zone/clanker/gradle/srcx/analysis/DependencyAnalyzer.kt @@ -71,14 +71,23 @@ private fun addSamePackageEdges( if (pkg.isEmpty()) return val sourceText = runCatching { component.source.file.readText() }.getOrDefault("") if (sourceText.isEmpty()) return + val codeOnly = stripComments(sourceText) allComponents .filter { it !== component && it.source.packageName == pkg && it.source.simpleName.length >= 2 } .filter { candidate -> - Regex("\\b${Regex.escape(candidate.source.simpleName)}\\b").containsMatchIn(sourceText) + Regex("\\b${Regex.escape(candidate.source.simpleName)}\\b").containsMatchIn(codeOnly) }.forEach { edges.add(ClassDependency(component, it)) } } +private fun stripComments(source: String): String { + val noBlockComments = source.replace(Regex("/\\*[\\s\\S]*?\\*/"), "") + return noBlockComments + .lines() + .filter { !it.trimStart().startsWith("//") } + .joinToString("\n") +} + private fun resolveSupertypeTarget( component: ClassifiedComponent, supertype: String, @@ -158,6 +167,7 @@ fun findHubClasses( fun findCycles(edges: List): List> { val adjacency = mutableMapOf>() for (edge in edges) { + if (edge.from.source.qualifiedName == edge.to.source.qualifiedName) continue adjacency .getOrPut(edge.from.source.qualifiedName) { mutableSetOf() } .add(edge.to.source.qualifiedName) diff --git a/src/main/kotlin/zone/clanker/gradle/srcx/analysis/ProjectAnalysis.kt b/src/main/kotlin/zone/clanker/gradle/srcx/analysis/ProjectAnalysis.kt index 4092040..8cf3d3b 100644 --- a/src/main/kotlin/zone/clanker/gradle/srcx/analysis/ProjectAnalysis.kt +++ b/src/main/kotlin/zone/clanker/gradle/srcx/analysis/ProjectAnalysis.kt @@ -41,6 +41,9 @@ data class ProjectAnalysis( zone.clanker.gradle.srcx.model .HubDependentRef(dep.name, dep.filePath, dep.line) } + val testFile = + hub.component.source.file.path + .contains("/test/") zone.clanker.gradle.srcx.model.HubClass( name = name, dependentCount = hub.count, @@ -48,6 +51,7 @@ data class ProjectAnalysis( filePath = hub.component.source.relativePath, line = hub.component.source.declarationLine, dependents = depRefs, + isTest = testFile, ) } return zone.clanker.gradle.srcx.model diff --git a/src/main/kotlin/zone/clanker/gradle/srcx/model/AnalysisSummary.kt b/src/main/kotlin/zone/clanker/gradle/srcx/model/AnalysisSummary.kt index f0e5332..c250b0c 100644 --- a/src/main/kotlin/zone/clanker/gradle/srcx/model/AnalysisSummary.kt +++ b/src/main/kotlin/zone/clanker/gradle/srcx/model/AnalysisSummary.kt @@ -54,6 +54,7 @@ data class HubClass( val filePath: String = "", val line: Int = 0, val dependents: List = emptyList(), + val isTest: Boolean = false, ) /** diff --git a/src/main/kotlin/zone/clanker/gradle/srcx/report/HotClassesRenderer.kt b/src/main/kotlin/zone/clanker/gradle/srcx/report/HotClassesRenderer.kt index 5dcd505..3a63e65 100644 --- a/src/main/kotlin/zone/clanker/gradle/srcx/report/HotClassesRenderer.kt +++ b/src/main/kotlin/zone/clanker/gradle/srcx/report/HotClassesRenderer.kt @@ -3,12 +3,10 @@ package zone.clanker.gradle.srcx.report import zone.clanker.gradle.srcx.model.HubClass /** - * Renders the hub-classes.md file listing hub classes with their dependents. + * Renders hub-classes.md listing hub classes with their dependents. * - * Hub classes are the most depended-on classes across the codebase. - * Each hub with [DETAIL_THRESHOLD] or more dependents gets a detailed section. - * - * @property hubs the hub classes sorted by dependent count descending + * Production classes are listed first, test classes at the end. + * Icons: production classes unmarked, test classes prefixed with ๐Ÿงช. */ internal class HotClassesRenderer( private val hubs: List, @@ -22,14 +20,20 @@ internal class HotClassesRenderer( appendLine() return@buildString } - appendLine("| Class | File | Dependents | Role |") - appendLine("|-------|------|------------|------|") - for (hub in hubs) { - appendLine("| `${hub.name}` | ${hub.filePath}:${hub.line} | ${hub.dependentCount} | ${hub.role} |") + val production = hubs.filter { !it.isTest } + val test = hubs.filter { it.isTest } + + if (production.isNotEmpty()) { + appendHubTable(production) + } + if (test.isNotEmpty()) { + appendLine("### Test") + appendLine() + appendHubTable(test, prefix = "\uD83E\uDDEA ") } - appendLine() for (hub in hubs.filter { it.dependentCount >= DETAIL_THRESHOLD }) { - appendLine("## ${hub.name}") + val icon = if (hub.isTest) "\uD83E\uDDEA " else "" + appendLine("## $icon${hub.name}") appendLine() for (dep in hub.dependents) { appendLine("- ${dep.name} โ€” ${dep.filePath}:${dep.line}") @@ -38,6 +42,18 @@ internal class HotClassesRenderer( } } + private fun StringBuilder.appendHubTable( + hubList: List, + prefix: String = "", + ) { + appendLine("| Class | File | Dependents | Role |") + appendLine("|-------|------|------------|------|") + for (hub in hubList) { + appendLine("| $prefix`${hub.name}` | ${hub.filePath}:${hub.line} | ${hub.dependentCount} | ${hub.role} |") + } + appendLine() + } + companion object { private const val DETAIL_THRESHOLD = 3 } From 94f7453c363698caa1de346d7312c6d0a9f1a6e9 Mon Sep 17 00:00:00 2001 From: slop Date: Sat, 11 Apr 2026 10:40:08 -0700 Subject: [PATCH 09/13] Improve output formats, remove flows, fix false positives MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Hub classes: tree format with โ”œโ”€โ”€ โ””โ”€โ”€ connectors, test paths marked ๐Ÿงช - Entry points: removed useless "First Call" column, added description - Cross-build: tree showing dependents per hub with file paths - Interfaces: fixed false detection of enum values, grouped by source set - Flows: removed entirely (import-chain tracing produced identical diagrams) - Dashboard: slimmed to overview + tables + links only, removed class diagram - Fixed false positive cycles from KDoc @see references (strip comments) - Fixed self-cycles in cycle detection (skip self-edges) --- .../gradle/srcx/report/CrossBuildRenderer.kt | 30 ++++ .../gradle/srcx/report/DashboardRenderer.kt | 31 +--- .../gradle/srcx/report/EntryPointsRenderer.kt | 13 +- .../gradle/srcx/report/FlowRenderer.kt | 54 ------- .../gradle/srcx/report/HotClassesRenderer.kt | 22 ++- .../gradle/srcx/report/InterfacesRenderer.kt | 70 +++++++-- .../clanker/gradle/srcx/task/ContextTask.kt | 28 ---- .../clanker/gradle/srcx/BuildEdgesTest.kt | 7 +- .../srcx/report/EntryPointsRendererTest.kt | 4 +- .../gradle/srcx/report/FlowRendererTest.kt | 137 ------------------ .../srcx/report/HotClassesRendererTest.kt | 9 +- .../srcx/report/InterfacesRendererTest.kt | 2 + 12 files changed, 125 insertions(+), 282 deletions(-) delete mode 100644 src/main/kotlin/zone/clanker/gradle/srcx/report/FlowRenderer.kt delete mode 100644 src/test/kotlin/zone/clanker/gradle/srcx/report/FlowRendererTest.kt diff --git a/src/main/kotlin/zone/clanker/gradle/srcx/report/CrossBuildRenderer.kt b/src/main/kotlin/zone/clanker/gradle/srcx/report/CrossBuildRenderer.kt index 2a07180..8f62047 100644 --- a/src/main/kotlin/zone/clanker/gradle/srcx/report/CrossBuildRenderer.kt +++ b/src/main/kotlin/zone/clanker/gradle/srcx/report/CrossBuildRenderer.kt @@ -5,6 +5,9 @@ import zone.clanker.gradle.srcx.model.AnalysisSummary /** * Renders the cross-build.md file showing cross-build references grouped by build pair. * + * Shows a tree per hub class with dependents marked by build origin. + * External-build dependents are marked with a link icon. + * * @property buildEdges dependency edges between builds * @property crossBuildAnalysis the cross-build analysis results (hubs, findings) */ @@ -52,6 +55,21 @@ internal class CrossBuildRenderer( appendLine("| `${hub.name}` | ${hub.filePath}:${hub.line} | ${hub.dependentCount} | ${hub.role} |") } appendLine() + for (hub in hubs.filter { it.dependents.isNotEmpty() }) { + appendLine("### ${hub.name}") + appendLine() + appendLine("```") + appendLine("${hub.filePath}:${hub.line} \u2014 ${hub.dependentCount} dependents") + val deps = hub.dependents + for ((index, dep) in deps.withIndex()) { + val isLast = index == deps.lastIndex + val connector = if (isLast) "\u2514\u2500\u2500" else "\u251C\u2500\u2500" + val externalMarker = if (isExternalBuild(dep.filePath, hub.filePath)) " \uD83D\uDD17" else "" + appendLine("$connector ${dep.filePath}:${dep.line}$externalMarker") + } + appendLine("```") + appendLine() + } } private fun StringBuilder.appendCrossBuildCycles() { @@ -64,4 +82,16 @@ internal class CrossBuildRenderer( } appendLine() } + + companion object { + /** + * Determines if a dependent is from a different build than the hub. + * Uses path prefix comparison as a heuristic. + */ + internal fun isExternalBuild(dependentPath: String, hubPath: String): Boolean { + val depRoot = dependentPath.substringBefore("/") + val hubRoot = hubPath.substringBefore("/") + return depRoot != hubRoot && depRoot.isNotEmpty() && hubRoot.isNotEmpty() + } + } } diff --git a/src/main/kotlin/zone/clanker/gradle/srcx/report/DashboardRenderer.kt b/src/main/kotlin/zone/clanker/gradle/srcx/report/DashboardRenderer.kt index 85ba79d..09babb4 100644 --- a/src/main/kotlin/zone/clanker/gradle/srcx/report/DashboardRenderer.kt +++ b/src/main/kotlin/zone/clanker/gradle/srcx/report/DashboardRenderer.kt @@ -18,7 +18,6 @@ internal class DashboardRenderer( private val includedBuilds: List, private val includedBuildSummaries: Map> = emptyMap(), private val buildEdges: List = emptyList(), - private val classDiagram: String = "", private val crossBuildAnalysis: AnalysisSummary? = null, ) { data class IncludedBuildRef( @@ -40,7 +39,6 @@ internal class DashboardRenderer( appendBuildGraph() appendIncludedBuilds() appendSplitFileLinks() - appendClassGraph() } private fun StringBuilder.appendOverview() { @@ -54,7 +52,6 @@ internal class DashboardRenderer( } ?: 0 } val subprojects = summaries.flatMap { it.subprojects }.distinct() - val packages = summaries.flatMap { s -> s.symbols.map { it.packageName.value } }.distinct().sorted() appendLine("## Overview") appendLine() @@ -71,9 +68,6 @@ internal class DashboardRenderer( if (subprojects.isNotEmpty()) { appendLine("- subprojects: ${subprojects.joinToString(", ")}") } - if (packages.isNotEmpty()) { - appendLine("- packages: ${packages.joinToString(", ")}") - } appendLine() } @@ -137,24 +131,11 @@ internal class DashboardRenderer( } private fun StringBuilder.appendSplitFileLinks() { - val hasHubs = crossBuildAnalysis?.hubs?.isNotEmpty() == true - val hasFindings = - summaries.any { s -> s.analysis?.findings?.isNotEmpty() == true } || - includedBuildSummaries.values.any { projects -> - projects.any { s -> s.analysis?.findings?.isNotEmpty() == true } - } - - if (!hasHubs && !hasFindings && buildEdges.isEmpty()) return - appendLine("## Details") appendLine() - if (hasHubs) { - appendLine("- [Hub Classes](hub-classes.md)") - } + appendLine("- [Hub Classes](hub-classes.md)") appendLine("- [Entry Points](entry-points.md)") - if (hasFindings) { - appendLine("- [Anti-Patterns](anti-patterns.md)") - } + appendLine("- [Anti-Patterns](anti-patterns.md)") appendLine("- [Interfaces](interfaces.md)") if (buildEdges.isNotEmpty() || crossBuildAnalysis != null) { appendLine("- [Cross-Build References](cross-build.md)") @@ -162,14 +143,6 @@ internal class DashboardRenderer( appendLine() } - private fun StringBuilder.appendClassGraph() { - if (classDiagram.isBlank()) return - appendLine("## Class Dependencies") - appendLine() - appendLine(classDiagram) - appendLine() - } - companion object { fun projectReportPath(projectPath: String): String { val sanitized = projectPath.replace(":", "/").trimStart('/') diff --git a/src/main/kotlin/zone/clanker/gradle/srcx/report/EntryPointsRenderer.kt b/src/main/kotlin/zone/clanker/gradle/srcx/report/EntryPointsRenderer.kt index 1ee01c9..23e162b 100644 --- a/src/main/kotlin/zone/clanker/gradle/srcx/report/EntryPointsRenderer.kt +++ b/src/main/kotlin/zone/clanker/gradle/srcx/report/EntryPointsRenderer.kt @@ -8,6 +8,8 @@ import zone.clanker.gradle.srcx.model.SymbolKind * Renders the entry-points.md file listing app entry points, test classes, * and test doubles (Mock/Fake/Stub classes). * + * Groups by source set: main entry points first, then test entry points and doubles. + * * @property summaries all project summaries to scan for entry points * @property appEntryPoints pre-classified app entry points from the analysis layer */ @@ -20,18 +22,18 @@ internal class EntryPointsRenderer( * * @property className simple class name * @property packageName package containing the class - * @property firstCall name of the first method called (if known) */ data class EntryPoint( val className: String, val packageName: String, - val firstCall: String = "", ) fun render(): String = buildString { appendLine("# Entry Points") appendLine() + appendLine("Classes that serve as application or test entry points into the codebase.") + appendLine() appendAppEntryPoints() appendTestEntryPoints() appendTestDoubles() @@ -45,11 +47,10 @@ internal class EntryPointsRenderer( appendLine() return } - appendLine("| Class | Package | First Call |") - appendLine("|-------|---------|------------|") + appendLine("| Class | Package |") + appendLine("|-------|---------|") for (ep in appEntryPoints) { - val firstCall = ep.firstCall.ifEmpty { "-" } - appendLine("| `${ep.className}` | ${ep.packageName} | $firstCall |") + appendLine("| `${ep.className}` | ${ep.packageName} |") } appendLine() } diff --git a/src/main/kotlin/zone/clanker/gradle/srcx/report/FlowRenderer.kt b/src/main/kotlin/zone/clanker/gradle/srcx/report/FlowRenderer.kt deleted file mode 100644 index 6eae9a0..0000000 --- a/src/main/kotlin/zone/clanker/gradle/srcx/report/FlowRenderer.kt +++ /dev/null @@ -1,54 +0,0 @@ -package zone.clanker.gradle.srcx.report - -/** - * Renders a single flow Mermaid sequence diagram file for an entry point. - * - * Each entry point gets its own file under `flows/{EntryPointName}.md`. - * - * @property entryPointName simple class name of the entry point - * @property sequenceDiagram the Mermaid sequence diagram content - */ -internal class FlowRenderer( - private val entryPointName: String, - private val sequenceDiagram: String, -) { - fun render(): String = - buildString { - appendLine("# $entryPointName Flow") - appendLine() - appendLine(sequenceDiagram) - } - - companion object { - /** - * Parse a combined sequence diagram output into per-entry-point chunks. - * - * The combined output from [zone.clanker.gradle.srcx.analysis.generateSequenceDiagrams] - * uses `### {Name} Flow` headers to separate diagrams. This function splits - * them into individual (name, content) pairs. - */ - fun splitDiagrams(combinedOutput: String): List> { - if (combinedOutput.isBlank()) return emptyList() - val sections = mutableListOf>() - val lines = combinedOutput.lines() - var currentName: String? = null - val currentContent = StringBuilder() - - for (line in lines) { - if (line.startsWith("### ") && line.endsWith(" Flow")) { - if (currentName != null && currentContent.isNotBlank()) { - sections.add(currentName to currentContent.toString().trim()) - currentContent.clear() - } - currentName = line.removePrefix("### ").removeSuffix(" Flow") - } else if (currentName != null) { - currentContent.appendLine(line) - } - } - if (currentName != null && currentContent.isNotBlank()) { - sections.add(currentName to currentContent.toString().trim()) - } - return sections - } - } -} diff --git a/src/main/kotlin/zone/clanker/gradle/srcx/report/HotClassesRenderer.kt b/src/main/kotlin/zone/clanker/gradle/srcx/report/HotClassesRenderer.kt index 3a63e65..af935f3 100644 --- a/src/main/kotlin/zone/clanker/gradle/srcx/report/HotClassesRenderer.kt +++ b/src/main/kotlin/zone/clanker/gradle/srcx/report/HotClassesRenderer.kt @@ -3,10 +3,10 @@ package zone.clanker.gradle.srcx.report import zone.clanker.gradle.srcx.model.HubClass /** - * Renders hub-classes.md listing hub classes with their dependents. + * Renders hub-classes.md listing hub classes with their dependents in tree format. * * Production classes are listed first, test classes at the end. - * Icons: production classes unmarked, test classes prefixed with ๐Ÿงช. + * Each hub with sufficient dependents gets a tree showing dependent file paths. */ internal class HotClassesRenderer( private val hubs: List, @@ -15,6 +15,8 @@ internal class HotClassesRenderer( buildString { appendLine("# Hub Classes") appendLine() + appendLine("Classes with the most inbound dependencies across the codebase.") + appendLine() if (hubs.isEmpty()) { appendLine("No hub classes detected.") appendLine() @@ -35,9 +37,18 @@ internal class HotClassesRenderer( val icon = if (hub.isTest) "\uD83E\uDDEA " else "" appendLine("## $icon${hub.name}") appendLine() - for (dep in hub.dependents) { - appendLine("- ${dep.name} โ€” ${dep.filePath}:${dep.line}") + appendLine("```") + appendLine("${hub.filePath}:${hub.line} \u2014 ${hub.dependentCount} dependents") + val productionDeps = hub.dependents.filter { !isTestPath(it.filePath) } + val testDeps = hub.dependents.filter { isTestPath(it.filePath) } + val allDeps = productionDeps + testDeps + for ((index, dep) in allDeps.withIndex()) { + val isLast = index == allDeps.lastIndex + val prefix2 = if (isLast) "\u2514\u2500\u2500" else "\u251C\u2500\u2500" + val testMarker = if (isTestPath(dep.filePath)) " \uD83E\uDDEA" else "" + appendLine("$prefix2 ${dep.filePath}:${dep.line}$testMarker") } + appendLine("```") appendLine() } } @@ -56,5 +67,8 @@ internal class HotClassesRenderer( companion object { private const val DETAIL_THRESHOLD = 3 + + private fun isTestPath(path: String): Boolean = + path.contains("/test/") || path.contains("Test") } } diff --git a/src/main/kotlin/zone/clanker/gradle/srcx/report/InterfacesRenderer.kt b/src/main/kotlin/zone/clanker/gradle/srcx/report/InterfacesRenderer.kt index 794180e..8338a18 100644 --- a/src/main/kotlin/zone/clanker/gradle/srcx/report/InterfacesRenderer.kt +++ b/src/main/kotlin/zone/clanker/gradle/srcx/report/InterfacesRenderer.kt @@ -9,9 +9,9 @@ import zone.clanker.gradle.srcx.model.SymbolKind * * Identifies interfaces by naming convention (prefixed with "I" or common interface * suffixes) and correlates with implementation classes. Tags mock implementations. + * Groups by source set (main vs test). * - * @property summaries all project summaries to scan - * @property interfaces pre-computed interface data (name, package, impl count, has mock) + * @property interfaces pre-computed interface data (name, package, impl count, has mock, source set) */ internal class InterfacesRenderer( private val interfaces: List, @@ -23,12 +23,14 @@ internal class InterfacesRenderer( * @property packageName package containing the interface * @property implementationCount number of known implementations * @property hasMock whether a mock implementation exists + * @property sourceSet the source set this interface belongs to (main, test, etc.) */ data class InterfaceInfo( val name: String, val packageName: String, val implementationCount: Int, val hasMock: Boolean, + val sourceSet: String = "main", ) fun render(): String = @@ -40,21 +42,36 @@ internal class InterfacesRenderer( appendLine() return@buildString } - appendLine("| Interface | Package | Implementations | Has Mock |") - appendLine("|-----------|---------|----------------|----------|") - for (iface in interfaces) { - val mockTag = if (iface.hasMock) "yes" else "no" - appendLine("| `${iface.name}` | ${iface.packageName} | ${iface.implementationCount} | $mockTag |") + val mainInterfaces = interfaces.filter { !it.sourceSet.contains("test", ignoreCase = true) } + val testInterfaces = interfaces.filter { it.sourceSet.contains("test", ignoreCase = true) } + + if (mainInterfaces.isNotEmpty()) { + appendInterfaceTable(mainInterfaces) + } + if (testInterfaces.isNotEmpty()) { + appendLine("### Test") + appendLine() + appendInterfaceTable(testInterfaces) } - appendLine() } + private fun StringBuilder.appendInterfaceTable(items: List) { + appendLine("| Interface | Package | Implementations | Has Mock |") + appendLine("|-----------|---------|----------------|----------|") + for (iface in items) { + val mockTag = if (iface.hasMock) "yes" else "no" + appendLine("| `${iface.name}` | ${iface.packageName} | ${iface.implementationCount} | $mockTag |") + } + appendLine() + } + companion object { /** * Build interface info from project summaries by correlating class names. * * Identifies interfaces by common naming patterns and counts implementations * by looking for classes that match the interface name with common suffixes/prefixes. + * Excludes enum values and non-interface-like classes. */ fun fromSummaries(summaries: List): List { val allClasses = @@ -70,7 +87,7 @@ internal class InterfacesRenderer( if (potentialInterfaces.isEmpty()) return emptyList() return potentialInterfaces - .map { (name, pkg) -> + .map { (name, pkg, sourceSet) -> val implCount = countImplementations(name, classNames) val hasMock = classNames.any { cn -> @@ -79,20 +96,29 @@ internal class InterfacesRenderer( cn == "Fake$name" || cn == "${name}Fake" } - InterfaceInfo(name, pkg, implCount, hasMock) + InterfaceInfo(name, pkg, implCount, hasMock, sourceSet) }.sortedByDescending { it.implementationCount } } - private fun findInterfacesFromAnalysis(summaries: List): List> { + private data class InterfaceCandidate( + val name: String, + val packageName: String, + val sourceSet: String, + ) + + @Suppress("NestedBlockDepth") + private fun findInterfacesFromAnalysis(summaries: List): List { // Look at findings that mention interfaces (from single-impl detection) - val fromFindings = mutableListOf>() + val fromFindings = mutableListOf() for (summary in summaries) { val findings = summary.analysis?.findings ?: continue for (finding in findings) { val match = INTERFACE_PATTERN.find(finding.message) if (match != null) { val ifaceName = match.groupValues[1] - fromFindings.add(ifaceName to summary.projectPath.value) + if (!isEnumLikeName(ifaceName)) { + fromFindings.add(InterfaceCandidate(ifaceName, summary.projectPath.value, "main")) + } } } } @@ -104,15 +130,17 @@ internal class InterfacesRenderer( ss.symbols .filter { it.kind == SymbolKind.CLASS } .filter { isLikelyInterface(it) } - .map { it.name.value to it.packageName.value } + .map { InterfaceCandidate(it.name.value, it.packageName.value, ss.name.value) } } } - return (fromFindings + fromNaming).distinctBy { it.first } + return (fromFindings + fromNaming).distinctBy { it.name } } private fun isLikelyInterface(symbol: SymbolEntry): Boolean { val name = symbol.name.value + // Exclude enum-like names (ALL_CAPS, single-word uppercase) + if (isEnumLikeName(name)) return false // Common interface naming patterns return name.endsWith("Service") || name.endsWith("Repository") || @@ -121,6 +149,18 @@ internal class InterfacesRenderer( (name.length > 2 && name.startsWith("I") && name[1].isUpperCase()) } + /** + * Returns true if the name looks like an enum value rather than an interface. + * Enum values are typically ALL_CAPS or single uppercase words without lowercase. + */ + @Suppress("ReturnCount") + private fun isEnumLikeName(name: String): Boolean { + if (name.isEmpty()) return false + if (name.all { it.isUpperCase() || it == '_' || it.isDigit() }) return true + if (name.contains('.')) return true + return false + } + private fun countImplementations(interfaceName: String, classNames: Set): Int { val baseName = interfaceName.removePrefix("I") return classNames.count { cn -> diff --git a/src/main/kotlin/zone/clanker/gradle/srcx/task/ContextTask.kt b/src/main/kotlin/zone/clanker/gradle/srcx/task/ContextTask.kt index ff8370e..b111ee7 100644 --- a/src/main/kotlin/zone/clanker/gradle/srcx/task/ContextTask.kt +++ b/src/main/kotlin/zone/clanker/gradle/srcx/task/ContextTask.kt @@ -21,7 +21,6 @@ import zone.clanker.gradle.srcx.analysis.buildDependencyGraph import zone.clanker.gradle.srcx.analysis.classifyAll import zone.clanker.gradle.srcx.analysis.findEntryPoints import zone.clanker.gradle.srcx.analysis.generateDependencyDiagram -import zone.clanker.gradle.srcx.analysis.generateSequenceDiagrams import zone.clanker.gradle.srcx.analysis.scanSources import zone.clanker.gradle.srcx.model.DependencyEntry import zone.clanker.gradle.srcx.model.ProjectSummary @@ -29,7 +28,6 @@ import zone.clanker.gradle.srcx.report.AntiPatternsRenderer import zone.clanker.gradle.srcx.report.CrossBuildRenderer import zone.clanker.gradle.srcx.report.DashboardRenderer import zone.clanker.gradle.srcx.report.EntryPointsRenderer -import zone.clanker.gradle.srcx.report.FlowRenderer import zone.clanker.gradle.srcx.report.HotClassesRenderer import zone.clanker.gradle.srcx.report.InterfacesRenderer import zone.clanker.gradle.srcx.report.ReportWriter @@ -155,7 +153,6 @@ abstract class ContextTask : DefaultTask() { includedBuilds = includedBuildRefs, includedBuildSummaries = includedBuildSummaries, buildEdges = buildEdges, - classDiagram = crossBuild.first, crossBuildAnalysis = crossBuildSummary, ) val dir = File(root, outDir) @@ -202,9 +199,6 @@ abstract class ContextTask : DefaultTask() { File(dir, "cross-build.md").writeText( CrossBuildRenderer(buildEdges, crossBuildSummary).render(), ) - - // flows/ - writeFlowFiles(dir, crossBuild.second) } private fun buildEntryPoints(analysis: ProjectAnalysis?): List { @@ -220,33 +214,11 @@ abstract class ContextTask : DefaultTask() { EntryPointsRenderer.EntryPoint( className = ep.source.simpleName, packageName = ep.source.packageName, - firstCall = ep.source.methods.firstOrNull() ?: "", ) } }.getOrDefault(emptyList()) } - private fun writeFlowFiles(dir: File, analysis: ProjectAnalysis?) { - if (analysis == null) return - val allDirs = collectAllSourceDirs(projectDirs.get(), includedBuildInfos.get()) - if (allDirs.isEmpty()) return - runCatching { - val sources = scanSources(allDirs) - val components = classifyAll(sources) - val depEdges = buildDependencyGraph(components) - val diagrams = generateSequenceDiagrams(components, depEdges) - val splitDiagrams = FlowRenderer.splitDiagrams(diagrams) - if (splitDiagrams.isNotEmpty()) { - val flowsDir = File(dir, "flows") - flowsDir.mkdirs() - for ((name, content) in splitDiagrams) { - val renderer = FlowRenderer(name, content) - File(flowsDir, "$name.md").writeText(renderer.render()) - } - } - } - } - private fun collectIncludedBuildSummaries( builds: List, ): Map> = diff --git a/src/test/kotlin/zone/clanker/gradle/srcx/BuildEdgesTest.kt b/src/test/kotlin/zone/clanker/gradle/srcx/BuildEdgesTest.kt index 5df59ab..8f4f1a2 100644 --- a/src/test/kotlin/zone/clanker/gradle/srcx/BuildEdgesTest.kt +++ b/src/test/kotlin/zone/clanker/gradle/srcx/BuildEdgesTest.kt @@ -4,6 +4,7 @@ import io.kotest.core.spec.style.BehaviorSpec import io.kotest.matchers.collections.shouldBeEmpty import io.kotest.matchers.shouldBe import io.kotest.matchers.string.shouldContain +import io.kotest.matchers.string.shouldNotContain import zone.clanker.gradle.srcx.analysis.buildDependencyGraph import zone.clanker.gradle.srcx.analysis.classifyAll import zone.clanker.gradle.srcx.analysis.generateDependencyDiagram @@ -245,13 +246,11 @@ class BuildEdgesTest : rootName = "workspace", summaries = emptyList(), includedBuilds = emptyList(), - classDiagram = diagram, ) val output = renderer.render() - then("it includes the class dependencies section") { - output shouldContain "## Class Dependencies" - output shouldContain "Service --> Repository" + then("it does not include the class dependencies inline") { + output shouldNotContain "## Class Dependencies" } } } diff --git a/src/test/kotlin/zone/clanker/gradle/srcx/report/EntryPointsRendererTest.kt b/src/test/kotlin/zone/clanker/gradle/srcx/report/EntryPointsRendererTest.kt index b290ac6..2ef3b86 100644 --- a/src/test/kotlin/zone/clanker/gradle/srcx/report/EntryPointsRendererTest.kt +++ b/src/test/kotlin/zone/clanker/gradle/srcx/report/EntryPointsRendererTest.kt @@ -20,14 +20,14 @@ class EntryPointsRendererTest : `when`("rendering with app entry points") { val entryPoints = listOf( - EntryPointsRenderer.EntryPoint("AppController", "com.example.app", "handleRequest"), + EntryPointsRenderer.EntryPoint("AppController", "com.example.app"), ) val renderer = EntryPointsRenderer(emptyList(), entryPoints) val output = renderer.render() then("it shows app entry points") { output shouldContain "## App Entry Points" - output shouldContain "| `AppController` | com.example.app | handleRequest |" + output shouldContain "| `AppController` | com.example.app |" } } diff --git a/src/test/kotlin/zone/clanker/gradle/srcx/report/FlowRendererTest.kt b/src/test/kotlin/zone/clanker/gradle/srcx/report/FlowRendererTest.kt deleted file mode 100644 index cb993cd..0000000 --- a/src/test/kotlin/zone/clanker/gradle/srcx/report/FlowRendererTest.kt +++ /dev/null @@ -1,137 +0,0 @@ -package zone.clanker.gradle.srcx.report - -import io.kotest.core.spec.style.BehaviorSpec -import io.kotest.matchers.collections.shouldHaveSize -import io.kotest.matchers.shouldBe -import io.kotest.matchers.string.shouldContain - -class FlowRendererTest : - BehaviorSpec({ - - given("a FlowRenderer") { - - `when`("rendering a single flow") { - val diagram = - """ - ```mermaid - sequenceDiagram - participant A as AppController - participant B as UserService - A->>B: - B-->>A: - ``` - """.trimIndent() - val renderer = FlowRenderer("AppController", diagram) - val output = renderer.render() - - then("it contains the header and diagram") { - output shouldContain "# AppController Flow" - output shouldContain "sequenceDiagram" - output shouldContain "participant A as AppController" - } - } - } - - given("FlowRenderer.splitDiagrams companion") { - - `when`("splitting an empty string") { - val result = FlowRenderer.splitDiagrams("") - - then("it returns an empty list") { - result shouldHaveSize 0 - } - } - - `when`("splitting a combined diagram output") { - val combined = - """ - ### AppController Flow - - ```mermaid - sequenceDiagram - participant A as AppController - ``` - - ### UserService Flow - - ```mermaid - sequenceDiagram - participant B as UserService - ``` - - """.trimIndent() - val result = FlowRenderer.splitDiagrams(combined) - - then("it splits into separate entries") { - result shouldHaveSize 2 - result[0].first shouldBe "AppController" - result[0].second shouldContain "participant A as AppController" - result[1].first shouldBe "UserService" - result[1].second shouldContain "participant B as UserService" - } - } - - `when`("splitting a single diagram") { - val single = - """ - ### MainApp Flow - - ```mermaid - sequenceDiagram - participant M as MainApp - ``` - """.trimIndent() - val result = FlowRenderer.splitDiagrams(single) - - then("it returns one entry") { - result shouldHaveSize 1 - result[0].first shouldBe "MainApp" - } - } - - `when`("splitting a blank string with whitespace") { - val result = FlowRenderer.splitDiagrams(" \n \n ") - - then("it returns an empty list") { - result shouldHaveSize 0 - } - } - - `when`("content before first header is ignored") { - val combined = - """ - Some preamble text that is not a header. - Another line before headers. - ### FirstFlow Flow - - ```mermaid - sequenceDiagram - participant X as FirstFlow - ``` - """.trimIndent() - val result = FlowRenderer.splitDiagrams(combined) - - then("it only captures content after the header") { - result shouldHaveSize 1 - result[0].first shouldBe "FirstFlow" - result[0].second shouldContain "participant X as FirstFlow" - } - } - - `when`("header with no content following is skipped") { - val combined = - """ - ### EmptySection Flow - ### RealSection Flow - - some content here - """.trimIndent() - val result = FlowRenderer.splitDiagrams(combined) - - then("it skips headers with blank content") { - result shouldHaveSize 1 - result[0].first shouldBe "RealSection" - } - } - } - }) diff --git a/src/test/kotlin/zone/clanker/gradle/srcx/report/HotClassesRendererTest.kt b/src/test/kotlin/zone/clanker/gradle/srcx/report/HotClassesRendererTest.kt index 2fb644d..a3206c9 100644 --- a/src/test/kotlin/zone/clanker/gradle/srcx/report/HotClassesRendererTest.kt +++ b/src/test/kotlin/zone/clanker/gradle/srcx/report/HotClassesRendererTest.kt @@ -17,6 +17,7 @@ class HotClassesRendererTest : then("it shows the header and no-data message") { output shouldContain "# Hub Classes" + output shouldContain "Classes with the most inbound dependencies across the codebase." output shouldContain "No hub classes detected." } } @@ -59,10 +60,12 @@ class HotClassesRendererTest : output shouldContain "| `Util` | util/Util.kt:1 | 2 | |" } - then("it shows detail section for hubs with 3+ dependents") { + then("it shows tree detail section for hubs with 3+ dependents") { output shouldContain "## ChangeConfig" - output shouldContain "- ChangeReader โ€” workflow/ChangeReader.kt:8" - output shouldContain "- ApplyTask โ€” task/ApplyTask.kt:18" + output shouldContain "model/ChangeConfig.kt:5 \u2014 7 dependents" + output shouldContain "\u251C\u2500\u2500 workflow/ChangeReader.kt:8" + output shouldContain "\u251C\u2500\u2500 task/ApplyTask.kt:18" + output shouldContain "\u2514\u2500\u2500 task/ProposeTask.kt:12" } then("it does not show detail section for hubs below threshold") { diff --git a/src/test/kotlin/zone/clanker/gradle/srcx/report/InterfacesRendererTest.kt b/src/test/kotlin/zone/clanker/gradle/srcx/report/InterfacesRendererTest.kt index 81555a4..555c2a3 100644 --- a/src/test/kotlin/zone/clanker/gradle/srcx/report/InterfacesRendererTest.kt +++ b/src/test/kotlin/zone/clanker/gradle/srcx/report/InterfacesRendererTest.kt @@ -43,12 +43,14 @@ class InterfacesRendererTest : packageName = "com.example.repo", implementationCount = 2, hasMock = true, + sourceSet = "main", ), InterfacesRenderer.InterfaceInfo( name = "ILogger", packageName = "com.example.log", implementationCount = 1, hasMock = false, + sourceSet = "main", ), ) val renderer = InterfacesRenderer(interfaces) From c8fded1bf8f153cea21aa262fe55b61a6ced2905 Mon Sep 17 00:00:00 2001 From: slop Date: Sat, 11 Apr 2026 14:23:10 -0700 Subject: [PATCH 10/13] Fix kotlin-compiler-embeddable version conflict MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove pinned 2.1.20 version โ€” use Gradle's embedded Kotlin version instead. The pinned version conflicts with Gradle 9.4.1's embedded Kotlin 2.3.0, causing ClasspathEntrySnapshotTransform failures in composite builds. Also remove self-applied srcx plugin from settings (causes bootstrap conflict) and rename build-logic for composite build compatibility. --- build.gradle.kts | 2 +- settings.gradle.kts | 8 +------- 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/build.gradle.kts b/build.gradle.kts index 90defa9..a7a8484 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -3,7 +3,7 @@ plugins { } dependencies { - implementation("org.jetbrains.kotlin:kotlin-compiler-embeddable:2.1.20") + implementation("org.jetbrains.kotlin:kotlin-compiler-embeddable") } gradlePlugin { diff --git a/settings.gradle.kts b/settings.gradle.kts index b0f240f..b9adbdd 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -1,15 +1,9 @@ pluginManagement { - includeBuild("build-logic") - repositories { - mavenLocal() - gradlePluginPortal() - mavenCentral() - } + includeBuild("build-logic") { name = "srcx-build-logic" } } plugins { id("clkx-settings") - id("zone.clanker.gradle.srcx") version "0.0.0-dev" } rootProject.name = "srcx" From f036f208849d6903c55fdd5f7020495f0776dd9a Mon Sep 17 00:00:00 2001 From: slop Date: Sun, 12 Apr 2026 14:33:07 -0700 Subject: [PATCH 11/13] Fix entry point classification: use classifyEntryPoints for proper TEST/APP/MOCK split Test classes (ending in Test/Spec, in /test/ paths) were incorrectly listed as App Entry Points because buildEntryPoints() used findEntryPoints() which returns graph roots without filtering tests. Replaced with classifyEntryPoints() which already handles TEST/MOCK/APP classification. Simplified EntryPointsRenderer to accept pre-classified entries instead of doing its own broken source-set-based detection. --- .../gradle/srcx/report/EntryPointsRenderer.kt | 102 ++++--------- .../clanker/gradle/srcx/task/ContextTask.kt | 25 +++- .../srcx/report/EntryPointsRendererTest.kt | 139 ++++++++---------- 3 files changed, 107 insertions(+), 159 deletions(-) diff --git a/src/main/kotlin/zone/clanker/gradle/srcx/report/EntryPointsRenderer.kt b/src/main/kotlin/zone/clanker/gradle/srcx/report/EntryPointsRenderer.kt index 23e162b..5ab31a9 100644 --- a/src/main/kotlin/zone/clanker/gradle/srcx/report/EntryPointsRenderer.kt +++ b/src/main/kotlin/zone/clanker/gradle/srcx/report/EntryPointsRenderer.kt @@ -1,9 +1,5 @@ package zone.clanker.gradle.srcx.report -import zone.clanker.gradle.srcx.model.ProjectSummary -import zone.clanker.gradle.srcx.model.SymbolEntry -import zone.clanker.gradle.srcx.model.SymbolKind - /** * Renders the entry-points.md file listing app entry points, test classes, * and test doubles (Mock/Fake/Stub classes). @@ -14,115 +10,67 @@ import zone.clanker.gradle.srcx.model.SymbolKind * @property appEntryPoints pre-classified app entry points from the analysis layer */ internal class EntryPointsRenderer( - private val summaries: List, - private val appEntryPoints: List = emptyList(), + private val classifiedEntryPoints: List = emptyList(), ) { /** - * A classified app entry point. + * A classified entry point with its kind. * * @property className simple class name * @property packageName package containing the class + * @property kind APP, TEST, or MOCK */ - data class EntryPoint( + data class ClassifiedEntry( val className: String, val packageName: String, + val kind: EntryKind, ) + enum class EntryKind { + APP, + TEST, + MOCK, + } + fun render(): String = buildString { appendLine("# Entry Points") appendLine() appendLine("Classes that serve as application or test entry points into the codebase.") appendLine() - appendAppEntryPoints() - appendTestEntryPoints() - appendTestDoubles() + appendSection("App Entry Points", classifiedEntryPoints.filter { it.kind == EntryKind.APP }) + appendSection("Test Entry Points", classifiedEntryPoints.filter { it.kind == EntryKind.TEST }) + appendMocks(classifiedEntryPoints.filter { it.kind == EntryKind.MOCK }) } - private fun StringBuilder.appendAppEntryPoints() { - appendLine("## App Entry Points") + private fun StringBuilder.appendSection(title: String, entries: List) { + appendLine("## $title") appendLine() - if (appEntryPoints.isEmpty()) { - appendLine("No app entry points detected.") + if (entries.isEmpty()) { + appendLine("None detected.") appendLine() return } appendLine("| Class | Package |") appendLine("|-------|---------|") - for (ep in appEntryPoints) { + for (ep in entries) { appendLine("| `${ep.className}` | ${ep.packageName} |") } appendLine() } - private fun StringBuilder.appendTestEntryPoints() { - val testClasses = findTestClasses() - appendLine("## Test Entry Points") + private fun StringBuilder.appendMocks(entries: List) { + appendLine("## Test Doubles") appendLine() - if (testClasses.isEmpty()) { - appendLine("No test classes found.") + if (entries.isEmpty()) { + appendLine("None detected.") appendLine() return } appendLine("| Class | Package |") appendLine("|-------|---------|") - for (tc in testClasses) { - appendLine("| `${tc.name}` | ${tc.packageName} |") - } - appendLine() - } - - private fun StringBuilder.appendTestDoubles() { - val doubles = findTestDoubles() - appendLine("## Test Doubles") - appendLine() - if (doubles.isEmpty()) { - appendLine("No test doubles found.") - appendLine() - return - } - appendLine("| Class | Package | Kind |") - appendLine("|-------|---------|------|") - for (td in doubles) { - appendLine("| `${td.name}` | ${td.packageName} | ${td.kind} |") + for (ep in entries) { + appendLine("| `${ep.className}` | ${ep.packageName} |") } appendLine() } - - private fun findTestClasses(): List = - summaries - .flatMap { summary -> - summary.sourceSets - .filter { it.name.value.contains("test", ignoreCase = true) } - .flatMap { ss -> - ss.symbols.filter { it.kind == SymbolKind.CLASS && it.name.value.endsWith("Test") } - } - }.distinctBy { "${it.packageName}.${it.name}" } - - private fun findTestDoubles(): List { - val allClasses = - summaries.flatMap { summary -> - summary.sourceSets.flatMap { ss -> - ss.symbols.filter { it.kind == SymbolKind.CLASS } - } - } - return allClasses - .mapNotNull { symbol -> - val name = symbol.name.value - val kind = - when { - name.startsWith("Mock") || name.endsWith("Mock") -> "Mock" - name.startsWith("Fake") || name.endsWith("Fake") -> "Fake" - name.startsWith("Stub") || name.endsWith("Stub") -> "Stub" - else -> null - } - kind?.let { TestDouble(symbol.name.value, symbol.packageName.value, it) } - }.distinctBy { "${it.packageName}.${it.name}" } - } - - private data class TestDouble( - val name: String, - val packageName: String, - val kind: String, - ) } diff --git a/src/main/kotlin/zone/clanker/gradle/srcx/task/ContextTask.kt b/src/main/kotlin/zone/clanker/gradle/srcx/task/ContextTask.kt index b111ee7..41d21de 100644 --- a/src/main/kotlin/zone/clanker/gradle/srcx/task/ContextTask.kt +++ b/src/main/kotlin/zone/clanker/gradle/srcx/task/ContextTask.kt @@ -15,11 +15,12 @@ import org.gradle.api.tasks.PathSensitive import org.gradle.api.tasks.PathSensitivity import org.gradle.api.tasks.TaskAction import zone.clanker.gradle.srcx.Srcx +import zone.clanker.gradle.srcx.analysis.EntryPointKind import zone.clanker.gradle.srcx.analysis.ProjectAnalysis import zone.clanker.gradle.srcx.analysis.analyzeProject import zone.clanker.gradle.srcx.analysis.buildDependencyGraph import zone.clanker.gradle.srcx.analysis.classifyAll -import zone.clanker.gradle.srcx.analysis.findEntryPoints +import zone.clanker.gradle.srcx.analysis.classifyEntryPoints import zone.clanker.gradle.srcx.analysis.generateDependencyDiagram import zone.clanker.gradle.srcx.analysis.scanSources import zone.clanker.gradle.srcx.model.DependencyEntry @@ -182,7 +183,7 @@ abstract class ContextTask : DefaultTask() { // entry-points.md val entryPoints = buildEntryPoints(crossBuild.second) File(dir, "entry-points.md").writeText( - EntryPointsRenderer(summaryList, entryPoints).render(), + EntryPointsRenderer(entryPoints).render(), ) // anti-patterns.md @@ -201,7 +202,7 @@ abstract class ContextTask : DefaultTask() { ) } - private fun buildEntryPoints(analysis: ProjectAnalysis?): List { + private fun buildEntryPoints(analysis: ProjectAnalysis?): List { if (analysis == null) return emptyList() val allDirs = collectAllSourceDirs(projectDirs.get(), includedBuildInfos.get()) if (allDirs.isEmpty()) return emptyList() @@ -209,16 +210,24 @@ abstract class ContextTask : DefaultTask() { val sources = scanSources(allDirs) val components = classifyAll(sources) val depEdges = buildDependencyGraph(components) - val entryPoints = findEntryPoints(components, depEdges) - entryPoints.map { ep -> - EntryPointsRenderer.EntryPoint( - className = ep.source.simpleName, - packageName = ep.source.packageName, + val classified = classifyEntryPoints(components, depEdges) + classified.map { ep -> + EntryPointsRenderer.ClassifiedEntry( + className = ep.component.source.simpleName, + packageName = ep.component.source.packageName, + kind = ep.kind.toEntryKind(), ) } }.getOrDefault(emptyList()) } + private fun EntryPointKind.toEntryKind(): EntryPointsRenderer.EntryKind = + when (this) { + EntryPointKind.APP -> EntryPointsRenderer.EntryKind.APP + EntryPointKind.TEST -> EntryPointsRenderer.EntryKind.TEST + EntryPointKind.MOCK -> EntryPointsRenderer.EntryKind.MOCK + } + private fun collectIncludedBuildSummaries( builds: List, ): Map> = diff --git a/src/test/kotlin/zone/clanker/gradle/srcx/report/EntryPointsRendererTest.kt b/src/test/kotlin/zone/clanker/gradle/srcx/report/EntryPointsRendererTest.kt index 2ef3b86..fd35fbd 100644 --- a/src/test/kotlin/zone/clanker/gradle/srcx/report/EntryPointsRendererTest.kt +++ b/src/test/kotlin/zone/clanker/gradle/srcx/report/EntryPointsRendererTest.kt @@ -2,15 +2,6 @@ package zone.clanker.gradle.srcx.report import io.kotest.core.spec.style.BehaviorSpec import io.kotest.matchers.string.shouldContain -import zone.clanker.gradle.srcx.model.FilePath -import zone.clanker.gradle.srcx.model.PackageName -import zone.clanker.gradle.srcx.model.ProjectPath -import zone.clanker.gradle.srcx.model.ProjectSummary -import zone.clanker.gradle.srcx.model.SourceSetName -import zone.clanker.gradle.srcx.model.SourceSetSummary -import zone.clanker.gradle.srcx.model.SymbolEntry -import zone.clanker.gradle.srcx.model.SymbolKind -import zone.clanker.gradle.srcx.model.SymbolName class EntryPointsRendererTest : BehaviorSpec({ @@ -18,11 +9,15 @@ class EntryPointsRendererTest : given("an EntryPointsRenderer") { `when`("rendering with app entry points") { - val entryPoints = + val entries = listOf( - EntryPointsRenderer.EntryPoint("AppController", "com.example.app"), + EntryPointsRenderer.ClassifiedEntry( + "AppController", + "com.example.app", + EntryPointsRenderer.EntryKind.APP, + ), ) - val renderer = EntryPointsRenderer(emptyList(), entryPoints) + val renderer = EntryPointsRenderer(entries) val output = renderer.render() then("it shows app entry points") { @@ -32,84 +27,82 @@ class EntryPointsRendererTest : } `when`("rendering with test classes") { - val summaries = + val entries = listOf( - ProjectSummary( - projectPath = ProjectPath(":app"), - symbols = emptyList(), - dependencies = emptyList(), - buildFile = "build.gradle.kts", - sourceDirs = emptyList(), - subprojects = emptyList(), - sourceSets = - listOf( - SourceSetSummary( - SourceSetName("test"), - listOf( - SymbolEntry( - SymbolName("AppTest"), - SymbolKind.CLASS, - PackageName("com.example"), - FilePath("AppTest.kt"), - 1, - ), - ), - listOf("src/test/kotlin"), - ), - ), + EntryPointsRenderer.ClassifiedEntry( + "AppTest", + "com.example", + EntryPointsRenderer.EntryKind.TEST, + ), + EntryPointsRenderer.ClassifiedEntry( + "ServiceSpec", + "com.example", + EntryPointsRenderer.EntryKind.TEST, ), ) - val renderer = EntryPointsRenderer(summaries) + val renderer = EntryPointsRenderer(entries) val output = renderer.render() then("it shows test entry points") { output shouldContain "## Test Entry Points" output shouldContain "| `AppTest` | com.example |" + output shouldContain "| `ServiceSpec` | com.example |" } } `when`("rendering with test doubles") { - val summaries = + val entries = listOf( - ProjectSummary( - projectPath = ProjectPath(":app"), - symbols = emptyList(), - dependencies = emptyList(), - buildFile = "build.gradle.kts", - sourceDirs = emptyList(), - subprojects = emptyList(), - sourceSets = - listOf( - SourceSetSummary( - SourceSetName("test"), - listOf( - SymbolEntry( - SymbolName("MockRepository"), - SymbolKind.CLASS, - PackageName("com.example"), - FilePath("MockRepository.kt"), - 1, - ), - SymbolEntry( - SymbolName("FakeService"), - SymbolKind.CLASS, - PackageName("com.example"), - FilePath("FakeService.kt"), - 1, - ), - ), - listOf("src/test/kotlin"), - ), - ), + EntryPointsRenderer.ClassifiedEntry( + "MockRepository", + "com.example", + EntryPointsRenderer.EntryKind.MOCK, + ), + EntryPointsRenderer.ClassifiedEntry( + "FakeService", + "com.example", + EntryPointsRenderer.EntryKind.MOCK, ), ) - val renderer = EntryPointsRenderer(summaries) + val renderer = EntryPointsRenderer(entries) val output = renderer.render() then("it shows test doubles") { output shouldContain "## Test Doubles" - output shouldContain "| `MockRepository` | com.example | Mock |" - output shouldContain "| `FakeService` | com.example | Fake |" + output shouldContain "| `MockRepository` | com.example |" + output shouldContain "| `FakeService` | com.example |" + } + } + + `when`("rendering with mixed entry points") { + val entries = + listOf( + EntryPointsRenderer.ClassifiedEntry( + "AppController", + "com.example", + EntryPointsRenderer.EntryKind.APP, + ), + EntryPointsRenderer.ClassifiedEntry( + "AppControllerTest", + "com.example", + EntryPointsRenderer.EntryKind.TEST, + ), + EntryPointsRenderer.ClassifiedEntry( + "MockService", + "com.example", + EntryPointsRenderer.EntryKind.MOCK, + ), + ) + val renderer = EntryPointsRenderer(entries) + val output = renderer.render() + + then("separates entries by kind") { + output shouldContain "## App Entry Points" + output shouldContain "| `AppController` | com.example |" + output shouldContain "## Test Entry Points" + output shouldContain "| `AppControllerTest` | com.example |" + output shouldContain "## Test Doubles" + output shouldContain "| `MockService` | com.example |" } } @@ -118,9 +111,7 @@ class EntryPointsRendererTest : val output = renderer.render() then("it shows no-data messages") { - output shouldContain "No app entry points detected." - output shouldContain "No test classes found." - output shouldContain "No test doubles found." + output shouldContain "None detected." } } } From 866dd822f0352e2c11a006124348967377063d63 Mon Sep 17 00:00:00 2001 From: slop Date: Sun, 12 Apr 2026 16:23:46 -0700 Subject: [PATCH 12/13] Fix dashboard view links resolving to wrong directory context.md lives inside .srcx/, so relative links to included build reports need an extra ../ to escape the .srcx directory first. --- .../zone/clanker/gradle/srcx/report/DashboardRenderer.kt | 2 +- .../clanker/gradle/srcx/report/DashboardIncludedBuildTest.kt | 4 ++-- .../zone/clanker/gradle/srcx/report/DashboardRendererTest.kt | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/main/kotlin/zone/clanker/gradle/srcx/report/DashboardRenderer.kt b/src/main/kotlin/zone/clanker/gradle/srcx/report/DashboardRenderer.kt index 09babb4..1b5f484 100644 --- a/src/main/kotlin/zone/clanker/gradle/srcx/report/DashboardRenderer.kt +++ b/src/main/kotlin/zone/clanker/gradle/srcx/report/DashboardRenderer.kt @@ -124,7 +124,7 @@ internal class DashboardRenderer( it.severity == FindingSeverity.WARNING || it.severity == FindingSeverity.FORBIDDEN } ?: 0 } ?: 0 - val link = "${ref.relativePath}/.srcx/context.md" + val link = "../${ref.relativePath}/.srcx/context.md" appendLine("| ${ref.name} | $projectCount | $symbolCount | $warningCount | [view]($link) |") } appendLine() diff --git a/src/test/kotlin/zone/clanker/gradle/srcx/report/DashboardIncludedBuildTest.kt b/src/test/kotlin/zone/clanker/gradle/srcx/report/DashboardIncludedBuildTest.kt index ce1a9aa..9cf6f3e 100644 --- a/src/test/kotlin/zone/clanker/gradle/srcx/report/DashboardIncludedBuildTest.kt +++ b/src/test/kotlin/zone/clanker/gradle/srcx/report/DashboardIncludedBuildTest.kt @@ -79,8 +79,8 @@ class DashboardIncludedBuildTest : } then("it links to the build's own .srcx directory") { - output shouldContain "[view](../libs/codec/.srcx/context.md)" - output shouldContain "[view](../libs/http-core/.srcx/context.md)" + output shouldContain "[view](../../libs/codec/.srcx/context.md)" + output shouldContain "[view](../../libs/http-core/.srcx/context.md)" } then("it has the correct table headers") { diff --git a/src/test/kotlin/zone/clanker/gradle/srcx/report/DashboardRendererTest.kt b/src/test/kotlin/zone/clanker/gradle/srcx/report/DashboardRendererTest.kt index 59df3b8..2063c2a 100644 --- a/src/test/kotlin/zone/clanker/gradle/srcx/report/DashboardRendererTest.kt +++ b/src/test/kotlin/zone/clanker/gradle/srcx/report/DashboardRendererTest.kt @@ -119,9 +119,9 @@ class DashboardRendererTest : then("it contains the included builds section") { output shouldContain "## Included Builds" output shouldContain "| gort |" - output shouldContain "[view](../gort/.srcx/context.md)" + output shouldContain "[view](../../gort/.srcx/context.md)" output shouldContain "| wrkx |" - output shouldContain "[view](../wrkx/.srcx/context.md)" + output shouldContain "[view](../../wrkx/.srcx/context.md)" } } From 05e6fc484377ba09d1c2db99774597f10615a9af Mon Sep 17 00:00:00 2001 From: slop Date: Sun, 12 Apr 2026 17:03:03 -0700 Subject: [PATCH 13/13] Address CodeRabbit review feedback - Fix DI violation message: "Dependency on concrete" instead of misleading "Constructor takes concrete" (detection is import-based) - Add Windows path separator check for test file exclusion consistency - Include included-build warnings in dashboard totalWarnings count - Add empty-state message in CrossBuildRenderer for present but empty analysis - Exclude mock/fake/stub classes from interface implementation counts --- .../gradle/srcx/analysis/AntiPatternDetector.kt | 6 ++++-- .../clanker/gradle/srcx/report/CrossBuildRenderer.kt | 11 +++++++++++ .../clanker/gradle/srcx/report/DashboardRenderer.kt | 9 ++++++++- .../clanker/gradle/srcx/report/InterfacesRenderer.kt | 11 +++++++++++ .../gradle/srcx/analysis/AntiPatternDetectorTest.kt | 4 ++-- .../gradle/srcx/report/InterfacesRendererTest.kt | 4 ++-- 6 files changed, 38 insertions(+), 7 deletions(-) diff --git a/src/main/kotlin/zone/clanker/gradle/srcx/analysis/AntiPatternDetector.kt b/src/main/kotlin/zone/clanker/gradle/srcx/analysis/AntiPatternDetector.kt index baf4f96..2b598cc 100644 --- a/src/main/kotlin/zone/clanker/gradle/srcx/analysis/AntiPatternDetector.kt +++ b/src/main/kotlin/zone/clanker/gradle/srcx/analysis/AntiPatternDetector.kt @@ -149,7 +149,9 @@ private fun detectForbiddenClassNames( .filter { c -> forbiddenPatterns.any { pattern -> c.source.simpleName.contains(pattern) } } .filter { !it.source.file.path - .contains("/test/") + .contains("/test/") && + !it.source.file.path + .contains("\\test\\") }.map { c -> val matched = forbiddenPatterns.first { c.source.simpleName.contains(it) } AntiPattern( @@ -212,7 +214,7 @@ private fun buildDiViolationPattern( val ifaceName = implementedInterfaces.first().source.simpleName val concreteName = resolved.source.simpleName val msg = - "Constructor takes concrete `$concreteName` instead of interface `$ifaceName`" + "Dependency on concrete `$concreteName` instead of interface `$ifaceName`" AntiPattern( severity = AntiPattern.Severity.WARNING, message = msg, diff --git a/src/main/kotlin/zone/clanker/gradle/srcx/report/CrossBuildRenderer.kt b/src/main/kotlin/zone/clanker/gradle/srcx/report/CrossBuildRenderer.kt index 8f62047..ed588a2 100644 --- a/src/main/kotlin/zone/clanker/gradle/srcx/report/CrossBuildRenderer.kt +++ b/src/main/kotlin/zone/clanker/gradle/srcx/report/CrossBuildRenderer.kt @@ -27,6 +27,7 @@ internal class CrossBuildRenderer( appendBuildEdges() appendCrossBuildHubs() appendCrossBuildCycles() + appendNoAnalysisData() } private fun StringBuilder.appendBuildEdges() { @@ -83,6 +84,16 @@ internal class CrossBuildRenderer( appendLine() } + private fun StringBuilder.appendNoAnalysisData() { + if (crossBuildAnalysis == null) return + val hubs = crossBuildAnalysis.hubs + val cycles = crossBuildAnalysis.cycles + if (hubs.isEmpty() && cycles.isEmpty()) { + appendLine("No shared hubs or cycles detected in cross-build analysis.") + appendLine() + } + } + companion object { /** * Determines if a dependent is from a different build than the hub. diff --git a/src/main/kotlin/zone/clanker/gradle/srcx/report/DashboardRenderer.kt b/src/main/kotlin/zone/clanker/gradle/srcx/report/DashboardRenderer.kt index 1b5f484..fc5f554 100644 --- a/src/main/kotlin/zone/clanker/gradle/srcx/report/DashboardRenderer.kt +++ b/src/main/kotlin/zone/clanker/gradle/srcx/report/DashboardRenderer.kt @@ -50,7 +50,14 @@ internal class DashboardRenderer( s.analysis?.findings?.count { it.severity == FindingSeverity.WARNING || it.severity == FindingSeverity.FORBIDDEN } ?: 0 - } + } + + includedBuildSummaries.values.sumOf { projects -> + projects.sumOf { s -> + s.analysis?.findings?.count { + it.severity == FindingSeverity.WARNING || it.severity == FindingSeverity.FORBIDDEN + } ?: 0 + } + } val subprojects = summaries.flatMap { it.subprojects }.distinct() appendLine("## Overview") diff --git a/src/main/kotlin/zone/clanker/gradle/srcx/report/InterfacesRenderer.kt b/src/main/kotlin/zone/clanker/gradle/srcx/report/InterfacesRenderer.kt index 8338a18..4b8591b 100644 --- a/src/main/kotlin/zone/clanker/gradle/srcx/report/InterfacesRenderer.kt +++ b/src/main/kotlin/zone/clanker/gradle/srcx/report/InterfacesRenderer.kt @@ -165,6 +165,7 @@ internal class InterfacesRenderer( val baseName = interfaceName.removePrefix("I") return classNames.count { cn -> cn != interfaceName && + !isMockOrFake(cn) && ( cn == "${baseName}Impl" || cn == "Default$interfaceName" || @@ -174,6 +175,16 @@ internal class InterfacesRenderer( } } + private fun isMockOrFake(className: String): Boolean { + val lower = className.lowercase() + return lower.startsWith("mock") || + lower.startsWith("fake") || + lower.startsWith("stub") || + lower.endsWith("mock") || + lower.endsWith("fake") || + lower.endsWith("stub") + } + private val INTERFACE_PATTERN = Regex("""Interface `(\w+)` has""") } } diff --git a/src/test/kotlin/zone/clanker/gradle/srcx/analysis/AntiPatternDetectorTest.kt b/src/test/kotlin/zone/clanker/gradle/srcx/analysis/AntiPatternDetectorTest.kt index e92a339..cb746f8 100644 --- a/src/test/kotlin/zone/clanker/gradle/srcx/analysis/AntiPatternDetectorTest.kt +++ b/src/test/kotlin/zone/clanker/gradle/srcx/analysis/AntiPatternDetectorTest.kt @@ -179,12 +179,12 @@ class AntiPatternDetectorTest : then("it detects the dependency inversion violation") { val dipViolation = patterns.filter { - it.message.contains("Constructor takes concrete") + it.message.contains("Dependency on concrete") } dipViolation shouldHaveSize 1 dipViolation[0].severity shouldBe AntiPattern.Severity.WARNING dipViolation[0].message shouldBe - "Constructor takes concrete `AgentDispatcher` instead of interface `Dispatcher`" + "Dependency on concrete `AgentDispatcher` instead of interface `Dispatcher`" } } diff --git a/src/test/kotlin/zone/clanker/gradle/srcx/report/InterfacesRendererTest.kt b/src/test/kotlin/zone/clanker/gradle/srcx/report/InterfacesRendererTest.kt index 555c2a3..f2f34c3 100644 --- a/src/test/kotlin/zone/clanker/gradle/srcx/report/InterfacesRendererTest.kt +++ b/src/test/kotlin/zone/clanker/gradle/srcx/report/InterfacesRendererTest.kt @@ -147,7 +147,7 @@ class InterfacesRendererTest : then("it detects the interface by Service suffix") { val userSvc = result.first { it.name == "UserService" } - userSvc.implementationCount shouldBe 2 + userSvc.implementationCount shouldBe 1 userSvc.hasMock shouldBe true } } @@ -199,7 +199,7 @@ class InterfacesRendererTest : then("it detects the I-prefixed interface with impl count and mock") { result shouldHaveSize 1 result[0].name shouldBe "ILogger" - result[0].implementationCount shouldBe 2 + result[0].implementationCount shouldBe 1 result[0].hasMock shouldBe true } }