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
3 changes: 3 additions & 0 deletions .github/workflows/build.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,9 @@ jobs:
- name: Check proguardShield
run: ./gradlew proguardShieldFast

- name: Check moduleRules
run: ./gradlew moduleRules

- name: Check spotless
run: ./gradlew spotlessCheck --init-script gradle/init.gradle.kts --stacktrace

Expand Down
10 changes: 10 additions & 0 deletions CHECK.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,11 @@
./gradlew proguardShieldFast
```

- moduleRules
```bash
./gradlew moduleRules
```

- spotless
```bash
./gradlew spotlessCheck --init-script gradle/init.gradle.kts
Expand Down Expand Up @@ -50,6 +55,11 @@
./gradlew proguardShieldFastBaseline
```

- moduleRules
```bash
./gradlew moduleRulesBaseline
```

- spotless
```bash
./gradlew spotlessApply --init-script gradle/init.gradle.kts
Expand Down
16 changes: 16 additions & 0 deletions build-logic/module-rules/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
plugins {
`kotlin-dsl`
}

dependencies {
testImplementation(libs.test.junit)
}

gradlePlugin {
plugins {
register("moduleRules") {
id = "moop.module.rules"
implementationClass = "ModuleRulesPlugin"
}
}
}
129 changes: 129 additions & 0 deletions build-logic/module-rules/src/main/kotlin/ModuleRulesPlugin.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import org.gradle.api.DefaultTask
import org.gradle.api.GradleException
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.api.artifacts.ProjectDependency
import org.gradle.api.file.ConfigurableFileCollection
import org.gradle.api.file.RegularFileProperty
import org.gradle.api.provider.ListProperty
import org.gradle.api.provider.Property
import org.gradle.api.tasks.Input
import org.gradle.api.tasks.InputFiles
import org.gradle.api.tasks.Internal
import org.gradle.api.tasks.OutputFile
import org.gradle.api.tasks.PathSensitive
import org.gradle.api.tasks.PathSensitivity
import org.gradle.api.tasks.TaskAction
import org.gradle.api.tasks.UntrackedTask
import org.gradle.kotlin.dsl.register
import org.gradle.kotlin.dsl.withType
import org.gradle.work.DisableCachingByDefault

/**
* Records module rule violations in module-rules.txt:
* `moduleRulesBaseline` writes the file and `moduleRules` fails when it is out of date.
*/
class ModuleRulesPlugin : Plugin<Project> {
override fun apply(root: Project) {
val modules = root.subprojects.filter { it.buildFile.exists() }
val moduleViolationFiles = modules.map { module ->
module.tasks.register<ModuleRulesViolationsTask>("moduleRulesViolations") {
violations.set(
module.provider {
findViolations(
path = module.path,
hasHilt = module.pluginManager.hasPlugin("dagger.hilt.android.plugin"),
dependencies = module.declaredDependencies(),
)
},
)
outputFile.set(module.layout.buildDirectory.file("module-rules-violations.txt"))
}.flatMap { it.outputFile }
}
root.tasks.register<ModuleRulesTask>("moduleRulesBaseline") {
description = "Writes the module rule violations to module-rules.txt."
update.set(true)
}
root.tasks.register<ModuleRulesTask>("moduleRules") {
description = "Fails when the module rule violations differ from module-rules.txt."
update.set(false)
}
root.tasks.withType<ModuleRulesTask>().configureEach {
group = "verification"
// Depending on the tasks by path makes configure-on-demand configure every module.
dependsOn(modules.map { "${it.path}:moduleRulesViolations" })
violationFiles.from(moduleViolationFiles)
baselineFile.set(root.layout.projectDirectory.file("module-rules.txt"))
}
}

/** Module paths from the declaration buckets; AGP copies them, and the module itself, into classpaths. */
private fun Project.declaredDependencies(): Set<String> = configurations
.filter { !it.isCanBeResolved && !it.isCanBeConsumed }
.flatMap { it.dependencies.withType(ProjectDependency::class.java) }
.map { it.path }
.toSet()
}

@DisableCachingByDefault(because = "Writes a few lines computed from its inputs")
abstract class ModuleRulesViolationsTask : DefaultTask() {
@get:Input
abstract val violations: ListProperty<String>

@get:OutputFile
abstract val outputFile: RegularFileProperty

@TaskAction
fun write() = outputFile.get().asFile.writeText(violations.get().joinToString("") { "$it\n" })
}

@UntrackedTask(because = "Checks or rewrites module-rules.txt in the source tree")
abstract class ModuleRulesTask : DefaultTask() {
@get:InputFiles
@get:PathSensitive(PathSensitivity.NONE)
abstract val violationFiles: ConfigurableFileCollection

@get:Input
abstract val update: Property<Boolean>

@get:Internal
abstract val baselineFile: RegularFileProperty

@TaskAction
fun run() {
val actual = violationFiles.flatMap { it.readLines() }.sorted()
val file = baselineFile.get().asFile
if (update.get()) {
file.writeText(actual.joinToString("") { "$it\n" })
return
}
val diff = baselineDiff(expected = file.readLines(), actual = actual)
if (diff.isNotEmpty()) {
throw GradleException(
"Module rules changed in module-rules.txt:\n" + diff.joinToString("\n") +
"\n\nIf this is intended, re-baseline with ./gradlew moduleRulesBaseline",
)
}
}
}

