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 91001b8..b9adbdd 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -1,5 +1,5 @@ pluginManagement { - includeBuild("build-logic") + includeBuild("build-logic") { name = "srcx-build-logic" } } plugins { diff --git a/src/main/kotlin/zone/clanker/gradle/srcx/Srcx.kt b/src/main/kotlin/zone/clanker/gradle/srcx/Srcx.kt index 9491d61..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 @@ -71,15 +72,21 @@ data object Srcx { } } + val DEFAULT_FORBIDDEN_PACKAGES: Set = + setOf("util", "utils", "helper", "helpers", "manager", "managers", "misc", "base") + + val DEFAULT_FORBIDDEN_CLASS_PATTERNS: Set = + setOf("Helper", "Manager", "Utils", "Util") + /** * DSL extension registered as `srcx { }` on the Settings object. * - * Controls the output directory and auto-generation behavior. - * * ```kotlin * srcx { * outputDir.set(".srcx") * autoGenerate.set(true) + * forbiddenPackages("legacy", "internal", "compat") + * forbiddenClassPatterns("Base", "Impl", "Abstract") * } * ``` * @@ -89,14 +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 + + @get:Internal + abstract val forbiddenPackageNames: SetProperty + + @get:Internal + abstract val forbiddenClassNamePatterns: SetProperty + + fun forbiddenPackages(vararg names: String) { + forbiddenPackageNames.addAll(names.toList()) + } + + fun forbiddenClassPatterns(vararg patterns: String) { + forbiddenClassNamePatterns.addAll(patterns.toList()) + } } /** @@ -120,6 +136,8 @@ data object Srcx { extension.outputDir.convention(OUTPUT_DIR) extension.autoGenerate.convention(false) extension.excludeDepScopes.convention(DEFAULT_EXCLUDED_DEP_SCOPES) + extension.forbiddenPackageNames.convention(DEFAULT_FORBIDDEN_PACKAGES) + extension.forbiddenClassNamePatterns.convention(DEFAULT_FORBIDDEN_CLASS_PATTERNS) settings.gradle.rootProject( Action { rootProject -> @@ -163,6 +181,8 @@ data object Srcx { task.includedBuildInfos.set( rootProject.provider { collectIncludedBuildInfos(rootProject) }, ) + 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 f3b9a49..2b598cc 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,8 +30,9 @@ data class AntiPattern( enum class Severity( val icon: String, ) { - WARNING("WARNING"), - INFO("INFO"), + FORBIDDEN("\uD83D\uDEAB"), + WARNING("⚠\uFE0F"), + INFO("ℹ\uFE0F"), } } @@ -40,15 +41,20 @@ fun detectAntiPatterns( components: List, edges: List, rootDir: File, + forbiddenPackages: Set = zone.clanker.gradle.srcx.Srcx.DEFAULT_FORBIDDEN_PACKAGES, + forbiddenClassPatterns: Set = zone.clanker.gradle.srcx.Srcx.DEFAULT_FORBIDDEN_CLASS_PATTERNS, ): List { val resolver = SupertypeResolver(components) val patterns = mutableListOf() - patterns.addAll(detectSmellClasses(components, rootDir)) + patterns.addAll(detectSmellClasses(components, rootDir, forbiddenPackages)) + patterns.addAll(detectForbiddenNames(components, rootDir, forbiddenPackages)) + patterns.addAll(detectForbiddenClassNames(components, rootDir, forbiddenClassPatterns)) 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 })) @@ -80,21 +86,151 @@ 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) } .map { c -> val roleLabel = c.role.name.lowercase() + val lastSegment = c.source.packageName.substringAfterLast(".") + val severity = + if (lastSegment in forbiddenPackages) { + AntiPattern.Severity.FORBIDDEN + } else { + AntiPattern.Severity.WARNING + } + val suggestion = + "Behavior in $roleLabel classes belongs closer to where it's used." AntiPattern( - severity = AntiPattern.Severity.WARNING, + 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, ) } +@Suppress("UnusedParameter") +private fun detectForbiddenNames( + components: List, + rootDir: File, + forbiddenPackages: Set, +): List { + val patterns = mutableListOf() + + val inForbiddenPackages = + components.filter { c -> + val lastSegment = c.source.packageName.substringAfterLast(".") + lastSegment in forbiddenPackages + } + 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 = suggestion, + ), + ) + } + + return patterns +} + +private fun detectForbiddenClassNames( + components: List, + rootDir: File, + forbiddenPatterns: Set, +): List = + components + .filter { c -> forbiddenPatterns.any { pattern -> c.source.simpleName.contains(pattern) } } + .filter { + !it.source.file.path + .contains("/test/") && + !it.source.file.path + .contains("\\test\\") + }.map { c -> + val matched = forbiddenPatterns.first { c.source.simpleName.contains(it) } + AntiPattern( + severity = AntiPattern.Severity.WARNING, + 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 name.", + ) + } + +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 + val concreteName = resolved.source.simpleName + val msg = + "Dependency on concrete `$concreteName` instead of interface `$ifaceName`" + AntiPattern( + severity = AntiPattern.Severity.WARNING, + message = msg, + file = c.source.file.relativeTo(rootDir), + 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}`", + file = c.source.file.relativeTo(rootDir), + suggestion = "Consider extracting an interface for `${resolved.source.simpleName}`.", + ) + } +} + private fun detectSingleImplInterfaces( components: List, resolver: SupertypeResolver, @@ -104,15 +240,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 @@ -131,13 +267,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, ) } @@ -207,9 +343,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.", ) } } @@ -242,14 +376,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/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/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 0507c63..8cf3d3b 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 }, @@ -40,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, @@ -47,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 @@ -60,14 +65,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, + 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()) val components = classifyAll(sources) val edges = buildDependencyGraph(components) - val antiPatterns = detectAntiPatterns(components, edges, rootDir) + 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) 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..c250b0c 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. @@ -48,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/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..ed588a2 --- /dev/null +++ b/src/main/kotlin/zone/clanker/gradle/srcx/report/CrossBuildRenderer.kt @@ -0,0 +1,108 @@ +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. + * + * 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) + */ +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() + appendNoAnalysisData() + } + + 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() + 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() { + val cycles = crossBuildAnalysis?.cycles ?: return + if (cycles.isEmpty()) return + appendLine("## Cross-Build Cycles") + appendLine() + for (cycle in cycles) { + appendLine("- ${cycle.joinToString(" -> ")}") + } + 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. + * 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 11a087f..fc5f554 100644 --- a/src/main/kotlin/zone/clanker/gradle/srcx/report/DashboardRenderer.kt +++ b/src/main/kotlin/zone/clanker/gradle/srcx/report/DashboardRenderer.kt @@ -1,26 +1,24 @@ 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 (hub-classes, entry-points, anti-patterns, etc.). */ +@Suppress("LongParameterList") internal class DashboardRenderer( private val rootName: String, private val summaries: List, 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( val name: String, @@ -40,9 +38,7 @@ internal class DashboardRenderer( appendProjects() appendBuildGraph() appendIncludedBuilds() - appendSymbols() - appendClassGraph() - appendProblems() + appendSplitFileLinks() } private fun StringBuilder.appendOverview() { @@ -51,10 +47,18 @@ 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 + } + + 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() - val packages = summaries.flatMap { s -> s.symbols.map { it.packageName.value } }.distinct().sorted() appendLine("## Overview") appendLine() @@ -71,9 +75,6 @@ internal class DashboardRenderer( if (subprojects.isNotEmpty()) { appendLine("- subprojects: ${subprojects.joinToString(", ")}") } - if (packages.isNotEmpty()) { - appendLine("- packages: ${packages.joinToString(", ")}") - } appendLine() } @@ -87,7 +88,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,124 +127,27 @@ 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" + val link = "../${ref.relativePath}/.srcx/context.md" appendLine("| ${ref.name} | $projectCount | $symbolCount | $warningCount | [view]($link) |") } 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) - } - - appendProjectDependencies(summary) - } - - private fun StringBuilder.appendSourceSetSymbols( - ss: SourceSetSummary, - hubs: Map, - ) { - appendLine("### ${ss.name}") - 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") - } + private fun StringBuilder.appendSplitFileLinks() { + appendLine("## Details") 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("- [Hub Classes](hub-classes.md)") + appendLine("- [Entry Points](entry-points.md)") + appendLine("- [Anti-Patterns](anti-patterns.md)") + appendLine("- [Interfaces](interfaces.md)") + if (buildEdges.isNotEmpty() || crossBuildAnalysis != null) { + appendLine("- [Cross-Build References](cross-build.md)") } - } - - private fun StringBuilder.appendClassGraph() { - if (classDiagram.isBlank()) return - appendLine("## Class Dependencies") - appendLine() - appendLine(classDiagram) - 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 warnings = combined.filter { it.second == FindingSeverity.WARNING } - val notes = combined.filter { it.second == FindingSeverity.INFO } - - if (warnings.isEmpty() && notes.isEmpty()) return - - appendLine("## Problems") appendLine() - if (warnings.isNotEmpty()) { - appendLine("### Warnings") - appendLine() - for ((source, _, finding) in warnings) { - appendLine("- **$source** — ${finding.message}") - } - appendLine() - } - if (notes.isNotEmpty()) { - appendLine("### Notes") - appendLine() - for ((source, _, finding) in notes) { - appendLine("- **$source** — ${finding.message}") - } - appendLine() - } } companion object { 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..5ab31a9 --- /dev/null +++ b/src/main/kotlin/zone/clanker/gradle/srcx/report/EntryPointsRenderer.kt @@ -0,0 +1,76 @@ +package zone.clanker.gradle.srcx.report + +/** + * 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 + */ +internal class EntryPointsRenderer( + private val classifiedEntryPoints: List = emptyList(), +) { + /** + * 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 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() + 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.appendSection(title: String, entries: List) { + appendLine("## $title") + appendLine() + if (entries.isEmpty()) { + appendLine("None detected.") + appendLine() + return + } + appendLine("| Class | Package |") + appendLine("|-------|---------|") + for (ep in entries) { + appendLine("| `${ep.className}` | ${ep.packageName} |") + } + appendLine() + } + + private fun StringBuilder.appendMocks(entries: List) { + appendLine("## Test Doubles") + appendLine() + if (entries.isEmpty()) { + appendLine("None detected.") + appendLine() + return + } + appendLine("| Class | Package |") + appendLine("|-------|---------|") + for (ep in entries) { + appendLine("| `${ep.className}` | ${ep.packageName} |") + } + appendLine() + } +} 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..af935f3 --- /dev/null +++ b/src/main/kotlin/zone/clanker/gradle/srcx/report/HotClassesRenderer.kt @@ -0,0 +1,74 @@ +package zone.clanker.gradle.srcx.report + +import zone.clanker.gradle.srcx.model.HubClass + +/** + * Renders hub-classes.md listing hub classes with their dependents in tree format. + * + * Production classes are listed first, test classes at the end. + * Each hub with sufficient dependents gets a tree showing dependent file paths. + */ +internal class HotClassesRenderer( + private val hubs: List, +) { + fun render(): String = + 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() + return@buildString + } + 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 ") + } + for (hub in hubs.filter { it.dependentCount >= DETAIL_THRESHOLD }) { + val icon = if (hub.isTest) "\uD83E\uDDEA " else "" + appendLine("## $icon${hub.name}") + appendLine() + 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() + } + } + + 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 + + private fun isTestPath(path: String): Boolean = + path.contains("/test/") || path.contains("Test") + } +} 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/InterfacesRenderer.kt b/src/main/kotlin/zone/clanker/gradle/srcx/report/InterfacesRenderer.kt new file mode 100644 index 0000000..4b8591b --- /dev/null +++ b/src/main/kotlin/zone/clanker/gradle/srcx/report/InterfacesRenderer.kt @@ -0,0 +1,190 @@ +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. + * Groups by source set (main vs test). + * + * @property interfaces pre-computed interface data (name, package, impl count, has mock, source set) + */ +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 + * @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 = + buildString { + appendLine("# Interfaces") + appendLine() + if (interfaces.isEmpty()) { + appendLine("No interfaces detected.") + appendLine() + return@buildString + } + 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) + } + } + + 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 = + 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, sourceSet) -> + 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, sourceSet) + }.sortedByDescending { it.implementationCount } + } + + 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() + 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] + if (!isEnumLikeName(ifaceName)) { + fromFindings.add(InterfaceCandidate(ifaceName, summary.projectPath.value, "main")) + } + } + } + } + + // 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 { InterfaceCandidate(it.name.value, it.packageName.value, ss.name.value) } + } + } + + 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") || + name.endsWith("Provider") || + name.endsWith("Factory") || + (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 -> + cn != interfaceName && + !isMockOrFake(cn) && + ( + cn == "${baseName}Impl" || + cn == "Default$interfaceName" || + cn == "Default$baseName" || + (cn.endsWith(baseName) && cn != baseName) + ) + } + } + + 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/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/main/kotlin/zone/clanker/gradle/srcx/task/ContextTask.kt b/src/main/kotlin/zone/clanker/gradle/srcx/task/ContextTask.kt index b8147c4..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,13 +15,22 @@ 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.classifyEntryPoints import zone.clanker.gradle.srcx.analysis.generateDependencyDiagram 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.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 @@ -86,18 +95,26 @@ 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" } /** - * 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() { @@ -128,7 +145,8 @@ 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 crossBuildSummary = crossBuild.second?.toSummary() val renderer = DashboardRenderer( rootName = rootName.get(), @@ -136,15 +154,80 @@ abstract class ContextTask : DefaultTask() { includedBuilds = includedBuildRefs, includedBuildSummaries = includedBuildSummaries, buildEdges = buildEdges, - classDiagram = classDiagram, + 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?, + ) { + // hub-classes.md + val allHubs = crossBuildSummary?.hubs ?: emptyList() + File(dir, "hub-classes.md").writeText(HotClassesRenderer(allHubs).render()) + + // entry-points.md + val entryPoints = buildEntryPoints(crossBuild.second) + File(dir, "entry-points.md").writeText( + EntryPointsRenderer(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(), + ) + } + + 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 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> = @@ -155,25 +238,50 @@ 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, + forbiddenPackages.get(), + forbiddenClassSuffixes.get(), + ) 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() } } } diff --git a/src/test/kotlin/zone/clanker/gradle/srcx/BuildEdgesTest.kt b/src/test/kotlin/zone/clanker/gradle/srcx/BuildEdgesTest.kt index 4ca7ed7..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" } } } @@ -294,12 +293,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/AntiPatternDetectorTest.kt b/src/test/kotlin/zone/clanker/gradle/srcx/analysis/AntiPatternDetectorTest.kt index db517f8..cb746f8 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("Dependency on concrete") + } + dipViolation shouldHaveSize 1 + dipViolation[0].severity shouldBe AntiPattern.Severity.WARNING + dipViolation[0].message shouldBe + "Dependency on 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/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/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 ceb1e1d..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)" } } @@ -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..fd35fbd --- /dev/null +++ b/src/test/kotlin/zone/clanker/gradle/srcx/report/EntryPointsRendererTest.kt @@ -0,0 +1,118 @@ +package zone.clanker.gradle.srcx.report + +import io.kotest.core.spec.style.BehaviorSpec +import io.kotest.matchers.string.shouldContain + +class EntryPointsRendererTest : + BehaviorSpec({ + + given("an EntryPointsRenderer") { + + `when`("rendering with app entry points") { + val entries = + listOf( + EntryPointsRenderer.ClassifiedEntry( + "AppController", + "com.example.app", + EntryPointsRenderer.EntryKind.APP, + ), + ) + val renderer = EntryPointsRenderer(entries) + val output = renderer.render() + + then("it shows app entry points") { + output shouldContain "## App Entry Points" + output shouldContain "| `AppController` | com.example.app |" + } + } + + `when`("rendering with test classes") { + val entries = + listOf( + EntryPointsRenderer.ClassifiedEntry( + "AppTest", + "com.example", + EntryPointsRenderer.EntryKind.TEST, + ), + EntryPointsRenderer.ClassifiedEntry( + "ServiceSpec", + "com.example", + EntryPointsRenderer.EntryKind.TEST, + ), + ) + 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 entries = + listOf( + EntryPointsRenderer.ClassifiedEntry( + "MockRepository", + "com.example", + EntryPointsRenderer.EntryKind.MOCK, + ), + EntryPointsRenderer.ClassifiedEntry( + "FakeService", + "com.example", + EntryPointsRenderer.EntryKind.MOCK, + ), + ) + val renderer = EntryPointsRenderer(entries) + val output = renderer.render() + + then("it shows test doubles") { + output shouldContain "## Test Doubles" + 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 |" + } + } + + `when`("rendering with no data") { + val renderer = EntryPointsRenderer(emptyList()) + val output = renderer.render() + + then("it shows no-data messages") { + output shouldContain "None detected." + } + } + } + }) 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..a3206c9 --- /dev/null +++ b/src/test/kotlin/zone/clanker/gradle/srcx/report/HotClassesRendererTest.kt @@ -0,0 +1,76 @@ +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 "# Hub Classes" + output shouldContain "Classes with the most inbound dependencies across the codebase." + 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 tree detail section for hubs with 3+ dependents") { + output shouldContain "## ChangeConfig" + 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") { + output shouldNotContain "## Util" + } + } + } + }) 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/InterfacesRendererTest.kt b/src/test/kotlin/zone/clanker/gradle/srcx/report/InterfacesRendererTest.kt new file mode 100644 index 0000000..f2f34c3 --- /dev/null +++ b/src/test/kotlin/zone/clanker/gradle/srcx/report/InterfacesRendererTest.kt @@ -0,0 +1,676 @@ +@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, + sourceSet = "main", + ), + InterfacesRenderer.InterfaceInfo( + name = "ILogger", + packageName = "com.example.log", + implementationCount = 1, + hasMock = false, + sourceSet = "main", + ), + ) + 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 1 + 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 1 + 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() + } + } + } + }) 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") {