diff --git a/src/main/kotlin/zone/clanker/gradle/srcx/scan/SymbolExtractor.kt b/src/main/kotlin/zone/clanker/gradle/srcx/scan/SymbolExtractor.kt index 9f9f639..38cdc34 100644 --- a/src/main/kotlin/zone/clanker/gradle/srcx/scan/SymbolExtractor.kt +++ b/src/main/kotlin/zone/clanker/gradle/srcx/scan/SymbolExtractor.kt @@ -34,10 +34,30 @@ import java.io.File * and dependencies from build files or the Gradle configuration API. */ object SymbolExtractor { + private val logger = + org.gradle.api.logging.Logging + .getLogger(SymbolExtractor::class.java) + /** Dependency scopes excluded from scanning by default. */ internal val DEFAULT_EXCLUDED_DEP_SCOPES = Srcx.DEFAULT_EXCLUDED_DEP_SCOPES + internal fun handleAnalysisFailure(e: Throwable, projectName: String): Nothing? { + when (e) { + is OutOfMemoryError -> { + logger.error( + "srcx: Out of memory analyzing '$projectName'. " + + "Increase heap with org.gradle.jvmargs=-Xmx8g in gradle.properties", + ) + throw e + } + else -> { + logger.warn("srcx: Analysis failed for '$projectName': ${e.message}") + return null + } + } + } + /** Minimum number of colon-separated parts in a Maven coordinate (group:artifact:version). */ private const val MIN_COORDINATE_PARTS = 3 @@ -121,7 +141,7 @@ object SymbolExtractor { val projectAnalysis = runCatching { analyzeProject(allDirs, project.projectDir).toSummary() - }.getOrNull() + }.getOrElse { e -> handleAnalysisFailure(e, project.name) } return ProjectSummary( projectPath = ProjectPath(project.path), @@ -167,7 +187,7 @@ object SymbolExtractor { val projectAnalysis = runCatching { analyzeProject(allDirs, projectDir).toSummary() - }.getOrNull() + }.getOrElse { e -> handleAnalysisFailure(e, projectDir.name) } return ProjectSummary( projectPath = ProjectPath(projectPath), @@ -229,7 +249,7 @@ object SymbolExtractor { val projectAnalysis = runCatching { analyzeProject(allDirs, projectDir).toSummary() - }.getOrNull() + }.getOrElse { e -> handleAnalysisFailure(e, projectDir.name) } return ProjectSummary( projectPath = ProjectPath(projectPath), 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 41d21de..2eef6c9 100644 --- a/src/main/kotlin/zone/clanker/gradle/srcx/task/ContextTask.kt +++ b/src/main/kotlin/zone/clanker/gradle/srcx/task/ContextTask.kt @@ -15,14 +15,6 @@ 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 @@ -32,7 +24,6 @@ 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 import java.io.File @@ -145,8 +136,10 @@ abstract class ContextTask : DefaultTask() { val includedBuildSummaries = collectIncludedBuildSummaries(builds) val buildPairs = builds.map { it.name to it.dir } val buildEdges = ReportWriter.computeBuildEdges(buildPairs, includedBuildSummaries) - val crossBuild = analyzeCrossBuild(projects, builds, root) - val crossBuildSummary = crossBuild.second?.toSummary() + + // Aggregate analysis from per-build summaries (robust, works on large repos) + val aggregatedSummary = aggregateAnalysis(summaryList, includedBuildSummaries) + val renderer = DashboardRenderer( rootName = rootName.get(), @@ -154,34 +147,32 @@ abstract class ContextTask : DefaultTask() { includedBuilds = includedBuildRefs, includedBuildSummaries = includedBuildSummaries, buildEdges = buildEdges, - crossBuildAnalysis = crossBuildSummary, + crossBuildAnalysis = aggregatedSummary, ) val dir = File(root, outDir) dir.mkdirs() File(dir, "context.md").writeText(renderer.render()) // Split detail files - writeSplitFiles(dir, summaryList, includedBuildSummaries, buildEdges, crossBuild, crossBuildSummary) + writeSplitFiles(dir, summaryList, includedBuildSummaries, buildEdges, aggregatedSummary) 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?, + aggregatedSummary: zone.clanker.gradle.srcx.model.AnalysisSummary?, ) { - // hub-classes.md - val allHubs = crossBuildSummary?.hubs ?: emptyList() + // hub-classes.md — from aggregated per-build analysis + val allHubs = aggregatedSummary?.hubs ?: emptyList() File(dir, "hub-classes.md").writeText(HotClassesRenderer(allHubs).render()) - // entry-points.md - val entryPoints = buildEntryPoints(crossBuild.second) + // entry-points.md — from aggregated per-build analysis + val entryPoints = buildEntryPointsFromSummaries(summaryList, includedBuildSummaries) File(dir, "entry-points.md").writeText( EntryPointsRenderer(entryPoints).render(), ) @@ -198,36 +189,46 @@ abstract class ContextTask : DefaultTask() { // cross-build.md File(dir, "cross-build.md").writeText( - CrossBuildRenderer(buildEdges, crossBuildSummary).render(), + CrossBuildRenderer(buildEdges, aggregatedSummary).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 buildEntryPointsFromSummaries( + summaryList: List, + includedBuildSummaries: Map>, + ): List { + val allSummaries = summaryList + includedBuildSummaries.values.flatten() + return allSummaries + .flatMap { summary -> + summary.sourceSets.flatMap { ss -> + val isTestSourceSet = ss.name.value.contains("test", ignoreCase = true) + ss.symbols + .filter { it.kind == zone.clanker.gradle.srcx.model.SymbolKind.CLASS } + .mapNotNull { symbol -> + val name = symbol.name.value + val isTest = + isTestSourceSet || + name.endsWith("Test") || + name.endsWith("Spec") + val isMock = + name.startsWith("Mock") || + name.endsWith("Mock") || + name.startsWith("Fake") || + name.endsWith("Fake") || + name.startsWith("Stub") || + name.endsWith("Stub") + val kind = + when { + isMock -> EntryPointsRenderer.EntryKind.MOCK + isTest -> EntryPointsRenderer.EntryKind.TEST + else -> EntryPointsRenderer.EntryKind.APP + } + EntryPointsRenderer.ClassifiedEntry(name, symbol.packageName.value, kind) + } + } + }.distinctBy { "${it.packageName}.${it.className}" } } - 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> = @@ -238,50 +239,33 @@ abstract class ContextTask : DefaultTask() { } } - 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) - val diagram = generateDependencyDiagram(components, depEdges) - diagram to analysis - }.onFailure { e -> - logger.warn("srcx: cross-build analysis failed: ${e.message}") - }.getOrDefault("" to null) + internal fun aggregateAnalysis( + summaryList: List, + includedBuildSummaries: Map>, + ): zone.clanker.gradle.srcx.model.AnalysisSummary? { + val allSummaries = summaryList + includedBuildSummaries.values.flatten() + val allAnalyses = allSummaries.mapNotNull { it.analysis } + if (allAnalyses.isEmpty()) return null + + val allHubs = + allAnalyses + .flatMap { it.hubs } + .sortedWith( + compareByDescending { it.dependentCount } + .thenBy { it.name }, + ).take(HUB_LIMIT) + + val allCycles = allAnalyses.flatMap { it.cycles }.distinct() + + return zone.clanker.gradle.srcx.model.AnalysisSummary( + findings = allAnalyses.flatMap { it.findings }.distinctBy { it.message }, + hubs = allHubs, + cycles = allCycles, + ) } - 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() } + companion object { + private const val HUB_LIMIT = 30 } } diff --git a/src/test/kotlin/zone/clanker/gradle/srcx/task/AggregateAnalysisTest.kt b/src/test/kotlin/zone/clanker/gradle/srcx/task/AggregateAnalysisTest.kt new file mode 100644 index 0000000..63a52b5 --- /dev/null +++ b/src/test/kotlin/zone/clanker/gradle/srcx/task/AggregateAnalysisTest.kt @@ -0,0 +1,169 @@ +package zone.clanker.gradle.srcx.task + +import io.kotest.core.spec.style.BehaviorSpec +import io.kotest.matchers.collections.shouldContainExactly +import io.kotest.matchers.collections.shouldHaveSize +import io.kotest.matchers.nulls.shouldBeNull +import io.kotest.matchers.nulls.shouldNotBeNull +import io.kotest.matchers.shouldBe +import org.gradle.testfixtures.ProjectBuilder +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.HubClass +import zone.clanker.gradle.srcx.model.ProjectPath +import zone.clanker.gradle.srcx.model.ProjectSummary + +class AggregateAnalysisTest : + BehaviorSpec({ + + fun summaryWithAnalysis(analysis: AnalysisSummary?) = + ProjectSummary( + projectPath = ProjectPath(":"), + symbols = emptyList(), + dependencies = emptyList(), + buildFile = "build.gradle.kts", + sourceDirs = emptyList(), + subprojects = emptyList(), + sourceSets = emptyList(), + analysis = analysis, + ) + + fun createTask(): ContextTask { + val project = ProjectBuilder.builder().build() + return project.tasks.create("testTask", ContextTask::class.java) + } + + given("aggregateAnalysis") { + + `when`("all summaries have no analysis") { + val task = createTask() + val summaries = listOf(summaryWithAnalysis(null)) + val result = task.aggregateAnalysis(summaries, emptyMap()) + + then("returns null") { + result.shouldBeNull() + } + } + + `when`("single build has hub classes") { + val task = createTask() + val hubs = + listOf( + HubClass("Foo", 10, "service", "Foo.kt", 1), + HubClass("Bar", 5, "", "Bar.kt", 1), + ) + val analysis = AnalysisSummary(emptyList(), hubs, emptyList()) + val result = + task.aggregateAnalysis( + listOf(summaryWithAnalysis(analysis)), + emptyMap(), + ) + + then("returns hubs sorted by dependent count") { + result.shouldNotBeNull() + result.hubs shouldHaveSize 2 + result.hubs[0].name shouldBe "Foo" + result.hubs[1].name shouldBe "Bar" + } + } + + `when`("multiple builds have hub classes") { + val task = createTask() + val buildA = + AnalysisSummary( + emptyList(), + listOf(HubClass("Alpha", 20, "", "Alpha.kt", 1)), + emptyList(), + ) + val buildB = + AnalysisSummary( + emptyList(), + listOf(HubClass("Beta", 30, "", "Beta.kt", 1)), + emptyList(), + ) + val result = + task.aggregateAnalysis( + listOf(summaryWithAnalysis(buildA)), + mapOf("lib" to listOf(summaryWithAnalysis(buildB))), + ) + + then("merges and ranks hubs across builds") { + result.shouldNotBeNull() + result.hubs shouldHaveSize 2 + result.hubs[0].name shouldBe "Beta" + result.hubs[1].name shouldBe "Alpha" + } + } + + `when`("findings exist across builds") { + val task = createTask() + val finding1 = Finding(FindingSeverity.WARNING, "msg1", "fix1") + val finding2 = Finding(FindingSeverity.WARNING, "msg2", "fix2") + val duplicate = Finding(FindingSeverity.WARNING, "msg1", "fix1") + val buildA = AnalysisSummary(listOf(finding1), emptyList(), emptyList()) + val buildB = AnalysisSummary(listOf(finding2, duplicate), emptyList(), emptyList()) + val result = + task.aggregateAnalysis( + listOf(summaryWithAnalysis(buildA)), + mapOf("lib" to listOf(summaryWithAnalysis(buildB))), + ) + + then("deduplicates findings by message") { + result.shouldNotBeNull() + result.findings shouldHaveSize 2 + } + } + + `when`("cycles exist across builds") { + val task = createTask() + val buildA = + AnalysisSummary( + emptyList(), + emptyList(), + listOf(listOf("A", "B", "A")), + ) + val buildB = + AnalysisSummary( + emptyList(), + emptyList(), + listOf(listOf("X", "Y", "X"), listOf("A", "B", "A")), + ) + val result = + task.aggregateAnalysis( + listOf(summaryWithAnalysis(buildA)), + mapOf("lib" to listOf(summaryWithAnalysis(buildB))), + ) + + then("deduplicates cycles") { + result.shouldNotBeNull() + result.cycles shouldHaveSize 2 + result.cycles.shouldContainExactly( + listOf("A", "B", "A"), + listOf("X", "Y", "X"), + ) + } + } + + `when`("some summaries have analysis and some don't") { + val task = createTask() + val analysis = + AnalysisSummary( + emptyList(), + listOf(HubClass("Hub", 5, "", "Hub.kt", 1)), + emptyList(), + ) + val result = + task.aggregateAnalysis( + listOf(summaryWithAnalysis(null)), + mapOf("lib" to listOf(summaryWithAnalysis(analysis))), + ) + + then("uses available analysis data") { + result.shouldNotBeNull() + result.hubs shouldHaveSize 1 + result.hubs[0].name shouldBe "Hub" + } + } + } + })