/**
* The rule violations of the module at [path], one line each: Hilt outside the app and impl modules,
* an impl module dependency outside the app, and a dependency on a higher layer.
*/
internal fun findViolations(path: String, hasHilt: Boolean, dependencies: Set<String>): List<String> = buildList {
val isApp = path == ":app"
val layer = path.layer()
if (hasHilt && !isApp && !path.isImpl()) add("hilt: $path")
if (!isApp) dependencies.filter { it.isImpl() }.forEach { add("impl: $path -> $it") }
if (layer >= 0) dependencies.filter { it.layer() > layer }.forEach { add("layer: $path -> $it") }
}

internal fun baselineDiff(expected: List<String>, actual: List<String>): List<String> =
(expected - actual.toSet()).map { "- $it" } + (actual - expected.toSet()).map { "+ $it" }

private val LAYERS = listOf("core", "data", "feature", "app")

private fun String.layer() = LAYERS.indexOf(split(':')[1])

private fun String.isImpl() = endsWith(":impl")
38 changes: 38 additions & 0 deletions build-logic/module-rules/src/test/kotlin/ModuleRulesPluginTest.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import org.junit.Assert.assertEquals
import org.junit.Test

class ModuleRulesPluginTest {

@Test
fun findViolations_reportsHiltOutsideAppAndImplModules() {
assertEquals(listOf("hilt: :core:kotlin"), findViolations(":core:kotlin", hasHilt = true, dependencies = emptySet()))
assertEquals(emptyList<String>(), findViolations(":app", hasHilt = true, dependencies = emptySet()))
assertEquals(emptyList<String>(), findViolations(":feature:home:impl", hasHilt = true, dependencies = emptySet()))
}

@Test
fun findViolations_reportsImplDependencyOutsideApp() {
assertEquals(
listOf("impl: :feature:home:api -> :feature:home:impl"),
findViolations(":feature:home:api", hasHilt = false, dependencies = setOf(":feature:home:impl")),
)
assertEquals(emptyList<String>(), findViolations(":app", hasHilt = false, dependencies = setOf(":feature:home:impl")))
}

@Test
fun findViolations_reportsDependencyOnHigherLayer() {
assertEquals(
listOf("layer: :core:datetime -> :data:model"),
findViolations(":core:datetime", hasHilt = false, dependencies = setOf(":data:model", ":core:kotlin", ":testing")),
)
assertEquals(emptyList<String>(), findViolations(":testing", hasHilt = false, dependencies = setOf(":feature:home:api")))
}

@Test
fun baselineDiff_listsRemovedThenAddedLines() {
assertEquals(
listOf("- hilt: :a", "+ hilt: :c"),
baselineDiff(expected = listOf("hilt: :a", "hilt: :b"), actual = listOf("hilt: :b", "hilt: :c")),
)
}
}
1 change: 1 addition & 0 deletions build-logic/settings.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,4 @@ dependencyResolutionManagement {

include(":convention")
include(":module-detector")
include(":module-rules")
1 change: 1 addition & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ plugins {
alias(libs.plugins.manifestShield) apply false
alias(libs.plugins.proguardShield) apply false
alias(libs.plugins.baselineprofile) apply false
id "moop.module.rules"
}

apply from: "$rootDir/gradle/version.gradle"
Expand Down
18 changes: 12 additions & 6 deletions check.sh
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
#!/bin/bash
# Check script for PR submission
# Validates dependencies, merged manifest, ProGuard/R8 rules, code formatting, lint.
# Validates dependencies, merged manifest, ProGuard/R8 rules, module rules, code formatting, lint.

# Exit immediately if any command fails
set -e
Expand All @@ -9,31 +9,37 @@ echo "Starting check validations..."
echo ""

# Verify dependency changes
echo "🔍 [1/5] Checking dependency guard..."
echo "🔍 [1/6] Checking dependency guard..."
./gradlew dependencyGuard
echo "✓ Dependency guard check passed"
echo ""

# Verify merged manifest changes
echo "🔍 [2/5] Checking manifest shield..."
echo "🔍 [2/6] Checking manifest shield..."
./gradlew manifestShield
echo "✓ Manifest shield check passed"
echo ""

# Verify ProGuard/R8 rule changes
echo "🔍 [3/5] Checking proguard shield..."
echo "🔍 [3/6] Checking proguard shield..."
./gradlew proguardShieldFast
echo "✓ ProGuard shield check passed"
echo ""

# Verify module structure rules against the baseline
echo "🔍 [4/6] Checking module rules..."
./gradlew moduleRules
echo "✓ Module rules check passed"
echo ""

# Verify code formatting
echo "🔍 [4/5] Checking code formatting..."
echo "🔍 [5/6] Checking code formatting..."
./gradlew spotlessCheck --init-script gradle/init.gradle.kts
echo "✓ Code formatting check passed"
echo ""

# Static analysis and lint checks
echo "🔍 [5/5] Running lint checks..."
echo "🔍 [6/6] Running lint checks..."
./gradlew lintDebug
echo "✓ Lint check passed"
echo ""
Expand Down
4 changes: 4 additions & 0 deletions module-rules.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
hilt: :core:kotlin
hilt: :feature:home:api
hilt: :testing
layer: :core:datetime -> :data:model
Loading