Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ plugins {
}

dependencies {
implementation("org.jetbrains.kotlin:kotlin-compiler-embeddable:2.1.20")
implementation("org.jetbrains.kotlin:kotlin-compiler-embeddable")
}

gradlePlugin {
Expand Down
2 changes: 1 addition & 1 deletion settings.gradle.kts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
pluginManagement {
includeBuild("build-logic")
includeBuild("build-logic") { name = "srcx-build-logic" }
}

plugins {
Expand Down
34 changes: 27 additions & 7 deletions src/main/kotlin/zone/clanker/gradle/srcx/Srcx.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -71,15 +72,21 @@ data object Srcx {
}
}

val DEFAULT_FORBIDDEN_PACKAGES: Set<String> =
setOf("util", "utils", "helper", "helpers", "manager", "managers", "misc", "base")

val DEFAULT_FORBIDDEN_CLASS_PATTERNS: Set<String> =
setOf("Helper", "Manager", "Utils", "Util")
Comment on lines +75 to +79
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot Apr 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check if DEFAULT_FORBIDDEN_PACKAGES includes "common" and "shared" in the current code
rg -n "DEFAULT_FORBIDDEN_PACKAGES" --type=kotlin -A 3

Repository: ClankerGuru/srcx

Length of output: 2034


Add common and shared to DEFAULT_FORBIDDEN_PACKAGES.

The PR objectives specify forbidden package names as: utils, helpers, managers, common, misc, base, shared. The current DEFAULT_FORBIDDEN_PACKAGES set at line 76 is missing common and shared.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/main/kotlin/zone/clanker/gradle/srcx/Srcx.kt` around lines 75 - 79,
DEFAULT_FORBIDDEN_PACKAGES is missing two entries from the spec; update the Set
assigned to DEFAULT_FORBIDDEN_PACKAGES in Srcx.kt to include "common" and
"shared" so it matches the PR objectives (keep the existing entries:
"util","utils","helper","helpers","manager","managers","misc","base" and add
"common","shared"). Ensure only the DEFAULT_FORBIDDEN_PACKAGES value is changed
and DEFAULT_FORBIDDEN_CLASS_PATTERNS remains untouched.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

╔══════════════════════════════════╗
║  ℹ️  Intentional                 ║
╚══════════════════════════════════╝

Same as the earlier comment -- "common" and "shared" were intentionally excluded from DEFAULT_FORBIDDEN_PACKAGES. These are valid, descriptive names used in many projects (e.g. Kotlin Multiplatform shared modules). The forbidden list targets genuinely vague names like "util", "helper", "manager", "misc", and "base". The plugin resolution analysis is also incorrect -- the plugin is registered in build.gradle.kts via the gradlePlugin block and resolved from the build-logic included build. settings.gradle.kts only applies "clkx-settings", not "zone.clanker.gradle.srcx".

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@ClankerGuru, understood and apologies for the noise! Both points are duly noted:

  1. common and shared exclusion — confirmed intentional; these are valid, widely-used package names (e.g. Kotlin Multiplatform shared modules) and the forbidden list is scoped to genuinely vague names like util, helper, manager, misc, and base.
  2. Plugin resolution — the plugin zone.clanker.gradle.srcx is registered via the gradlePlugin block in build.gradle.kts and resolved from the build-logic included build. settings.gradle.kts only applies clkx-settings, not zone.clanker.gradle.srcx.

I'll update my memory so I don't repeat these mistakes in future reviews.

(╯°□°)╯彡🐇 trips over wrong analysis, tumbles away


✏️ Learnings added
Learnt from: ClankerGuru
Repo: ClankerGuru/srcx PR: 7
File: src/main/kotlin/zone/clanker/gradle/srcx/Srcx.kt:75-79
Timestamp: 2026-04-13T14:29:46.817Z
Learning: In the srcx project (ClankerGuru/srcx), the `zone.clanker.gradle.srcx` plugin is registered via the `gradlePlugin` block in `build.gradle.kts` and resolved from the build-logic included build. `settings.gradle.kts` only applies the `clkx-settings` plugin, not `zone.clanker.gradle.srcx`.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.


🧠 Learnings used
Learnt from: ClankerGuru
Repo: ClankerGuru/srcx PR: 0
File: :0-0
Timestamp: 2026-04-13T00:30:35.774Z
Learning: In the srcx project (ClankerGuru/srcx), the forbidden package names intentionally exclude `common` and `shared` because they are valid, widely-used package names in the codebase. Only `utils`, `helpers`, `managers`, `misc`, and `base` are treated as forbidden package names. The PR description listing `common` and `shared` as forbidden was inaccurate; the implementation is correct.


/**
* 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")
* }
* ```
*
Expand All @@ -89,14 +96,23 @@ data object Srcx {
abstract class SettingsExtension
@Inject
constructor() {
/** Output directory relative to the root project. */
abstract val outputDir: Property<String>

/** When true, compileKotlin/compileJava tasks will finalize with srcx-context. */
abstract val autoGenerate: Property<Boolean>

/** Dependency scopes to exclude from scanning. All others are discovered automatically. */
abstract val excludeDepScopes: SetProperty<String>

@get:Internal
abstract val forbiddenPackageNames: SetProperty<String>

@get:Internal
abstract val forbiddenClassNamePatterns: SetProperty<String>

fun forbiddenPackages(vararg names: String) {
forbiddenPackageNames.addAll(names.toList())
}

fun forbiddenClassPatterns(vararg patterns: String) {
forbiddenClassNamePatterns.addAll(patterns.toList())
}
}

/**
Expand All @@ -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 ->
Expand Down Expand Up @@ -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 =
Expand Down
183 changes: 160 additions & 23 deletions src/main/kotlin/zone/clanker/gradle/srcx/analysis/AntiPatternDetector.kt
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
@file:Suppress("ktlint:standard:filename")
@file:Suppress("ktlint:standard:filename", "TooManyFunctions")

package zone.clanker.gradle.srcx.analysis

Expand Down Expand Up @@ -30,8 +30,9 @@ data class AntiPattern(
enum class Severity(
val icon: String,
) {
WARNING("WARNING"),
INFO("INFO"),
FORBIDDEN("\uD83D\uDEAB"),
WARNING("⚠\uFE0F"),
INFO("ℹ\uFE0F"),
}
}

Expand All @@ -40,15 +41,20 @@ fun detectAntiPatterns(
components: List<ClassifiedComponent>,
edges: List<ClassDependency>,
rootDir: File,
forbiddenPackages: Set<String> = zone.clanker.gradle.srcx.Srcx.DEFAULT_FORBIDDEN_PACKAGES,
forbiddenClassPatterns: Set<String> = zone.clanker.gradle.srcx.Srcx.DEFAULT_FORBIDDEN_CLASS_PATTERNS,
): List<AntiPattern> {
val resolver = SupertypeResolver(components)
val patterns = mutableListOf<AntiPattern>()

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 }))
Expand Down Expand Up @@ -80,21 +86,151 @@ private class SupertypeResolver(
private fun detectSmellClasses(
components: List<ClassifiedComponent>,
rootDir: File,
forbiddenPackages: Set<String>,
): List<AntiPattern> =
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<ClassifiedComponent>,
rootDir: File,
forbiddenPackages: Set<String>,
): List<AntiPattern> {
val patterns = mutableListOf<AntiPattern>()

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<ClassifiedComponent>,
rootDir: File,
forbiddenPatterns: Set<String>,
): List<AntiPattern> =
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<ClassifiedComponent>,
resolver: SupertypeResolver,
rootDir: File,
): List<AntiPattern> {
val nonTestComponents =
components.filter { c ->
!c.source.file.path
.contains("/test/") &&
!c.source.file.path
.contains("\\test\\") &&
!c.source.isInterface
}

val patterns = mutableListOf<AntiPattern>()

nonTestComponents.forEach { c ->
patterns.addAll(checkImportsForDiViolations(c, resolver, rootDir))
}

return patterns.distinctBy { it.message }
}

private fun checkImportsForDiViolations(
c: ClassifiedComponent,
resolver: SupertypeResolver,
rootDir: File,
): List<AntiPattern> =
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.",
)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
} 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<ClassifiedComponent>,
resolver: SupertypeResolver,
Expand All @@ -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
Expand All @@ -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,
)
}

Expand Down Expand Up @@ -207,9 +343,7 @@ private fun detectCircularDeps(edges: List<ClassDependency>): List<AntiPattern>
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.",
)
}
}
Expand Down Expand Up @@ -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 {
Expand Down
Loading
Loading