From 2159b1355e788be26ce60a04c67843ddd2327c9d Mon Sep 17 00:00:00 2001 From: slop Date: Sun, 12 Apr 2026 18:14:13 -0700 Subject: [PATCH 1/4] Aggregate hub classes and entry points from per-build analysis The cross-build PSI analysis tried to parse every source file across all included builds in one shot, which OOMs on large repos. Hub classes, entry points, and cross-build reports all silently returned empty. Now aggregates results from per-build ProjectSummary.analysis data that SymbolExtractor already computes per included build. This is the same path anti-patterns use (which always worked). Removed the monolithic analyzeCrossBuild() and its collectAllSourceDirs() helper. Hub classes are merged and ranked by dependent count across all builds. Entry points are classified from source set metadata. --- .../clanker/gradle/srcx/task/ContextTask.kt | 158 ++++++++---------- 1 file changed, 71 insertions(+), 87 deletions(-) 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..cdb0678 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,48 @@ 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 -> null + } + kind?.let { + EntryPointsRenderer.ClassifiedEntry(name, symbol.packageName.value, it) + } + } + } + }.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 +241,31 @@ 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) + private 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 } + .sortedByDescending { it.dependentCount } + .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 } } From 5087d1e0145596c69d4a490a5f8e09d17e764e84 Mon Sep 17 00:00:00 2001 From: slop Date: Sun, 12 Apr 2026 18:20:06 -0700 Subject: [PATCH 2/4] Add AggregateAnalysisTest covering hub merge, dedup, and null handling --- .../clanker/gradle/srcx/task/ContextTask.kt | 2 +- .../gradle/srcx/task/AggregateAnalysisTest.kt | 169 ++++++++++++++++++ 2 files changed, 170 insertions(+), 1 deletion(-) create mode 100644 src/test/kotlin/zone/clanker/gradle/srcx/task/AggregateAnalysisTest.kt 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 cdb0678..022afe2 100644 --- a/src/main/kotlin/zone/clanker/gradle/srcx/task/ContextTask.kt +++ b/src/main/kotlin/zone/clanker/gradle/srcx/task/ContextTask.kt @@ -241,7 +241,7 @@ abstract class ContextTask : DefaultTask() { } } - private fun aggregateAnalysis( + internal fun aggregateAnalysis( summaryList: List, includedBuildSummaries: Map>, ): zone.clanker.gradle.srcx.model.AnalysisSummary? { 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" + } + } + } + }) From e31d2a862ccc74a5032c1ed9518d9e453a42af13 Mon Sep 17 00:00:00 2001 From: slop Date: Sun, 12 Apr 2026 18:57:37 -0700 Subject: [PATCH 3/4] Add OOM error handling with actionable gradle.properties hint When PSI analysis runs out of memory, now logs: "srcx: Out of memory analyzing 'project'. Increase heap with org.gradle.jvmargs=-Xmx8g in gradle.properties" Other failures log the specific error message instead of silently returning null. --- .../gradle/srcx/scan/SymbolExtractor.kt | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) 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..feb3b27 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,25 @@ 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 + private fun handleAnalysisFailure(e: Throwable, projectName: String) { + when (e) { + is OutOfMemoryError -> + logger.error( + "srcx: Out of memory analyzing '$projectName'. " + + "Increase heap with org.gradle.jvmargs=-Xmx8g in gradle.properties", + ) + else -> logger.warn("srcx: Analysis failed for '$projectName': ${e.message}") + } + } + /** Minimum number of colon-separated parts in a Maven coordinate (group:artifact:version). */ private const val MIN_COORDINATE_PARTS = 3 @@ -121,6 +136,8 @@ object SymbolExtractor { val projectAnalysis = runCatching { analyzeProject(allDirs, project.projectDir).toSummary() + }.onFailure { e -> + handleAnalysisFailure(e, project.name) }.getOrNull() return ProjectSummary( @@ -167,6 +184,8 @@ object SymbolExtractor { val projectAnalysis = runCatching { analyzeProject(allDirs, projectDir).toSummary() + }.onFailure { e -> + handleAnalysisFailure(e, projectDir.name) }.getOrNull() return ProjectSummary( @@ -229,6 +248,8 @@ object SymbolExtractor { val projectAnalysis = runCatching { analyzeProject(allDirs, projectDir).toSummary() + }.onFailure { e -> + handleAnalysisFailure(e, projectDir.name) }.getOrNull() return ProjectSummary( From 08f3495b25273782a3ad794753e2ecbd5ff584f1 Mon Sep 17 00:00:00 2001 From: slop Date: Sun, 12 Apr 2026 19:12:17 -0700 Subject: [PATCH 4/4] Address CodeRabbit: fix APP entry points, rethrow OOM, deterministic hub sort MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - APP entry points were dropped (mapped to null) — now classified as APP - OOM rethrows after logging hint instead of silently returning null - Hub ranking uses secondary sort by name for deterministic output --- .../gradle/srcx/scan/SymbolExtractor.kt | 23 +++++++++---------- .../clanker/gradle/srcx/task/ContextTask.kt | 12 +++++----- 2 files changed, 17 insertions(+), 18 deletions(-) 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 feb3b27..38cdc34 100644 --- a/src/main/kotlin/zone/clanker/gradle/srcx/scan/SymbolExtractor.kt +++ b/src/main/kotlin/zone/clanker/gradle/srcx/scan/SymbolExtractor.kt @@ -42,14 +42,19 @@ object SymbolExtractor { internal val DEFAULT_EXCLUDED_DEP_SCOPES = Srcx.DEFAULT_EXCLUDED_DEP_SCOPES - private fun handleAnalysisFailure(e: Throwable, projectName: String) { + internal fun handleAnalysisFailure(e: Throwable, projectName: String): Nothing? { when (e) { - is OutOfMemoryError -> + is OutOfMemoryError -> { logger.error( "srcx: Out of memory analyzing '$projectName'. " + "Increase heap with org.gradle.jvmargs=-Xmx8g in gradle.properties", ) - else -> logger.warn("srcx: Analysis failed for '$projectName': ${e.message}") + throw e + } + else -> { + logger.warn("srcx: Analysis failed for '$projectName': ${e.message}") + return null + } } } @@ -136,9 +141,7 @@ object SymbolExtractor { val projectAnalysis = runCatching { analyzeProject(allDirs, project.projectDir).toSummary() - }.onFailure { e -> - handleAnalysisFailure(e, project.name) - }.getOrNull() + }.getOrElse { e -> handleAnalysisFailure(e, project.name) } return ProjectSummary( projectPath = ProjectPath(project.path), @@ -184,9 +187,7 @@ object SymbolExtractor { val projectAnalysis = runCatching { analyzeProject(allDirs, projectDir).toSummary() - }.onFailure { e -> - handleAnalysisFailure(e, projectDir.name) - }.getOrNull() + }.getOrElse { e -> handleAnalysisFailure(e, projectDir.name) } return ProjectSummary( projectPath = ProjectPath(projectPath), @@ -248,9 +249,7 @@ object SymbolExtractor { val projectAnalysis = runCatching { analyzeProject(allDirs, projectDir).toSummary() - }.onFailure { e -> - handleAnalysisFailure(e, projectDir.name) - }.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 022afe2..2eef6c9 100644 --- a/src/main/kotlin/zone/clanker/gradle/srcx/task/ContextTask.kt +++ b/src/main/kotlin/zone/clanker/gradle/srcx/task/ContextTask.kt @@ -221,11 +221,9 @@ abstract class ContextTask : DefaultTask() { when { isMock -> EntryPointsRenderer.EntryKind.MOCK isTest -> EntryPointsRenderer.EntryKind.TEST - else -> null + else -> EntryPointsRenderer.EntryKind.APP } - kind?.let { - EntryPointsRenderer.ClassifiedEntry(name, symbol.packageName.value, it) - } + EntryPointsRenderer.ClassifiedEntry(name, symbol.packageName.value, kind) } } }.distinctBy { "${it.packageName}.${it.className}" } @@ -252,8 +250,10 @@ abstract class ContextTask : DefaultTask() { val allHubs = allAnalyses .flatMap { it.hubs } - .sortedByDescending { it.dependentCount } - .take(HUB_LIMIT) + .sortedWith( + compareByDescending { it.dependentCount } + .thenBy { it.name }, + ).take(HUB_LIMIT) val allCycles = allAnalyses.flatMap { it.cycles }.distinct()